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.
This commit is contained in:
bowenliang123 committed 2026-09-25 01:05:33 +08:00
1 parent 7abe7dacce
commit 0eb33c3d23
2 files changed
+71 -3

No files matched your search

+30 -3
View File
@@ -55,9 +55,15 @@ interface SessionQueryLike {
listSessions(signal?: AbortSignal): Promise<unknown>
}
/** 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
+41
View File
@@ -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