From 0eb33c3d23d30f0d69df80d1752c0fa85f577b90 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Fri, 25 Sep 2026 01:05:33 +0800 Subject: [PATCH] fix(host): re-prove the projection-cache probe face per dsh generation dsh 0.1.7-rc.2 dropped the offset parameter from sessionProjectionCache.cachedSnapshot (the lifecycle identity now rides the header alone). The warm-up's fixed 3-arg probe bound the branded offset onto `keys`, where `new Set(0)` threw for every session with served rows - so the whole corpus read as unserved and took the full cold-read path on each process's first dashboard open (contained per session, but minutes of repeated decode+fold on a large one). The probe now tries the 0.1.5-rc.1 spelling (meta, offset, keys) first and falls back to the 0.1.7-rc.2 spelling (meta, keys); a record-less session returns undefined without throwing on either face, which stays the correct "not served". Covered by a two-face fixture. --- src/host/backfill.ts | 33 ++++++++++++++++++++++++++--- tests/host/backfill.spec.ts | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/host/backfill.ts b/src/host/backfill.ts index f898efc..c961654 100644 --- a/src/host/backfill.ts +++ b/src/host/backfill.ts @@ -55,9 +55,15 @@ interface SessionQueryLike { listSessions(signal?: AbortSignal): Promise } -/** The cache's two cold-path verbs, as consumed. */ +/** + * The cache's two cold-path verbs, as consumed (re-proved at runtime). The + * cached-rows probe's face differs across the supported range — dsh + * 0.1.5-rc.1 takes `(meta, inheritedEventCount, keys)` while dsh 0.1.7-rc.2 + * dropped the offset (`(meta, keys)`, the lifecycle identity riding the + * header alone) — so {@link servesRows} re-proves the shape per call. + */ interface ProjectionCacheLike { - cachedSnapshot(header: SessionHeader, inheritedEventCount: unknown, keys?: readonly string[]): unknown + cachedSnapshot(meta: SessionHeader, offsetOrKeys: unknown, keys?: readonly string[]): unknown coldSnapshot(header: SessionHeader, inheritedEventCount: unknown, events: readonly SessionEvent[]): unknown } @@ -115,6 +121,27 @@ function headerOf(record: unknown): SessionHeader | null { return header as unknown as SessionHeader } +/** The probe keys: the projection keys the cache must already serve. */ +const PROBE_KEYS = ['contextActivity', 'contextTimeline'] + +/** + * Probe the cache through the face the running generation serves. The older + * spelling is tried first and the newer one answers the fallback: on a + * 0.1.7-rc.2 cache the 3-arg call binds the branded offset onto `keys`, + * where `new Set(0)` throws as soon as a served record is reached — while a + * record-less session returns `undefined` without throwing, which is the + * correct "not served" either way. On 0.1.5-rc.1 the 3-arg face answers + * directly; if it ever throws there, the 2-arg retry degrades to the same + * "not served" verdict through the caller's catch. + */ +function cachedSnapshotOf(cache: ProjectionCacheLike, header: SessionHeader): unknown { + try { + return cache.cachedSnapshot(header, SessionLogOffset(0), PROBE_KEYS) + } catch { + return cache.cachedSnapshot(header, PROBE_KEYS) + } +} + /** Whether the cache already serves BOTH projection rows for this header (nothing to backfill). */ function servesRows(cache: ProjectionCacheLike, header: SessionHeader): boolean { try { @@ -124,7 +151,7 @@ function servesRows(cache: ProjectionCacheLike, header: SessionHeader): boolean // Both keys must be served: a version-stale row (the timeline's head // gained `lastUser` at stateVersion 20) reads as absent here, so the // session's stale rows get their one cold refold at startup. - const block = asRecord(cache.cachedSnapshot(header, SessionLogOffset(0), ['contextActivity', 'contextTimeline'])) + const block = asRecord(cachedSnapshotOf(cache, header)) const values = asRecord(block?.values) return values !== null && values.contextActivity !== undefined diff --git a/tests/host/backfill.spec.ts b/tests/host/backfill.spec.ts index 598c876..433ec07 100644 --- a/tests/host/backfill.spec.ts +++ b/tests/host/backfill.spec.ts @@ -284,6 +284,47 @@ describe('watchActivityBackfill', () => { assert.deepEqual(state.coldSnapshots, []) }) + test('the 0.1.7 face answers when the 3-arg spelling throws (the rc.2 cachedSnapshot signature)', async () => { + // dsh 0.1.7-rc.2 dropped the probe's offset parameter: a 3-arg call binds + // the branded offset onto `keys`, where `new Set(0)` throws as soon as a + // served record is reached. The probe must fall through to the newer + // 2-arg face instead of reading every served session as "not served". + const ctx = new Context() + const state = fakeState([{ header: { id: 'a', cwd: '/repo/a' } }]) + const arities: number[] = [] + const route: RouteBox = {} + ctx.provide('connection', connectionOf(route)) + ctx.provide('sessionQuery', { + listSessions: async () => { + arities.push(0) + return state.listed + }, + }) + ctx.provide('sessionProjectionCache', { + cachedSnapshot: (header: { id: string }, offsetOrKeys: unknown, keys?: unknown) => { + arities.push(keys === undefined ? 2 : 3) + if (keys !== undefined) throw new TypeError('offset is not iterable') + return header.id === 'a' + ? { asOfSeq: 0, values: { contextActivity: { days: {} }, contextTimeline: { ok: true } } } + : undefined + }, + coldSnapshot: () => { + state.coldSnapshots.push('x') + return {} + }, + }) + ctx.provide('sessionPersistence', { open: async () => ({}) }) + ctx.provide('sessions', { get: () => undefined }) + const dispose = watchActivityBackfill(ctx) + await trigger(route) + await until(() => (arities.length > 0 ? true : undefined), 'the corpus was queried') + await new Promise(resolve => setTimeout(resolve, 40)) + dispose() + assert.deepEqual(state.coldReads, [], 'the newer face served the rows — no cold read') + assert.deepEqual(state.coldSnapshots, []) + assert.deepEqual([...new Set(arities)].sort(), [0, 2, 3], 'the 3-arg spelling was tried first, the 2-arg face answered') + }) + test('a rejection landing AFTER abort skips the warn (the unload owns the silence)', async () => { const ctx = new Context() let started = false