fix: batch cancellable title reads

This commit is contained in:
Hypatia May
2026-07-24 18:13:11 +08:00
parent f5fc7ac04a
commit fec4ce52cc
23 changed files with 1152 additions and 150 deletions

View File

@@ -12,8 +12,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. |
| `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. |
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. |
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. |
## Invariants every backend must honor
@@ -40,10 +40,10 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
| Hook | Role |
|---|---|
| `name` | Backend label for the dispose-failure `AggregateError`. |
| `loadStored(id)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. |
| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. |
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
| `list()` | List all stored metadata. |
| `list(signal?)` | List all stored metadata, observing optional cancellation. |
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).

View File

@@ -40,8 +40,10 @@ export interface PersistenceBackend<TornMarker = unknown> {
* `id` before repair or state publication. Used by resume/load, live adoption,
* and — via `!== undefined` — the create-collision probe. The returned
* `tornMarker` is present iff there is a torn tail to truncate.
* @param id - persisted session id to resolve.
* @param signal - optional cancellation for backend read work.
*/
loadStored(id: SessionId): Promise<StoredPrefix<TornMarker> | undefined>
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined>
/**
* Durably append a CONTIGUOUS batch, lazily materializing the session first
@@ -60,8 +62,11 @@ export interface PersistenceBackend<TornMarker = unknown> {
*/
commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>
/** List all stored (materialized) sessions' metadata. */
list(): Promise<SessionHeader[]>
/**
* List all stored (materialized) sessions' metadata.
* @param signal - optional cancellation for backend listing work.
*/
list(signal?: AbortSignal): Promise<SessionHeader[]>
/**
* Optional lifecycle teardown (e.g. close a database handle). Awaited by the
@@ -267,14 +272,26 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* Read a detached valid stored prefix without recovery mutations or
* coordinator-state publication.
* @param id - persisted session to inspect.
* @param signal - optional cancellation for queued and backend read work.
* @returns stored header and events before any synthetic recovery closers.
*/
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this.inspectCore(id))
inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this.inspectCore(id, signal), signal)
}
private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const stored = await this.backend.loadStored(id)
private async inspectCore(
id: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
signal?.throwIfAborted()
let stored: StoredPrefix<TornMarker> | undefined
try {
stored = await this.backend.loadStored(id, signal)
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
throw error
}
signal?.throwIfAborted()
if (stored === undefined) throw new Error(`session "${id}" not found`)
this.assertStoredId(id, stored.meta)
this.assertVersion(stored.meta)
@@ -331,9 +348,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* public methods must NOT call each other (deadlock); they call the unserialized
* `*Core` helpers instead.
*/
private serialize<T>(id: SessionId, op: () => Promise<T> | T): Promise<T> {
private serialize<T>(
id: SessionId,
op: () => Promise<T> | T,
signal?: AbortSignal,
): Promise<T> {
const prior = this.chains.get(id) ?? Promise.resolve()
const next = prior.then(op, op)
let started = false
const run = (): Promise<T> | T => {
signal?.throwIfAborted()
started = true
return op()
}
const next = prior.then(run, run)
// Keep the chain alive but swallow this op's rejection for the NEXT waiter
// (the caller still sees the real rejection via `next`).
const tail = next.then(() => undefined, () => undefined)
@@ -343,7 +370,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
void tail.then(() => {
if (this.chains.get(id) === tail) this.chains.delete(id)
})
return next
return signal === undefined ? next : observeQueuedAbort(next, signal, () => started)
}
/** Build a state for a session discovered in storage but not yet in memory. */
@@ -615,3 +642,50 @@ export class PersistenceCoordinator<TornMarker = unknown> {
live.pending.splice(0, batch.length)
}
}
/**
* Give an observation caller a prompt cancellation view of queued work.
*
* The serialized `operation` remains in the same-id chain and checks the signal
* before invoking backend work. Observing its settlement here therefore cannot
* detach a storage read or let a later operation overtake its predecessor.
*/
function observeQueuedAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
started: () => boolean,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false
const finish = (callback: () => void): void => {
if (settled) return
settled = true
signal.removeEventListener('abort', onAbort)
callback()
}
const onAbort = (): void => {
if (started()) return
finish(() => {
try {
signal.throwIfAborted()
} catch (reason: unknown) {
rejectObservation(reject, reason)
return
}
/* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted */
reject(new Error('persistence observation abort event lacked an aborted signal'))
})
}
signal.addEventListener('abort', onAbort, { once: true })
operation.then(
(value) => { finish(() => { resolve(value) }) },
(reason: unknown) => { finish(() => { rejectObservation(reject, reason) }) },
)
if (signal.aborted) onAbort()
})
}
/** Preserve an exact provider or AbortSignal reason, including legacy non-Error values. */
function rejectObservation(reject: (reason?: unknown) => void, reason: unknown): void {
reject(reason)
}

View File

@@ -103,15 +103,17 @@ export abstract class SessionPersistence extends Service {
* This read is serialized with writes for the same id and returns detached
* values, so observers cannot mutate backend-owned state.
* @param id - the persisted session to inspect.
* @param signal - optional cancellation for queued and backend read work.
* @returns the header and valid stored event prefix exactly as observed.
*/
abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
/**
* Lightweight listing from metadata, without a full-log parse.
* @param signal - optional cancellation for backend listing work.
* @returns one header per materialized session.
*/
abstract list(): Promise<SessionHeader[]>
abstract list(signal?: AbortSignal): Promise<SessionHeader[]>
/**
* List materialized sessions with cheap per-log change tokens.

View File

@@ -222,6 +222,21 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('rejects pre-aborted observation reads with the exact cancellation reason', async () => {
const { persistence, dispose } = await make()
try {
const reason = new Error('persistence observation cancelled')
const controller = new AbortController()
controller.abort(reason)
await expect(persistence.list(controller.signal)).rejects.toBe(reason)
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
.rejects.toBe(reason)
} finally {
await dispose()
}
})
it('lists stable lightweight revisions that change after an append', async () => {
const { persistence, dispose } = await make()
try {

View File

@@ -94,8 +94,8 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
return this.coordinator.load(id)
}
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id)
inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id, signal)
}
// --- PersistenceBackend hooks (the Map storage primitives) ---
@@ -132,7 +132,8 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[])
}
async list(): Promise<SessionHeader[]> {
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
signal?.throwIfAborted()
return [...this.store.values()].map(e => structuredClone(e.meta))
}
@@ -153,10 +154,10 @@ class ControlledBackend implements PersistenceBackend<never> {
loadAttempts = 0
repairAttempts = 0
beforeAppend?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
await this.beforeLoadStored?.(++this.loadAttempts)
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> {
await this.beforeLoadStored?.(++this.loadAttempts, signal)
const entry = this.store.get(id)
if (entry === undefined) return undefined
return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) }
@@ -348,6 +349,109 @@ describe('PersistenceCoordinator stored identity', () => {
})
})
describe('PersistenceCoordinator observation cancellation', () => {
it('promptly rejects a queued inspect without invoking it and keeps the same-id chain healthy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('queued-inspect-cancellation')
backend.store.set(id, { meta: meta(id), events: oneTurnLog() })
const loadGate = Promise.withResolvers<boolean>()
backend.beforeLoadStored = async (attempt) => {
if (attempt === 1) await loadGate.promise
}
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const prior = coordinator.inspect(id)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
const controller = new AbortController()
const reason = new Error('queued inspect cancelled')
const queued = coordinator.inspect(id, controller.signal)
let observedReason: unknown
const observedAbort = queued.catch((error: unknown) => {
observedReason = error
})
controller.abort(reason)
await vi.waitFor(() => { expect(observedReason).toBe(reason) })
expect(backend.loadAttempts).toBe(1)
const subsequent = coordinator.inspect(id)
expect(backend.loadAttempts).toBe(1)
loadGate.resolve(true)
await expect(prior).resolves.toMatchObject({ meta: { id } })
await observedAbort
await expect(subsequent).resolves.toMatchObject({ meta: { id } })
expect(backend.loadAttempts).toBe(2)
await vi.waitFor(() => {
expect((coordinator as unknown as CoordinatorInternals).chains.size).toBe(0)
})
} finally {
loadGate.resolve(true)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('waits for active cooperative inspection cleanup before rejecting cancellation', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('active-inspect-cancellation')
backend.store.set(id, { meta: meta(id), events: oneTurnLog() })
const cleanupGate = Promise.withResolvers<boolean>()
let cleanupComplete = false
backend.beforeLoadStored = async (_attempt, signal) => {
await new Promise<void>((resolve) => {
signal?.addEventListener('abort', () => {
void cleanupGate.promise.then(() => {
cleanupComplete = true
resolve()
})
}, { once: true })
})
throw new Error('backend cancellation after cleanup')
}
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const controller = new AbortController()
const reason = new Error('active inspect cancelled')
const pending = coordinator.inspect(id, controller.signal)
let observedReason: unknown
const observed = pending.catch((error: unknown) => {
observedReason = error
})
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
controller.abort(reason)
await Promise.resolve()
expect(observedReason).toBeUndefined()
expect(cleanupComplete).toBe(false)
cleanupGate.resolve(true)
await observed
expect(cleanupComplete).toBe(true)
expect(observedReason).toBe(reason)
const backendFailure = new Error('later inspection failure')
backend.beforeLoadStored = () => Promise.reject(backendFailure)
await expect(coordinator.inspect(id)).rejects.toBe(backendFailure)
} finally {
cleanupGate.resolve(true)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
})
describe('PersistenceCoordinator retirement', () => {
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
const ctx = new Context()