Skip to content

Commit 1470d66

Browse files
committed
refactor(project): Reset result cache state on source-changed retry
allTasksCompleted reset the index state when throwing SourceChangedDuringBuildError but left resultCacheState untouched. Because BuildServer reuses the same ProjectBuildCache instance across retries, a prior prepareProjectBuildAndValidateCache that had transitioned the field to NO_CACHE or FRESH_AND_IN_USE caused the retry to trip the PENDING_VALIDATION invariant in the RESTORING_DEPENDENCY_INDICES branch and fail with "Unexpected result cache state after restoring dependency indices for project XYZ: no_cache".
1 parent 7aff80b commit 1470d66

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

packages/project/lib/build/cache/ProjectBuildCache.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,12 @@ export default class ProjectBuildCache {
12881288
this.#combinedIndexState = INDEX_STATES.RESTORING_PROJECT_INDICES;
12891289
this.#sourceIndex = null;
12901290
this.#taskCache.clear();
1291+
// Result cache state must also be reset: prepareProjectBuildAndValidateCache may have
1292+
// already transitioned it to NO_CACHE or FRESH_AND_IN_USE in the aborted build. The
1293+
// retry's prepareProjectBuildAndValidateCache asserts PENDING_VALIDATION after the
1294+
// dependency-index restore step, so a leftover non-PENDING_VALIDATION value would
1295+
// trip that assertion.
1296+
this.#resultCacheState = RESULT_CACHE_STATES.PENDING_VALIDATION;
12911297

12921298
throw new SourceChangedDuringBuildError(this.#project.getName());
12931299
}

packages/project/test/lib/build/BuildServer.integration.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import sinonGlobal from "sinon";
33
import {fileURLToPath} from "node:url";
44
import {setTimeout} from "node:timers/promises";
55
import fs from "node:fs/promises";
6+
import {appendFileSync} from "node:fs";
67
import {graphFromPackageDependencies} from "../../../lib/graph/graph.js";
78
import {setLogLevel} from "@ui5/logger";
89
import Cache from "../../../lib/build/cache/Cache.js";
@@ -762,6 +763,81 @@ test.serial(
762763
}
763764
);
764765

766+
// Regression: a build that hits the NO_CACHE state in prepareProjectBuildAndValidateCache
767+
// (because the source signature does not match anything in the persistent cache) and then
768+
// throws SourceChangedDuringBuildError from allTasksCompleted used to fail on retry with
769+
// "Unexpected result cache state after restoring dependency indices for project XYZ: no_cache".
770+
// The fix resets #resultCacheState to PENDING_VALIDATION in the source-changed branch.
771+
//
772+
// Repro recipe — must hit *all* of these conditions on the same ProjectBuildCache instance:
773+
// 1. A first build runs to completion, populating the persistent index + result cache.
774+
// 2. The project is invalidated (a real source change observed by the watcher) so the next
775+
// reader request drives a second build.
776+
// 3. The second build's #initSourceIndex finds an existing index cache and transitions to
777+
// RESTORING_DEPENDENCY_INDICES (rather than INITIAL, which short-circuits prepare).
778+
// 4. prepareProjectBuildAndValidateCache sees a source-signature mismatch against the
779+
// persisted result cache and sets #resultCacheState = NO_CACHE.
780+
// 5. A *further* on-disk source change lands during the second build, but the watcher path
781+
// is stubbed so the abort signal is never set. allTasksCompleted's revalidateSourceIndex
782+
// then throws SourceChangedDuringBuildError instead of taking the abort path.
783+
test.serial("Source change during second build retries cleanly without no_cache error", async (t) => {
784+
const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "library.d");
785+
await fixtureTester.serveProject({
786+
config: {excludedTasks: ["minify"]}
787+
});
788+
789+
const changedFilePath = `${fixtureTester.fixturePath}/main/src/library/d/some.js`;
790+
const originalContent = await fs.readFile(changedFilePath, {encoding: "utf8"});
791+
792+
// Build 1 — populates the on-disk index + result cache.
793+
await fixtureTester.requestResource({resource: "/resources/library/d/some.js"});
794+
795+
// Invalidate the project for build 2 by touching the source file. Use the live watcher
796+
// path here: a real modification needs to flow through _projectResourceChanged so the
797+
// project transitions to INVALIDATED and the next request enqueues a rebuild.
798+
await fs.writeFile(changedFilePath, originalContent + "\n// pre-build-2 change\n");
799+
await setTimeout(500); // let the watcher fire and settle
800+
801+
// Now suppress further watcher-driven aborts. The mid-build modification below is meant
802+
// to flow through #revalidateSourceIndex inside allTasksCompleted, *not* through the
803+
// watcher — otherwise the abort path runs first and the no_cache assertion never fires.
804+
t.context.sinon.stub(fixtureTester.buildServer, "_projectResourceChanged");
805+
806+
// During build 2's task pipeline, append a second on-disk change. Hook the first task
807+
// that is *not* short-circuited from cache (replaceCopyright) so the synchronous write
808+
// lands well before allTasksCompleted's #revalidateSourceIndex reads from disk.
809+
let triggered = false;
810+
const handler = (event) => {
811+
if (
812+
!triggered &&
813+
event.projectName === "library.d" &&
814+
event.status === "task-start" &&
815+
event.taskName === "replaceCopyright"
816+
) {
817+
triggered = true;
818+
appendFileSync(changedFilePath, "\n// mid-build-2 change\n");
819+
}
820+
};
821+
process.on("ui5.project-build-status", handler);
822+
823+
let resource;
824+
try {
825+
// Without the fix this rejects with
826+
// "Unexpected result cache state after restoring dependency indices for project XYZ: no_cache".
827+
resource = await fixtureTester._reader.byPath("/resources/library/d/some.js");
828+
} finally {
829+
process.off("ui5.project-build-status", handler);
830+
}
831+
832+
t.true(triggered, "Test setup precondition: source change handler fired during build 2");
833+
834+
const servedContent = await resource.getString();
835+
t.true(servedContent.includes("pre-build-2 change"),
836+
"Retry served content reflecting the pre-build-2 change");
837+
t.true(servedContent.includes("mid-build-2 change"),
838+
"Retry served content reflecting the mid-build-2 change");
839+
});
840+
765841
function getFixturePath(fixtureName) {
766842
return fileURLToPath(new URL(`../../fixtures/${fixtureName}`, import.meta.url));
767843
}

packages/project/test/lib/build/cache/ProjectBuildCache.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,79 @@ test("projectSourcesChanged after SourceChangedDuringBuildError does not corrupt
812812
);
813813
});
814814

815+
test("Retry after SourceChangedDuringBuildError when prior build set NO_CACHE: " +
816+
"resultCacheState resets to PENDING_VALIDATION", async (t) => {
817+
// Regression test for "Unexpected result cache state after restoring dependency indices
818+
// for project ...: no_cache".
819+
//
820+
// Sequence:
821+
// 1. Initial build: prepareProjectBuildAndValidateCache validates the result cache,
822+
// finds no match, transitions resultCacheState to NO_CACHE.
823+
// 2. allTasksCompleted detects a source change during build (file changed after the
824+
// build started but before the watcher's abort signal propagated). It throws
825+
// SourceChangedDuringBuildError and resets indexState — but historically did not
826+
// reset resultCacheState, leaving it stuck on NO_CACHE.
827+
// 3. BuildServer re-enqueues the project. The retry's prepareProjectBuildAndValidateCache
828+
// enters the RESTORING_DEPENDENCY_INDICES branch, which asserts resultCacheState ===
829+
// PENDING_VALIDATION. With the leftover NO_CACHE the assertion threw.
830+
const project = createMockProject();
831+
const cacheManager = createMockCacheManager();
832+
833+
const initialResource = createMockResource("/test.js", "hash1", 1000, 100, 1);
834+
const changedResource = createMockResource("/test.js", "hash2", 2000, 200, 2);
835+
let revalidate = false;
836+
project.getSourceReader.callsFake(() => ({
837+
byGlob: sinon.stub().callsFake(() => {
838+
return Promise.resolve([revalidate ? changedResource : initialResource]);
839+
}),
840+
byPath: sinon.stub().resolves(initialResource)
841+
}));
842+
843+
const indexCache = {
844+
version: "1.0",
845+
indexTree: {
846+
version: 1,
847+
indexTimestamp: 1000,
848+
root: {
849+
hash: "hash1",
850+
children: {
851+
"test.js": {
852+
hash: "hash1",
853+
metadata: {path: "/test.js", lastModified: 1000, size: 100, inode: 1}
854+
}
855+
}
856+
}
857+
},
858+
tasks: []
859+
};
860+
cacheManager.readIndexCache.resolves(indexCache);
861+
// readResultMetadata returns null by default → #findResultCache returns false → NO_CACHE.
862+
863+
const cache = await ProjectBuildCache.create(project, "sig", cacheManager);
864+
await cache.initSourceIndex();
865+
866+
const mockDependencyReader = {
867+
byGlob: sinon.stub().resolves([]),
868+
byPath: sinon.stub().resolves(null)
869+
};
870+
871+
// Step 1: initial build path — drives resultCacheState to NO_CACHE.
872+
await cache.prepareProjectBuildAndValidateCache(mockDependencyReader);
873+
874+
// Step 2: source changed during build — flip the source reader to the modified resource.
875+
revalidate = true;
876+
const error = await t.throwsAsync(() => cache.allTasksCompleted());
877+
t.true(error.message.includes("test.project"), "SourceChangedDuringBuildError thrown");
878+
879+
// Step 3: BuildServer retry — must NOT throw "Unexpected result cache state".
880+
revalidate = false; // pretend the file is stable on the retry
881+
await cache.initSourceIndex();
882+
await t.notThrowsAsync(
883+
() => cache.prepareProjectBuildAndValidateCache(mockDependencyReader),
884+
"prepareProjectBuildAndValidateCache succeeds on retry"
885+
);
886+
});
887+
815888
test("prepareProjectBuildAndValidateCache: returns false for empty cache", async (t) => {
816889
const project = createMockProject();
817890
const cacheManager = createMockCacheManager();

0 commit comments

Comments
 (0)