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:
1 parent
7abe7dacce
commit
0eb33c3d23
2 files changed
+71
-3
No files matched your search
+30
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user