fix: cancel exact session observations

This commit is contained in:
Hypatia May
2026-07-24 20:08:38 +08:00
parent 66585635c8
commit 04c8a17de5
12 changed files with 328 additions and 37 deletions
@@ -11,11 +11,11 @@
- `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`.
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
- `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture.
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
- `readEvent(request, signal?)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
- `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles.
## Filtering and extraction
@@ -82,23 +82,37 @@ export class SessionCorpus {
* A known live target never consults persistence, so an optional backend's
* failure cannot make current in-memory history unreadable.
* @param sessionId - session to resolve.
* @param signal - optional cancellation for persisted source resolution.
* @returns detached live-preferred header and events.
*/
async load(sessionId: SessionId): Promise<LogicalSession> {
async load(sessionId: SessionId, signal?: AbortSignal): Promise<LogicalSession> {
signal?.throwIfAborted()
const live = this._ctx.sessions.get(sessionId)
if (live !== undefined) return snapshotLive(live)
if (live !== undefined) {
const snapshot = snapshotLive(live)
signal?.throwIfAborted()
return snapshot
}
const persistence = this._persistence
if (persistence === undefined) throw notFound(sessionId)
const listed = (await listPersisted(persistence)).find(header => header.id === sessionId)
const listed = (await listPersisted(persistence, signal)).find(header => header.id === sessionId)
signal?.throwIfAborted()
if (listed === undefined) throw notFound(sessionId)
const loaded = await inspectPersisted(persistence, sessionId)
const loaded = await inspectPersisted(persistence, sessionId, signal)
signal?.throwIfAborted()
const attached = this._ctx.sessions.get(sessionId)
if (attached !== undefined) return snapshotLive(attached)
if (attached !== undefined) {
const snapshot = snapshotLive(attached)
signal?.throwIfAborted()
return snapshot
}
assertSessionHeadersCompatible(loaded.meta, listed)
return {
const snapshot = {
header: structuredClone(loaded.meta),
events: loaded.events.map(event => structuredClone(event)),
}
signal?.throwIfAborted()
return snapshot
}
/**
@@ -272,22 +272,26 @@ export abstract class SessionQueryService extends Service {
/**
* Trace known ancestry and descendants from one corpus observation.
* @param sessionId - logical session id to trace.
* @param signal - optional cancellation for persistence listing.
* @returns a complete lineage or an explicit unresolved parent boundary.
* @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
*/
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
const records = await this._corpus.listSessions()
async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> {
const records = await this._corpus.listSessions(signal)
signal?.throwIfAborted()
return tracing.traceSession(records, sessionId)
}
/**
* Trace one event's direct positional and provenance relationships.
* @param request - target session id and event seq.
* @param signal - optional cancellation for persisted source resolution.
* @returns source header, direct links, and the target's positional replacement chain.
* @throws when source resolution fails, the target is absent, or surface/provenance validation fails.
*/
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTraceObservation> {
const loaded = await this._corpus.load(request.sessionId)
async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation> {
const loaded = await this._corpus.load(request.sessionId, signal)
signal?.throwIfAborted()
return {
session: loaded.header,
...tracing.traceEvent(request.sessionId, loaded.events, request.seq),
@@ -297,14 +301,15 @@ export abstract class SessionQueryService extends Service {
/**
* Read one full event plus a bounded raw-log context window.
* @param request - target session/seq and context sizes.
* @param signal - optional cancellation for persisted source resolution.
* @returns cloned target and neighboring events.
*/
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> {
async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise<SessionEventWindow> {
const before = this._readWindow('before', request.before)
const after = this._readWindow('after', request.after)
const sessionId = request.sessionId
const seq = request.seq
return this._readEvent(sessionId, seq, before, after)
return this._readEvent(sessionId, seq, before, after, signal)
}
private async _readEvent(
@@ -312,8 +317,10 @@ export abstract class SessionQueryService extends Service {
seq: number,
before: number,
after: number,
signal?: AbortSignal,
): Promise<SessionEventWindow> {
const loaded = await this._corpus.load(sessionId)
const loaded = await this._corpus.load(sessionId, signal)
signal?.throwIfAborted()
const target = loaded.events[seq]
if (target === undefined || target.seq !== seq) {
throw new SessionQueryError(
@@ -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'))
})