fix: cancel exact session observations
This commit is contained in:
@@ -142,6 +142,34 @@ const cancellableSessionListings = [
|
||||
},
|
||||
] as const
|
||||
|
||||
interface CancellableExactRead {
|
||||
readonly name: 'traceSession' | 'traceEvent' | 'readEvent'
|
||||
readonly inspects: boolean
|
||||
readonly run: (
|
||||
ctx: Context,
|
||||
sessionId: SessionIdType,
|
||||
signal: AbortSignal,
|
||||
) => Promise<unknown>
|
||||
}
|
||||
|
||||
const cancellableExactReads: readonly CancellableExactRead[] = [
|
||||
{
|
||||
name: 'traceSession',
|
||||
inspects: false,
|
||||
run: (ctx, sessionId, signal) => ctx.sessionQuery.traceSession(sessionId, signal),
|
||||
},
|
||||
{
|
||||
name: 'traceEvent',
|
||||
inspects: true,
|
||||
run: (ctx, sessionId, signal) => ctx.sessionQuery.traceEvent({ sessionId, seq: 0 }, signal),
|
||||
},
|
||||
{
|
||||
name: 'readEvent',
|
||||
inspects: true,
|
||||
run: (ctx, sessionId, signal) => ctx.sessionQuery.readEvent({ sessionId, seq: 0 }, signal),
|
||||
},
|
||||
] as const
|
||||
|
||||
describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => {
|
||||
it('preserves an exact pre-abort reason without entering persistence', async () => {
|
||||
TestPersistence.reset()
|
||||
@@ -223,6 +251,167 @@ describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => {
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) => {
|
||||
it('preserves an exact pre-abort reason without entering persistence', async () => {
|
||||
const persisted = header('pre-aborted-exact-read')
|
||||
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read cancelled before start')
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(run(ctx, persisted.id, controller.signal)).rejects.toBe(reason)
|
||||
expect(TestPersistence.listCalls).toBe(0)
|
||||
expect(TestPersistence.inspectCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('forwards in-flight list cancellation and waits for cleanup before rejecting', async () => {
|
||||
const persisted = header('cancelled-exact-list')
|
||||
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read list cancelled in flight')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.listOverride = async (signal) => {
|
||||
if (signal === undefined) throw new Error('expected exact-read listing signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
started.resolve(undefined)
|
||||
await aborted
|
||||
abortObserved.resolve(undefined)
|
||||
await cleanup.promise
|
||||
active = false
|
||||
signal.throwIfAborted()
|
||||
return []
|
||||
}
|
||||
|
||||
const pending = run(ctx, persisted.id, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
await abortObserved.promise
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectCalls).toEqual([])
|
||||
|
||||
cleanup.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for an ignoring backend to return before preserving the abort reason', async () => {
|
||||
const persisted = header('ignored-exact-signal')
|
||||
const entry = { meta: persisted, events: eventLog() }
|
||||
TestPersistence.reset([entry])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read cancelled while backend ignored signal')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
if (inspects) {
|
||||
TestPersistence.inspectOverride = async () => {
|
||||
active = true
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
active = false
|
||||
return structuredClone(entry)
|
||||
}
|
||||
} else {
|
||||
TestPersistence.listOverride = async () => {
|
||||
active = true
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
active = false
|
||||
return [structuredClone(persisted)]
|
||||
}
|
||||
}
|
||||
|
||||
const pending = run(ctx, persisted.id, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectSignals).toEqual(inspects ? [controller.signal] : [])
|
||||
|
||||
release.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(cancellableExactReads.filter(read => read.inspects))(
|
||||
'$name persisted inspection cancellation',
|
||||
({ run }) => {
|
||||
it('forwards cancellation and waits for inspection cleanup before rejecting', async () => {
|
||||
const persisted = header('cancelled-exact-inspect')
|
||||
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read inspection cancelled in flight')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.inspectOverride = async (_sessionId, signal) => {
|
||||
if (signal === undefined) throw new Error('expected exact-read inspection signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
started.resolve(undefined)
|
||||
await aborted
|
||||
abortObserved.resolve(undefined)
|
||||
await cleanup.promise
|
||||
active = false
|
||||
signal.throwIfAborted()
|
||||
throw new Error('unreachable after exact-read cancellation')
|
||||
}
|
||||
|
||||
const pending = run(ctx, persisted.id, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
await abortObserved.promise
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectSignals).toEqual([controller.signal])
|
||||
|
||||
cleanup.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => {
|
||||
const valid = header('valid-log', 2)
|
||||
@@ -862,9 +1051,15 @@ describe('session-query exact reads', () => {
|
||||
await ctx.plugin(TestPersistence)
|
||||
TestPersistence.listFailure = new Error('list unavailable')
|
||||
TestPersistence.inspectFailure = new Error('inspect unavailable')
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2)
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } })
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: 1 }, signal))
|
||||
.resolves.toMatchObject({ session: { id: live.id }, target: { seq: 1 } })
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 }, signal))
|
||||
.resolves.toMatchObject({ target: { seq: 1 } })
|
||||
expect(TestPersistence.listSignals).toEqual([])
|
||||
expect(TestPersistence.inspectSignals).toEqual([])
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user