From b6f6d041470397e5317dffbf2042f91a8a13de9a Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 10 Jul 2026 12:44:46 +0900 Subject: [PATCH] Bound shared query and catalog context retention --- src/app-server/query/cache.ts | 3 ++- src/features/threads/catalog/thread-catalog.ts | 16 +++++++++++++++- tests/app-server/query-cache.test.ts | 6 ++++++ .../threads/catalog/thread-catalog.test.ts | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index 52537157..0ad18abd 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -32,6 +32,7 @@ import { cloneModelMetadata, cloneSharedServerMetadata, cloneThreads } from "./s const THREAD_LIST_STALE_TIME_MS = 10_000; const APP_SERVER_METADATA_STALE_TIME_MS = 10_000; const MODELS_STALE_TIME_MS = 60_000; +const APP_SERVER_QUERY_GC_TIME_MS = 5 * 60_000; export interface AppServerQueryClientRunner { runWithClient( @@ -388,7 +389,7 @@ function createAppServerQueryClient(): QueryClient { return new QueryClient({ defaultOptions: { queries: { - gcTime: Infinity, + gcTime: APP_SERVER_QUERY_GC_TIME_MS, retry: false, refetchOnReconnect: false, refetchOnWindowFocus: false, diff --git a/src/features/threads/catalog/thread-catalog.ts b/src/features/threads/catalog/thread-catalog.ts index 65670b2e..4668c576 100644 --- a/src/features/threads/catalog/thread-catalog.ts +++ b/src/features/threads/catalog/thread-catalog.ts @@ -2,6 +2,7 @@ import type { ObservedResultListener } from "../../../app-server/query/observed- import type { Thread } from "../../../domain/threads/model"; type ThreadListObserver = ObservedResultListener; +const MAX_RETAINED_THREAD_CATALOG_CONTEXTS = 4; interface ThreadCatalogStore { contextKey(): string; @@ -113,7 +114,20 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo function threadCatalogFactsForContext(factsByContext: Map, contextKey: string): ThreadCatalogFacts { const existing = factsByContext.get(contextKey); - if (existing) return existing; + if (existing) { + factsByContext.delete(contextKey); + factsByContext.set(contextKey, existing); + return existing; + } + while (factsByContext.size >= MAX_RETAINED_THREAD_CATALOG_CONTEXTS) { + let leastRecentlyUsed: string | undefined; + for (const key of factsByContext.keys()) { + leastRecentlyUsed = key; + break; + } + if (leastRecentlyUsed === undefined) break; + factsByContext.delete(leastRecentlyUsed); + } const facts = { active: pendingThreadListFacts(), archived: pendingThreadListFacts(), diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index 1a286b39..e6b377a3 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -17,6 +17,12 @@ import { import type { SharedServerMetadata } from "../../src/domain/server/metadata"; describe("AppServerQueryCache", () => { + it("allows inactive query contexts to be garbage-collected", () => { + const cache = new AppServerQueryCache(); + + expect(cache.client.getDefaultOptions().queries?.gcTime).toBe(300_000); + }); + it("preserves successful metadata resource values when probes fail", () => { const cache = new AppServerQueryCache(); const context = cacheContext(); diff --git a/tests/features/threads/catalog/thread-catalog.test.ts b/tests/features/threads/catalog/thread-catalog.test.ts index 7e00c4ae..ac14d9e9 100644 --- a/tests/features/threads/catalog/thread-catalog.test.ts +++ b/tests/features/threads/catalog/thread-catalog.test.ts @@ -194,6 +194,22 @@ describe("ThreadCatalog", () => { expect(catalog.activeSnapshot()).toEqual([thread("started-a")]); }); + it("evicts lifecycle facts for least-recently-used connection contexts", () => { + const context = { codexPath: "codex-a", vaultPath: "/vault" }; + const { catalog } = catalogFixture({ context: () => context }); + catalog.apply({ type: "thread-started", thread: thread("started-a") }); + + for (const suffix of ["b", "c", "d", "e"]) { + context.codexPath = `codex-${suffix}`; + catalog.activeSnapshot(); + } + + context.codexPath = "codex-a"; + catalog.apply({ type: "active-list-snapshot-received", threads: [thread("server-a")] }); + + expect(catalog.activeSnapshot()).toEqual([thread("server-a")]); + }); + it("keeps rollback fork metadata until active snapshots catch up to the rollback version", () => { const { catalog } = catalogFixture(); const forkBeforeRollback = thread("forked", false, { name: "Before", preview: "Before rollback", updatedAt: 20 });