refactor: harden session store updates
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptsDir,
|
||||
updateLastRoute,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
} from "./sessions.js";
|
||||
|
||||
@@ -137,6 +138,56 @@ describe("sessions", () => {
|
||||
expect(store[mainSessionKey]?.compactionCount).toBe(2);
|
||||
});
|
||||
|
||||
it("updateSessionStore preserves concurrent additions", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, "{}", "utf-8");
|
||||
|
||||
await Promise.all([
|
||||
updateSessionStore(storePath, (store) => {
|
||||
store["agent:main:one"] = { sessionId: "sess-1", updatedAt: 1 };
|
||||
}),
|
||||
updateSessionStore(storePath, (store) => {
|
||||
store["agent:main:two"] = { sessionId: "sess-2", updatedAt: 2 };
|
||||
}),
|
||||
]);
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store["agent:main:one"]?.sessionId).toBe("sess-1");
|
||||
expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("updateSessionStore keeps deletions when concurrent writes happen", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:old": { sessionId: "sess-old", updatedAt: 1 },
|
||||
"agent:main:keep": { sessionId: "sess-keep", updatedAt: 2 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
updateSessionStore(storePath, (store) => {
|
||||
delete store["agent:main:old"];
|
||||
}),
|
||||
updateSessionStore(storePath, (store) => {
|
||||
store["agent:main:new"] = { sessionId: "sess-new", updatedAt: 3 };
|
||||
}),
|
||||
]);
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store["agent:main:old"]).toBeUndefined();
|
||||
expect(store["agent:main:keep"]?.sessionId).toBe("sess-keep");
|
||||
expect(store["agent:main:new"]?.sessionId).toBe("sess-new");
|
||||
});
|
||||
|
||||
it("loadSessionStore auto-migrates legacy provider keys to channel keys", async () => {
|
||||
const mainSessionKey = "agent:main:main";
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
|
||||
@@ -45,9 +45,16 @@ export function clearSessionStoreCacheForTest(): void {
|
||||
SESSION_STORE_CACHE.clear();
|
||||
}
|
||||
|
||||
export function loadSessionStore(storePath: string): Record<string, SessionEntry> {
|
||||
type LoadSessionStoreOptions = {
|
||||
skipCache?: boolean;
|
||||
};
|
||||
|
||||
export function loadSessionStore(
|
||||
storePath: string,
|
||||
opts: LoadSessionStoreOptions = {},
|
||||
): Record<string, SessionEntry> {
|
||||
// Check cache first if enabled
|
||||
if (isSessionStoreCacheEnabled()) {
|
||||
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
|
||||
const cached = SESSION_STORE_CACHE.get(storePath);
|
||||
if (cached && isSessionStoreCacheValid(cached)) {
|
||||
const currentMtimeMs = getFileMtimeMs(storePath);
|
||||
@@ -88,7 +95,7 @@ export function loadSessionStore(storePath: string): Record<string, SessionEntry
|
||||
}
|
||||
|
||||
// Cache the result if caching is enabled
|
||||
if (isSessionStoreCacheEnabled()) {
|
||||
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
|
||||
SESSION_STORE_CACHE.set(storePath, {
|
||||
store: structuredClone(store), // Store a copy to prevent external mutations
|
||||
loadedAt: Date.now(),
|
||||
@@ -168,6 +175,19 @@ export async function saveSessionStore(
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSessionStore<T>(
|
||||
storePath: string,
|
||||
mutator: (store: Record<string, SessionEntry>) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
return await withSessionStoreLock(storePath, async () => {
|
||||
// Always re-read inside the lock to avoid clobbering concurrent writers.
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const result = await mutator(store);
|
||||
await saveSessionStoreUnlocked(storePath, store);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
type SessionStoreLockOptions = {
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user