authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 21:38:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:38:20-07:00
logbfe40e6d2574447b0651bdb41337db743ddbe12c
tree4d8460b216d9076f68bbd17a53be23b8106980b7
parent327a29ef56e215c69a5db5d0b6f8df89f04f5d5b
signature Signed by PGP key B5690EEEBB952194

fix(server): the PR cache sweep only removes real entry files (#16285)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 8 insertions(+), 3 deletions(-)

apps/server/src/pullRequest/PullRequestReadCache.test.ts+5-2
...@@ -44,18 +44,21 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => {...@@ -44,18 +44,21 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => {
44 );44 );
45 const unrelated = "unrelated.json";45 const unrelated = "unrelated.json";
46 yield* fs.writeFileString(`${directory}/${unrelated}`, "{}");46 yield* fs.writeFileString(`${directory}/${unrelated}`, "{}");
47 // Starts with the store id but is not an entry file.
48 const prefixed = "pr-v2-backup.json";
49 yield* fs.writeFileString(`${directory}/${prefixed}`, "{}");
47 const justExpired = Duration.sum(50 const justExpired = Duration.sum(
48 PullRequestReadCache.ENTRY_FILE_MAX_AGE,51 PullRequestReadCache.ENTRY_FILE_MAX_AGE,
49 Duration.seconds(1),52 Duration.seconds(1),
50 );53 );
51 yield* ageFiles(directory, [stale!, revisions!, unrelated], justExpired);54 yield* ageFiles(directory, [stale!, revisions!, unrelated, prefixed], justExpired);
52 yield* ageFiles(directory, [fresh!], PullRequestReadCache.ENTRY_FILE_MAX_AGE);55 yield* ageFiles(directory, [fresh!], PullRequestReadCache.ENTRY_FILE_MAX_AGE);
5356
54 yield* PullRequestReadCache.pruneExpiredEntryFiles(directory);57 yield* PullRequestReadCache.pruneExpiredEntryFiles(directory);
5558
56 assert.deepStrictEqual(59 assert.deepStrictEqual(
57 (yield* fs.readDirectory(directory)).toSorted(),60 (yield* fs.readDirectory(directory)).toSorted(),
58 [fresh!, revisions!, unrelated].toSorted(),61 [fresh!, revisions!, unrelated, prefixed].toSorted(),
59 );62 );
60 }),63 }),
61 );64 );
apps/server/src/pullRequest/PullRequestReadCache.ts+3-1
...@@ -27,6 +27,8 @@ const CONCURRENT_READS = 512;...@@ -27,6 +27,8 @@ const CONCURRENT_READS = 512;
27// Persistence prefixes every entry key with the store id, so each entry file27// Persistence prefixes every entry key with the store id, so each entry file
28// name starts with it. The scope `revisions` file does not.28// name starts with it. The scope `revisions` file does not.
29const STORE_ID = "pr-v2";29const STORE_ID = "pr-v2";
30// An entry file is the store id followed by the SHA-256 hex digest of its key.
31const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`);
30/**32/**
31 * Entries expire a minute after they are written, and a write sets the file's33 * Entries expire a minute after they are written, and a write sets the file's
32 * mtime. A file untouched for a day is long expired; the day only leaves slack34 * mtime. A file untouched for a day is long expired; the day only leaves slack
...@@ -186,7 +188,7 @@ export const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpir...@@ -186,7 +188,7 @@ export const pruneExpiredEntryFiles = Effect.fn("PullRequestReadCache.pruneExpir
186 const path = yield* Path.Path;188 const path = yield* Path.Path;
187 const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE);189 const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(ENTRY_FILE_MAX_AGE);
188 const entries = (yield* fileSystem.readDirectory(directory)).filter((name) =>190 const entries = (yield* fileSystem.readDirectory(directory)).filter((name) =>
189 name.startsWith(STORE_ID),191 ENTRY_FILE_NAME.test(name),
190 );192 );
191 // One file at a time, and `partition` visits every file, so one locked file193 // One file at a time, and `partition` visits every file, so one locked file
192 // does not stop the sweep.194 // does not stop the sweep.