From 6c0d7c6f1d1602ec214278322de01e73fdbd17d8 Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Mon, 12 Oct 2026 14:23:50 +0200 Subject: [PATCH 1/2] refactor(project): Name one run of a step an invocation Give one execution of a step a consistent name. A map step (keys/each) has one invocation per key; a scalar step (run) has exactly one invocation. The term pairs with the existing invocationData the StepRunner persists per key. Replace the previous mixed use of "unit" in StepRunner, the build cache, and the related tests, docs, and skill notes. Rename the internal callback parameter unitCtx to invocationCtx. Keep "unit" where it means something else: "unit test", "unit of caching", a module path, or a measurement unit. JIRA: CPOUI5FOUNDATION-1388 --- .../skills/incremental-build/architecture.md | 40 ++-- .../performance-investigation.md | 8 +- .../docs/pages/extensibility/CustomTasks.md | 6 +- packages/project/lib/build/TaskRunner.js | 2 +- .../lib/build/cache/BuildStageCache.js | 2 +- .../lib/build/cache/ProjectBuildCache.js | 10 +- .../project/lib/build/helpers/StepRunner.js | 182 +++++++++--------- .../project/lib/resources/ProjectResources.js | 2 +- .../fixtures/application.a/task.step-based.js | 12 +- .../ProjectBuilder.caching.integration.js | 6 +- .../ProjectBuilder.customTasks.integration.js | 10 +- packages/project/test/lib/build/TaskRunner.js | 2 +- .../test/lib/build/cache/ProjectBuildCache.js | 32 +-- .../test/lib/build/helpers/StepRunner.js | 24 +-- 14 files changed, 169 insertions(+), 169 deletions(-) diff --git a/.claude/skills/incremental-build/architecture.md b/.claude/skills/incremental-build/architecture.md index 0896c27c8f9..35837b61834 100644 --- a/.claude/skills/incremental-build/architecture.md +++ b/.claude/skills/incremental-build/architecture.md @@ -48,7 +48,7 @@ Use this table to locate source files. ALWAYS read the relevant source file befo | `fileWatcher` | `lib/build/helpers/fileWatcher.js` | Watcher-backend facade. Exposes a `subscribe()` matching `@parcel/watcher`'s contract and picks a backend once per process: `UI5_WATCH_MODE=polling\|native` forces the choice, otherwise it auto-detects containers (`/.dockerenv`, `/run/.containerenv`, PID 1 cgroup) and uses polling there. Also falls back to polling if the native `@parcel/watcher` binding cannot load. `UI5_WATCH_MODE=off` disables watching entirely: `subscribe()` returns an inert subscription (callback never invoked, `unsubscribe` a no-op) so no backend loads, for CI and other environments where sources do not change while the server runs. The memoized decision is a mode string exposed via `shouldUsePolling()` and `isWatchingDisabled()`. All three watchers subscribe through this facade rather than importing `@parcel/watcher` directly | | `pollingWatcher` | `lib/build/helpers/pollingWatcher.js` | Pure-JS polling backend. Walks the tree and diffs an `{mtimeMs, size}` snapshot every 250 ms (`DEFAULT_POLL_INTERVAL_MS`), emitting the same `{type, path}` events as the native backend. Needed on bind-mounted container volumes where inotify misses writes made from outside the container | | `TaskRunner` | `lib/build/TaskRunner.js` | Task composition, execution loop, abort handling. Hands every task a plain `MonitoredTaskUtil`; for a step-based task it calls the task's step factory once per build at plan time to discover step names for `setTasks`, keeps the returned step array on the task, and reuses it to drive the `StepRunner` with per-stage hooks (`#createStepStageHooks`: `prepareStage`/`getPreviousInvocationData`/`createStageContext`/`recordStage`), and reports the task skipped/executed from the runner's `anyStepExecuted`. A legacy task runs a plain body with no step runner | -| `StepRunner` | `lib/build/helpers/StepRunner.js` | Per-task driver behind the step-factory build API, driving **one pipeline stage per step** (Phase B). Runs scalar and map steps in array order; per step it calls `prepareStage` (switch + cache verdict), runs/restores the step's units against a fresh per-stage recording context and per-step `MonitoredTaskUtil`, threads `needs` returns between steps, folds the stage's own keys' reads/inputs (`#foldStageKeys`, including cached keys) and derives the stage's stale outputs (`#computeStaleOutputs`), then calls `recordStage`. No cross-step fold. See "Step-Based Build Tasks" | +| `StepRunner` | `lib/build/helpers/StepRunner.js` | Per-task driver behind the step-factory build API, driving **one pipeline stage per step** (Phase B). Runs scalar and map steps in array order; per step it calls `prepareStage` (switch + cache verdict), runs/restores the step's invocations against a fresh per-stage recording context and per-step `MonitoredTaskUtil`, threads `needs` returns between steps, folds the stage's own keys' reads/inputs (`#foldStageKeys`, including cached keys) and derives the stage's stale outputs (`#computeStaleOutputs`), then calls `recordStage`. No cross-step fold. See "Step-Based Build Tasks" | | `Cache` enum | `lib/build/cache/Cache.js` | Cache mode constants: `Default`, `Force`, `ReadOnly`, `Off` (CLI `--cache` option) | | `ProjectBuildCache` | `lib/build/cache/ProjectBuildCache.js` | Cache orchestration per project: index management, stage lookup, result recording. The unit of caching is a **stage**: `#stageCaches` is keyed by stage id (a legacy task's `task/{taskName}`, or a step-based task's `task/{taskName}::step/{stepName}` per step); `prepareStageExecutionAndValidateCache(taskName, stepName?)` and `recordStageResult({taskName, …, stepName?})` operate per stage; `setTasks([{taskName, stepNames?}])` creates one stage per step | | `BuildStageCache` | `lib/build/cache/BuildStageCache.js` | Per-stage resource request tracking and index management (one instance per stage, keyed in `ProjectBuildCache` by stage id). Also holds the stage's `TaskInputSet` (non-resource inputs) and exposes `getInputSignature(resolveValue)` | @@ -296,7 +296,7 @@ Contract for task authors: read an env var through `taskUtil.getEnv(name)` (not A step-based task default-exports a factory `build(options) => Step[]` instead of a task body. The factory receives `options` only (never readers or `taskUtil`), so it cannot close over build state; every input a step reads arrives through the step's own arguments. This replaced the old differential mode where a task received `changedProjectResourcePaths` and did its own delta bookkeeping. Step-based tasks: `minify` (one map step over resources), `buildThemes` (one map step over themes), the three `replace*` tasks, `escapeNonAsciiCharacters` and `enhanceManifest` (one map step each), and `generateThemeDesignerResources` (two scalar steps plus a themes map step, wired by `needs`). -**Lazy processor imports.** `TaskRunner` imports a step-based task's module and calls its factory at plan time, before the cache decides whether any step runs (see "Driving one stage per step"). So a step-based task module imports its heavy processors lazily, inside the step bodies (`const p = (await import("../processors/...")).default`), not at module top level. The factory stays cheap: it only returns step descriptors. A build whose steps are all cache hits never runs a step body, so the processor module graphs stay unloaded, in particular the `minify` and `enhanceManifest` and `generateThemeDesignerResources` processors (`minifier`'s worker pool, `manifestEnhancer`'s `semver`, the less generator) and `buildThemes`' `less-openui5` graph. For `buildThemes` the deferral is in `themeBuilderWorker.js`: it imports `themeBuilder` (and `less-openui5`) lazily inside its worker entry `execThemeBuild`, so the main thread importing it for the `FsMainThreadInterface` and (de)serialize helpers, and plan-time discovery, do not evaluate that graph; its light `workerpool` import stays static because the worker registration (`workerpool.worker`) runs at module load. A custom step-based task should follow the same contract. The step bodies are the only place a processor is needed, so a cold build loads each processor once, when its first unit runs. Measured on the library step-based task set (`minify`, `buildThemes`, the three `replace*`, `escapeNonAsciiCharacters`, `enhanceManifest`, `generateThemeDesignerResources`), plan-time module evaluation dropped from ~58 ms to ~40 ms (min-of-5 fresh-process import of all task modules; see performance-investigation.md item 18). +**Lazy processor imports.** `TaskRunner` imports a step-based task's module and calls its factory at plan time, before the cache decides whether any step runs (see "Driving one stage per step"). So a step-based task module imports its heavy processors lazily, inside the step bodies (`const p = (await import("../processors/...")).default`), not at module top level. The factory stays cheap: it only returns step descriptors. A build whose steps are all cache hits never runs a step body, so the processor module graphs stay unloaded, in particular the `minify` and `enhanceManifest` and `generateThemeDesignerResources` processors (`minifier`'s worker pool, `manifestEnhancer`'s `semver`, the less generator) and `buildThemes`' `less-openui5` graph. For `buildThemes` the deferral is in `themeBuilderWorker.js`: it imports `themeBuilder` (and `less-openui5`) lazily inside its worker entry `execThemeBuild`, so the main thread importing it for the `FsMainThreadInterface` and (de)serialize helpers, and plan-time discovery, do not evaluate that graph; its light `workerpool` import stays static because the worker registration (`workerpool.worker`) runs at module load. A custom step-based task should follow the same contract. The step bodies are the only place a processor is needed, so a cold build loads each processor once, when its first invocation runs. Measured on the library step-based task set (`minify`, `buildThemes`, the three `replace*`, `escapeNonAsciiCharacters`, `enhanceManifest`, `generateThemeDesignerResources`), plan-time module evaluation dropped from ~58 ms to ~40 ms (min-of-5 fresh-process import of all task modules; see performance-investigation.md item 18). **One stage per step.** Each step is promoted to its own pipeline stage: a scalar step is a stage; a map step is a single stage carrying an internal per-key delta (keys never become stages — they are discovered at runtime by `keys()` and must stay out of the static stage set and out of the result-stage cartesian product). A step-based task therefore contributes N stages (one per step) rather than one task stage; a legacy task still contributes one stage. Stage ids are `task/{taskName}::step/{stepName}` for a step-based task's steps and `task/{taskName}` for a legacy task (or a step-based task that returns no steps). This unifies steps and stages: per-step caching runs through the same stage-cache, tag-replay, and CAS-return machinery as any stage, rather than a parallel sub-cache folded inside one task stage. The result-stage signature grows by one factor per step, but only dependency-reading steps carrying a delta contribute a factor >1, so the growth is bounded (measured at 1.00× on the realistic framework-library task set, since the scalar steps read no dependencies — see the measurement note below). @@ -304,9 +304,9 @@ Two step shapes, run in array order by `StepRunner.runSteps()`: - scalar `{name, needs?, run}` where `run: async ({needs, workspace, dependencies, taskUtil, options}) => value?` runs once. - map `{name, needs?, sequential?, keys, each}` where `keys: async ({needs, workspace, dependencies, taskUtil, options}) => keySet` enumerates the key set and `each: async (key, {needs, workspace, dependencies, taskUtil, options}) => value?` runs once per key. -A scalar step is a one-key group (a single implicit unit); a map step is a multi-key group. Both go through the same per-key machinery within their stage, so delta selection, stale-output derivation, and per-stage request/input folding are uniform across shapes. +A scalar step is a one-key group (a single implicit invocation); a map step is a multi-key group. Both go through the same per-key machinery within their stage, so delta selection, stale-output derivation, and per-stage request/input folding are uniform across shapes. -**`needs` wiring.** A step lists earlier step names in `needs`; those steps' returns arrive as `needs.` in its context (both `keys` and `each` for a map step). A step may reference only earlier steps, so array order is always a valid execution order. One `needs` object is shared by a step's `keys` enumerator and all of its units, and is frozen (shallowly): a unit assigning to `needs.` would otherwise leak into its siblings and into the `needsInputs` recorded for whichever unit ran next, making a delta build's per-unit selection depend on execution order. `@ui5/builder`'s standalone `runSteps` freezes it too, so a step behaves the same under both runners. Because steps share `needs` state in memory, `StepRunner` remains the per-task driver that runs all of a task's steps in order — it just drives one stage per step (see below) instead of folding them into one. +**`needs` wiring.** A step lists earlier step names in `needs`; those steps' returns arrive as `needs.` in its context (both `keys` and `each` for a map step). A step may reference only earlier steps, so array order is always a valid execution order. One `needs` object is shared by a step's `keys` enumerator and all of its invocations, and is frozen (shallowly): an invocation assigning to `needs.` would otherwise leak into its siblings and into the `needsInputs` recorded for whichever invocation ran next, making a delta build's per-invocation selection depend on execution order. `@ui5/builder`'s standalone `runSteps` freezes it too, so a step behaves the same under both runners. Because steps share `needs` state in memory, `StepRunner` remains the per-task driver that runs all of a task's steps in order — it just drives one stage per step (see below) instead of folding them into one. **Declaration validation.** The whole step list is validated **before any stage is created**, by `validateSteps(steps, {taskName?})` (exported from `StepRunner.js`). `TaskRunner` calls it at discovery, right after the factory returns and before it derives the step names for `setTasks`: a stage id is `task/{taskName}::step/{stepName}`, so a duplicate step name would create two stages sharing one id — the error must be raised before `setTasks`, not when the step later runs. `StepRunner.runSteps()` calls it again at its entry, so a directly or standalone instantiated runner validates identically. The rules are static over the descriptors (no reader, taskUtil, options or cache state): the factory must return an array; each element must be an object with a non-empty string `name`; names must be unique; a step is either scalar (`run`) or map (`keys` **and** `each`), never both nor neither (a half-defined map names the missing half); `needs`, when present, must be an array of strings naming only **earlier** steps (so a forward or self reference throws, which is what makes a `needs` cycle impossible). Scope is `@ui5/project`; `@ui5/builder`'s standalone `runSteps` does not yet share this validator. @@ -351,37 +351,37 @@ export default function build(options) { } ``` -Two DSL rules make the per-step recording honest, and both are visible here. The factory is pure over `options`: it may branch on `namespace`, precompute `pattern`, and include or omit steps, all from values that are part of the build signature. And no `run`/`keys`/`each` body closes over a reader or `taskUtil`; every input arrives as a callback argument (`workspace`, `dependencies`, `taskUtil`, `needs`, `options`), so a read cannot escape the step it belongs to. A matching contract holds for writes: every write a unit makes goes through its own `workspace`, the only writable handle a step receives (the factory gets `options` only, and `taskUtil.getProject().getReader()` is read-only), so a unit's recorded `writes` are the complete set `#computeStaleOutputs` needs to drop a path the unit stops producing. A custom step that reaches a writer another way leaves that write unrecorded, and its stale output would survive a delta build. +Two DSL rules make the per-step recording honest, and both are visible here. The factory is pure over `options`: it may branch on `namespace`, precompute `pattern`, and include or omit steps, all from values that are part of the build signature. And no `run`/`keys`/`each` body closes over a reader or `taskUtil`; every input arrives as a callback argument (`workspace`, `dependencies`, `taskUtil`, `needs`, `options`), so a read cannot escape the step it belongs to. A matching contract holds for writes: every write an invocation makes goes through its own `workspace`, the only writable handle a step receives (the factory gets `options` only, and `taskUtil.getProject().getReader()` is read-only), so an invocation's recorded `writes` are the complete set `#computeStaleOutputs` needs to drop a path the invocation stops producing. A custom step that reaches a writer another way leaves that write unrecorded, and its stale output would survive a delta build. **How these steps become stages.** The factory above produces up to three stages, one per returned step: `task/generateThemeDesignerResources::step/scan`, `.../step/libraryTheming` (present only when `namespace` is set, so the stage set follows the factory's branching and is known before execution), and `.../step/themes` (one stage carrying the per-theme key delta, since keys are discovered at runtime and never become stages). `libraryTheming` is keyed on `scan`'s return through `needs`, so it stays cached while `hasThemes` holds even as individual `library.source.less` files change; the `themes` map step regenerates only the themes whose keys changed. `TaskRunner` calls the factory once per build at plan time (pure over `options`) to collect the step names for `setTasks`, so `ProjectBuildCache` creates these stages in step order before the build runs. It keeps the returned step array on the task and reuses it when the step runs, so the factory is not called a second time. -**Returns.** A step may return resources (stored in the CAS by integrity, as before) or a JSON-serializable value (persisted inline with the unit's invocation data). Either is injected into a consumer via `needs.`, and the return's signature (resource integrities, or the serialized value) folds into the consumer's per-unit selection so a changed producer return re-runs the consumer. A map producer's return signature is order-independent: it hashes a `keyId -> signature` map sorted by `keyId`, not a positional list in `keys()` order, so a key reordering that changes nothing semantically (an adapter change, filesystem ordering, a reader-collection reshuffle) does not move the signature and re-run every consumer for nothing. A step returning nothing has a `null` return descriptor. A returned value that is neither resources nor JSON-serializable throws. +**Returns.** A step may return resources (stored in the CAS by integrity, as before) or a JSON-serializable value (persisted inline with the invocation's invocation data). Either is injected into a consumer via `needs.`, and the return's signature (resource integrities, or the serialized value) folds into the consumer's per-invocation selection so a changed producer return re-runs the consumer. A map producer's return signature is order-independent: it hashes a `keyId -> signature` map sorted by `keyId`, not a positional list in `keys()` order, so a key reordering that changes nothing semantically (an adapter change, filesystem ordering, a reader-collection reshuffle) does not move the signature and re-run every consumer for nothing. A step returning nothing has a `null` return descriptor. A returned value that is neither resources nor JSON-serializable throws. **The `stepBased` opt-in.** A task is step-based only when it declares it; absent, the default export is a legacy task body and runs unchanged. A standard task sets `stepBased: true` in its build-definition entry (`definitions/*.js`), read by `_addTask`. A custom task declares a static `stepBased` export on its task module, surfaced by `Task#getStepBased` and honored from Specification Version 5.0 (`_addCustomTask` computes `specVersion.gte("5.0") && (await getStepBased()) === true`); a `stepBased` export below 5.0 is ignored and the task runs as a legacy body. There is no shape-sniffing: both legacy and factory are default-export functions, disambiguated by the flag. -**Driving one stage per step.** Before the build, `TaskRunner.runTasks` calls each step-based task's factory once (pure over `options`) to discover its step names and hands `ProjectBuildCache.setTasks` a `[{taskName, stepNames?}]` list, which creates one stage per step in step order. The returned step array is kept on the task (frozen, so a custom task cannot mutate the shared value) and reused when the step runs, so the factory is called once per build rather than once to discover and once to execute. One call is the single source of truth: discovery and execution cannot drive different stages, so no runtime check is needed to reconcile two factory calls. The `options` object is completed at registration time (`_addTask` fills in `projectName`, `projectNamespace`) and is fixed for a `TaskRunner`'s lifetime: a changed `ui5.yaml` task configuration rebuilds the whole serving stack through `Supervisor.reinitialize`, giving a fresh `TaskRunner`, while a plain source-change rebuild reuses the `TaskRunner` and re-derives the step array. Re-deriving per build keeps the steps in step with the options without any explicit invalidation. A factory body that reads the environment or the clock untracked is still impure, but a single call means its one output drives both the stage list and execution consistently. For each step, the `StepRunner` drives that step's own stage via hooks the `TaskRunner` supplies (`#createStepStageHooks`): `prepareStage(stepName)` switches the project to the step's stage and returns its cache verdict (`prepareStageExecutionAndValidateCache(taskName, stepName)`); `createStageContext()` builds fresh monitored `workspace`/`dependencies` readers and a `MonitoredTaskUtil` bound to that stage (so the reads reflect the cumulative output of all earlier stages with this stage's writer on top); `recordStage(stepName, …)` records the step's stage (`recordStageResult({taskName, …, stepName})`). A fully-cached stage (`prepareStage` returns `true`) does not run: its return is rebuilt from the persisted per-key data (in key order) so later steps' `needs` resolve, and its tag operations are replayed. A `needs` return is excluded from the stage signature, so a full-hit consumer whose producer re-ran with a changed return would otherwise serve stale output. The `StepRunner` checks this on the full-hit path (`#needsReturnChanged`) and, when it fires, reopens the stage with a fresh writer and re-runs it as a full execution (`reopenStage` returns a falsy verdict, not a delta). Reopen installs a fresh empty writable stage, so pruning the re-run to only the affected units would need re-seeding it with the cached outputs first, so the whole stage re-runs instead. No shipped multi-step task pays for this: only `generateThemeDesignerResources` wires `needs`, and a changed `scan` return regenerates its themes anyway. The verdict (`true` / delta object / falsy) is read three ways in `runSteps`. An explicit verdict shape would read more clearly but is deferred, since that shape is shared with the stage-signature code. A task is reported to the `ProjectBuildLogger` as skipped (`skipTask`) when every step was served from cache, else as executed. Its skip verdict is only known once every stage has been driven, so it cannot be announced up front like a legacy task: the `StepRunner` instead calls `notifyStepExecution` from the first stage that stops being a pure cache hit, before that stage runs anything, and `TaskRunner.#createTaskExecutionReport` turns that into `startTask`. `task-start` therefore precedes the work it announces, and the matching `endTask` (with the union of the stages' written paths) follows it, so a `project-build-status` consumer sees the task running while it runs. A step-based task that returns **no** steps still has one stage (`task/{taskName}`) that is prepared and recorded (empty), so its empty result caches and a later build reports it skipped. +**Driving one stage per step.** Before the build, `TaskRunner.runTasks` calls each step-based task's factory once (pure over `options`) to discover its step names and hands `ProjectBuildCache.setTasks` a `[{taskName, stepNames?}]` list, which creates one stage per step in step order. The returned step array is kept on the task (frozen, so a custom task cannot mutate the shared value) and reused when the step runs, so the factory is called once per build rather than once to discover and once to execute. One call is the single source of truth: discovery and execution cannot drive different stages, so no runtime check is needed to reconcile two factory calls. The `options` object is completed at registration time (`_addTask` fills in `projectName`, `projectNamespace`) and is fixed for a `TaskRunner`'s lifetime: a changed `ui5.yaml` task configuration rebuilds the whole serving stack through `Supervisor.reinitialize`, giving a fresh `TaskRunner`, while a plain source-change rebuild reuses the `TaskRunner` and re-derives the step array. Re-deriving per build keeps the steps in step with the options without any explicit invalidation. A factory body that reads the environment or the clock untracked is still impure, but a single call means its one output drives both the stage list and execution consistently. For each step, the `StepRunner` drives that step's own stage via hooks the `TaskRunner` supplies (`#createStepStageHooks`): `prepareStage(stepName)` switches the project to the step's stage and returns its cache verdict (`prepareStageExecutionAndValidateCache(taskName, stepName)`); `createStageContext()` builds fresh monitored `workspace`/`dependencies` readers and a `MonitoredTaskUtil` bound to that stage (so the reads reflect the cumulative output of all earlier stages with this stage's writer on top); `recordStage(stepName, …)` records the step's stage (`recordStageResult({taskName, …, stepName})`). A fully-cached stage (`prepareStage` returns `true`) does not run: its return is rebuilt from the persisted per-key data (in key order) so later steps' `needs` resolve, and its tag operations are replayed. A `needs` return is excluded from the stage signature, so a full-hit consumer whose producer re-ran with a changed return would otherwise serve stale output. The `StepRunner` checks this on the full-hit path (`#needsReturnChanged`) and, when it fires, reopens the stage with a fresh writer and re-runs it as a full execution (`reopenStage` returns a falsy verdict, not a delta). Reopen installs a fresh empty writable stage, so pruning the re-run to only the affected invocations would need re-seeding it with the cached outputs first, so the whole stage re-runs instead. No shipped multi-step task pays for this: only `generateThemeDesignerResources` wires `needs`, and a changed `scan` return regenerates its themes anyway. The verdict (`true` / delta object / falsy) is read three ways in `runSteps`. An explicit verdict shape would read more clearly but is deferred, since that shape is shared with the stage-signature code. A task is reported to the `ProjectBuildLogger` as skipped (`skipTask`) when every step was served from cache, else as executed. Its skip verdict is only known once every stage has been driven, so it cannot be announced up front like a legacy task: the `StepRunner` instead calls `notifyStepExecution` from the first stage that stops being a pure cache hit, before that stage runs anything, and `TaskRunner.#createTaskExecutionReport` turns that into `startTask`. `task-start` therefore precedes the work it announces, and the matching `endTask` (with the union of the stages' written paths) follows it, so a `project-build-status` consumer sees the task running while it runs. A step-based task that returns **no** steps still has one stage (`task/{taskName}`) that is prepared and recorded (empty), so its empty result caches and a later build reports it skipped. **Standalone (uncached) runner.** `@ui5/builder` exposes `runSteps(factory, {workspace, dependencies, taskUtil, options})` (`lib/tasks/runSteps.js`) for standalone task invocation with no build cache: it runs every step in order, fans out each `keys` set, threads `needs` in memory, and buffers map writes in key order, without delta selection, persistence, or tag replay. It is an independent implementation (it does not use the `@ui5/project` `StepRunner`); the full cached engine stays in `@ui5/project` because `@ui5/builder` cannot import it. The two runners stay separate but share one definition of the write-buffer contract: the same-path guard (with its user-visible error message) and the key-order flush live in `@ui5/fs/internal/stepWriteBuffer` (`assertDistinctWrite`, `flushWriteBuffer`), reached by both because both already depend on `@ui5/fs`. The two writers differ only in which method they override: `@ui5/project`'s `RecordingReaderWriter` overrides `_write` (it also records writes for the cache, and the base `write` has already defaulted options), while `@ui5/builder`'s `BufferedWriter` overrides the public `write` to preserve the caller's exact arguments; both buffer `{resource, args, index}` entries and flush them identically. -Per unit within a stage, the callback receives its own recording readers (`{workspace, dependencies}`) that delegate to the stage's monitored readers and additionally attribute each read to the unit, plus a per-unit `MonitoredTaskUtil` wrapping the stage's one. The per-unit monitor attributes the unit's non-resource inputs (`getEnv`, `getTime`, `getProject(name).getVersion()`, `isRootProject`, `getDependencies`) and its `getTag`/`setTag`/`clearTag` operations to the unit, while reads still delegate through the stage monitor so a full build's stage-level recording stays the union that keys the stage. Key identity (`StepRunner.#keyId`) is content-based: a resource key by its path and a content discriminator (path distinguishes resources that share content but produce different output; the discriminator makes a content change a new key, so the unit re-runs and its previous output is dropped rather than served stale), a string key by its value. Compound keys are the caller's responsibility to express as a stable string. The discriminator is **tiered like `isResourceUnchanged`** (see §"HashTree") rather than always the SSRI integrity: `lastModified` + `size` when both are statically available (`getLastModified()` returns a number and `hasSize()` is true, so no content read), falling back to the integrity when either is missing — a memory-backed or generated resource with no `lastModified`, or one whose size is not statically known. A resource restored from a stage cache carries its `integrity`, so the fallback `getIntegrity()` resolves without reading content and that path stays cheap. This avoids hashing every enumerated key's full content before delta selection; it is why `#resolveEntries` is cheap for a broad `workspace.byGlob` step (`replaceCopyright`, `replaceVersion`) on a stale-cache build (see performance-investigation.md §12). The residual staleness risk is identical to `isResourceUnchanged`'s and accepted for the same reason: a content change that preserves **both** `lastModified` and `size` keeps the same key and does not re-run the unit — a real edit moves mtime, so the gap is only for mtime-preserving replacements (`cp -p`, `tar -x`, atomic rename) that also hold size constant, and the changed-path delta does not independently cover this case because it is derived through the same tiered comparison. Because the stat tier and the integrity tier use distinct prefixes (`\0m…\0s…` vs `\0i…`), a key never aliases across the two tiers. +Per invocation within a stage, the callback receives its own recording readers (`{workspace, dependencies}`) that delegate to the stage's monitored readers and additionally attribute each read to the invocation, plus a per-invocation `MonitoredTaskUtil` wrapping the stage's one. The per-invocation monitor attributes the invocation's non-resource inputs (`getEnv`, `getTime`, `getProject(name).getVersion()`, `isRootProject`, `getDependencies`) and its `getTag`/`setTag`/`clearTag` operations to the invocation, while reads still delegate through the stage monitor so a full build's stage-level recording stays the union that keys the stage. Key identity (`StepRunner.#keyId`) is content-based: a resource key by its path and a content discriminator (path distinguishes resources that share content but produce different output; the discriminator makes a content change a new key, so the invocation re-runs and its previous output is dropped rather than served stale), a string key by its value. Compound keys are the caller's responsibility to express as a stable string. The discriminator is **tiered like `isResourceUnchanged`** (see §"HashTree") rather than always the SSRI integrity: `lastModified` + `size` when both are statically available (`getLastModified()` returns a number and `hasSize()` is true, so no content read), falling back to the integrity when either is missing — a memory-backed or generated resource with no `lastModified`, or one whose size is not statically known. A resource restored from a stage cache carries its `integrity`, so the fallback `getIntegrity()` resolves without reading content and that path stays cheap. This avoids hashing every enumerated key's full content before delta selection; it is why `#resolveEntries` is cheap for a broad `workspace.byGlob` step (`replaceCopyright`, `replaceVersion`) on a stale-cache build (see performance-investigation.md §12). The residual staleness risk is identical to `isResourceUnchanged`'s and accepted for the same reason: a content change that preserves **both** `lastModified` and `size` keeps the same key and does not re-run the invocation — a real edit moves mtime, so the gap is only for mtime-preserving replacements (`cp -p`, `tar -x`, atomic rename) that also hold size constant, and the changed-path delta does not independently cover this case because it is derived through the same tiered comparison. Because the stat tier and the integrity tier use distinct prefixes (`\0m…\0s…` vs `\0i…`), a key never aliases across the two tiers. -> This is a signature-shape change to the per-key invocation data keys, not a persisted-cache-format change requiring a `CACHE_VERSION`/`BUILD_SIG_VERSION` bump: the per-key invocation data is re-derived each build and a changed key identity simply looks like a new key (the unit re-runs), which is the correct and safe outcome. The feature is unreleased, so no backward-compat shim is added. +> This is a signature-shape change to the per-key invocation data keys, not a persisted-cache-format change requiring a `CACHE_VERSION`/`BUILD_SIG_VERSION` bump: the per-key invocation data is re-derived each build and a changed key identity simply looks like a new key (the invocation re-runs), which is the correct and safe outcome. The feature is unreleased, so no backward-compat shim is added. Execution and writes: -- `sequential: true` (always set for a scalar step) persists each unit's writes immediately, so a later unit reads what an earlier one wrote. +- `sequential: true` (always set for a scalar step) persists each invocation's writes immediately, so a later invocation reads what an earlier one wrote. - The default (`sequential: false`) runs a map step's keys concurrently: writes are buffered and flushed in key order after all keys finish; two concurrent keys writing the same path throw, since concurrent keys must be independent. -- Steps run in array order, and the abort signal is checked between units; a single in-flight unit is not interrupted. +- Steps run in array order, and the abort signal is checked between invocations; a single in-flight invocation is not interrupted. Delta selection and cache integration (per stage): -- On a delta build, a unit re-runs when its key is new, its recorded reads intersect the changed project/dependency paths (the reverse mapping that re-runs the owner of a changed cross-resource input, e.g. a `.js` whose `.js.map` changed, or a theme whose gating marker was added), one of its recorded non-resource inputs no longer resolves to its stored value (an env var flipped, a dependency version bumped, or a time bucket rolled over between runs, since the bucket derives from the run's shared `BuildContext` timestamp), or a `needs` return it consumed changed. The input is re-derived through `ProjectBuildCache.getResolveInputValue()`, the same resolver the stage-level input lookup uses; without a resolver (standalone use) a unit is selected on its resource reads alone. A scalar step's single implicit unit is the exception: it re-runs whenever its stage takes the delta path (not a full cache hit), without consulting the per-unit reads delta. A scalar step is one unit, so there is nothing finer to prune, and the reads delta cannot catch a file newly matching a glob the step evaluated: the recorder stores resolved paths, not patterns (`#foldStageKeys`), so a file that did not exist on the previous build is in no recorded read, yet the stage-level monitor did record the glob, so an added match moves the stage signature and `prepareStage` returns a delta verdict. Re-running the one unit on that verdict is what makes `generateThemeDesignerResources`'s `scan` step observe a first theme being added (map steps are already covered: `keys()` re-enumerates and a new key has no previous entry). A full stage-cache hit still keeps the scalar step cached. -- `StepRunner.#computeStaleOutputs` reports paths a unit produced before but no longer produces (a re-run unit that writes fewer paths, or a removed key). The dropped-path comparison runs over the units that actually executed this build, while the set of paths still claimed is taken from the stage's complete per-key data, so a path a cached unit owns is never dropped because a sibling key disappeared. It is scoped to the one stage (a stage owns its outputs), and the `recordStage` hook appends the stale paths to the stage's delta `changedProjectResourcePaths` so `recordStageResult`'s stage merge drops them. -- A map step's `keys` enumerator owns no key, so it runs against the stage's own context rather than a per-unit recording one: it runs whenever the stage runs, so its reads and non-resource inputs are captured by the stage's monitored readers and `MonitoredTaskUtil` and fold into the stage signature, and the tags it sets are recorded as the stage's tag operations, which a fully cached stage restores along with its writer. -- `StepRunner.#foldStageKeys` folds every one of a stage's keys' reads and non-resource inputs — including keys served from cache on a delta build, whose reads and inputs the stage-level monitor never observed — into one request set and one input set, which the `recordStage` hook merges into the stage-level monitored requests before `recordStageResult` re-keys the stage. The recorder stores resolved paths and a path is commonly read by more than one key, so `#foldStageKeys` accumulates into `Set`s (deduped per read bucket) and the `recordStage` hook's `foldReadsInto` adds only the fold paths the stage monitor did not already request; the duplicates would otherwise collapse downstream in `ResourceRequestGraph.findExactMatch` (which keys on a `Set`), but carrying them inflated the recording that graph rebuilds. Deduplication does not move the stage signature — the set of reads is unchanged, only its representation. This is the map step's internal key-delta fold (kept in Phase B); there is no cross-**step** fold — each step's stage records only its own keys. The consumed `needs` returns are deliberately excluded from this fold (tracked in the separate per-key `needsInputs` field, see below): they drive per-unit selection only and are re-derived from producer reads/inputs that are themselves tracked, so `resolveInputValue` has no resolver for them and folding one into the stage signature would permanently miss the stage cache. Omitting this per-key fold makes a stage whose keys read `taskUtil.getTime` (`replaceCopyright`, `replaceBuildtime`) fail to reconverge to a cached signature across builds, because a delta build that restores every key records no inputs at the stage level. -- A unit served from cache replays its recorded `set`/`clear` tag operations via `ProjectResources.replayTagOperations`, routed by tag to the monitored project or build tag collection and applied by path, so its tags reappear in this build's tag operations (captured by `recordStageResult` like a unit that ran). A unit whose key is gone is not replayed, so a removed unit's tags do not linger. `get` operations carry no persistent effect and are skipped. -- Per-key invocation data (`Map`) is persisted **inside the step's own `stage_metadata` row** (`metadata.stepInvocationData`, as `[[keyId, entry], ...]` pairs since JSON has no Map), keyed by the stage signature the stage output is keyed under, so the per-key map and the stage output can never pair with a different run's data. It is embedded by `ProjectBuildCache.#prepareStageCache` and extracted by `#processStageCacheMetadata`; a cache lookup (`prepareStageExecutionAndValidateCache`) stashes the matched stage's map into `#stepInvocationData` so `getStepInvocationData(stageId)` returns the signature-matched "previous" data, and a running stage overwrites it via `setStepInvocationData`. `needsInputs` holds the consumed `needs` return signatures for per-unit selection and is intentionally kept out of the stage input fold. Because a `needs` return is excluded from the stage signature, a cached consumer's correctness rests on two checks rather than the signature: `#needsReturnChanged` on the full-hit path and the `needsInputs` comparison in `#selectStepsToRun` on the delta path. These are the only two paths that reach a cached stage through the `StepRunner`. A whole project restored from the project-level result cache skips the `StepRunner` entirely (and `ProjectBuildCache.#importStages` installs its stages blindly by signature), yet that is safe without a needs check: the result signature aggregates every stage's inputs, producer stages included, so any change that could alter a producer's return perturbs the result signature, misses the result cache, and defers to the `StepRunner` where the two checks run. The map rides on the stage's own `stage_metadata` write, which happens only for a stage recorded this build, so a full cache hit (not re-recorded) never rewrites it; a map step whose key set dropped to zero re-records under a new signature with an embedded `[]`, so the next build that matches it sees no stale keys. -- Return values are first-class: `StepRunner` stores a resource return's content in the CAS via `ProjectBuildCache.getStepReturnValueStore()` (`store` buffers the compressed content deduped against stage rows; `flush`, called by the driver once per step after its units have returned, writes that step's buffer in a single transaction) and records per-unit descriptors in the `returns` field of the invocation entry; a serializable value is recorded inline. On a delta build a unit served from cache has its return rebuilt from the CAS (or read back from the inline value), so a map step's result array is reassembled in key order from a mix of freshly-returned and restored entries and handed to consumers via `needs`. A returned resource whose path collides with a written output is stored once by integrity and rebuilt independently of that output. - -Removing an input resource yields a per-unit delta: `ResourceRequestManager.getDeltas` includes removed paths in the delta's `changedPaths`, so a unit that read the removed input re-runs (or, for a gone key, drops out) and its stale output is dropped via the stale-output merge above, while the other units stay cached. Known gap (parked): a processor-library version (e.g. terser, less-openui5) is not yet a tracked per-unit input, so a task whose output depends on a processor version can serve stale until a follow-up routes that version through the per-unit `taskUtil` (open-gaps §1). +- On a delta build, an invocation re-runs when its key is new, its recorded reads intersect the changed project/dependency paths (the reverse mapping that re-runs the owner of a changed cross-resource input, e.g. a `.js` whose `.js.map` changed, or a theme whose gating marker was added), one of its recorded non-resource inputs no longer resolves to its stored value (an env var flipped, a dependency version bumped, or a time bucket rolled over between runs, since the bucket derives from the run's shared `BuildContext` timestamp), or a `needs` return it consumed changed. The input is re-derived through `ProjectBuildCache.getResolveInputValue()`, the same resolver the stage-level input lookup uses; without a resolver (standalone use) an invocation is selected on its resource reads alone. A scalar step's single implicit invocation is the exception: it re-runs whenever its stage takes the delta path (not a full cache hit), without consulting the per-invocation reads delta. A scalar step is one invocation, so there is nothing finer to prune, and the reads delta cannot catch a file newly matching a glob the step evaluated: the recorder stores resolved paths, not patterns (`#foldStageKeys`), so a file that did not exist on the previous build is in no recorded read, yet the stage-level monitor did record the glob, so an added match moves the stage signature and `prepareStage` returns a delta verdict. Re-running the one invocation on that verdict is what makes `generateThemeDesignerResources`'s `scan` step observe a first theme being added (map steps are already covered: `keys()` re-enumerates and a new key has no previous entry). A full stage-cache hit still keeps the scalar step cached. +- `StepRunner.#computeStaleOutputs` reports paths an invocation produced before but no longer produces (a re-run invocation that writes fewer paths, or a removed key). The dropped-path comparison runs over the invocations that actually executed this build, while the set of paths still claimed is taken from the stage's complete per-key data, so a path a cached invocation owns is never dropped because a sibling key disappeared. It is scoped to the one stage (a stage owns its outputs), and the `recordStage` hook appends the stale paths to the stage's delta `changedProjectResourcePaths` so `recordStageResult`'s stage merge drops them. +- A map step's `keys` enumerator owns no key, so it runs against the stage's own context rather than a per-invocation recording one: it runs whenever the stage runs, so its reads and non-resource inputs are captured by the stage's monitored readers and `MonitoredTaskUtil` and fold into the stage signature, and the tags it sets are recorded as the stage's tag operations, which a fully cached stage restores along with its writer. +- `StepRunner.#foldStageKeys` folds every one of a stage's keys' reads and non-resource inputs — including keys served from cache on a delta build, whose reads and inputs the stage-level monitor never observed — into one request set and one input set, which the `recordStage` hook merges into the stage-level monitored requests before `recordStageResult` re-keys the stage. The recorder stores resolved paths and a path is commonly read by more than one key, so `#foldStageKeys` accumulates into `Set`s (deduped per read bucket) and the `recordStage` hook's `foldReadsInto` adds only the fold paths the stage monitor did not already request; the duplicates would otherwise collapse downstream in `ResourceRequestGraph.findExactMatch` (which keys on a `Set`), but carrying them inflated the recording that graph rebuilds. Deduplication does not move the stage signature — the set of reads is unchanged, only its representation. This is the map step's internal key-delta fold (kept in Phase B); there is no cross-**step** fold — each step's stage records only its own keys. The consumed `needs` returns are deliberately excluded from this fold (tracked in the separate per-key `needsInputs` field, see below): they drive per-invocation selection only and are re-derived from producer reads/inputs that are themselves tracked, so `resolveInputValue` has no resolver for them and folding one into the stage signature would permanently miss the stage cache. Omitting this per-key fold makes a stage whose keys read `taskUtil.getTime` (`replaceCopyright`, `replaceBuildtime`) fail to reconverge to a cached signature across builds, because a delta build that restores every key records no inputs at the stage level. +- An invocation served from cache replays its recorded `set`/`clear` tag operations via `ProjectResources.replayTagOperations`, routed by tag to the monitored project or build tag collection and applied by path, so its tags reappear in this build's tag operations (captured by `recordStageResult` like an invocation that ran). An invocation whose key is gone is not replayed, so a removed invocation's tags do not linger. `get` operations carry no persistent effect and are skipped. +- Per-key invocation data (`Map`) is persisted **inside the step's own `stage_metadata` row** (`metadata.stepInvocationData`, as `[[keyId, entry], ...]` pairs since JSON has no Map), keyed by the stage signature the stage output is keyed under, so the per-key map and the stage output can never pair with a different run's data. It is embedded by `ProjectBuildCache.#prepareStageCache` and extracted by `#processStageCacheMetadata`; a cache lookup (`prepareStageExecutionAndValidateCache`) stashes the matched stage's map into `#stepInvocationData` so `getStepInvocationData(stageId)` returns the signature-matched "previous" data, and a running stage overwrites it via `setStepInvocationData`. `needsInputs` holds the consumed `needs` return signatures for per-invocation selection and is intentionally kept out of the stage input fold. Because a `needs` return is excluded from the stage signature, a cached consumer's correctness rests on two checks rather than the signature: `#needsReturnChanged` on the full-hit path and the `needsInputs` comparison in `#selectStepsToRun` on the delta path. These are the only two paths that reach a cached stage through the `StepRunner`. A whole project restored from the project-level result cache skips the `StepRunner` entirely (and `ProjectBuildCache.#importStages` installs its stages blindly by signature), yet that is safe without a needs check: the result signature aggregates every stage's inputs, producer stages included, so any change that could alter a producer's return perturbs the result signature, misses the result cache, and defers to the `StepRunner` where the two checks run. The map rides on the stage's own `stage_metadata` write, which happens only for a stage recorded this build, so a full cache hit (not re-recorded) never rewrites it; a map step whose key set dropped to zero re-records under a new signature with an embedded `[]`, so the next build that matches it sees no stale keys. +- Return values are first-class: `StepRunner` stores a resource return's content in the CAS via `ProjectBuildCache.getStepReturnValueStore()` (`store` buffers the compressed content deduped against stage rows; `flush`, called by the driver once per step after its invocations have returned, writes that step's buffer in a single transaction) and records per-invocation descriptors in the `returns` field of the invocation entry; a serializable value is recorded inline. On a delta build an invocation served from cache has its return rebuilt from the CAS (or read back from the inline value), so a map step's result array is reassembled in key order from a mix of freshly-returned and restored entries and handed to consumers via `needs`. A returned resource whose path collides with a written output is stored once by integrity and rebuilt independently of that output. + +Removing an input resource yields a per-invocation delta: `ResourceRequestManager.getDeltas` includes removed paths in the delta's `changedPaths`, so an invocation that read the removed input re-runs (or, for a gone key, drops out) and its stale output is dropped via the stale-output merge above, while the other invocations stay cached. Known gap (parked): a processor-library version (e.g. terser, less-openui5) is not yet a tracked per-invocation input, so a task whose output depends on a processor version can serve stale until a follow-up routes that version through the per-invocation `taskUtil` (open-gaps §1). **Result-stage-signature growth (Phase B measurement).** The result-stage signature is a cartesian product over per-**stage** candidate dependency signatures (`ProjectBuildCache.#getPossibleResultStageSignatures`). Promoting each scalar step to its own stage adds a factor per stage, but a stage contributes a factor >1 only when it read dependencies AND carries a dependency delta. In the realistic framework-library task set (`minify` + `buildThemes` + `generateThemeDesignerResources`), the scalar steps (`scan`, `libraryTheming`) read no dependencies, so they add factor-1 stages; the dependency reads stay with the single `themes`/`buildThemes` map stage that owns them. Measured growth under a broad dependency change: **1.00×** (both a real-build probe over the integration corpus and a forced worst-case harness with two dependency-reading stages each carrying a 2-node delta — current product 4, projected product 4). A dependency-reading scalar step WOULD multiply the product; the realistic set avoids it. diff --git a/.claude/skills/incremental-build/performance-investigation.md b/.claude/skills/incremental-build/performance-investigation.md index d1f91f183c2..f2df5daa2f8 100644 --- a/.claude/skills/incremental-build/performance-investigation.md +++ b/.claude/skills/incremental-build/performance-investigation.md @@ -394,7 +394,7 @@ Findings: The one-stage-per-step design was reviewed as the suspected structural cause of the branch building slower than `main`. Measured, it is not. Count stages from the perf log (`importStages ... with N stages`, or the unique `task/...::step/...` ids) and compare `main` to this branch for a `ui5 build` and a `ui5 build --all`: a library builds with the same stage count on both. Every shipped step-based task (`minify`, `buildThemes`, the three `replace*`, `escapeNonAsciiCharacters`, `enhanceManifest`) is single-step, and `generateThemeDesignerResources` (the only multi-step task, three steps) is not in the default library task set, so step count equals task count. A single-step task already is exactly one stage, so collapsing it to `task/{taskName}` removes no stage, leaves `StepRunner`'s per-key machinery in place, and was measured and rejected as a pure stage-id rename. Per-stage `updateProjectIndices` cost is the same on both revisions. -The branch's warm and stale deltas against `main` are real but are not a stage-count effect. On a stale build they are the per-key map-step machinery (`#keyId` content hashing, item 12; the delta fold, item 13; per-unit allocation). On a warm build the project is served from cache and tasks are skipped, so the delta is startup and module loading, not the step pipeline; the plan-time module-loading part is item 18. Precise numbers live in the benchmark results repository alongside the config that produced them, not here, since they drift with the code. +The branch's warm and stale deltas against `main` are real but are not a stage-count effect. On a stale build they are the per-key map-step machinery (`#keyId` content hashing, item 12; the delta fold, item 13; per-invocation allocation). On a warm build the project is served from cache and tasks are skipped, so the delta is startup and module loading, not the step pipeline; the plan-time module-loading part is item 18. Precise numbers live in the benchmark results repository alongside the config that produced them, not here, since they drift with the code. ### 15. The step invocation sidecar is re-serialized only for stages that re-recorded @@ -413,7 +413,7 @@ Largest single row ~94 KB (`replaceVersion`), ~515 KB across the four projects p The fix tracks which stages re-recorded (a dirty `Set` added to by `setStepInvocationData`, the only mutation path; `getStepInvocationData` loads without marking) and emits only those. On a one-file delta build (`Button.js` edited), `sap.m` re-records 3 of its 7 loaded step stages; the other 4 (and every step stage of the three dependencies, all full hits on this delta) are no longer rewritten. `writeCache` for `sap.m` was ~150 ms on this scenario; the sidecar serialization it now skips is a small fraction of that, so the direct wall-clock win is minor in CLI mode where the write is deferred off the critical path. As with the request-graph dirty-flag fix (§13), it matters more in BuildServer mode (Phase 4), where cache writes are awaited and each skipped row shortens the awaited write. -Alongside this, `#storeStepReturns` previously opened one SQLite transaction per returning unit; it now buffers compressed rows and the driver flushes one transaction per step (`StepRunner.#runGroup` calls the store's `flush` after a step's units run). No shipped builder task returns resources from `each`, so this path is latent, but the first task that does would otherwise pay a transaction per key. The return descriptors are also built from the metadata `#prepareStageResources` already computed rather than re-reading each resource's `getIntegrity()`/`getSize()`. +Alongside this, `#storeStepReturns` previously opened one SQLite transaction per returning invocation; it now buffers compressed rows and the driver flushes one transaction per step (`StepRunner.#runGroup` calls the store's `flush` after a step's invocations run). No shipped builder task returns resources from `each`, so this path is latent, but the first task that does would otherwise pay a transaction per key. The return descriptors are also built from the metadata `#prepareStageResources` already computed rather than re-reading each resource's `getIntegrity()`/`getSize()`. ### 16. The stage prefetch never paid off and was removed @@ -444,7 +444,7 @@ Left as-is after analysis: Skipped after measurement (no win in any scenario): - **Write-buffer flush.** `StepRunner.#flushWriteBuffer` drains a map step's buffered writes with a sequential `await` loop. The duplicate-path rejection is enforced at buffer insertion, not flush, so the flush is a pure replay of distinct-path writes to the in-memory stage workspace, and the buffer already holds every write before the flush, so parallelizing does not cut peak memory. Instrumented totals: on a **stale** one-file build no flush batch exceeds 50 writes (sub-millisecond); on a **cold** build the flush totals ~147 ms across the whole ~21 s build (largest single batch 2565 writes in ~40 ms, ~15 µs/write, consistent with memory-adapter writes). Parallelizing would save a fraction of that against a real write-ordering risk, so the sequential loop stays. -- **Per-unit `MonitoredTaskUtil` proxy.** The proxy allocates a wrapper closure per tracked-method access. Counting `get`-trap invocations across a whole build: ~4,973 on a stale one-file build, ~183,261 on a cold build (every unit runs). At tens of nanoseconds per short-lived closure this is a few milliseconds on a ~21 s cold build and negligible on stale, so caching bound methods on the per-unit proxy (against the deliberate per-unit isolation) was not worth it. +- **Per-invocation `MonitoredTaskUtil` proxy.** The proxy allocates a wrapper closure per tracked-method access. Counting `get`-trap invocations across a whole build: ~4,973 on a stale one-file build, ~183,261 on a cold build (every invocation runs). At tens of nanoseconds per short-lived closure this is a few milliseconds on a ~21 s cold build and negligible on stale, so caching bound methods on the per-invocation proxy (against the deliberate per-invocation isolation) was not worth it. **Aggregate before/after (hyperfine, isolated `UI5_DATA_DIR`):** @@ -453,7 +453,7 @@ Skipped after measurement (no win in any scenario): | Stale (one file appended to `Button.js`, warmup 2 / runs 8) | 4.630 s ± 0.067 | 4.723 s ± 0.086 | | Cold (empty cache, warmup 1 / runs 3) | 21.438 s ± 0.165 | 20.598 s ± 0.195 | -Stale is within noise (the ranges overlap; these edits barely execute when few units run). Cold is ~0.8 s faster (~4%), consistent with the O(n squared) accumulation removal being largest where ~12k resources are written. The headline is correctness and hygiene, with a measurable cold-build improvement and no stale regression. +Stale is within noise (the ranges overlap; these edits barely execute when few invocations run). Cold is ~0.8 s faster (~4%), consistent with the O(n squared) accumulation removal being largest where ~12k resources are written. The headline is correctness and hygiene, with a measurable cold-build improvement and no stale regression. ### 18. Step-based task modules must not import their processors at plan time diff --git a/internal/documentation/docs/pages/extensibility/CustomTasks.md b/internal/documentation/docs/pages/extensibility/CustomTasks.md index 63812dde9f8..444a149e25b 100644 --- a/internal/documentation/docs/pages/extensibility/CustomTasks.md +++ b/internal/documentation/docs/pages/extensibility/CustomTasks.md @@ -316,7 +316,7 @@ There are two kinds of steps, run in array order: * **Scalar step** `{name, needs?, run}` — runs once. `run: async ({needs, workspace, dependencies, taskUtil, options}) => value?` * **Map step** `{name, needs?, keys, each}` — runs once per key. `keys: async ({needs, workspace, dependencies, taskUtil, options}) => keySet` enumerates the keys, and `each: async (key, {needs, workspace, dependencies, taskUtil, options}) => value?` processes one key. A key is a resource or a stable string. -Each step gets its own `name` (a non-empty string, unique within the task). A map step is the usual cache-aware shape: the build cache treats every key as its own cached unit, so a delta build re-processes only the keys whose inputs changed. +Each step gets its own `name` (a non-empty string, unique within the task). A map step is the usual cache-aware shape: the build cache treats every key as its own cached invocation, so a delta build re-processes only the keys whose inputs changed. A step may list earlier step names in `needs`; those steps' return values then arrive as `needs.` in the step's callbacks. A step may reference only steps that come before it in the array, so there can be no cycle. A step's return value (resources, or any JSON-serializable value) folds into the cache signature of every step that consumes it, so a changed producer re-runs its consumers. @@ -355,7 +355,7 @@ export const stepBased = true; export default function build(options) { return [{ name: "render", - // One cached unit per Markdown file, so a delta build re-renders only the files that changed. + // One cached invocation per Markdown file, so a delta build re-renders only the files that changed. keys: async ({workspace}) => workspace.byGlob("**/*.md"), each: async (resource, {workspace, taskUtil}) => { // Import the processor lazily so a fully cached build does not load it @@ -391,7 +391,7 @@ const log = getLogger("builder:tasks:renderMarkdownFiles"); module.exports = function build(options) { return [{ name: "render", - // One cached unit per Markdown file, so a delta build re-renders only the files that changed. + // One cached invocation per Markdown file, so a delta build re-renders only the files that changed. keys: async ({workspace}) => workspace.byGlob("**/*.md"), each: async (resource, {workspace, taskUtil}) => { // Import the processor lazily so a fully cached build does not load it diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index e61900e3eca..8c6121865d6 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -57,7 +57,7 @@ function mergeInputRecordings(base, extra) { * Folds a stage's per-key reads (the {@link StepRunner} fold) into the stage-level monitored requests, * deduping against what the base recording already requests. * - * The recorder stores resolved paths and the glob patterns a unit issued, and a path or pattern is commonly + * The recorder stores resolved paths and the glob patterns an invocation issued, and a path or pattern is commonly * read by more than one key (a shared marker probe, a dependency each key resolves, a shared glob) and also * recorded at the stage level, so a plain concatenation carried duplicates that only collapse later in the * request graph (which keys on a Set). Deduping here keeps the recording the request graph rebuilds minimal; diff --git a/packages/project/lib/build/cache/BuildStageCache.js b/packages/project/lib/build/cache/BuildStageCache.js index cd61a89661c..5a76498e4c4 100644 --- a/packages/project/lib/build/cache/BuildStageCache.js +++ b/packages/project/lib/build/cache/BuildStageCache.js @@ -100,7 +100,7 @@ export default class BuildStageCache { // future use case, bundling many files from outside the UI5 dirs (e.g. node_modules) into the // build result, would want per-file delta re-runs like project/dependency; that needs a // changed-root-path signal, list-valued root signatures in the stage delta candidates, and root - // reads threaded into step unit selection, and is left as a separate change. + // reads threaded into step invocation selection, and is left as a separate change. this.#rootRequestManagers = rootRequestManagers ?? { gitignore: new ResourceRequestManager(projectName, `${stageId}#root`, false), noGitignore: new ResourceRequestManager(projectName, `${stageId}#root-no-gitignore`, false), diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index f12b9beb051..635ed6257cc 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -123,7 +123,7 @@ export default class ProjectBuildCache { // setStepInvocationData. #stepInvocationData = new Map(); - // Compressed CAS rows for resources returned by the step runner, buffered per unit and flushed in + // Compressed CAS rows for resources returned by the step runner, buffered per invocation and flushed in // one transaction per step via the return value store's flush() (see #flushStepReturns). #pendingStepReturnCasRows = []; @@ -1065,8 +1065,8 @@ export default class ProjectBuildCache { * Persists the content of resources a step returned and describes them for later * reconstruction. Content goes through the same compression and dedup pipeline as stage resources; * the compressed rows are buffered in {@link #pendingStepReturnCasRows} and written by - * {@link #flushStepReturns}, which the driver calls once per step so a step returning many units - * costs one transaction rather than one per unit. The descriptors are recorded in the step's + * {@link #flushStepReturns}, which the driver calls once per step so a step returning many invocations + * costs one transaction rather than one per invocation. The descriptors are recorded in the step's * invocation data. The CAS write uses INSERT OR IGNORE, so content shared with a stage output is * stored once and a build that later fails before the flush leaves nothing behind. * @@ -1094,8 +1094,8 @@ export default class ProjectBuildCache { /** * Writes the step-return CAS rows buffered since the last flush in a single transaction. The driver - * calls this once per step (after the step's units have stored their returns), so one step costs one - * transaction regardless of how many units returned resources. A no-op when nothing was buffered. + * calls this once per step (after the step's invocations have stored their returns), so one step costs one + * transaction regardless of how many invocations returned resources. A no-op when nothing was buffered. */ #flushStepReturns() { if (!this.#pendingStepReturnCasRows.length) { diff --git a/packages/project/lib/build/helpers/StepRunner.js b/packages/project/lib/build/helpers/StepRunner.js index 72d97c9738c..2f5b377ca0f 100644 --- a/packages/project/lib/build/helpers/StepRunner.js +++ b/packages/project/lib/build/helpers/StepRunner.js @@ -7,7 +7,7 @@ import MonitoredTaskUtil from "./MonitoredTaskUtil.js"; const log = getLogger("build:helpers:StepRunner"); -// Key identity of a scalar step's single implicit unit. A scalar step is a one-key group, so its +// Key identity of a scalar step's single implicit invocation. A scalar step is a one-key group, so its // invocation data has exactly one entry under this fixed id. const SCALAR_KEY_ID = "scalar:0"; @@ -119,7 +119,7 @@ export function validateSteps(steps, {taskName} = {}) { } /** - * Collects the reads and writes of a single step (a scalar step's implicit unit, or one key of a map + * Collects the reads and writes of a single step (a scalar step's implicit invocation, or one key of a map * step). Project reads (workspace) and dependency reads are kept apart so they can be folded back into * the task's project vs. dependency request graph independently: a dependency path folded into the * project graph would resolve against the wrong reader and corrupt the signature. @@ -127,7 +127,7 @@ export function validateSteps(steps, {taskName} = {}) { class StepRecorder { projectReads = new Set(); dependencyReads = new Set(); - // Glob patterns a unit issued, kept so a cached key's glob stays in the stage request set: a stored + // Glob patterns an invocation issued, kept so a cached key's glob stays in the stage request set: a stored // pattern is re-executed against the current reader on lookup, which is how a newly matching file moves // the stage signature. Without it, a map-step key served from cache would drop its globs (its each body // does not re-run), so an added match would not re-run the stage. Project and dependency patterns are @@ -187,8 +187,8 @@ class RecordingReaderWriter extends AbstractReaderWriter { * @param {StepRecorder} recorder Recorder for this step * @param {Map|null} writeBuffer Shared buffer for concurrent writes, or * null to write through immediately (sequential mode) - * @param {number} stepIndex Position of this unit in the key order, used to flush buffered writes - * deterministically and to detect two units writing the same path + * @param {number} stepIndex Position of this invocation in the key order, used to flush buffered writes + * deterministically and to detect two invocations writing the same path */ constructor(workspace, recorder, writeBuffer, stepIndex) { super(workspace.getName()); @@ -248,30 +248,30 @@ class RecordingReaderWriter extends AbstractReaderWriter { * key set and each runs once per key * * - * Write contract: every write a unit makes must go through its own workspace (the recording - * reader/writer passed in the unit's context). A write through any other handle (a stage workspace captured - * in a closure, a writer reached some other way) is not attributed to the unit, so a delta build cannot drop - * that output when the unit stops producing it: {@link #computeStaleOutputs} derives stale outputs from the - * unit's recorded writes, so an unrecorded write leaves a stale file behind. The step API hands - * a unit no writable handle other than its workspace (the factory receives options + * Write contract: every write an invocation makes must go through its own workspace (the recording + * reader/writer passed in the invocation's context). A write through any other handle (a stage workspace captured + * in a closure, a writer reached some other way) is not attributed to the invocation, so a delta build cannot drop + * that output when the invocation stops producing it: {@link #computeStaleOutputs} derives stale outputs from the + * invocation's recorded writes, so an unrecorded write leaves a stale file behind. The step API hands + * an invocation no writable handle other than its workspace (the factory receives options * only, and taskUtil.getProject().getReader() is read-only), so honoring this is the default; a * custom step that reaches a writer another way breaks the stale-output guarantee. * * Steps run in array order via {@link #runSteps}. A scalar step is a one-key group; a map step is a - * multi-key group. Each unit runs against per-step recording readers and a per-step + * multi-key group. Each invocation runs against per-step recording readers and a per-step * [MonitoredTaskUtil]{@link @ui5/project/build/helpers/MonitoredTaskUtil} that record what it reads, * writes, returns, reads as a non-resource input, and tags. The recording lets a delta build re-run only - * the units whose observed inputs changed and drop the outputs of units that no longer produce them, - * without any delta bookkeeping in the task itself. A unit whose recorded non-resource input (an env var, - * a dependency version) no longer resolves to its stored value re-runs; a unit whose consumed - * needs return changed re-runs; a unit served from cache replays its recorded tag operations + * the invocations whose observed inputs changed and drop the outputs of invocations that no longer produce them, + * without any delta bookkeeping in the task itself. An invocation whose recorded non-resource input (an env var, + * a dependency version) no longer resolves to its stored value re-runs; an invocation whose consumed + * needs return changed re-runs; an invocation served from cache replays its recorded tag operations * so its tags reappear this build. * * A step lists earlier step names in needs, and those steps' returns arrive as * needs.<name>. A step may return resources (stored in the CAS by integrity, rebuilt on a - * cache hit) or a JSON-serializable value (persisted inline with the unit's invocation data). Either is + * cache hit) or a JSON-serializable value (persisted inline with the invocation's invocation data). Either is * injected into a consumer via needs, and the return's signature folds into the consumer's - * per-unit selection so a changed producer return re-runs the consumer. + * per-invocation selection so a changed producer return re-runs the consumer. * * A key is identified by content and identity: a resource key by its path and a content discriminator (the * path distinguishes resources that share content but produce different output, the discriminator makes a @@ -314,8 +314,8 @@ export default class StepRunner { * @param {object} [parameters.options] Task options, passed through to each step's context * @param {function(string): Promise<(object|boolean)>} [parameters.prepareStage] Switches the project to * the named step's stage and returns its cache verdict: true (fully cached, do not run), - * an object (delta cacheInfo for the map step's internal key-delta), or a falsy value (run every unit). - * Absent for standalone use (no cache): every unit runs. This verdict is read three ways in + * an object (delta cacheInfo for the map step's internal key-delta), or a falsy value (run every invocation). + * Absent for standalone use (no cache): every invocation runs. This verdict is read three ways in * {@link #runSteps}: compared against true for the full-hit short-circuit, passed to * markStageExecuting as a "was this a delta" flag, and treated as "run everything if * falsy" by {@link #selectStepsToRun}. An explicit verdict shape would read more clearly, but it is @@ -332,7 +332,7 @@ export default class StepRunner { * @param {function(): {workspace, dependencies, taskUtil, monitoredTaskUtil, getResourceRequests, * getInputRecording}} parameters.createStageContext Returns a fresh per-stage context bound to the * stage the last prepareStage switched to: the monitored workspace/dependencies readers, - * the pass-through taskUtil the step units wrap per key, and drains for the monitored + * the pass-through taskUtil the step invocations wrap per key, and drains for the monitored * task-level requests and inputs. Called once per step that runs. * @param {function(string): (Map|undefined)} [parameters.getPreviousInvocationData] * Returns the named step's stage's previous per-key invocation data, or undefined on a first build. @@ -340,21 +340,21 @@ export default class StepRunner { * store(resources) (persist content, return path-aligned descriptors) and * restore(descriptor) (rebuild a resource from a descriptor). Absent for standalone * use without a build cache: resource returns are then handed back for the current build but not - * persisted, so a later delta build cannot restore a unit that is served from cache. Serializable + * persisted, so a later delta build cannot restore an invocation that is served from cache. Serializable * returns persist inline and need no store. * @param {function(string, string): (string|undefined)} [parameters.resolveInputValue] Re-derives the * current normalized value of a recorded non-resource input (env var, time bucket, dependency - * version, ...), the same resolver the task-level input lookup uses. A cached unit whose recorded + * version, ...), the same resolver the task-level input lookup uses. A cached invocation whose recorded * input no longer resolves to its stored value is re-run. Absent for standalone use, where no input - * can be re-resolved and a unit is selected on its resource reads alone. - * @param {function(Array): void} [parameters.applyTagOperations] Replays a restored unit's - * recorded tag operations into the project tag collection, so a unit served from cache contributes + * can be re-resolved and an invocation is selected on its resource reads alone. + * @param {function(Array): void} [parameters.applyTagOperations] Replays a restored invocation's + * recorded tag operations into the project tag collection, so an invocation served from cache contributes * the same tags it would have set had it run. Absent for standalone use, where tags are not persisted. * @param {function(boolean): void} [parameters.notifyStepExecution] Called once, before the first step * that actually executes runs anything, with whether that step's stage carries a delta verdict. The * TaskRunner reports the task started from it, so task-start precedes the work it * announces. Not called when every step is served from cache (the task is reported skipped instead). - * @param {AbortSignal} [parameters.signal] Build abort signal, checked between units + * @param {AbortSignal} [parameters.signal] Build abort signal, checked between invocations */ constructor({ steps, options, prepareStage, recordStage, createStageContext, getPreviousInvocationData, @@ -375,7 +375,7 @@ export default class StepRunner { } /** - * Runs the step list in array order, caching each unit's result. + * Runs the step list in array order, caching each invocation's result. * * A scalar step runs its run once; a map step enumerates keys via keys then * runs each per key. Before a step runs, a needs object is assembled from the @@ -425,7 +425,7 @@ export default class StepRunner { this.#signal?.throwIfAborted(); // Switch the project to this step's own stage and get its cache verdict. Standalone use (no - // prepareStage) always runs every unit. + // prepareStage) always runs every invocation. let cacheInfo = this.#prepareStage ? await this.#prepareStage(step.name) : false; const previous = this.#getPreviousInvocationData ? this.#getPreviousInvocationData(step.name) : undefined; @@ -459,9 +459,9 @@ export default class StepRunner { `step '${step.name}': re-running despite a full stage-cache hit ` + `(a consumed needs return changed, or the stage's invocation data cannot be restored)`); // Reopen the stage (fresh writer) and re-run it as a full execution: the hook returns a - // falsy verdict, so #selectStepsToRun runs every unit rather than pruning. Reopening installs + // falsy verdict, so #selectStepsToRun runs every invocation rather than pruning. Reopening installs // a fresh EMPTY writable stage (unlike a delta verdict, whose stage was pre-seeded with the - // cached outputs at import), so the units cannot be pruned without first re-seeding that + // cached outputs at import), so the invocations cannot be pruned without first re-seeding that // stage; re-running the whole stage keeps the output complete without that machinery. No // shipped multi-step task is penalized: generateThemeDesignerResources is the only task // wiring needs, and a changed scan return means its themes must regenerate anyway. The cost @@ -471,7 +471,7 @@ export default class StepRunner { } // A step past the fully-cached short-circuit executes its stage (fresh recording), even if it - // enumerates zero units this build (an empty map step). This is "the task ran" for reporting, + // enumerates zero invocations this build (an empty map step). This is "the task ran" for reporting, // distinct from a stage served entirely from cache. markStageExecuting(cacheInfo); @@ -483,14 +483,14 @@ export default class StepRunner { let callback; let options; if (isScalar) { - // A scalar step is a single implicit unit; writes persist immediately (sequential) so the + // A scalar step is a single implicit invocation; writes persist immediately (sequential) so the // step reads back its own writes and later steps see them. entries = [{key: undefined, index: 0, keyId: SCALAR_KEY_ID}]; - callback = (key, unitCtx) => step.run(unitCtx); + callback = (key, invocationCtx) => step.run(invocationCtx); options = {sequential: true}; } else { entries = await this.#enumerateKeys(step, needs, ctx); - callback = (key, unitCtx) => step.each(key, unitCtx); + callback = (key, invocationCtx) => step.each(key, invocationCtx); options = step.sequential ? {sequential: true} : undefined; } @@ -531,7 +531,7 @@ export default class StepRunner { * - previous === undefined: no sidecar at all. A scalar producer would restore an * undefined return that crashes a consumer dereferencing it through needs. * - A scalar step whose previous.size !== 1: a scalar step records exactly its one implicit - * unit, so any other count means the sidecar does not match. + * invocation, so any other count means the sidecar does not match. * - A needs-declaring step whose previous.size === 0: {@link #needsReturnChanged} * has no recorded needsInputs to compare against, so it cannot observe that a consumed * producer return changed (the zero-key map step gated behind a needs flag). Re-running @@ -611,26 +611,26 @@ export default class StepRunner { } /** - * Output paths a stage produced on a previous build but no longer produces (a re-run unit that writes + * Output paths a stage produced on a previous build but no longer produces (a re-run invocation that writes * fewer paths, or a key gone this build), so they can be dropped from the carried-forward stage. Scoped * to this one stage: a stage owns its outputs, so a path it stops producing is stale for it. * * A path is only dropped when nothing in the stage claims it this build, so the writes of every key * present this build are subtracted, including those of a key served from cache (which still owns the - * paths it wrote on an earlier build). The re-run comparison, in contrast, runs over the units that - * actually executed: a cached unit's entry is carried over from previous unchanged, so + * paths it wrote on an earlier build). The re-run comparison, in contrast, runs over the invocations that + * actually executed: a cached invocation's entry is carried over from previous unchanged, so * comparing it against itself could never report a dropped path anyway. * - * This relies on each unit's recorded writes being a complete record of what it produced, - * which holds only while units honor the write contract (see the class description): a write that went - * through a handle other than the unit's workspace is absent from writes, so + * This relies on each invocation's recorded writes being a complete record of what it produced, + * which holds only while invocations honor the write contract (see the class description): a write that went + * through a handle other than the invocation's workspace is absent from writes, so * this derivation cannot drop it and the stale output survives. * * @param {Map|undefined} previous The stage's previous per-key invocation data * @param {Map} invocationData The stage's complete per-key invocation data this build - * (re-run units merged over the carried-over cached ones) + * (re-run invocations merged over the carried-over cached ones) * @param {Array<{keyId: string}>} entries The stage's key entries this build - * @param {Map} freshInvocationData The per-key data of the units that ran this build + * @param {Map} freshInvocationData The per-key data of the invocations that ran this build * @returns {string[]} Paths to drop */ #computeStaleOutputs(previous, invocationData, entries, freshInvocationData) { @@ -651,7 +651,7 @@ export default class StepRunner { } const reRun = freshInvocationData.get(keyId); if (reRun) { - // Re-run unit: any path it owned but did not re-write is stale. + // Re-run invocation: any path it owned but did not re-write is stale. prev.writes.forEach((path) => { if (!reRun.writes.includes(path)) { stale.add(path); @@ -688,7 +688,7 @@ export default class StepRunner { * where these two checks run. The gap the needs checks close is specific to a single consumer stage whose * own signature omits the producer; aggregation closes it at the project level. * - * The recorder stores resolved paths and the glob patterns a unit issued, and the same path or pattern is + * The recorder stores resolved paths and the glob patterns an invocation issued, and the same path or pattern is * commonly read by more than one key (a shared marker probe, a dependency a map step's keys each resolve, * a shared glob), so the raw concatenation held one entry per read. The duplicates collapse downstream (the * request graph keys on a Set), but carrying them inflates the recording the TaskRunner folds onto the @@ -739,10 +739,10 @@ export default class StepRunner { * Assembles the needs object passed to a step from the recorded returns of the steps it * names. * - * One object is shared by the step's keys enumerator and all of its units, so it is - * frozen: a unit assigning to needs.<producer> would otherwise leak into its - * siblings and into the recorded needsInputs of whichever unit ran next, making a delta - * build's per-unit selection depend on execution order. The freeze is shallow, since a producer may + * One object is shared by the step's keys enumerator and all of its invocations, so it is + * frozen: an invocation assigning to needs.<producer> would otherwise leak into its + * siblings and into the recorded needsInputs of whichever invocation ran next, making a delta + * build's per-invocation selection depend on execution order. The freeze is shallow, since a producer may * return resources whose own state must stay writable; a step that needs a mutable copy makes one. * * @param {string[]} [names] Names of earlier steps this step needs @@ -759,7 +759,7 @@ export default class StepRunner { } /** - * The current return signatures of the steps a step needs, for per-unit delta selection. + * The current return signatures of the steps a step needs, for per-invocation delta selection. * * @param {string[]} [names] Names of earlier steps this step needs * @returns {Map} Producer step name to its current return signature @@ -803,9 +803,9 @@ export default class StepRunner { } /** - * The combined return signature of a step, used by a consumer's per-unit selection to detect a changed - * producer return. A scalar step's signature derives from its single unit's return; a map step's from the - * order-independent set of its units' returns. + * The combined return signature of a step, used by a consumer's per-invocation selection to detect a changed + * producer return. A scalar step's signature derives from its single invocation's return; a map step's from the + * order-independent set of its invocations' returns. * * The map case hashes a keyId -> signature map sorted by keyId, not a * positional array in entries order. entries order is keys() @@ -814,9 +814,9 @@ export default class StepRunner { * would otherwise move the signature and re-run every consumer for nothing. * * Both cases return a fixed-size SHA-256 hex digest rather than the raw serialization, so the value is - * consistent with every other signature in the cache system and the per-unit needsInputs + * consistent with every other signature in the cache system and the per-invocation needsInputs * each consumer records stays O(1) in size. The raw map serialization would be O(producer key count), - * copied into every consumer unit, so a map producer feeding a map consumer would persist an + * copied into every consumer invocation, so a map producer feeding a map consumer would persist an * O(producers x consumers) sidecar. * * @param {Map} invocationData The step's per-key invocation data this build @@ -826,7 +826,7 @@ export default class StepRunner { */ #computeStepReturnSignature(invocationData, entries, isScalar) { if (isScalar) { - // A scalar step is a single implicit unit, so there is no key order to normalize. + // A scalar step is a single implicit invocation, so there is no key order to normalize. const first = entries[0]; const raw = first ? this.#returnDescriptorSignature(invocationData?.get(first.keyId)?.returns) : "none"; @@ -876,7 +876,7 @@ export default class StepRunner { if (key && typeof key.getIntegrity === "function") { // The path distinguishes resources that share content but produce different output (e.g. two // libraries' identical library.source.less); the trailing component makes a content change a - // new key, so the unit re-runs and its previous output is dropped rather than served stale. + // new key, so the invocation re-runs and its previous output is dropped rather than served stale. // // That trailing component is tiered like isResourceUnchanged (utils.js), cheapest first, to // avoid hashing every key's content on every build (a stale-cache sap.m build spent ~420 ms @@ -888,7 +888,7 @@ export default class StepRunner { // // Residual staleness risk, identical to isResourceUnchanged's and accepted for the same // reason: a content change that preserves BOTH lastModified and size keeps the same key, so - // the unit is not re-run. A real edit moves mtime; the gap is for mtime-preserving replacements + // the invocation is not re-run. A real edit moves mtime; the gap is for mtime-preserving replacements // (cp -p, tar -x, atomic rename) that also hold size constant. The changed-path delta does not // cover this case either, since it is derived through the same tiered comparison. const lastModified = key.getLastModified?.(); @@ -906,14 +906,14 @@ export default class StepRunner { } /** - * Validates a unit's return value and, for a resource return, persists its content in the CAS. A + * Validates an invocation's return value and, for a resource return, persists its content in the CAS. A * serializable value is persisted inline in the invocation entry; a resource return is stored in the * CAS by integrity; anything else throws. * - * @param {*} returnValue The value the unit returned + * @param {*} returnValue The value the invocation returned * @returns {Promise} The persisted return descriptor: * {kind: "resources", isArray, items: [{path, integrity, ...}]}, - * {kind: "value", value}, or null when the unit returned nothing (or a + * {kind: "value", value}, or null when the invocation returned nothing (or a * resource return has no store to persist against) */ async #recordReturn(returnValue) { @@ -935,9 +935,9 @@ export default class StepRunner { } /** - * Rebuilds a cached unit's return from the descriptor recorded on its previous run. + * Rebuilds a cached invocation's return from the descriptor recorded on its previous run. * - * @param {object|null} [returns] The recorded return descriptor, or falsy when the unit returned nothing + * @param {object|null} [returns] The recorded return descriptor, or falsy when the invocation returned nothing * @returns {*} The value, the single resource, the array of resources, or undefined */ #restoreReturn(returns) { @@ -959,10 +959,10 @@ export default class StepRunner { * Classifies a return value as nothing, resource(s), or a serializable value, throwing on anything * else (a returned array mixing resources and non-resources, or a value that is not JSON-serializable). * - * @param {*} value The value the unit returned + * @param {*} value The value the invocation returned * @returns {{kind: string, isArray: boolean|undefined, resources: object[]|undefined, * value: *}|null} The classified - * return, or null when the unit returned nothing + * return, or null when the invocation returned nothing */ #normalizeReturn(value) { if (value === undefined || value === null) { @@ -995,8 +995,8 @@ export default class StepRunner { #selectStepsToRun(entries, previous, needsSignatures, cacheInfo, isScalar) { if (!cacheInfo || !previous || isScalar) { - // Full build, or a step with no previous data: run every unit. A scalar step always re-runs - // on a delta verdict too: it is a single implicit unit, so the per-unit reads delta cannot + // Full build, or a step with no previous data: run every invocation. A scalar step always re-runs + // on a delta verdict too: it is a single implicit invocation, so the per-invocation reads delta cannot // prune it, and that delta cannot catch a file newly matching a glob the step evaluated. The // recorder stores resolved paths, not patterns, so a file that did not exist on the previous // build appears in no recorded read; the owning stage signature does change (the stage-level @@ -1013,22 +1013,22 @@ export default class StepRunner { // New key (a new string key, or a resource whose changed content yields a new integrity). return true; } - // A unit whose recorded reads intersect the changed paths must re-run: this is the reverse + // An invocation whose recorded reads intersect the changed paths must re-run: this is the reverse // mapping that re-runs the owner of a changed cross-resource input (a .js whose .js.map // changed, a theme whose gating marker was added or removed). if (prev.reads.some((path) => changedProject.has(path)) || prev.dependencyReads.some((path) => changedDependency.has(path))) { return true; } - // A unit whose recorded non-resource input no longer resolves to its stored value must re-run, - // so only the unit that read a changed env var, rolled-over time bucket or bumped dependency + // An invocation whose recorded non-resource input no longer resolves to its stored value must re-run, + // so only the invocation that read a changed env var, rolled-over time bucket or bumped dependency // version re-runs. Without a resolver (standalone use) an input cannot be re-derived, so the - // unit is selected on its resource reads alone. + // invocation is selected on its resource reads alone. if (this.#resolveInputValue && prev.inputs?.some( (input) => this.#resolveInputValue(input.type, input.name) !== input.value)) { return true; } - // A unit whose consumed needs return changed must re-run: the producer ran (or was restored) + // An invocation whose consumed needs return changed must re-run: the producer ran (or was restored) // earlier this build, so its current return signature is known. A producer that re-ran with a // changed return advances its signature; a restored producer keeps its previous one. if (needsSignatures && prev.needsInputs?.some( @@ -1040,12 +1040,12 @@ export default class StepRunner { } /** - * Replays a restored unit's recorded tag operations into the project tag collection, so a unit served + * Replays a restored invocation's recorded tag operations into the project tag collection, so an invocation served * from cache contributes the same tags it would have set had it run. get operations carry * no persistent effect and are skipped by the applier. A no-op without an applier (standalone use) or - * when the unit recorded no tag operations. + * when the invocation recorded no tag operations. * - * @param {Array} [tagOperations] The unit's recorded tag operations + * @param {Array} [tagOperations] The invocation's recorded tag operations */ #replayTagOperations(tagOperations) { if (this.#applyTagOperations && tagOperations?.length) { @@ -1057,7 +1057,7 @@ export default class StepRunner { if (!cacheInfo || !previous) { return current; } - // A delta build re-runs only some units, so the persisted map must stay the complete set: keep a + // A delta build re-runs only some invocations, so the persisted map must stay the complete set: keep a // previous entry whose key is still present but was not re-run, drop keys no longer present, and // let a re-run entry supersede its predecessor. const currentKeyIds = new Set(entries.map((entry) => entry.keyId)); @@ -1074,26 +1074,26 @@ export default class StepRunner { } /** - * Runs one step's stage (a scalar step's implicit unit, or a map step's keys): selects the units to - * run, restores the rest from cache, records each unit's reads/writes/inputs/tags/return, and merges + * Runs one step's stage (a scalar step's implicit invocation, or a map step's keys): selects the invocations to + * run, restores the rest from cache, records each invocation's reads/writes/inputs/tags/return, and merges * the result into the stage's persisted invocation data. * * @param {string} group Step name * @param {Array<{key: *, index: number, keyId: string}>} entries Resolved key entries * @param {object} [options] Optional settings ({sequential}) - * @param {Function} callback async (key, unitCtx) => value? + * @param {Function} callback async (key, invocationCtx) => value? * @param {object} context - * @param {object} [context.needs] The step's needs object, injected into each unit's context + * @param {object} [context.needs] The step's needs object, injected into each invocation's context * @param {Map} [context.needsSignatures] Current producer return signatures, recorded - * with each unit for the next build's selection + * with each invocation for the next build's selection * @param {object|boolean} [context.cacheInfo] The stage's delta cache verdict (map step internal key-delta) * @param {Map} [context.previous] The stage's previous per-key invocation data * @param {object} context.ctx The per-stage context ({workspace, dependencies, taskUtil}) - * @param {boolean} [context.isScalar] Whether this stage is a scalar step (a single implicit unit), + * @param {boolean} [context.isScalar] Whether this stage is a scalar step (a single implicit invocation), * which is selected to run on any delta verdict (see {@link #selectStepsToRun}) * @returns {Promise<{results: Array, invocationData: Map, * freshInvocationData: Map}>} Per-key results aligned to entries order, - * the stage's complete per-key invocation data, and the subset of it recorded by the units that + * the stage's complete per-key invocation data, and the subset of it recorded by the invocations that * ran this build */ async #runGroup(group, entries, options, callback, @@ -1109,9 +1109,9 @@ export default class StepRunner { const recordedNeeds = needsSignatures ? [...needsSignatures].map(([name, value]) => ({name, value})) : []; - // A unit served from cache did not run, so its return is rebuilt from the previous run's recorded + // An invocation served from cache did not run, so its return is rebuilt from the previous run's recorded // descriptor, and its recorded tag operations are replayed so its tags reappear this build. Its - // slots are disjoint from the re-run units below, so this can happen before or after they run. + // slots are disjoint from the re-run invocations below, so this can happen before or after they run. for (const {keyId, index} of entries) { if (toRunIndices.has(index)) { continue; @@ -1127,8 +1127,8 @@ export default class StepRunner { const workspace = new RecordingReaderWriter(ctx.workspace, recorder, writeBuffer, index); const dependencies = ctx.dependencies ? new RecordingReader(ctx.dependencies, recorder) : undefined; - // A per-unit MonitoredTaskUtil wrapping the stage's taskUtil: it attributes the unit's - // non-resource inputs and tag operations to the unit for per-unit selection and restore, while + // A per-invocation MonitoredTaskUtil wrapping the stage's taskUtil: it attributes the invocation's + // non-resource inputs and tag operations to the invocation for per-invocation selection and restore, while // reads still delegate through the stage's monitored readers. const taskUtil = new MonitoredTaskUtil(ctx.taskUtil, {recordTagOperations: true}); @@ -1156,15 +1156,15 @@ export default class StepRunner { } } - // The return value store buffers each unit's returned content; flush this step's buffer in one - // transaction now that every unit has recorded its return. A no-op for a step that returned + // The return value store buffers each invocation's returned content; flush this step's buffer in one + // transaction now that every invocation has recorded its return. A no-op for a step that returned // nothing, and for standalone use with no store. this.#returnValueStore?.flush?.(); const invocationData = this.#mergeInvocationData(currentInvocationData, entries, previous, cacheInfo); if (log.isLevelEnabled("verbose")) { - log.verbose(`step '${group}': ran ${toRun.length} of ${entries.length} unit(s)`); + log.verbose(`step '${group}': ran ${toRun.length} of ${entries.length} invocation(s)`); } return {results, invocationData, freshInvocationData: currentInvocationData}; } diff --git a/packages/project/lib/resources/ProjectResources.js b/packages/project/lib/resources/ProjectResources.js index 4f63ae1a4f5..292c1906305 100644 --- a/packages/project/lib/resources/ProjectResources.js +++ b/packages/project/lib/resources/ProjectResources.js @@ -394,7 +394,7 @@ class ProjectResources { * * Used when a step-based task's stage was a full cache hit that must be re-run after all (a consumed * producer needs return changed): the fast-path restore had swapped in a read-only cached - * stage via {@link #setStage}, so the stage has no writer, and re-running its units needs a writable + * stage via {@link #setStage}, so the stage has no writer, and re-running its invocations needs a writable * one. The reopened stage reads all previous stages exactly as {@link #useStage} does; the re-run's * output goes into the fresh writer. * diff --git a/packages/project/test/fixtures/application.a/task.step-based.js b/packages/project/test/fixtures/application.a/task.step-based.js index 1021fb396e3..8881955e72b 100644 --- a/packages/project/test/fixtures/application.a/task.step-based.js +++ b/packages/project/test/fixtures/application.a/task.step-based.js @@ -1,17 +1,17 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:stepBasedTask"); -// Custom step-based task: one cached unit per `.src` resource under /procEach, expressed as a single map -// step. The step's keys enumerator lists the `.src` resources; each unit reads its sibling `.dep` file as +// Custom step-based task: one cached invocation per `.src` resource under /procEach, expressed as a single map +// step. The step's keys enumerator lists the `.src` resources; each invocation reads its sibling `.dep` file as // a cross-resource input through the step workspace and writes a combined `.out`. Because the `.dep` read -// goes through the step workspace, it is recorded as that unit's input, so on a delta build changing only -// a `.dep` re-runs its owning unit and leaves the others served from cache. This is the same reverse +// goes through the step workspace, it is recorded as that invocation's input, so on a delta build changing only +// a `.dep` re-runs its owning invocation and leaves the others served from cache. This is the same reverse // mapping the built-in minify relies on for its `.js.map` -> `.js` relation, expressed by a custom task. module.exports = function build() { return [{ name: "procEach", keys: async ({workspace, taskUtil, options: {projectNamespace}}) => { - // Tag from the enumerator, not from a unit: the enumerator runs whenever the stage runs, but + // Tag from the enumerator, not from an invocation: the enumerator runs whenever the stage runs, but // it owns no key, so its tags have no per-key invocation entry to be replayed from. They // survive a fully cached stage through the stage's own recorded tag operations. const omittedResources = await workspace.byGlob(`/resources/${projectNamespace}/procEach/*.omitme`); @@ -25,7 +25,7 @@ module.exports = function build() { each: async (srcResource, {workspace, taskUtil}) => { const srcPath = srcResource.getPath(); const depPath = srcPath.replace(/\.src$/, ".dep"); - // Read the cross-resource input through the step workspace so it is tracked as this unit's input. + // Read the cross-resource input through the step workspace so it is tracked as this invocation's input. const depResource = await workspace.byPath(depPath); const depContent = depResource ? await depResource.getString() : ""; const srcContent = await srcResource.getString(); diff --git a/packages/project/test/lib/build/ProjectBuilder.caching.integration.js b/packages/project/test/lib/build/ProjectBuilder.caching.integration.js index f768f50631e..45415ccf58e 100644 --- a/packages/project/test/lib/build/ProjectBuilder.caching.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.caching.integration.js @@ -22,9 +22,9 @@ function themeOutputs(namespace) { // namespaces (`lib/one`, `lib/two`), each gated by its own `library.js` marker. Adding/removing a // marker changes which single theme should be (re)built, and the others must stay served from cache. // -// buildThemes builds each theme as a map-step unit (CPOUI5FOUNDATION-1363), so adding a marker now +// buildThemes builds each theme as a map-step invocation (CPOUI5FOUNDATION-1363), so adding a marker now // rebuilds only the newly enabled theme, and removing a marker rebuilds nothing: the removed input -// yields a delta (ResourceRequestManager.getDeltas includes removed paths), the owning unit drops out, +// yields a delta (ResourceRequestManager.getDeltas includes removed paths), the owning invocation drops out, // and its stale output is dropped from the carried-forward stage while the other theme stays cached. test.serial( @@ -1063,7 +1063,7 @@ test.serial("Build application.a (custom task reads a root config file, tracked // generateThemeDesignerResources opens with a scalar "scan" step whose body globs // `library.source.less` to decide whether the library has any themes, and writes that verdict into the // library `.theming` as the bIgnore flag (bIgnore true == no themes, so the SAP Theme Designer skips the -// library). A scalar step is a single implicit unit, so the per-unit reads delta cannot prune or select +// library). A scalar step is a single implicit invocation, so the per-invocation reads delta cannot prune or select // it, and that delta cannot see a file that newly matches the glob: the recorder stores resolved paths, // not patterns, so a file absent on the previous build appears in no recorded read. Re-running the scalar // step on any delta verdict (the owning stage signature does change, because the stage-level monitor diff --git a/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js b/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js index 6fcdb5a8b36..cd8ec1831d9 100644 --- a/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js @@ -528,7 +528,7 @@ builder: }); }); -// The `.out` a unit of the step-based custom task fixture writes per `.src` key (see task.step-based.js). +// The `.out` an invocation of the step-based custom task fixture writes per `.src` key (see task.step-based.js). // procEachOut is the virtual path recorded in writtenResources; procEachDist is the on-disk location, // where an application build has dropped the `/resources/id1/` namespace prefix. const procEachOut = (name) => `/resources/id1/procEach/${name}.out`; @@ -540,9 +540,9 @@ test.serial("Build application.a (step-based custom task with per-step delta cac await fixtureTester._initialize(); // The custom task "step-based-task" (Specification Version 5.0, a step-based factory) runs one - // cached map-step unit per `.src` file. Each unit reads its sibling `.dep` through the step workspace, - // so that `.dep` is a tracked input of the owning unit alone. Changing only `a.dep` must re-run only - // a's unit and leave b's unit served from cache, proving per-step delta caching for a custom task. + // cached map-step invocation per `.src` file. Each invocation reads its sibling `.dep` through the step workspace, + // so that `.dep` is a tracked input of the owning invocation alone. Changing only `a.dep` must re-run only + // a's invocation and leave b's invocation served from cache, proving per-step delta caching for a custom task. const procEachDir = `${fixtureTester.fixturePath}/webapp/procEach`; await fs.mkdir(procEachDir, {recursive: true}); await fs.writeFile(`${procEachDir}/a.src`, "source-a"); @@ -620,7 +620,7 @@ test.serial("Build application.a (step-based custom task with per-step delta cac // A map step's keys() enumerator owns no key of its own, so anything it does is attributed to the stage -// rather than to a unit. For resource tags that matters across a fully cached stage: the enumerator does +// rather than to an invocation. For resource tags that matters across a fully cached stage: the enumerator does // not run at all, and the tag has to come back from the stage's recorded tag operations. test.serial("Build application.a (step-based custom task: a tag set in keys() survives a cached stage)", async (t) => { diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index 14c38a4a179..1b364a71f3b 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -2396,7 +2396,7 @@ function createStepReportingFixture(t, {stepBased, fullyCached = false}) { getStageId: (taskName, stepName) => stepName === undefined ? `task/${taskName}` : `task/${taskName}::step/${stepName}`, prepareStageExecutionAndValidateCache: sinon.stub().resolves(fullyCached), - // A fully cached scalar stage carries its one-entry sidecar ("scalar:0" is the implicit unit's key + // A fully cached scalar stage carries its one-entry sidecar ("scalar:0" is the implicit invocation's key // id). Without it, a full hit is treated as unrestorable and re-runs (see #canRestoreCachedStage). getStepInvocationData: sinon.stub().returns( fullyCached ? new Map([["scalar:0", {returns: null, tagOperations: []}]]) : undefined), diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index bf8cae4aa7d..e4b9c0f85a1 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -1444,7 +1444,7 @@ test("writeCache does not rewrite a stage that was not re-recorded", async (t) = t.is(writesAfterSecond, 1, "an unchanged stage row is not rewritten on a later build"); }); -test("step return storage buffers per unit and flushes one transaction per step", async (t) => { +test("step return storage buffers per invocation and flushes one transaction per step", async (t) => { const project = createMockProject(); const cacheManager = createMockCacheManager(); const cache = new ProjectBuildCache(project, "sig", cacheManager); @@ -1452,7 +1452,7 @@ test("step return storage buffers per unit and flushes one transaction per step" const store = cache.getStepReturnValueStore(); - // The driver calls store() once per returning unit. Three units each return one resource. + // The driver calls store() once per returning invocation. Three invocations each return one resource. const resA = createMockResource("/out/a.js", "int-a", 10, 12, 1); const resB = createMockResource("/out/b.js", "int-b", 20, 12, 2); const resC = createMockResource("/out/c.js", "int-c", 30, 12, 3); @@ -1461,7 +1461,7 @@ test("step return storage buffers per unit and flushes one transaction per step" const dA = await store.store([resA]); const dB = await store.store([resB]); const dC = await store.store([resC]); - t.is(cacheManager.transaction.callCount, txBefore, "storing a unit's return opens no transaction"); + t.is(cacheManager.transaction.callCount, txBefore, "storing an invocation's return opens no transaction"); // Descriptors are built from the metadata #prepareStageResources already computed, identical to // re-reading each resource's integrity/size/lastModified/inode. @@ -1471,8 +1471,8 @@ test("step return storage buffers per unit and flushes one transaction per step" store.flush(); t.is(cacheManager.transaction.callCount, txBefore + 1, - "flush writes every buffered unit's content in exactly one transaction"); - t.is(cacheManager.putCompressedContent.callCount, 3, "each unit's content written once"); + "flush writes every buffered invocation's content in exactly one transaction"); + t.is(cacheManager.putCompressedContent.callCount, 3, "each invocation's content written once"); store.flush(); t.is(cacheManager.transaction.callCount, txBefore + 1, "an empty flush opens no transaction"); @@ -2761,8 +2761,8 @@ test("validateCache: dependency-set change is general, not specific to the build }); // Integration: CAS-backed step return values through a real SQLite CacheManager. Proves a returned -// resource is correct after a full build (stored), an unchanged rebuild (every unit restored from CAS), -// and a delta build (the changed unit re-runs, the others restore from CAS). The map step's reassembled +// resource is correct after a full build (stored), an unchanged rebuild (every invocation restored from CAS), +// and a delta build (the changed invocation re-runs, the others restore from CAS). The map step's reassembled // returns are observed through a needs consumer, since runSteps() itself returns nothing. function createIntegrationWorkspace(sources = []) { const store = new Map(sources.map((res) => [res.getPath(), res])); @@ -2785,8 +2785,8 @@ test.serial("Integration: step return values round-trip through the real CAS", a const buildCache = new ProjectBuildCache(project, "build-sig", cacheManager, Cache.Default); const returnValueStore = buildCache.getStepReturnValueStore(); - // A producer map step over keys "a" and "b": each unit reads its per-key input (so a change to that - // input re-runs the owning unit) and returns a freshly-built resource. A consumer scalar step captures + // A producer map step over keys "a" and "b": each invocation reads its per-key input (so a change to that + // input re-runs the owning invocation) and returns a freshly-built resource. A consumer scalar step captures // the producer's reassembled returns via needs, the observable route now that runSteps returns nothing. let captured; const buildSteps = (marker, ran) => [ @@ -2827,7 +2827,7 @@ test.serial("Integration: step return values round-trip through the real CAS", a }).runSteps(); } - // Build 1: full build. Every unit runs; returns are stored in the CAS. + // Build 1: full build. Every invocation runs; returns are stored in the CAS. let producerData; await runBuild({marker: "content", captureInvocation: (step, data) => { if (step === "g") { @@ -2837,7 +2837,7 @@ test.serial("Integration: step return values round-trip through the real CAS", a t.is(await captured[0].getString(), "content-a", "Build 1 hands back the fresh resource for a"); t.is(await captured[1].getString(), "content-b", "Build 1 hands back the fresh resource for b"); - // Build 2: unchanged rebuild. No changed paths, so no producer unit re-runs; both returns come from CAS. + // Build 2: unchanged rebuild. No changed paths, so no producer invocation re-runs; both returns come from CAS. // Only the producer's persisted data is carried forward; the consumer re-runs to capture the returns. const ran2 = []; await runBuild({ @@ -2845,18 +2845,18 @@ test.serial("Integration: step return values round-trip through the real CAS", a verdicts: {g: {changedProjectResourcePaths: [], changedDependencyResourcePaths: []}}, previousData: new Map([["g", producerData]]), }); - t.deepEqual(ran2, [], "No producer unit re-ran on the unchanged rebuild"); + t.deepEqual(ran2, [], "No producer invocation re-ran on the unchanged rebuild"); t.is(await captured[0].getString(), "content-a", "Unchanged rebuild restored a from the CAS"); t.is(await captured[1].getString(), "content-b", "Unchanged rebuild restored b from the CAS"); - // Build 3: delta build. Only /in/a changed, so unit "a" re-runs (fresh) while "b" restores from CAS. + // Build 3: delta build. Only /in/a changed, so invocation "a" re-runs (fresh) while "b" restores from CAS. const ran3 = []; await runBuild({ marker: "fresh", ran: ran3, verdicts: {g: {changedProjectResourcePaths: ["/in/a"], changedDependencyResourcePaths: []}}, previousData: new Map([["g", producerData]]), }); - t.deepEqual(ran3, ["a"], "Delta build re-ran only the unit whose input changed"); - t.is(await captured[0].getString(), "fresh-a", "Delta build handed back the re-run unit's fresh return"); - t.is(await captured[1].getString(), "content-b", "Delta build restored the unchanged unit b from the CAS"); + t.deepEqual(ran3, ["a"], "Delta build re-ran only the invocation whose input changed"); + t.is(await captured[0].getString(), "fresh-a", "Delta build handed back the re-run invocation's fresh return"); + t.is(await captured[1].getString(), "content-b", "Delta build restored the unchanged invocation b from the CAS"); }); diff --git a/packages/project/test/lib/build/helpers/StepRunner.js b/packages/project/test/lib/build/helpers/StepRunner.js index b72fb1242a4..26b74b0cd0b 100644 --- a/packages/project/test/lib/build/helpers/StepRunner.js +++ b/packages/project/test/lib/build/helpers/StepRunner.js @@ -143,7 +143,7 @@ function invocationDataOf(recorded, step) { // --- Step-factory API (runSteps) --- -test("runSteps runs a scalar step once and records its single unit", async (t) => { +test("runSteps runs a scalar step once and records its single invocation", async (t) => { const {runner, recorded, workspace} = makeDriver({ steps: [ {name: "s", run: async ({workspace}) => { @@ -155,7 +155,7 @@ test("runSteps runs a scalar step once and records its single unit", async (t) = await runner.runSteps(); t.true(workspace.store.has("/out"), "Scalar step's write persisted"); - t.is(invocationDataOf(recorded, "s").size, 1, "Scalar step recorded one implicit unit"); + t.is(invocationDataOf(recorded, "s").size, 1, "Scalar step recorded one implicit invocation"); }); test("runSteps runs a map step's each once per enumerated key", async (t) => { @@ -172,7 +172,7 @@ test("runSteps runs a map step's each once per enumerated key", async (t) => { await runner.runSteps(); t.deepEqual(ran.sort(), ["a", "b"], "each ran once per key"); - t.is(invocationDataOf(recorded, "m").size, 2, "Map step recorded one unit per key"); + t.is(invocationDataOf(recorded, "m").size, 2, "Map step recorded one invocation per key"); t.true(workspace.store.has("/out/a") && workspace.store.has("/out/b"), "Both keys' writes persisted"); }); @@ -444,7 +444,7 @@ test("Delta build re-runs a consumer when its producer's return changed", async }); test("Delta build keeps a map consumer cached when its producer is restored unchanged", async (t) => { - // A scalar step always re-runs on a delta verdict (a single implicit unit cannot be pruned), so the + // A scalar step always re-runs on a delta verdict (a single implicit invocation cannot be pruned), so the // delta-path "keep the consumer cached when its producer is unchanged" behavior is exercised through a // map consumer: its one key stays cached because the producer return it consumed did not change. const stepsFor = (ran) => [ @@ -486,8 +486,8 @@ test("Delta build keeps a map consumer cached when its producer is restored unch test("Delta build re-runs a scalar step whose glob gains a newly matching file", async (t) => { // A scalar step globs for themes. On build 1 the workspace has none, so the step records no reads and // returns hasThemes:false. On build 2 a file matching the glob is added. The recorder stores resolved - // paths, not the glob pattern, so the added file is in no previous read and the per-unit reads delta - // cannot select the step. A scalar step is a single implicit unit, so it must re-run on any delta + // paths, not the glob pattern, so the added file is in no previous read and the per-invocation reads delta + // cannot select the step. A scalar step is a single implicit invocation, so it must re-run on any delta // verdict rather than serve its stale cached return. const stepsFor = (ran) => [ {name: "scan", run: async ({workspace}) => { @@ -829,7 +829,7 @@ test("A map producer's changed return re-runs its consumer", async (t) => { test("A producer's return signature is a fixed-size digest, not a raw serialization", async (t) => { // The consumer records each producer it needs under needsInputs[].value, which is the producer's // return signature. That value must be a SHA-256 hex digest regardless of producer shape (finding 5): - // a raw map serialization is O(producer key count) and is copied into every consumer unit, so a map + // a raw map serialization is O(producer key count) and is copied into every consumer invocation, so a map // producer feeding a map consumer would persist an O(producers x consumers) sidecar. A digest keeps the // recorded value O(1) and consistent with every other signature in the cache system. const digest = /^[0-9a-f]{64}$/; @@ -1065,7 +1065,7 @@ test("Key identity changes when a resource's content changes", async (t) => { test("A content change drops the previous output rather than serving it stale", async (t) => { // The property the integrity hash guaranteed, now carried by lastModified + size: when a key resource's // content changes, its key identity changes, so the old key disappears and its output is dropped as stale, - // while the unit re-runs under the new key producing fresh output. A stale (not re-run, not dropped) key + // while the invocation re-runs under the new key producing fresh output. A stale (not re-run, not dropped) key // would keep serving the old output. The output path is derived from the content so the drop is observable: // the old key wrote /out/old, the re-run writes /out/new, and /out/old must be reported stale. const stepsFor = (ran) => [ @@ -1085,7 +1085,7 @@ test("A content change drops the previous output rather than serving it stale", // A delta build whose changed-path verdict does NOT list /in/a: the re-run is driven solely by the new key // identity, exactly the case the integrity hash existed to cover (a mtime-moving edit the stage's own - // changed-path delta did not surface, e.g. because no unit recorded a read of /in/a). + // changed-path delta did not surface, e.g. because no invocation recorded a read of /in/a). const ran = []; const build2 = makeDriver({ workspace: createWorkspace([createFsResource("/in/a", {content: "new", lastModified: 2000, size: 3})]), @@ -1149,7 +1149,7 @@ test("A filesystem key and a memory key never collide on the same path", async ( t.not(fsKey, memKey, "A stat-tiered key and an integrity-tiered key for the same path differ"); }); -test("A step's needs is frozen, so one unit cannot leak into its siblings", async (t) => { +test("A step's needs is frozen, so one invocation cannot leak into its siblings", async (t) => { const seen = []; let keysError; let eachError; @@ -1173,9 +1173,9 @@ test("A step's needs is frozen, so one unit cannot leak into its siblings", asyn await runner.runSteps(); t.truthy(keysError, "Assigning to needs from the keys enumerator throws"); - t.truthy(eachError, "Assigning to needs from a unit throws"); + t.truthy(eachError, "Assigning to needs from an invocation throws"); t.deepEqual(seen, [["a", "original"], ["b", "original"]], - "Every unit sees the producer's return, unaffected by its siblings"); + "Every invocation sees the producer's return, unaffected by its siblings"); }); test("notifyStepExecution fires before the first executing step does any work", async (t) => { From b77a9fa1d3451b517c2c5a7fe4f89ad9bb7b5d4f Mon Sep 17 00:00:00 2001 From: Matthias Osswald Date: Wed, 14 Oct 2026 09:29:01 +0200 Subject: [PATCH 2/2] refactor(project): Name the per-key internals an invocation Rename the step-grained internals that actually operate on the per-key grain to use the invocation name. A recorder is created once per invocation, the per-key worker runs once per invocation, and the selection method filters per-key entries, so none of them are step-grained: - StepRecorder -> InvocationRecorder, stepIndex -> invocationIndex - the per-key worker arrow function runStep -> runInvocation - the private method #selectStepsToRun -> #selectInvocationsToRun The step-grained names stay: the public runSteps method and the @ui5/builder runSteps task helper, #computeStepReturnSignature which folds every key into one step signature, and #notifyStepExecution with the anyStepExecuted / notifyStepExecution boundary names. A step is the authored unit, an invocation is one run of it. --- .../project/lib/build/helpers/StepRunner.js | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/project/lib/build/helpers/StepRunner.js b/packages/project/lib/build/helpers/StepRunner.js index 2f5b377ca0f..0734ed922f8 100644 --- a/packages/project/lib/build/helpers/StepRunner.js +++ b/packages/project/lib/build/helpers/StepRunner.js @@ -119,12 +119,12 @@ export function validateSteps(steps, {taskName} = {}) { } /** - * Collects the reads and writes of a single step (a scalar step's implicit invocation, or one key of a map + * Collects the reads and writes of a single invocation (a scalar step's one invocation, or one key of a map * step). Project reads (workspace) and dependency reads are kept apart so they can be folded back into * the task's project vs. dependency request graph independently: a dependency path folded into the * project graph would resolve against the wrong reader and corrupt the signature. */ -class StepRecorder { +class InvocationRecorder { projectReads = new Set(); dependencyReads = new Set(); // Glob patterns an invocation issued, kept so a cached key's glob stays in the stage request set: a stored @@ -180,22 +180,22 @@ class RecordingReaderWriter extends AbstractReaderWriter { #workspace; #recorder; #writeBuffer; - #stepIndex; + #invocationIndex; /** * @param {@ui5/fs/AbstractReaderWriter} workspace Task-level monitored workspace - * @param {StepRecorder} recorder Recorder for this step + * @param {InvocationRecorder} recorder Recorder for this invocation * @param {Map|null} writeBuffer Shared buffer for concurrent writes, or * null to write through immediately (sequential mode) - * @param {number} stepIndex Position of this invocation in the key order, used to flush buffered writes + * @param {number} invocationIndex Position of this invocation in the key order, used to flush buffered writes * deterministically and to detect two invocations writing the same path */ - constructor(workspace, recorder, writeBuffer, stepIndex) { + constructor(workspace, recorder, writeBuffer, invocationIndex) { super(workspace.getName()); this.#workspace = workspace; this.#recorder = recorder; this.#writeBuffer = writeBuffer; - this.#stepIndex = stepIndex; + this.#invocationIndex = invocationIndex; } async _byGlob(virPattern, options) { @@ -225,8 +225,8 @@ class RecordingReaderWriter extends AbstractReaderWriter { // Concurrent mode: buffer and flush in key order once all keys finish. Concurrent keys are // required to be independent, so two keys writing the same path is a contract violation // rather than a last-wins race. - assertDistinctWrite(this.#writeBuffer, resourcePath, this.#stepIndex); - this.#writeBuffer.set(resourcePath, {resource, args: [options], index: this.#stepIndex}); + assertDistinctWrite(this.#writeBuffer, resourcePath, this.#invocationIndex); + this.#writeBuffer.set(resourcePath, {resource, args: [options], index: this.#invocationIndex}); return; } // Sequential mode: persist immediately so a later key reads what this key wrote. @@ -318,7 +318,7 @@ export default class StepRunner { * Absent for standalone use (no cache): every invocation runs. This verdict is read three ways in * {@link #runSteps}: compared against true for the full-hit short-circuit, passed to * markStageExecuting as a "was this a delta" flag, and treated as "run everything if - * falsy" by {@link #selectStepsToRun}. An explicit verdict shape would read more clearly, but it is + * falsy" by {@link #selectInvocationsToRun}. An explicit verdict shape would read more clearly, but it is * deferred: the shape is shared with the stage-signature code and reshaping it belongs with that work. * @param {function(string): Promise<(object|boolean)>} [parameters.reopenStage] Reopens the named step's * stage with a fresh live writer after a full cache hit that must be re-run (a consumed @@ -439,8 +439,8 @@ export default class StepRunner { // Full stage-cache hit: the step's own stage signature matched. A consumed needs return is // deliberately excluded from the stage signature (see #foldStageKeys), so a full hit can // occur even though a producer this step needs re-ran this build with a changed return. The - // delta path catches that via needsInputs in #selectStepsToRun, but a full hit never runs - // #selectStepsToRun. Check it here: when a consumed return changed, reopen the stage with a + // delta path catches that via needsInputs in #selectInvocationsToRun, but a full hit never runs + // #selectInvocationsToRun. Check it here: when a consumed return changed, reopen the stage with a // live writer and re-run it rather than serving stale cached output. if (this.#canRestoreCachedStage(step, previous, isScalar) && !this.#needsReturnChanged(previous, needsSignatures)) { @@ -459,8 +459,8 @@ export default class StepRunner { `step '${step.name}': re-running despite a full stage-cache hit ` + `(a consumed needs return changed, or the stage's invocation data cannot be restored)`); // Reopen the stage (fresh writer) and re-run it as a full execution: the hook returns a - // falsy verdict, so #selectStepsToRun runs every invocation rather than pruning. Reopening installs - // a fresh EMPTY writable stage (unlike a delta verdict, whose stage was pre-seeded with the + // falsy verdict, so #selectInvocationsToRun runs every invocation rather than pruning. Reopening + // installs a fresh EMPTY writable stage (unlike a delta verdict, whose stage was pre-seeded with the // cached outputs at import), so the invocations cannot be pruned without first re-seeding that // stage; re-running the whole stage keeps the output complete without that machinery. No // shipped multi-step task is penalized: generateThemeDesignerResources is the only task @@ -560,7 +560,7 @@ export default class StepRunner { /** * Whether any of a fully-cached stage's keys consumed a needs return whose current * signature differs from the value it recorded on its previous run. Mirrors the needs check in - * {@link #selectStepsToRun}, applied to the full-hit path where that selection never runs. A stage with + * {@link #selectInvocationsToRun}, applied to the full-hit path where that selection never runs. A stage with * no previous data, no needs, or unchanged returns reports false, so the * full-hit fast path is preserved for the common case. * @@ -678,7 +678,7 @@ export default class StepRunner { * * Because a consumer stage's signature omits the producer return, a cached consumer's correctness rests * on two checks instead of the signature: {@link #needsReturnChanged} on the full-hit path and the - * needsInputs comparison in {@link #selectStepsToRun} on the delta path. These are the only + * needsInputs comparison in {@link #selectInvocationsToRun} on the delta path. These are the only * two paths that reach a cached stage through the StepRunner. A whole project restored from the * project-level result cache never runs the StepRunner at all (and ProjectBuildCache.#importStages * installs those stages blindly by signature), yet that is safe without a needs check: the result @@ -993,7 +993,7 @@ export default class StepRunner { return {kind: "value", value}; } - #selectStepsToRun(entries, previous, needsSignatures, cacheInfo, isScalar) { + #selectInvocationsToRun(entries, previous, needsSignatures, cacheInfo, isScalar) { if (!cacheInfo || !previous || isScalar) { // Full build, or a step with no previous data: run every invocation. A scalar step always re-runs // on a delta verdict too: it is a single implicit invocation, so the per-invocation reads delta cannot @@ -1090,7 +1090,7 @@ export default class StepRunner { * @param {Map} [context.previous] The stage's previous per-key invocation data * @param {object} context.ctx The per-stage context ({workspace, dependencies, taskUtil}) * @param {boolean} [context.isScalar] Whether this stage is a scalar step (a single implicit invocation), - * which is selected to run on any delta verdict (see {@link #selectStepsToRun}) + * which is selected to run on any delta verdict (see {@link #selectInvocationsToRun}) * @returns {Promise<{results: Array, invocationData: Map, * freshInvocationData: Map}>} Per-key results aligned to entries order, * the stage's complete per-key invocation data, and the subset of it recorded by the invocations that @@ -1100,7 +1100,7 @@ export default class StepRunner { {needs, needsSignatures, cacheInfo, previous, ctx, isScalar}) { const sequential = options?.sequential ?? false; const concurrent = !sequential; - const toRun = this.#selectStepsToRun(entries, previous, needsSignatures, cacheInfo, isScalar); + const toRun = this.#selectInvocationsToRun(entries, previous, needsSignatures, cacheInfo, isScalar); const toRunIndices = new Set(toRun.map((entry) => entry.index)); const currentInvocationData = new Map(); @@ -1121,9 +1121,9 @@ export default class StepRunner { this.#replayTagOperations(prev?.tagOperations); } - const runStep = async ({key, keyId, index}) => { + const runInvocation = async ({key, keyId, index}) => { this.#signal?.throwIfAborted(); - const recorder = new StepRecorder(); + const recorder = new InvocationRecorder(); const workspace = new RecordingReaderWriter(ctx.workspace, recorder, writeBuffer, index); const dependencies = ctx.dependencies ? new RecordingReader(ctx.dependencies, recorder) : undefined; @@ -1148,11 +1148,11 @@ export default class StepRunner { }; if (concurrent) { - await Promise.all(toRun.map(runStep)); + await Promise.all(toRun.map(runInvocation)); await flushWriteBuffer(writeBuffer, ctx.workspace); } else { for (const entry of toRun) { - await runStep(entry); + await runInvocation(entry); } }