diff --git a/.agents/skills/incremental-build/architecture.md b/.agents/skills/incremental-build/architecture.md index 003e36999a8..ed6d160b5b3 100644 --- a/.agents/skills/incremental-build/architecture.md +++ b/.agents/skills/incremental-build/architecture.md @@ -367,7 +367,15 @@ Two DSL rules make the per-step recording honest, and both are visible here. The **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 task descriptor (step-based vs legacy).** From Specification Version 5.0 (and for every built-in task), a task module's default export is a factory `(options) => descriptor`. The descriptor carries the whole task contract in one object: exactly one of `steps` (the step-based path) or `task` (a legacy single-stage body), plus the optional `determineRequiredDependencies` / `determineBuildSignature` callbacks and an optional declared `specVersion`. A task is step-based when its descriptor has a `steps` array. This replaced the former scattered opt-in (a static `stepBased` export on a custom task module, or a `stepBased: true` entry in `definitions/*.js`) and the former top-level named-export callbacks. The descriptor is validated by `validateDescriptor` (exported from `StepRunner.js`, next to `validateSteps`): exactly one of `steps`/`task`, callbacks must be functions, a declared `specVersion` must equal the task extension's configured Specification Version. For a **custom** task extension below 5.0 the gate is a hard version boundary with no shape-sniffing: the default export is the legacy task body invoked directly (any value it returns is ignored), so there is no descriptor and none of its callbacks apply. **This `specVersion` is the custom task extension's own (from its `ui5.yaml`), unrelated to the Specification Version of the project being built; built-in tasks carry the descriptor shape unconditionally and their project-specVersion gating stays in `definitions/*.js`.** Note the cache-internal `stepBased` flag (the persisted `stages` array `[stageId, stepBased?1:0]`, `BuildStageCache`), which means "this stage ran the step runner", is a different thing and is **retained** — only the task-API opt-in was removed. +**The task descriptor (step-based vs legacy).** From Specification Version 5.0 (and for every built-in task), a task module's default export is a factory `(options) => descriptor`. The descriptor carries the whole task contract in one object: exactly one of `steps` (the step-based path) or `task` (a legacy single-stage body), plus the optional `determineRequiredDependencies` / `determineBuildSignature` callbacks and an optional declared `specVersion`. A task is step-based when its descriptor has a `steps` array. This replaced the former scattered opt-in (a static `stepBased` export on a custom task module, or a `stepBased: true` entry in `definitions/*.js`) and the former top-level named-export callbacks. The descriptor is validated by `validateDescriptor` (exported from `StepRunner.js`, next to `validateSteps`): exactly one of `steps`/`task`, callbacks must be functions, a declared `specVersion` must equal the task extension's configured Specification Version, and an optional `cache` flag must be a boolean (see "Cache-exempt custom tasks"). For a **custom** task extension below 5.0 the gate is a hard version boundary with no shape-sniffing: the default export is the legacy task body invoked directly (any value it returns is ignored), so there is no descriptor and none of its callbacks apply. **This `specVersion` is the custom task extension's own (from its `ui5.yaml`), unrelated to the Specification Version of the project being built; built-in tasks carry the descriptor shape unconditionally and their project-specVersion gating stays in `definitions/*.js`.** Note the cache-internal `stepBased` flag (the persisted `stages` array `[stageId, stepBased?1:0]`, `BuildStageCache`), which means "this stage ran the step runner", is a different thing and is **retained** — only the task-API opt-in was removed. + +**Cache-exempt custom tasks.** A custom task can be exempt from the build cache, so it executes on every build. Many custom tasks read and write files outside the resource model (direct `fs` access), which the cache cannot track, so a cached result could not be invalidated when such an untracked input changes, and skipping the task would drop its untracked side effects. A custom task is exempt when it declares a Specification Version below 5.0 (no descriptor, legacy body, assumed to do untracked I/O) or when a 5.0 task sets `cache: false` on its descriptor. The single rule lives in `getCustomTaskCacheExemption(isSpecV5, descriptor)` (exported from `TaskDefinitions.js`), returning `{reason: "specVersion" | "optOut"}` or `undefined`. Built-in and other standard tasks are always cacheable (a built-in descriptor could set `cache: false`, honored uniformly, but none does). The exemption only ever disables caching, and is scoped to the task's own project, so an untracked write that reaches *another* project's inputs is still outside what any invalidation tracks. + +Exemption keeps the project's *other* tasks cached. Two things change for an exempt task, and only for it: +- Its stages are always executed and never stored. `prepareStageExecutionAndValidateCache` returns `false` (no cache lookup) for an exempt stage, and `recordStageResult` withholds the stage output and its per-key map from the cache (it skips `#stageCache.addSignature`). The signature tuple, request indices (`recordRequests`) and written-path accounting still run, so the result-signature machinery and the persisted `stages`/`stage_request_metadata` stay consistent. The exempt stage's request metadata is restorable, so only its `stage_metadata` and the project result row are absent, and neither is ever read for such a project. +- The project's result cache is disabled. `ProjectBuildContext.create` detects an exempt task via `TaskDefinitions.getCustomTaskCacheExemptions()` (sharing the memoized descriptors with the build-signature phase) and calls `ProjectBuildCache.disableResultCache()` **before** `validateCache` runs. The result-cache fast path can skip the whole task loop before any stage runs, so it must be off up front. `#findResultCache` then force-misses and `#prepareResultCache` writes nothing, so `validateCache` always reports the project not-fresh and the task loop runs, reaching the exempt task while the cacheable stages are still served from their individual stage caches inside that loop. `possiblyRequiresBuild` is unaffected: an exempt project only reaches `isFresh()` right after a real build this session, where skipping a redundant rebuild is correct (the cached reader serves until the next source change). + +The per-stage exemption flows from `TaskRunner` into `ProjectBuildCache.setTasks` via a per-task `cacheable` flag (absence means cacheable, keeping the payload minimal). `setTasks` collects the stage ids of `cacheable: false` tasks into `#cacheExemptStageIds` and, as a belt-and-suspenders for callers that bypass `ProjectBuildContext` (e.g. tests), also sets the result-cache-disabled flag when any exempt stage is present. The `TaskRunner` reports the exemption to the user when such a task runs. A below-5.0 task warns **once per process** (`warnedAboutUncacheableCustomTasks`, module-scoped, since a long-running `ui5 serve` must not repeat it per rebuild), while a deliberate 5.0 `cache: false` opt-out is logged at verbose level. **One factory call, memoized on `TaskDefinitions`.** The descriptor is resolved once per build and shared by the two phases that need it. `TaskDefinitions.getBuildSignatures()` (which needs `determineBuildSignature`) runs inside `ProjectBuildContext.create()`, before the `ProjectBuildCache` and `TaskRunner` exist; `TaskRunner` (which needs `steps`/`task`/`determineRequiredDependencies`) runs later off the **same** `TaskDefinitions` instance. For a built-in, `TaskDefinitions.#resolveStandardDescriptor` calls the factory from `taskRepository.getTask(name)` and caches it on the standard-task map entry (`getStandardDescriptor` exposes it to `TaskRunner`); a skipped (`skip:true`) or `taskFunction`-overridden task has no descriptor and falls back to hashing its options. For a custom task, `Task#getDescriptor(taskName, options)` memoizes per project and runtime `taskName`: two projects that use the same shared extension instance receive descriptors created with their own options, while a `--N`-suffixed second configuration within one project also gets its own descriptor. `TaskDefinitions` completes `projectName`/`projectNamespace` into each standard task's options **before** the first factory call, so a factory that branches on `projectNamespace` at factory time (`generateThemeDesignerResources` emits its `libraryTheming` step only for a namespace) produces the same descriptor at signature time and at execution. diff --git a/packages/project/lib/build/TaskDefinitions.js b/packages/project/lib/build/TaskDefinitions.js index 462420b61ff..52ffec1d330 100644 --- a/packages/project/lib/build/TaskDefinitions.js +++ b/packages/project/lib/build/TaskDefinitions.js @@ -1,6 +1,39 @@ import crypto from "node:crypto"; import {getLogger} from "@ui5/logger"; import {validateDescriptor} from "./helpers/StepRunner.js"; +import composeTaskList from "./helpers/composeTaskList.js"; + +/** + * Decides whether a custom task is exempt from the build cache, the single rule shared by the + * build-signature phase (which disables the project's result cache, see + * [ProjectBuildContext]{@link @ui5/project/build/helpers/ProjectBuildContext}) and the TaskRunner + * (which marks the task's stages non-cacheable and emits the user-facing warning). + * + * A custom task below Specification Version 5.0 is always exempt: it has no descriptor, and its legacy + * body may perform file reads and writes outside the resource model that the cache cannot track, so a + * cached result could not be invalidated when those untracked inputs change. From 5.0 a task opts out + * explicitly by setting cache: false on its descriptor. + * + * Takes the already-resolved isSpecV5 rather than the Specification Version object so the + * callers, which have computed it, do not repeat the comparison. + * + * @public + * @param {boolean} isSpecV5 Whether the custom task extension's own configured Specification Version is + * 5.0 or higher (unrelated to the Specification Version of the project being built) + * @param {object} [descriptor] The task's resolved descriptor (Specification Version 5.0 and later) + * @returns {{reason: string}|undefined} An exemption with its reason + * ("specVersion" below 5.0, "optOut" for an explicit cache: false), + * or undefined if the task is cacheable + */ +export function getCustomTaskCacheExemption(isSpecV5, descriptor) { + if (!isSpecV5) { + return {reason: "specVersion"}; + } + if (descriptor && descriptor.cache === false) { + return {reason: "optOut"}; + } + return undefined; +} export default class TaskDefinitions { #graph; @@ -239,4 +272,56 @@ export default class TaskDefinitions { async getStandardDescriptor(taskName, taskDef) { return this.#resolveStandardDescriptor(taskName, taskDef); } + + /** + * Returns the cache exemptions of the project's selected custom tasks, keyed by runtime task name + * (with the --<number> suffix a repeated configuration gets). A task is listed only + * when it is selected for execution and exempt from the build cache. + * + * Shares one factory call per custom task with {@link #getBuildSignatures} (the descriptor is memoized + * per project and runtime task name on the {@link @ui5/project/specifications/extensions/Task} extension), so this + * adds no extra task-module evaluation. The {@link @ui5/project/build/helpers/ProjectBuildContext} uses + * it to decide whether to disable the project's result cache; the exemptions are re-derived per task by + * the TaskRunner from the same descriptors for the per-stage handling and the user-facing warning. + * + * @public + * @param {@ui5/project/build/ProjectBuilder~BuildConfiguration} [buildConfig] Build task selection + * @returns {Promise>} + * Exempt custom tasks and why (reason is "specVersion" or "optOut", + * see {@link getCustomTaskCacheExemption}) + */ + async getCustomTaskCacheExemptions(buildConfig = {includedTasks: [], excludedTasks: []}) { + await this._initTasks(); + const selectedTasks = new Set(composeTaskList( + [...this.#standardTasks.keys(), ...this.#customTasks.keys()], buildConfig)); + const projectName = this.#project.getName(); + const projectNamespace = this.#project.getNamespace(); + const exemptions = new Map(); + for (const [taskName, {taskDef, task}] of this.#customTasks) { + // Task selection applies to every configuration of a repeated task, as in TaskRunner. + if (!selectedTasks.has(taskName.replace(/--\d+$/, ""))) { + continue; + } + const specVersion = task.getSpecVersion(); + const isSpecV5 = specVersion.gte("5.0"); + // Below 5.0 there is no descriptor; the exemption rule only needs the descriptor to read the + // explicit opt-out flag, which exists from 5.0. + const descriptor = isSpecV5 ? + await task.getDescriptor(taskName, { + projectName, + projectNamespace, + configuration: taskDef.configuration, + taskName + }) : undefined; + const exemption = getCustomTaskCacheExemption(isSpecV5, descriptor); + if (exemption) { + exemptions.set(taskName, { + reason: exemption.reason, + extensionName: task.getName(), + specVersion: specVersion.toString() + }); + } + } + return exemptions; + } } diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index f36154d2734..96f22a2e506 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -2,8 +2,14 @@ import {getLogger} from "@ui5/logger"; import composeTaskList from "./helpers/composeTaskList.js"; import MonitoredTaskUtil from "./helpers/MonitoredTaskUtil.js"; import StepRunner, {validateSteps} from "./helpers/StepRunner.js"; +import {getCustomTaskCacheExemption} from "./TaskDefinitions.js"; import {createReaderCollection, createMonitor} from "@ui5/fs/resourceFactory"; +// Process-level guard for the single warning about custom tasks that are exempt from the build cache +// because they declare a Specification Version below 5.0. A long-running `ui5 serve` process rebuilds +// many times across many projects; the user needs to be told once, not on every rebuild. +let warnedAboutUncacheableCustomTasks = false; + const EMPTY_RESOURCE_REQUESTS = {paths: [], patterns: []}; /** @@ -219,15 +225,23 @@ class TaskRunner { const stageTasks = allTasks.map((taskName) => { const taskDef = this._tasks[taskName]; const steps = taskDef.descriptor?.steps; - if (!steps) { - return {taskName}; + const stageTask = {taskName}; + if (steps) { + // Validate the whole step list before it creates any stage: setTasks below derives a stage id + // per step name, so a duplicate name or a malformed declaration must be rejected here, not + // later when the step runs (by then two steps sharing a name have already created two stages + // with one id). + validateSteps(steps, {taskName}); + taskDef.steps = Object.freeze(steps); + stageTask.stepNames = steps.map((step) => step.name); + } + // Only flag an exempt task; absence means cacheable (setTasks defaults to true). This keeps the + // payload minimal and reports the exemption to the user when such a task runs. + if (taskDef.cacheable === false) { + stageTask.cacheable = false; + this.#reportCacheExemption(taskDef.cacheExemption); } - // Validate the whole step list before it creates any stage: setTasks below derives a stage id per - // step name, so a duplicate name or a malformed declaration must be rejected here, not later when - // the step runs (by then two steps sharing a name have already created two stages with one id). - validateSteps(steps, {taskName}); - taskDef.steps = Object.freeze(steps); - return {taskName, stepNames: steps.map((step) => step.name)}; + return stageTask; }); this._buildCache.setTasks(stageTasks); @@ -425,6 +439,9 @@ class TaskRunner { stepBased, descriptor, options, + // Standard tasks are cacheable. A descriptor may still opt out via cache:false (none of the + // built-ins do), honored uniformly with custom tasks. + cacheable: descriptor ? descriptor.cache !== false : true, requiredDependencies: requiresDependencies ? this._directDependencies : new Set() }; this._taskExecutionOrder.push(taskName); @@ -536,6 +553,11 @@ class TaskRunner { // A custom task is step-based when its descriptor carries a steps array (Specification Version 5.0 // and later). Below 5.0 there is no descriptor and the task runs as a legacy body. const stepBased = !!descriptor?.steps; + // Decide cache exemption from the same rule the ProjectBuildContext used to disable the result + // cache, over the same (memoized) descriptor and the isV5 flag already computed above (so no extra + // Specification Version comparison). An exempt task's stages are always executed and never stored + // (see ProjectBuildCache); runTasks reports the exemption to the user. + const cacheExemption = getCustomTaskCacheExemption(isV5, descriptor); this._tasks[taskName] = { task: this._createCustomTaskWrapper({ task, @@ -554,6 +576,12 @@ class TaskRunner { stepBased, descriptor, options: descriptorOptions, + cacheable: !cacheExemption, + cacheExemption: cacheExemption ? { + reason: cacheExemption.reason, + extensionName: task.getName(), + specVersion: specVersion.toString() + } : undefined, requiredDependencies }; @@ -729,6 +757,43 @@ class TaskRunner { }; } + /** + * Reports that a custom task is exempt from the build cache, so the user understands why that task runs + * on every build. + * + * A task below Specification Version 5.0 is exempt implicitly (reason "specVersion"): its + * legacy body may perform file I/O outside the resource model that the cache cannot track. This is + * warned once per process (a long-running ui5 serve rebuilds repeatedly, and the user needs + * telling once, not per rebuild). A Specification Version 5.0 task that set cache: false + * (reason "optOut") made a deliberate choice, so it is only logged at verbose level. + * + * @param {{reason: string, extensionName: string, specVersion: string}} [exemption] The task's exemption, + * or undefined for a cacheable task + */ + #reportCacheExemption(exemption) { + if (!exemption) { + return; + } + const projectName = this._project.getName(); + if (exemption.reason === "specVersion") { + if (warnedAboutUncacheableCustomTasks) { + return; + } + warnedAboutUncacheableCustomTasks = true; + this._log.warn( + `Custom task ${exemption.extensionName} of project ${projectName} defines Specification ` + + `Version ${exemption.specVersion} and is therefore not cached. Tasks below Specification ` + + `Version 5.0 may read or write files outside the build's resource model, which the build ` + + `cache cannot track, so their results are never cached and such tasks run on every build. ` + + `This can slow down (incremental) builds. Update affected tasks to Specification Version 5.0 ` + + `to make them cacheable.`); + } else { + this._log.verbose( + `Custom task ${exemption.extensionName} of project ${projectName} opted out of the build ` + + `cache (cache: false in its task descriptor); it runs on every build.`); + } + } + /** * Builds the start/end reporting pair for one execution of a step-based task. * diff --git a/packages/project/lib/build/cache/ProjectBuildCache.js b/packages/project/lib/build/cache/ProjectBuildCache.js index 635ed6257cc..db134fa120e 100644 --- a/packages/project/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/lib/build/cache/ProjectBuildCache.js @@ -109,6 +109,19 @@ export default class ProjectBuildCache { #combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES; #resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION; + // Stage ids of tasks exempt from the build cache (a custom task below Specification Version 5.0, or one + // that opted out via its descriptor). Populated by setTasks from the per-task cacheable flag. An exempt + // stage is always executed (never served from its stage cache) and its result is not stored, so a task + // doing untracked out-of-bound file I/O runs on every build. See setTasks and + // prepareStageExecutionAndValidateCache. + #cacheExemptStageIds = new Set(); + + // Whether the project's result cache is disabled because it has at least one cache-exempt custom task. + // Set by disableResultCache (from ProjectBuildContext, before validateCache runs) so the project-level + // result cache never skips the task loop that must reach the exempt task; also re-derived in setTasks + // from the exempt stage ids for callers that bypass ProjectBuildContext (e.g. tests). + #resultCacheDisabled = false; + // Per-stage step-runner invocation data (see lib/build/helpers/StepRunner.js), keyed by stage id. // Each value is a Map of key identity -> {reads, dependencyReads, writes, inputs, needsInputs, // tagOperations, returns} recorded during the stage's last run. It lets a delta build map a changed @@ -405,14 +418,29 @@ export default class ProjectBuildCache { * @returns {boolean} True if the cache is fresh */ isFresh() { - // When cache=Off, always return false to force rebuilds - if (this.#cacheMode === Cache.Off) { + // A disabled result cache requires task execution even after a successful build in this session. + if (this.#cacheMode === Cache.Off || this.#resultCacheDisabled) { return false; } return this.#combinedIndexState === INDEX_STATES.FRESH && this.#resultCacheState === RESULT_CACHE_STATES.FRESH_AND_IN_USE; } + /** + * Disables the project-level result cache for this project. + * + * Called by the {@link @ui5/project/build/helpers/ProjectBuildContext} when the project has at least one + * cache-exempt custom task, before {@link #validateCache} runs. With the result cache disabled, + * validateCache never reports the project fresh from cache, so the build always runs its task loop and + * reaches the exempt task. The project's cacheable stages are still served from their individual stage + * caches within that loop; only the whole-project skip and the result-cache write are suppressed. + * + * @public + */ + disableResultCache() { + this.#resultCacheDisabled = true; + } + /** * Loads a cached result stage from persistent storage if available * @@ -425,6 +453,15 @@ export default class ProjectBuildCache { * or false if no cache found */ #findResultCache() { + if (this.#resultCacheDisabled) { + // The project has a cache-exempt task that must run on every build. Never serve the whole + // project from the result cache, so the task loop always runs and reaches that task. The + // cacheable stages are still served from their individual stage caches during that loop. + log.verbose( + `Result cache is disabled for project ${this.#project.getName()} ` + + `due to a cache-exempt task; forcing a build`); + return false; + } const resultSignatures = this.#getPossibleResultStageSignatures(); if (resultSignatures.includes(this.#currentResultSignature)) { log.verbose( @@ -712,6 +749,14 @@ export default class ProjectBuildCache { // Switch project to new stage this.#project.getProjectResources().useStage(stageId); log.verbose(`Preparing execution for stage ${stageId} in project ${this.#project.getName()}...`); + if (this.#cacheExemptStageIds.has(stageId)) { + // A cache-exempt task's stage: never consult the cache, always execute. recordStageResult + // likewise does not store it (see there), so a task doing untracked out-of-bound file I/O + // runs on every build. There is no restorable "previous" per-key data for the stage. + log.verbose(`Stage ${stageId} is exempt from caching; executing without a cache lookup`); + this.#stepInvocationData.set(stageId, undefined); + return false; + } if (!stageCache) { log.verbose(`No stage cache found`); // No cached stage to restore from: this build has no "previous" per-key data for the stage. @@ -1342,10 +1387,17 @@ export default class ProjectBuildCache { // Store resulting stage in stage cache. The step runner set the stage's per-key map via // setStepInvocationData immediately before this call (undefined for a legacy task), so it travels // with the stage under this signature and is persisted inside the stage's own metadata row. - this.#stageCache.addSignature( - stageId, stageSignature, this.#project.getProjectResources().getStage(), - writtenResourcePaths, projectTagOperations, buildTagOperations, - this.#stepInvocationData.get(stageId)); + // + // A cache-exempt stage is the exception: it is never stored, so it cannot be served from cache on a + // later build and its owning task always re-runs. Everything above still ran (the signature tuple, + // the request indices, the written-path accounting) so the result-signature machinery stays + // consistent; only the stage output and its per-key map are withheld from the cache. + if (!this.#cacheExemptStageIds.has(stageId)) { + this.#stageCache.addSignature( + stageId, stageSignature, this.#project.getProjectResources().getStage(), + writtenResourcePaths, projectTagOperations, buildTagOperations, + this.#stepInvocationData.get(stageId)); + } // Update stage cache with new metadata log.verbose(`Stage ${stageId} produced ${writtenResourcePaths.length} resources`); @@ -1437,20 +1489,40 @@ export default class ProjectBuildCache { * step order, so each step is cached and folded into the result-stage signature independently. The step * names are discovered by the caller from the task factory before the build runs. * + * A task with cacheable: false is exempt from the build cache: all its stages are marked + * exempt, so each is always executed and never stored (see + * {@link #prepareStageExecutionAndValidateCache} and {@link #recordStageResult}). The presence of any + * exempt stage also disables the project's result cache, matching what the + * {@link @ui5/project/build/helpers/ProjectBuildContext} already set before validateCache, so a caller + * that drives this cache without that context (e.g. a test) gets the same behavior. + * * @public - * @param {Array<{taskName: string, stepNames: string[]|undefined}>} tasks Tasks to initialize stages for, in - * execution order. stepNames (in step order) is present for a step-based task. + * @param {Array<{taskName: string, stepNames: string[]|undefined, cacheable: boolean|undefined}>} tasks + * Tasks to initialize stages for, in execution order. stepNames (in step order) is present + * for a step-based task. cacheable defaults to true; false marks + * the task's stages exempt from the cache. */ setTasks(tasks) { const stageIds = []; - for (const {taskName, stepNames} of tasks) { + this.#cacheExemptStageIds = new Set(); + for (const {taskName, stepNames, cacheable} of tasks) { + const taskStageIds = []; if (stepNames && stepNames.length) { for (const stepName of stepNames) { - stageIds.push(this.#stageIdFor(taskName, stepName)); + taskStageIds.push(this.#stageIdFor(taskName, stepName)); } } else { - stageIds.push(this.#stageIdFor(taskName)); + taskStageIds.push(this.#stageIdFor(taskName)); } + if (cacheable === false) { + for (const stageId of taskStageIds) { + this.#cacheExemptStageIds.add(stageId); + } + } + stageIds.push(...taskStageIds); + } + if (this.#cacheExemptStageIds.size) { + this.#resultCacheDisabled = true; } this.#project.getProjectResources().initStages(stageIds); // Remember the order so the dependency signature is composed over stages deterministically. @@ -2093,6 +2165,12 @@ export default class ProjectBuildCache { * @returns {{projectId: string, buildSignature: string, stageSignature: string, metadata: object}|null} */ #prepareResultCache() { + if (this.#resultCacheDisabled) { + // A cache-exempt task disabled this project's result cache (see disableResultCache). Writing a + // result row would reference the exempt stage, whose output is deliberately not stored, and it + // could never be matched anyway (#findResultCache force-misses), so write nothing. + return null; + } const stageSignature = this.#currentResultSignature; if (stageSignature === this.#cachedResultSignature) { // No changes to already cached result stage diff --git a/packages/project/lib/build/helpers/ProjectBuildContext.js b/packages/project/lib/build/helpers/ProjectBuildContext.js index a6a02ab2e08..92f86db2792 100644 --- a/packages/project/lib/build/helpers/ProjectBuildContext.js +++ b/packages/project/lib/build/helpers/ProjectBuildContext.js @@ -83,9 +83,21 @@ class ProjectBuildContext { ctx._buildSignature = await getProjectSignature( baseSignature, taskSignatures, project, buildContext.getGraph(), buildContext.getTaskRepository()); - const cacheMode = buildContext.getBuildConfig().cache; + const buildConfig = buildContext.getBuildConfig(); + const cacheMode = buildConfig.cache; ctx._buildCache = new ProjectBuildCache(project, ctx._buildSignature, cacheManager, cacheMode, (type, name) => ctx.resolveInputValue(type, name)); + + // A selected custom task exempt from the build cache (below Specification Version 5.0, or opted out via its + // descriptor) must execute on every build. The per-stage exemption (set later via TaskRunner.setTasks) + // forces that task's stages to re-run, but the project-level result cache can skip the whole task loop + // before any stage runs, so it must be disabled here, before validateCache runs. The TaskRunner emits + // the user-facing warning when such a task runs. Detection shares the memoized descriptors + // with the build-signature phase, so it adds no task-module evaluation. + const cacheExemptions = await ctx._taskDefinitions.getCustomTaskCacheExemptions(buildConfig); + if (cacheExemptions.size) { + ctx._buildCache.disableResultCache(); + } return ctx; } diff --git a/packages/project/lib/build/helpers/StepRunner.js b/packages/project/lib/build/helpers/StepRunner.js index bc10a910e6a..7def65fb178 100644 --- a/packages/project/lib/build/helpers/StepRunner.js +++ b/packages/project/lib/build/helpers/StepRunner.js @@ -168,6 +168,14 @@ export function validateDescriptor(descriptor, {taskName} = {}) { `Task descriptor${where} 'specVersion' must be a string, ` + `got ${describeValue(descriptor.specVersion)}`); } + // A task may opt out of the build cache by setting 'cache' to false. Omitted means cacheable. + // Only a literal false disables caching; any other value is a declaration error rather than a + // silent "truthy means cacheable". + if (descriptor.cache !== undefined && typeof descriptor.cache !== "boolean") { + throw new Error( + `Task descriptor${where} 'cache' must be a boolean, ` + + `got ${describeValue(descriptor.cache)}`); + } } /** diff --git a/packages/project/test/fixtures/application.a/task.cache-exempt-legacy.js b/packages/project/test/fixtures/application.a/task.cache-exempt-legacy.js new file mode 100644 index 00000000000..3dd65eefd74 --- /dev/null +++ b/packages/project/test/fixtures/application.a/task.cache-exempt-legacy.js @@ -0,0 +1,11 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:cacheExemptLegacy"); + +// Specification Version 4.0 legacy task: the default export is the task body itself, not a descriptor +// factory. A custom task below Specification Version 5.0 may read and write files outside the build's +// resource model, which the build cache cannot track, so it is never cached and runs on every build. +module.exports = async function({workspace, options: {projectNamespace}}) { + log.verbose("Cache-exempt legacy task executed"); + // Read a project resource so the task participates in the pipeline like a real custom task would. + await workspace.byPath(`/resources/${projectNamespace}/manifest.json`); +}; diff --git a/packages/project/test/fixtures/application.a/task.cache-exempt-optout.js b/packages/project/test/fixtures/application.a/task.cache-exempt-optout.js new file mode 100644 index 00000000000..4ce11802f66 --- /dev/null +++ b/packages/project/test/fixtures/application.a/task.cache-exempt-optout.js @@ -0,0 +1,15 @@ +const Logger = require("@ui5/logger"); +const log = Logger.getLogger("builder:tasks:cacheExemptOptOut"); + +// Specification Version 5.0 descriptor that opts out of the build cache via `cache: false`. The task +// could otherwise be cached, but the author declares it must run on every build (e.g. because it writes +// files outside the build's resource model that the cache cannot track). +module.exports = function build() { + return { + cache: false, + task: async ({workspace, options: {projectNamespace}}) => { + log.verbose("Cache-exempt opt-out task executed"); + await workspace.byPath(`/resources/${projectNamespace}/manifest.json`); + } + }; +}; diff --git a/packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-legacy.yaml b/packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-legacy.yaml new file mode 100644 index 00000000000..9c260c34c5d --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-legacy.yaml @@ -0,0 +1,17 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: cache-exempt-task + afterTask: minify +--- +specVersion: "4.0" +kind: extension +type: task +metadata: + name: cache-exempt-task +task: + path: task.cache-exempt-legacy.js diff --git a/packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-optOut.yaml b/packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-optOut.yaml new file mode 100644 index 00000000000..082d8ed3aeb --- /dev/null +++ b/packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-optOut.yaml @@ -0,0 +1,17 @@ +--- +specVersion: "5.0" +type: application +metadata: + name: application.a +builder: + customTasks: + - name: cache-exempt-optout-task + afterTask: minify +--- +specVersion: "5.0" +kind: extension +type: task +metadata: + name: cache-exempt-optout-task +task: + path: task.cache-exempt-optout.js diff --git a/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js b/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js index cd8ec1831d9..f60aa72b7c1 100644 --- a/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.customTasks.integration.js @@ -5,6 +5,48 @@ import {createFixtureTesterFactory, registerBuildHooks} from "./__helper__/Proje const FixtureTester = createFixtureTesterFactory("customTasks"); registerBuildHooks(test); +/** + * Collects a project's executed and skipped task names from the `ui5.project-build-status` feed of the + * most recent build (the fixture tester resets the event stubs at the start of each build). + * + * @param {object} t AVA execution context + * @param {string} projectName Project to collect task statuses for + * @returns {{executed: string[], skipped: string[]}} Task names that ran (task-start) and were skipped + */ +function taskStatuses(t, projectName) { + const executed = []; + const skipped = []; + for (const [event] of t.context.projectBuildStatusEventStub.args) { + if (event.projectName !== projectName) { + continue; + } + if (event.status === "task-start") { + executed.push(event.taskName); + } else if (event.status === "task-skip") { + skipped.push(event.taskName); + } + } + return {executed, skipped}; +} + +/** + * Names of the projects that ran a build cycle in the most recent build, as opposed to being served + * wholesale from the result cache (which emits no `project-build-start`). + * + * @param {object} t AVA execution context + * @returns {string[]} Project names that started a build + */ +function builtProjects(t) { + const names = []; + for (const [event] of t.context.buildStatusEventStub.args) { + if (event.status === "project-build-start") { + names.push(event.projectName); + } + } + return names; +} + + test.serial("Build application.a (custom task and tag handling)", async (t) => { const fixtureTester = new FixtureTester(t, "application.a"); const destPath = fixtureTester.destPath; @@ -660,3 +702,89 @@ test.serial("Build application.a (step-based custom task: a tag set in keys() su await t.throwsAsync(fs.readFile(`${destPath}/procEach/keep.omitme`, {encoding: "utf8"}), undefined, "#2 build (stage served from cache): the tagged resource is still omitted"); }); + +test.serial("Build application.a (custom task below Specification Version 5.0 is never cached)", async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 build (no cache): everything builds, the cache-exempt custom task runs. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-cacheExempt-legacy.yaml"}, + config: {destPath, cleanDest: true}, + }); + t.true(taskStatuses(t, "application.a").executed.includes("cache-exempt-task"), + "#1 build: the cache-exempt custom task ran"); + + // #2 build (no source changes): a cacheable dependency is served wholesale from the result cache and + // does not rebuild, but application.a rebuilds because its result cache is disabled by the exempt task. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-cacheExempt-legacy.yaml"}, + config: {destPath, cleanDest: true}, + }); + const built = builtProjects(t); + t.true(built.includes("application.a"), + `#2 build: application.a rebuilt despite no changes (built: ${built})`); + t.false(built.includes("library.a"), + `#2 build: a cacheable dependency stayed fully served from cache (built: ${built})`); + + const {executed, skipped} = taskStatuses(t, "application.a"); + t.true(executed.includes("cache-exempt-task"), + "#2 build: the exempt task ran again on an unchanged rebuild"); + t.false(skipped.includes("cache-exempt-task"), + "#2 build: the exempt task was never served from cache"); + t.true(skipped.includes("minify"), + `#2 build: cacheable standard tasks were still served from cache (skipped: ${skipped})`); +}); + +for (const {taskName, rootConfigPath} of [ + {taskName: "cache-exempt-task", rootConfigPath: "ui5-customTask-cacheExempt-legacy.yaml"}, + {taskName: "cache-exempt-optout-task", rootConfigPath: "ui5-customTask-cacheExempt-optOut.yaml"} +]) { + test.serial(`Build application.a (excluded ${taskName} preserves the result cache)`, async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const buildOptions = { + graphConfig: {rootConfigPath}, + config: {destPath: fixtureTester.destPath, cleanDest: true, excludedTasks: [taskName]} + }; + + await fixtureTester.buildProject(buildOptions); + t.true(builtProjects(t).includes("application.a"), "The first build runs the application tasks"); + t.false(taskStatuses(t, "application.a").executed.includes(taskName), "The exempt task is excluded"); + + await fixtureTester.buildProject(buildOptions); + t.deepEqual(builtProjects(t), [], "The unchanged second build uses the project result caches"); + }); +} + +test.serial("Build application.a (Specification Version 5.0 custom task opts out of caching via cache:false)", + async (t) => { + const fixtureTester = new FixtureTester(t, "application.a"); + const destPath = fixtureTester.destPath; + + // #1 build (no cache): everything builds, the opted-out custom task runs. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-cacheExempt-optOut.yaml"}, + config: {destPath, cleanDest: true}, + }); + t.true(taskStatuses(t, "application.a").executed.includes("cache-exempt-optout-task"), + "#1 build: the opted-out custom task ran"); + + // #2 build (no source changes): the task opted out via cache:false, so application.a rebuilds and + // the task runs again, while its cacheable standard tasks stay served from cache. + await fixtureTester.buildProject({ + graphConfig: {rootConfigPath: "ui5-customTask-cacheExempt-optOut.yaml"}, + config: {destPath, cleanDest: true}, + }); + const built = builtProjects(t); + t.true(built.includes("application.a"), + `#2 build: application.a rebuilt despite no changes (built: ${built})`); + + const {executed, skipped} = taskStatuses(t, "application.a"); + t.true(executed.includes("cache-exempt-optout-task"), + "#2 build: the opted-out task ran again on an unchanged rebuild"); + t.false(skipped.includes("cache-exempt-optout-task"), + "#2 build: the opted-out task was never served from cache"); + t.true(skipped.includes("minify"), + `#2 build: cacheable standard tasks were still served from cache (skipped: ${skipped})`); + }); + diff --git a/packages/project/test/lib/build/TaskDefinitions.js b/packages/project/test/lib/build/TaskDefinitions.js index 659bed93378..119b58c657f 100644 --- a/packages/project/test/lib/build/TaskDefinitions.js +++ b/packages/project/test/lib/build/TaskDefinitions.js @@ -643,3 +643,87 @@ test("getBuildSignatures: Empty project hashes the empty string", async (t) => { const expectedHash = crypto.createHash("sha256").update("").digest("hex"); t.is(sig, expectedHash, "Returns sha256 hash of empty key string"); }); + +test("getCustomTaskCacheExemptions: a custom task below Specification Version 5.0 is exempt", async (t) => { + const {TaskDefinitions, graph, taskUtil, taskRepository, customTask} = t.context; + // Default spec-version stub reports gte("5.0") === false, i.e. a pre-5.0 custom task. + customTask.getSpecVersion = () => ({ + gte: () => false, + toString: () => "4.0", + }); + const project = getMockProject("module"); + project.getCustomTasks = () => [{name: "legacyTask"}]; + const taskDefinitions = new TaskDefinitions(graph, project, taskUtil, taskRepository); + + const exemptions = await taskDefinitions.getCustomTaskCacheExemptions(); + t.deepEqual([...exemptions.keys()], ["legacyTask"], "The pre-5.0 custom task is exempt"); + t.deepEqual(exemptions.get("legacyTask"), + {reason: "specVersion", extensionName: "custom task name", specVersion: "4.0"}, + "Exemption records the version reason and the extension's details"); +}); + +test("getCustomTaskCacheExemptions: a 5.0 task opts out via cache:false", async (t) => { + const {TaskDefinitions, graph, taskUtil, taskRepository, customTask} = t.context; + customTask.getSpecVersion = () => ({ + gte: () => true, + toString: () => "5.0", + }); + customTask.getDescriptor = t.context.sinon.stub().resolves({task: () => {}, cache: false}); + const project = getMockProject("module"); + project.getCustomTasks = () => [{name: "optOutTask", configuration: {foo: "bar"}}]; + const taskDefinitions = new TaskDefinitions(graph, project, taskUtil, taskRepository); + + const exemptions = await taskDefinitions.getCustomTaskCacheExemptions(); + t.deepEqual(exemptions.get("optOutTask"), + {reason: "optOut", extensionName: "custom task name", specVersion: "5.0"}, + "Exemption records the opt-out reason"); + t.deepEqual(customTask.getDescriptor.getCall(0).args[1], { + projectName: "project.b", + projectNamespace: "project/b", + configuration: {foo: "bar"}, + taskName: "optOutTask", + }, "The descriptor is resolved with the task's completed options"); +}); + +test("getCustomTaskCacheExemptions: a cacheable 5.0 task is not listed", async (t) => { + const {TaskDefinitions, graph, taskUtil, taskRepository, customTask} = t.context; + customTask.getSpecVersion = () => ({ + gte: () => true, + toString: () => "5.0", + }); + // No cache flag means cacheable. + customTask.getDescriptor = t.context.sinon.stub().resolves({task: () => {}}); + const project = getMockProject("module"); + project.getCustomTasks = () => [{name: "cacheableTask"}]; + const taskDefinitions = new TaskDefinitions(graph, project, taskUtil, taskRepository); + + const exemptions = await taskDefinitions.getCustomTaskCacheExemptions(); + t.is(exemptions.size, 0, "A cacheable custom task is not exempt"); +}); + +for (const {name, excludedTasks, includedTasks, expected} of [ + {name: "excluded task", excludedTasks: ["legacyTask"], includedTasks: [], expected: []}, + {name: "all tasks excluded", excludedTasks: ["*"], includedTasks: [], expected: []}, + { + name: "task included after wildcard exclusion", + excludedTasks: ["*"], includedTasks: ["legacyTask"], expected: ["legacyTask", "legacyTask--2"] + }, + { + name: "all tasks included after exclusion", + excludedTasks: ["legacyTask"], includedTasks: ["*"], expected: ["legacyTask", "legacyTask--2"] + } +]) { + test(`getCustomTaskCacheExemptions: ${name}`, async (t) => { + const {TaskDefinitions, graph, taskUtil, taskRepository, customTask} = t.context; + customTask.getSpecVersion = () => ({gte: () => false, toString: () => "4.0"}); + const project = getMockProject("module"); + project.getCustomTasks = () => [ + {name: "legacyTask"}, + {name: "legacyTask", afterTask: "legacyTask"} + ]; + const taskDefinitions = new TaskDefinitions(graph, project, taskUtil, taskRepository); + + const exemptions = await taskDefinitions.getCustomTaskCacheExemptions({excludedTasks, includedTasks}); + t.deepEqual([...exemptions.keys()], expected); + }); +} diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index a5a3434d75b..660e49ade68 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -164,6 +164,7 @@ test.beforeEach(async (t) => { endTask: sinon.stub(), skipTask: sinon.stub(), verbose: sinon.stub(), + warn: sinon.stub(), perf: sinon.stub(), isLevelEnabled: sinon.stub().returns(true), }; @@ -709,6 +710,7 @@ test("Custom task is called correctly", async (t) => { const getRequiredDependenciesCallbackStub = sinon.stub().resolves(undefined); graph.getExtension.returns({ + getName: () => "custom task name", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, @@ -768,6 +770,7 @@ test("Custom task with legacy spec version", async (t) => { }; const getRequiredDependenciesCallbackStub = sinon.stub().resolves(undefined); graph.getExtension.returns({ + getName: () => "custom task name", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, @@ -828,6 +831,7 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as const requiredDependenciesCallbackStub = sinon.stub().resolves(new Set(["dep.b"])); const getRequiredDependenciesCallbackStub = sinon.stub().resolves(requiredDependenciesCallbackStub); graph.getExtension.returns({ + getName: () => "custom task name", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, @@ -2473,3 +2477,80 @@ for (const path of ["standard", "custom"]) { t.deepEqual(order, [`skipTask:${taskName}`], "A task whose every stage was cached only reports a skip"); }); } + +test("Custom task below Specification Version 5.0 is exempt from caching and warns once", async (t) => { + const {taskRepository, projectBuildLogger, buildCache, sinon, customTask} = t.context; + const project = getMockProject("application"); + project.getBundles = emptyarray; + project.getCustomTasks = () => [ + {name: "legacyTask", afterTask: "minify"} + ]; + // Standard tasks are step-based factories returning an empty step list, so the step runner is a no-op. + taskRepository.getTask = sinon.stub().returns({task: sinon.stub().returns({steps: []})}); + // A pre-5.0 custom task: no descriptor, body from getTask(), always exempt from caching. + customTask.getSpecVersion = () => ({gte: () => false, toString: () => "4.0"}); + customTask.getTask = async () => sinon.stub(); + + const taskRunner = createTaskRunner(t, project); + await taskRunner.runTasks(); + + const stageTasks = buildCache.setTasks.firstCall.firstArg; + const legacyEntry = stageTasks.find((entry) => entry.taskName === "legacyTask"); + t.deepEqual(legacyEntry, {taskName: "legacyTask", cacheable: false}, + "The exempt task is flagged cacheable:false for the build cache"); + const minifyEntry = stageTasks.find((entry) => entry.taskName === "minify"); + t.false("cacheable" in minifyEntry, + "A cacheable standard task carries no cacheable flag (absence means cacheable)"); + + t.is(projectBuildLogger.warn.callCount, 1, "A single warning was logged"); + t.regex(projectBuildLogger.warn.firstCall.firstArg, + /custom task name of project project\.b defines Specification Version 4\.0 and is therefore not cached/, + "The warning names the task, project and Specification Version"); +}); + +test("Multiple custom tasks below Specification Version 5.0 warn only once per process", async (t) => { + const {taskRepository, projectBuildLogger, sinon, customTask} = t.context; + const project = getMockProject("application"); + project.getBundles = emptyarray; + // Two configurations of the same pre-5.0 extension; both are exempt. + project.getCustomTasks = () => [ + {name: "legacyTask", afterTask: "minify"}, + {name: "legacyTask", afterTask: "minify"} + ]; + taskRepository.getTask = sinon.stub().returns({task: sinon.stub().returns({steps: []})}); + customTask.getSpecVersion = () => ({gte: () => false, toString: () => "4.0"}); + customTask.getTask = async () => sinon.stub(); + + const taskRunner = createTaskRunner(t, project); + await taskRunner.runTasks(); + + t.is(projectBuildLogger.warn.callCount, 1, + "Only one warning is logged even though two exempt custom tasks run"); +}); + +test("Specification Version 5.0 custom task opts out of caching via cache:false", async (t) => { + const {taskRepository, projectBuildLogger, buildCache, sinon, customTask} = t.context; + const project = getMockProject("application"); + project.getBundles = emptyarray; + project.getCustomTasks = () => [ + {name: "optOutTask", afterTask: "minify"} + ]; + taskRepository.getTask = sinon.stub().returns({task: sinon.stub().returns({steps: []})}); + // A 5.0 task that opts out: the body comes from descriptor.task, cache:false marks it exempt. + customTask.getSpecVersion = () => ({gte: () => true, toString: () => "5.0"}); + customTask.getDescriptor = sinon.stub().resolves({task: sinon.stub(), cache: false}); + + const taskRunner = createTaskRunner(t, project); + await taskRunner.runTasks(); + + const stageTasks = buildCache.setTasks.firstCall.firstArg; + const optOutEntry = stageTasks.find((entry) => entry.taskName === "optOutTask"); + t.deepEqual(optOutEntry, {taskName: "optOutTask", cacheable: false}, + "The opted-out task is flagged cacheable:false for the build cache"); + + t.is(projectBuildLogger.warn.callCount, 0, + "A deliberate opt-out does not warn"); + t.true(projectBuildLogger.verbose.getCalls().some((call) => + /opted out of the build cache \(cache: false/.test(call.firstArg)), + "The deliberate opt-out is logged at verbose level"); +}); diff --git a/packages/project/test/lib/build/cache/ProjectBuildCache.js b/packages/project/test/lib/build/cache/ProjectBuildCache.js index e4b9c0f85a1..8ae6d5333e2 100644 --- a/packages/project/test/lib/build/cache/ProjectBuildCache.js +++ b/packages/project/test/lib/build/cache/ProjectBuildCache.js @@ -2860,3 +2860,87 @@ test.serial("Integration: step return values round-trip through the real CAS", a 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"); }); + +test("An exempt task requires another build when the cache instance is reused", async (t) => { + const project = createMockProject(); + const cache = new ProjectBuildCache(project, "sig", createMockCacheManager()); + cache.disableResultCache(); + await cache.initSourceIndex(); + cache.setTasks([{taskName: "exemptTask", cacheable: false}]); + + for (let build = 1; build <= 3; build++) { + t.false(cache.isFresh(), `Build ${build}: the project cannot skip the task loop`); + t.false(await cache.validateCache(project.getReader()), + `Build ${build}: background validation requires a build`); + t.false(await cache.validateCache(project.getReader(), {prepareForBuild: true}), + `Build ${build}: build validation requires task execution`); + await cache.prepareStageExecutionAndValidateCache("exemptTask"); + await cache.recordStageResult({ + taskName: "exemptTask", + projectResourceRequests: {paths: new Set(), patterns: new Set()}, + dependencyResourceRequests: {paths: new Set(), patterns: new Set()}, + }); + await cache.allTasksCompleted(); + } +}); + +test("Exempt stage is always executed and never consults the stage cache", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = new ProjectBuildCache(project, "sig", cacheManager); + await cache.initSourceIndex(); + + cache.setTasks([{taskName: "exemptTask", cacheable: false}]); + + // Record the stage once so its BuildStageCache exists; a cacheable stage would then attempt a lookup + // on the next prepare. The exempt stage must not. + await cache.prepareStageExecutionAndValidateCache("exemptTask"); + await cache.recordStageResult({ + taskName: "exemptTask", + projectResourceRequests: {paths: new Set(), patterns: new Set()}, + dependencyResourceRequests: {paths: new Set(), patterns: new Set()}, + }); + + cacheManager.findExistingStageSignatures.resetHistory(); + cacheManager.readStageCache.resetHistory(); + + const verdict = await cache.prepareStageExecutionAndValidateCache("exemptTask"); + t.is(verdict, false, "The exempt stage is always executed (no cache verdict)"); + t.is(cacheManager.findExistingStageSignatures.callCount, 0, + "No persistent stage-cache lookup is performed for an exempt stage"); + t.is(cacheManager.readStageCache.callCount, 0, + "No stage cache is read for an exempt stage"); +}); + +test("An exempt task is neither stored as a stage nor written to the result cache, " + + "while a cacheable sibling stage is stored", async (t) => { + const project = createMockProject(); + const cacheManager = createMockCacheManager(); + const cache = new ProjectBuildCache(project, "sig", cacheManager); + await cache.initSourceIndex(); + + cache.setTasks([ + {taskName: "cacheableTask"}, + {taskName: "exemptTask", cacheable: false}, + ]); + + const emptyRequests = () => ({paths: new Set(), patterns: new Set()}); + for (const taskName of ["cacheableTask", "exemptTask"]) { + await cache.prepareStageExecutionAndValidateCache(taskName); + await cache.recordStageResult({ + taskName, + projectResourceRequests: emptyRequests(), + dependencyResourceRequests: emptyRequests(), + }); + } + await cache.allTasksCompleted(); + await cache.writeCache(); + + const stageWrites = cacheManager.writeStageCache.getCalls().map((call) => call.args[2]); + t.true(stageWrites.includes("task/cacheableTask"), + "The cacheable stage is persisted"); + t.false(stageWrites.includes("task/exemptTask"), + "The exempt stage is never persisted"); + t.is(cacheManager.writeResultMetadata.callCount, 0, + "The result cache is not written for a project that has an exempt task"); +}); diff --git a/packages/project/test/lib/build/helpers/StepRunner.js b/packages/project/test/lib/build/helpers/StepRunner.js index 988618066d0..aa22352e238 100644 --- a/packages/project/test/lib/build/helpers/StepRunner.js +++ b/packages/project/test/lib/build/helpers/StepRunner.js @@ -1,5 +1,5 @@ import test from "ava"; -import StepRunner from "../../../../lib/build/helpers/StepRunner.js"; +import StepRunner, {validateDescriptor} from "../../../../lib/build/helpers/StepRunner.js"; function createResource(resourcePath, content = resourcePath) { return { @@ -1406,3 +1406,20 @@ test("A cached key's glob pattern stays in the stage fold on a delta build (find "The re-run key's glob pattern is folded in too"); }); + +test("validateDescriptor: a 'cache' flag must be a boolean", (t) => { + // cache:false (opt out) and cache:true (explicit opt in) are both accepted. + t.notThrows(() => validateDescriptor({task: () => {}, cache: false})); + t.notThrows(() => validateDescriptor({task: () => {}, cache: true})); + // Omitting it is accepted and means cacheable. + t.notThrows(() => validateDescriptor({task: () => {}})); +}); + +test("validateDescriptor: a non-boolean 'cache' flag throws", (t) => { + t.throws(() => validateDescriptor({task: () => {}, cache: "no"}, {taskName: "myTask"}), { + message: "Task descriptor for task 'myTask' 'cache' must be a boolean, got a string", + }); + t.throws(() => validateDescriptor({task: () => {}, cache: 0}), { + message: /'cache' must be a boolean, got a number/, + }); +});