Skip to content

Commit c993d62

Browse files
committed
fix(usage): include ctime in the Antigravity cache fingerprint
A rewrite that restores size and mtime still moves ctime, so the cache no longer serves stale candidates in that case. The reuse test now proves a cache hit with an exclusive lock instead of a forged rewrite, and the forged rewrite gets its own test that expects a fresh read.
1 parent a68589b commit c993d62

3 files changed

Lines changed: 40 additions & 30 deletions

File tree

‎apps/server/src/usage/UsageService.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* from its cached parse position so only the appended bytes are read.
1111
* OpenCode's SQLite reader queries the live database each scan so WAL writes
1212
* remain visible. Antigravity databases are memoised in memory while the
13-
* database and its WAL keep the same `(size, mtime)`.
13+
* database and its WAL keep the same `(size, mtime, ctime)`.
1414
*
1515
* @module UsageService
1616
*/

‎apps/server/src/usage/antigravityUsageReader.ts‎

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -258,17 +258,15 @@ async function readDatabase(path: string, fallbackTimestamp: number): Promise<Us
258258
}
259259

260260
interface CachedDatabase {
261-
readonly size: number;
262-
readonly mtimeMs: number;
263-
readonly walSize: number | null;
264-
readonly walMtimeMs: number | null;
261+
readonly fingerprint: string;
265262
readonly candidates: readonly UsageCandidate[];
266263
}
267264

268265
/**
269266
* Parsed databases keyed by canonical path. An entry is reused only while both
270-
* the database and its `-wal` sidecar are unchanged, because new rows land in
271-
* the WAL without touching the main file until a checkpoint.
267+
* the database and its `-wal` sidecar keep the same size, mtime and ctime: new
268+
* rows land in the WAL without touching the main file until a checkpoint, and
269+
* ctime moves on any content write even when mtime is restored.
272270
*/
273271
export const makeAntigravityUsageCache = () => new Map<string, CachedDatabase>();
274272

@@ -372,25 +370,16 @@ export async function readAntigravityUsage(
372370
visited.add(canonical);
373371
const stat = await NodeFSP.stat(path);
374372
const wal = await NodeFSP.stat(`${path}-wal`).catch(() => null);
373+
const fingerprint = [stat, wal]
374+
.map((file) => (file ? `${file.size}:${file.mtimeMs}:${file.ctimeMs}` : "-"))
375+
.join("/");
375376
const cached = cache?.get(canonical);
376377
let candidates: readonly UsageCandidate[];
377-
if (
378-
cached !== undefined &&
379-
cached.size === stat.size &&
380-
cached.mtimeMs === stat.mtimeMs &&
381-
cached.walSize === (wal?.size ?? null) &&
382-
cached.walMtimeMs === (wal?.mtimeMs ?? null)
383-
) {
378+
if (cached?.fingerprint === fingerprint) {
384379
candidates = cached.candidates;
385380
} else {
386381
candidates = await readDatabase(path, stat.mtimeMs);
387-
cache?.set(canonical, {
388-
size: stat.size,
389-
mtimeMs: stat.mtimeMs,
390-
walSize: wal?.size ?? null,
391-
walMtimeMs: wal?.mtimeMs ?? null,
392-
candidates,
393-
});
382+
cache?.set(canonical, { fingerprint, candidates });
394383
}
395384
const fileIndex = files.length;
396385
files.push({ root, path, records: [] });

‎apps/server/src/usage/usageTranscriptReader.test.ts‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,31 @@ describe("SQLite usage readers", () => {
739739
);
740740

741741
it("reuses an unchanged Antigravity database instead of decoding it again", async () => {
742+
const path = NodePath.join(dir, "session-1.db");
743+
const db = new NodeSqlite.DatabaseSync(path);
744+
try {
745+
db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)");
746+
db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, antigravityGeneration("r-1"));
747+
const cache = makeAntigravityUsageCache();
748+
assert.deepStrictEqual((await readAntigravityUsage(dir, 0, cache)).errors, []);
749+
750+
// An exclusive lock makes a fresh read fail without touching the file, so
751+
// only a cache hit can still return the earlier records.
752+
db.exec("BEGIN EXCLUSIVE");
753+
assert.strictEqual((await readAntigravityUsage(dir, 0)).errors.length, 1);
754+
const cached = await readAntigravityUsage(dir, 0, cache);
755+
assert.deepStrictEqual(cached.errors, []);
756+
assert.deepStrictEqual(
757+
cached.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
758+
["antigravity:11:r-1"],
759+
);
760+
db.exec("ROLLBACK");
761+
} finally {
762+
db.close();
763+
}
764+
});
765+
766+
it("rereads an Antigravity database rewritten with its size and mtime restored", async () => {
742767
const path = NodePath.join(dir, "session-1.db");
743768
const db = new NodeSqlite.DatabaseSync(path);
744769
try {
@@ -749,20 +774,16 @@ describe("SQLite usage readers", () => {
749774
}
750775
await NodeFSP.utimes(path, 1780000000, 1780000000);
751776
const cache = makeAntigravityUsageCache();
752-
const first = await readAntigravityUsage(dir, 0, cache);
753-
assert.deepStrictEqual(first.errors, []);
777+
assert.deepStrictEqual((await readAntigravityUsage(dir, 0, cache)).errors, []);
754778

755-
// Same size and mtime, but no longer a readable database: only a cache hit
756-
// can still return the earlier records.
757779
const { size } = await NodeFSP.stat(path);
758780
await NodeFSP.writeFile(path, Buffer.alloc(size));
759781
await NodeFSP.utimes(path, 1780000000, 1780000000);
760-
assert.strictEqual((await readAntigravityUsage(dir, 0)).errors.length, 1);
761-
const cached = await readAntigravityUsage(dir, 0, cache);
762-
assert.deepStrictEqual(cached.errors, []);
782+
const next = await readAntigravityUsage(dir, 0, cache);
783+
assert.deepStrictEqual(next.errors, [path]);
763784
assert.deepStrictEqual(
764-
cached.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
765-
["antigravity:11:r-1"],
785+
next.files.flatMap((file) => file.records),
786+
[],
766787
);
767788
});
768789

0 commit comments

Comments
 (0)