From 6e88fbdd18fa28af6575eb64d4ed6e860c711055 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Oct 2026 18:55:42 +0100 Subject: [PATCH 1/3] refactor(project): Support cache-exempt build stages Some tasks must execute on every build because their inputs or side effects are outside the resource model. Allow setTasks to mark their stages as exempt with a false cacheable flag. Skip cache lookups and writes for exempt stages while retaining their request indices and written-path accounting. Disable the project result cache and make isFresh honor that state across repeated builds. Other stages remain cacheable. Cover stage lookup, persistence, and repeated validation with one cache instance in the cache tests. --- .../lib/build/cache/ProjectBuildCache.js | 100 ++++++++++++++++-- .../test/lib/build/cache/ProjectBuildCache.js | 84 +++++++++++++++ 2 files changed, 173 insertions(+), 11 deletions(-) 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/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"); +}); From 7457e05043adf19658863e52a86d871adc47abdf Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Oct 2026 18:55:57 +0100 Subject: [PATCH 2/3] refactor(project): Exempt selected custom tasks from the build cache Custom tasks below Specification Version 5.0 may perform untracked I/O. Run them on every build. From 5.0, allow task descriptors to opt out by setting cache to false, and validate that the cache flag is a boolean. Share the exemption rule between TaskDefinitions and TaskRunner. Apply composeTaskList before disabling the project result cache, so excluded tasks preserve caching. Honor wildcard selection and repeated task configurations. Warn once per process for legacy tasks and log explicit opt-outs at verbose level. Include unit tests and fixtures for both exemption cases. Integration tests verify repeated execution, cached sibling tasks, and result-cache reuse when the exempt task is excluded. --- packages/project/lib/build/TaskDefinitions.js | 85 ++++++++++++ packages/project/lib/build/TaskRunner.js | 81 +++++++++-- .../lib/build/helpers/ProjectBuildContext.js | 14 +- .../project/lib/build/helpers/StepRunner.js | 8 ++ .../application.a/task.cache-exempt-legacy.js | 11 ++ .../application.a/task.cache-exempt-optout.js | 15 ++ .../ui5-customTask-cacheExempt-legacy.yaml | 17 +++ .../ui5-customTask-cacheExempt-optOut.yaml | 17 +++ .../ProjectBuilder.customTasks.integration.js | 128 ++++++++++++++++++ .../project/test/lib/build/TaskDefinitions.js | 84 ++++++++++++ packages/project/test/lib/build/TaskRunner.js | 81 +++++++++++ .../test/lib/build/helpers/StepRunner.js | 19 ++- 12 files changed, 550 insertions(+), 10 deletions(-) create mode 100644 packages/project/test/fixtures/application.a/task.cache-exempt-legacy.js create mode 100644 packages/project/test/fixtures/application.a/task.cache-exempt-optout.js create mode 100644 packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-legacy.yaml create mode 100644 packages/project/test/fixtures/application.a/ui5-customTask-cacheExempt-optOut.yaml 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/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/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/, + }); +}); From 40889e1acdc4499aa577edbc0594be79d42465a7 Mon Sep 17 00:00:00 2001 From: Merlin Beutlberger Date: Mon, 26 Oct 2026 18:56:14 +0100 Subject: [PATCH 3/3] docs: Document custom-task cache exemptions Document the legacy-task exemption and the descriptor opt-out in the incremental-build architecture reference. Describe stage execution, result-cache suppression, and the warning behavior. --- .agents/skills/incremental-build/architecture.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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.