diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index 2485be7a97a30..bb6eb38f16146 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -123,6 +123,27 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { }), ); + it.effect.each(["", "{", '{"pr":{"revision":42}}'])( + "recovers from malformed scope revisions without reusing stale entries: %j", + (corrupt) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const original = yield* cacheLayer(directory); + yield* original.get("summary", Effect.succeed("stale"), ["pr"]); + yield* fs.writeFileString(directory + "/revisions", corrupt); + const recovered = yield* cacheLayer(directory); + let reads = 0; + const lookup = Effect.sync(() => String(++reads)); + assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "1"); + assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "1"); + yield* recovered.invalidate("pr"); + assert.strictEqual(yield* recovered.get("summary", lookup, ["pr"]), "2"); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", lookup, ["pr"]), "2"); + assert.strictEqual(reads, 2); + }), + ); it.effect("resumes caching after a failed scope read", () => Effect.gen(function* () { const backing = yield* KeyValueStore.KeyValueStore; diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 62ac7da3789d6..6ea0aea8f76b6 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -88,9 +88,17 @@ export const make = Effect.gen(function* () { crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.map(Hex.encode)); const revisions = yield* Cache.makeWith( () => - backing - .get("revisions") - .pipe(Effect.flatMap((raw) => Schema.decodeEffect(revisionCodec)(raw ?? "{}"))), + backing.get("revisions").pipe( + Effect.flatMap((raw) => Schema.decodeEffect(revisionCodec)(raw ?? "{}")), + Effect.catchTags({ + SchemaError: Effect.fn("PullRequestReadCache.recoverRevisions")(function* () { + // Without scope revisions, persisted PR entries cannot be trusted. + yield* backing.clear; + yield* Effect.logWarning("PR cache reset after invalid scope revisions"); + return yield* Schema.decodeEffect(revisionCodec)("{}"); + }), + }), + ), { capacity: 1, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero),