summaryrefslogtreecommitdiffhomepage
path: root/src/features/conversation-cache
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/conversation-cache')
-rw-r--r--src/features/conversation-cache/cache.test.ts19
-rw-r--r--src/features/conversation-cache/cache.ts56
-rw-r--r--src/features/conversation-cache/types.ts4
3 files changed, 70 insertions, 9 deletions
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts
index ce1c60c..4306d51 100644
--- a/src/features/conversation-cache/cache.test.ts
+++ b/src/features/conversation-cache/cache.test.ts
@@ -15,8 +15,16 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk =>
*/
function createFakeStore(): ConversationChunkStore {
const store = new Map<string, StoredChunk[]>();
+ const epochs = new Map<string, number>();
return {
+ async historyEpoch(id) {
+ return epochs.get(id);
+ },
+ async reset(id, epoch) {
+ store.delete(id);
+ epochs.set(id, epoch);
+ },
async load(conversationId) {
return store.get(conversationId) ?? [];
},
@@ -33,6 +41,7 @@ function createFakeStore(): ConversationChunkStore {
async delete(conversationId) {
store.delete(conversationId);
+ epochs.delete(conversationId);
},
async index() {
@@ -195,3 +204,13 @@ describe("cache.delete", () => {
expect(result).toEqual([]);
});
});
+
+it("serializes invalidation and rejects stale epoch commits", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("c", [chunk(1)]); // Legacy rows have no trusted epoch.
+ expect(await cache.commit("c", [chunk(101)], 1)).toEqual([chunk(101)]);
+ await Promise.all([cache.commit("c", [chunk(102)], 1), cache.reset("c", 2)]);
+ expect(await cache.commit("c", [chunk(103)], 1)).toEqual([]);
+ expect(await store.historyEpoch("c")).toBe(2);
+});
diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts
index 4953944..431a2a7 100644
--- a/src/features/conversation-cache/cache.ts
+++ b/src/features/conversation-cache/cache.ts
@@ -3,6 +3,9 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationChunkStore } from "./types";
export interface ConversationCache {
+ historyEpoch(conversationId: string): Promise<number | undefined>;
+ reset(conversationId: string, historyEpoch: number): Promise<void>;
+
/** Load all cached chunks for a conversation. */
load(conversationId: string): Promise<readonly StoredChunk[]>;
@@ -10,7 +13,11 @@ export interface ConversationCache {
* Load + reconcile + append new chunks.
* Returns the merged cache (the new authoritative cache for this conversation).
*/
- commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
+ commit(
+ conversationId: string,
+ incoming: readonly StoredChunk[],
+ historyEpoch?: number,
+ ): Promise<readonly StoredChunk[]>;
/** Return the `?sinceSeq=` cursor for the next incremental sync. */
sinceSeq(conversationId: string): Promise<number>;
@@ -43,21 +50,52 @@ export function createConversationCache(
): ConversationCache {
const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;
+ // Serialize mutation per conversation, including invalidation, so an old fetch
+ // cannot resurrect rows after a compaction broadcast has invalidated them.
+ const pending = new Map<string, Promise<unknown>>();
+ function exclusive<T>(id: string, run: () => Promise<T>): Promise<T> {
+ const next = (pending.get(id) ?? Promise.resolve()).catch(() => {}).then(run);
+ pending.set(id, next);
+ void next
+ .finally(() => {
+ if (pending.get(id) === next) pending.delete(id);
+ })
+ .catch(() => {});
+ return next;
+ }
return {
+ historyEpoch: async (id) => {
+ await pending.get(id);
+ return store.historyEpoch(id);
+ },
+ reset: (id, epoch) =>
+ exclusive(id, async () => {
+ const current = await store.historyEpoch(id);
+ if (current === undefined || current < epoch) await store.reset(id, epoch);
+ }),
async load(conversationId) {
+ await pending.get(conversationId);
return store.load(conversationId);
},
- async commit(conversationId, incoming) {
- const cached = await store.load(conversationId);
- const { merged, toAppend } = reconcileCache(cached, incoming);
- if (toAppend.length > 0) {
- await store.append(conversationId, toAppend);
- }
- return merged;
+ commit(conversationId, incoming, epoch) {
+ return exclusive(conversationId, async () => {
+ if (epoch !== undefined) {
+ const current = await store.historyEpoch(conversationId);
+ if (current !== undefined && current > epoch) return store.load(conversationId);
+ if (current !== epoch) await store.reset(conversationId, epoch);
+ }
+ const cached = await store.load(conversationId);
+ const { merged, toAppend } = reconcileCache(cached, incoming);
+ if (toAppend.length > 0) {
+ await store.append(conversationId, toAppend);
+ }
+ return merged;
+ });
},
async sinceSeq(conversationId) {
+ await pending.get(conversationId);
const cached = await store.load(conversationId);
return nextSinceSeq(cached);
},
@@ -72,7 +110,7 @@ export function createConversationCache(
},
async delete(conversationId) {
- await store.delete(conversationId);
+ await exclusive(conversationId, () => store.delete(conversationId));
},
};
}
diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts
index 345ab71..a273f34 100644
--- a/src/features/conversation-cache/types.ts
+++ b/src/features/conversation-cache/types.ts
@@ -17,6 +17,10 @@ export interface ConversationCacheIndexEntry {
* All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
*/
export interface ConversationChunkStore {
+ historyEpoch(conversationId: string): Promise<number | undefined>;
+ /** Atomically replace cached rows with empty history at this epoch. */
+ reset(conversationId: string, historyEpoch: number): Promise<void>;
+
/** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
load(conversationId: string): Promise<readonly StoredChunk[]>;