Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/server/src/routes/threads/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,12 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void {
summaryOnly,
includeProviderUnhandledOperations,
};
const paramsKey = buildThreadTimelineParamsKey(keyArgs);
const full = timelineCache.getOrBuild(
buildThreadTimelineCacheKey({ ...keyArgs, maxSeq }),
{
paramsKey,
revisionKey: buildThreadTimelineCacheKey({ ...keyArgs, maxSeq }),
},
() => {
const { profile, response } = buildThreadTimelineWithProfile(
deps.db,
Expand Down Expand Up @@ -402,7 +406,6 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void {
query.afterSequence,
"afterSequence",
);
const paramsKey = buildThreadTimelineParamsKey(keyArgs);
const previous =
afterSequence === undefined
? undefined
Expand Down
41 changes: 24 additions & 17 deletions apps/server/src/services/threads/timeline-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@ import type { ThreadTimelinePageRequest } from "./timeline-pagination.js";
* (detail view + side-chat tabs), debounced realtime invalidations that fire
* after the tail already settled, and re-opening a thread.
*
* Keying on the thread high-water `maxSeq` makes invalidation implicit: any
* appended event bumps `maxSeq`, producing a new key and a cold rebuild. The
* key MUST also include every other input the projection depends on:
* Keying each revision on the thread high-water `maxSeq` makes invalidation
* implicit: any appended event bumps `maxSeq`, producing a cold rebuild. The
* cache keeps only the newest revision for each request shape; otherwise an
* active thread with compact projected rows can pin one full response per
* event until the global LRU fills. The request shape MUST include every other
* input the projection depends on:
* `thread.status` (interrupt flips earlier rows), `environmentId` (workspace
* root relativizes file paths), provider display name (labels dynamic-provider
* diagnostic rows), and the row-shape request flags. Event pruning
* (`pruneResolvedItemDeltas`, background-task progress) is output-preserving
* and never lowers `maxSeq`, so it cannot stale a cached entry.
*
* Entries with many rows are not cached: an expanded active turn (the streaming
* case) produces hundreds of rows AND a `maxSeq` that changes on every event,
* so caching it only thrashes the LRU and pins large objects for no reuse. Idle
* windows collapse completed turns to a handful of rows regardless of thread
* size, so the cap excludes exactly the entries that would never be reused.
* Entries with many rows are not cached: an expanded active turn produces
* hundreds of rows, so caching it would pin a large object for little reuse.
*/

const DEFAULT_MAX_ENTRIES = 128;
Expand All @@ -40,7 +40,7 @@ export interface ThreadTimelineCacheOptions {

export interface ThreadTimelineCache {
getOrBuild(
key: string,
keys: { paramsKey: string; revisionKey: string },
build: () => ThreadTimelineResponse,
): ThreadTimelineResponse;
/** Number of currently cached entries (for tests/metrics). */
Expand All @@ -53,21 +53,28 @@ export function createThreadTimelineCache(
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
const maxCacheableRows =
options.maxCacheableRows ?? DEFAULT_MAX_CACHEABLE_ROWS;
const entries = new Map<string, ThreadTimelineResponse>();
const entries = new Map<
string,
{ revisionKey: string; value: ThreadTimelineResponse }
>();

return {
getOrBuild(key, build) {
const cached = entries.get(key);
if (cached !== undefined) {
getOrBuild({ paramsKey, revisionKey }, build) {
const cached = entries.get(paramsKey);
if (cached?.revisionKey === revisionKey) {
// Re-insert to mark most-recently-used.
entries.delete(key);
entries.set(key, cached);
return cached;
entries.delete(paramsKey);
entries.set(paramsKey, cached);
return cached.value;
}

const value = build();
// A newer revision supersedes the old response even when the new value
// is too large to cache. Keeping the stale value cannot produce a hit
// and needlessly retains its rows.
entries.delete(paramsKey);
if (value.rows.length <= maxCacheableRows) {
entries.set(key, value);
entries.set(paramsKey, { revisionKey, value });
while (entries.size > maxEntries) {
const oldest = entries.keys().next().value;
if (oldest === undefined) {
Expand Down
51 changes: 38 additions & 13 deletions apps/server/test/services/threads/timeline-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,35 +57,60 @@ const baseKeyArgs: ThreadTimelineCacheKeyArgs = {
includeProviderUnhandledOperations: false,
};

function cacheKeys(revisionKey: string, paramsKey = revisionKey) {
return { paramsKey, revisionKey };
}

describe("createThreadTimelineCache", () => {
it("builds once for the same key and serves cached on repeat", () => {
const cache = createThreadTimelineCache();
const build = vi.fn(() => makeResponse(3));

const first = cache.getOrBuild("k", build);
const second = cache.getOrBuild("k", build);
const first = cache.getOrBuild(cacheKeys("k"), build);
const second = cache.getOrBuild(cacheKeys("k"), build);

expect(build).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
expect(cache.size).toBe(1);
});

it("rebuilds when the key changes (e.g. new maxSeq)", () => {
it("rebuilds and replaces the prior revision for the same request shape", () => {
const cache = createThreadTimelineCache();
const build = vi.fn(() => makeResponse(3));

cache.getOrBuild("k1", build);
cache.getOrBuild("k2", build);
cache.getOrBuild(
cacheKeys(buildThreadTimelineCacheKey(baseKeyArgs), "latest-shape"),
build,
);
cache.getOrBuild(
cacheKeys(
buildThreadTimelineCacheKey({ ...baseKeyArgs, maxSeq: 11 }),
"latest-shape",
),
build,
);

expect(build).toHaveBeenCalledTimes(2);
expect(cache.size).toBe(1);
});

it("retains separate request shapes independently", () => {
const cache = createThreadTimelineCache();
const build = vi.fn(() => makeResponse(3));

cache.getOrBuild(cacheKeys("revision", "latest-page"), build);
cache.getOrBuild(cacheKeys("revision", "older-page"), build);

expect(build).toHaveBeenCalledTimes(2);
expect(cache.size).toBe(2);
});

it("does not cache responses above the row cap (streaming expanded turns)", () => {
const cache = createThreadTimelineCache({ maxCacheableRows: 5 });
const build = vi.fn(() => makeResponse(50));

cache.getOrBuild("k", build);
cache.getOrBuild("k", build);
cache.getOrBuild(cacheKeys("k"), build);
cache.getOrBuild(cacheKeys("k"), build);

expect(build).toHaveBeenCalledTimes(2);
expect(cache.size).toBe(0);
Expand All @@ -95,15 +120,15 @@ describe("createThreadTimelineCache", () => {
const cache = createThreadTimelineCache({ maxEntries: 2 });
const build = vi.fn(() => makeResponse(1));

cache.getOrBuild("a", build); // [a]
cache.getOrBuild("b", build); // [a,b]
cache.getOrBuild("a", build); // touch a -> [b,a]
cache.getOrBuild("c", build); // evict b -> [a,c]
cache.getOrBuild(cacheKeys("a"), build); // [a]
cache.getOrBuild(cacheKeys("b"), build); // [a,b]
cache.getOrBuild(cacheKeys("a"), build); // touch a -> [b,a]
cache.getOrBuild(cacheKeys("c"), build); // evict b -> [a,c]

expect(cache.size).toBe(2);
const buildAgain = vi.fn(() => makeResponse(1));
cache.getOrBuild("a", buildAgain); // still cached
cache.getOrBuild("b", buildAgain); // evicted -> rebuild
cache.getOrBuild(cacheKeys("a"), buildAgain); // still cached
cache.getOrBuild(cacheKeys("b"), buildAgain); // evicted -> rebuild
expect(buildAgain).toHaveBeenCalledTimes(1);
});
});
Expand Down