fix: refresh session-store cache when file size changes within same mtime tick

The session-store cache used only mtime for invalidation. In fast CI
runs (especially under bun), test writes to the session store can
complete within the same filesystem mtime granularity (~1s on HFS+/ext4),
so the cache returns stale data. This caused non-deterministic failures
in model precedence tests where a session override written to disk was
not observed by the next loadSessionStore() call.

Fix: add file size as a secondary cache invalidation signal. The cache
now checks both mtimeMs and sizeBytes — if either differs from the
cached values, it reloads from disk.

Changes:
- cache-utils.ts: add getFileSizeBytes() helper
- sessions/store.ts: extend SessionStoreCacheEntry with sizeBytes field,
  check size in cache-hit path, populate size on cache writes
- sessions.cache.test.ts: add regression test for same-mtime rewrite
This commit is contained in:
Josh Lehman
2026-03-02 13:33:08 -08:00
committed by Peter Steinberger
parent f9025c3f55
commit 1212328c8d
3 changed files with 53 additions and 2 deletions

View File

@@ -25,3 +25,11 @@ export function getFileMtimeMs(filePath: string): number | undefined {
return undefined;
}
}
export function getFileSizeBytes(filePath: string): number | undefined {
try {
return fs.statSync(filePath).size;
} catch {
return undefined;
}
}

View File

@@ -198,4 +198,38 @@ describe("Session Store Cache", () => {
const loaded = loadSessionStore(storePath);
expect(loaded).toEqual({});
});
it("should refresh cache when file is rewritten within the same mtime tick", async () => {
// This reproduces the CI flake where fast test writes complete within the
// same mtime granularity (typically 1s on HFS+/ext4), so mtime-only
// invalidation returns stale cached data.
const store1: Record<string, SessionEntry> = {
"session:1": createSessionEntry({ sessionId: "id-1", displayName: "Original" }),
};
await saveSessionStore(storePath, store1);
// Warm the cache
const loaded1 = loadSessionStore(storePath);
expect(loaded1["session:1"].displayName).toBe("Original");
// Rewrite the file directly (bypassing saveSessionStore's write-through
// cache) with different content but preserve the same mtime so only size
// changes.
const store2: Record<string, SessionEntry> = {
"session:1": createSessionEntry({ sessionId: "id-1", displayName: "Original" }),
"session:2": createSessionEntry({ sessionId: "id-2", displayName: "Added" }),
};
const json2 = JSON.stringify(store2, null, 2);
fs.writeFileSync(storePath, json2);
// Force mtime to match the cached value so only size differs
const stat = fs.statSync(storePath);
fs.utimesSync(storePath, stat.atime, stat.mtime);
// The cache should detect the size change and reload from disk
const loaded2 = loadSessionStore(storePath);
expect(loaded2["session:2"]).toBeDefined();
expect(loaded2["session:2"].displayName).toBe("Added");
});
});

View File

@@ -17,7 +17,12 @@ import {
normalizeSessionDeliveryFields,
type DeliveryContext,
} from "../../utils/delivery-context.js";
import { getFileMtimeMs, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js";
import {
getFileMtimeMs,
getFileSizeBytes,
isCacheEnabled,
resolveCacheTtlMs,
} from "../cache-utils.js";
import { loadConfig } from "../config.js";
import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js";
import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js";
@@ -39,6 +44,7 @@ type SessionStoreCacheEntry = {
loadedAt: number;
storePath: string;
mtimeMs?: number;
sizeBytes?: number;
serialized?: string;
};
@@ -208,7 +214,8 @@ export function loadSessionStore(
const cached = SESSION_STORE_CACHE.get(storePath);
if (cached && isSessionStoreCacheValid(cached)) {
const currentMtimeMs = getFileMtimeMs(storePath);
if (currentMtimeMs === cached.mtimeMs) {
const currentSizeBytes = getFileSizeBytes(storePath);
if (currentMtimeMs === cached.mtimeMs && currentSizeBytes === cached.sizeBytes) {
// Return a deep copy to prevent external mutations affecting cache
return structuredClone(cached.store);
}
@@ -288,6 +295,7 @@ export function loadSessionStore(
loadedAt: Date.now(),
storePath,
mtimeMs,
sizeBytes: getFileSizeBytes(storePath),
serialized: serializedFromDisk,
});
}
@@ -667,6 +675,7 @@ function updateSessionStoreWriteCaches(params: {
loadedAt: Date.now(),
storePath: params.storePath,
mtimeMs,
sizeBytes: getFileSizeBytes(params.storePath),
serialized: params.serialized,
});
}