fix(session-projection-cache): coldSnapshot honors not-found with zero registered units
Review finding (PR #791): with no projection definitions registered, restoreFloor() is undefined and the fast path returned a successful empty snapshot without touching persistence — a nonexistent session 'succeeded', violating the documented not-found contract in that supported topology. The no-unit branch now probes readFrom(id, 0): an absent log rejects with the seam's not-found, a present one dates the empty cut at its stored end.
This commit is contained in:
@@ -141,8 +141,14 @@ export class SessionProjectionCache extends Service {
|
|||||||
async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
|
async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
|
||||||
const cached = this.checkpointOf(id)
|
const cached = this.checkpointOf(id)
|
||||||
const floor = this.ctx.sessionProjections.restoreFloor(cached)
|
const floor = this.ctx.sessionProjections.restoreFloor(cached)
|
||||||
if (floor === undefined) return { asOfSeq: -1, values: {} }
|
|
||||||
const persistence = this.ctx.sessionPersistence
|
const persistence = this.ctx.sessionPersistence
|
||||||
|
if (floor === undefined) {
|
||||||
|
// No unit registered: nothing to fold, but the not-found contract must
|
||||||
|
// hold in this topology too — the probe read rejects for an absent log
|
||||||
|
// and dates the empty cut for a present one.
|
||||||
|
const probe = await persistence.readFrom(id, 0, signal)
|
||||||
|
return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} }
|
||||||
|
}
|
||||||
let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||||
const tail = await persistence.readFrom(id, floor, signal)
|
const tail = await persistence.readFrom(id, floor, signal)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -252,4 +252,26 @@ describe('SessionProjectionCache cold read', () => {
|
|||||||
const { cache } = await harness()
|
const { cache } = await harness()
|
||||||
await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
|
await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
|
||||||
|
// Same composition minus any registered unit: restoreFloor is undefined,
|
||||||
|
// yet coldSnapshot must still reject for an absent log (probe read) and
|
||||||
|
// serve an empty cut at the stored end for a present one.
|
||||||
|
const pool = new MemoryMediaPool()
|
||||||
|
const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
|
||||||
|
const ctx = new Context()
|
||||||
|
contexts.push(ctx)
|
||||||
|
await ctx.plugin(Storage)
|
||||||
|
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||||
|
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||||
|
ctx.storage.mount('domain', facility)
|
||||||
|
ctx.provide('storageDomain', facility)
|
||||||
|
await ctx.plugin(SessionStore)
|
||||||
|
await ctx.plugin(SessionProjectionRegistry)
|
||||||
|
ctx.provide('sessionPersistence', fakePersistence(logs) as never)
|
||||||
|
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||||
|
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
|
||||||
|
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare')))
|
||||||
|
.resolves.toEqual({ asOfSeq: 2, values: {} })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user