feat(session-query): checkpoint build round 1

This commit is contained in:
Hypatia May
2026-07-10 16:51:19 +08:00
parent 42ebbfdf8f
commit aa1dc0e2c7
40 changed files with 3174 additions and 102 deletions

View File

@@ -26,6 +26,8 @@ The two first-party backends were byte-identical (or same-algorithm) for ALL of
`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice).
After an append or load-time repair commits, the coordinator emits the observe-only `session/persisted` notification described in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Its snapshotted header and seq range let derived read models invalidate safely; synchronous dispatch failures and rejected listeners are contained and never fail durability. Truncate-only HMR adoption emits no repair notification while the live session still owns the open turn.
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
| Hook | Role |

View File

@@ -27,7 +27,7 @@
import { Context } from 'cordis'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { assertSerializable, seedCoversPrefix } from './index.ts'
import { assertSerializable, seedCoversPrefix, type SessionPersistedChange } from './index.ts'
/**
* A stored session's durable prefix as read back from a backend: its
@@ -229,13 +229,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// event inside it — before the op runs would otherwise have those changes
// persisted. The clone is taken synchronously (at call time).
const batch = events.map(e => structuredClone(e))
return this.serialize(id, () => this.appendCore(id, batch))
return this.serialize(id, () => this._appendCore(id, batch))
}
private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
private async _appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
if (events.length === 0) return
let state = this.states.get(id)
if (state === undefined) state = await this.adopt(id) // calls loadCore, not load
if (state === undefined) state = await this.adopt(id) // calls _loadCore, not load
// Contiguity contract: each event's seq must continue the stored log.
for (const [i, event] of events.entries()) {
@@ -247,8 +247,14 @@ export class PersistenceCoordinator<TornMarker = unknown> {
await this.backend.appendBatch(state.meta, events, state.materialized)
// The durable write is the transaction: mark materialized + advance the
// cursor as soon as it commits (uniform across backends).
const fromSeq = state.cursor
state.materialized = true
state.cursor += events.length
this._notifyPersisted(state.meta, {
kind: 'append',
fromSeq,
toSeq: state.cursor - 1,
})
}
/**
@@ -259,10 +265,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* @returns the header plus the event log, ending on a balanced `turn/end`.
*/
load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this.loadCore(id))
return this.serialize(id, () => this._loadCore(id))
}
private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
private async _loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const stored = await this.backend.loadStored(id)
if (stored === undefined) throw new Error(`session "${id}" not found`)
const { meta, events, tornMarker } = stored
@@ -281,10 +287,21 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// there is no state-path ordering dependency (uniform across backends).
if (tornMarker !== undefined || closers.length > 0) {
await this.backend.commitRepair(meta, tornMarker, closers)
this._notifyPersisted(meta, {
kind: 'repair',
fromSeq: events.length,
toSeq: balanced.length - 1,
})
}
// The state keeps its OWN copy of the meta; the returned value is separate so
// a consumer mutating loaded.meta cannot corrupt the backend's metadata.
this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true })
const owner = this.states.get(id)?.owner
// The state keeps its OWN copy of the meta; preserve a live owner already
// bound to the id so a read-side load cannot downgrade adoption state.
this.states.set(id, {
meta: { ...meta },
cursor: balanced.length,
materialized: true,
...owner !== undefined ? { owner } : {},
})
return { meta, events: balanced }
}
@@ -314,11 +331,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/** Build a state for a session discovered in storage but not yet in memory. */
private async adopt(id: SessionId): Promise<SessionState> {
// loadCore (NOT load) — adopt runs inside an already-serialized op, so
// _loadCore (NOT load) — adopt runs inside an already-serialized op, so
// re-entering the chain via the public load() would deadlock.
await this.loadCore(id)
await this._loadCore(id)
const state = this.states.get(id)
/* v8 ignore next -- loadCore always sets the state for the id */
/* v8 ignore next -- _loadCore always sets the state for the id */
if (!state) throw new Error(`failed to adopt session "${id}"`)
return state
}
@@ -475,7 +492,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// resume.
const live = await this.backend.loadLive(id, session.header.cwd)
if (live !== undefined) {
// Do NOT route through loadCore(): that crash-repairs open turns as
// Do NOT route through _loadCore(): that crash-repairs open turns as
// interrupted, which is wrong for HMR while the live Session is still the
// authority and may append the real step/turn end later.
await this.serialize(id, () => this.adoptLivePrefix(session, seed, live))
@@ -515,7 +532,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
owner: session,
})
const suffix = seed.slice(events.length)
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
if (suffix.length > 0) await this._appendCore(session.header.id, suffix)
}
private async flush(session: Session): Promise<void> {
@@ -546,9 +563,20 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/* v8 ignore next -- state is always set by the awaited init before flush */
const cursor = state?.cursor ?? 0
const fresh = batch.filter(e => e.seq >= cursor)
// appendCore (NOT the serialized append) — drain already runs inside the
// _appendCore (NOT the serialized append) — drain already runs inside the
// per-session chain, so re-entering via append() would deadlock.
if (fresh.length > 0) await this.appendCore(session.header.id, fresh)
if (fresh.length > 0) await this._appendCore(session.header.id, fresh)
buffer.splice(0, batch.length)
}
/** Notify derived read models after source data commits. */
private _notifyPersisted(meta: SessionHeader, change: SessionPersistedChange): void {
const header = structuredClone(meta)
const snapshot = structuredClone(change)
void Promise.resolve()
.then(() => this.ctx.parallel('session/persisted', header, snapshot))
.catch((error: unknown) => {
this.ctx.logger.warn(`${this.backend.name}: session/persisted listener failed after ${change.kind} for "${meta.id}": ${String(error)}`)
})
}
}

View File

@@ -36,6 +36,29 @@ declare module 'cordis' {
interface Context {
sessionPersistence: SessionPersistence
}
interface Events {
/**
* A persistence backend committed a canonical session-log change. This is
* an observe-only notification for derived read models: the durable write
* has already succeeded, and listener failures are contained rather than
* propagated into append, load, flush, or teardown.
* @param header - snapshotted persisted session metadata.
* @param change - committed seq range and whether it was an append or repair.
* @mode parallel
*/
'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise<void> | void
}
}
/** A committed persisted-log change observed by derived read models. */
export interface SessionPersistedChange {
/** Whether ordinary append or load-time repair committed the change. */
kind: 'append' | 'repair'
/** First seq affected by the commit. */
fromSeq: number
/** Last seq appended; less than `fromSeq` when repair only removed a torn fragment. */
toSeq: number
}
/**

View File

@@ -31,6 +31,7 @@ import { Context, type Fiber } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '../src/index.ts'
import type { SessionPersistedChange } from '../src/index.ts'
import { meta, oneTurnLog, appendLog } from './contract.ts'
/**
@@ -123,6 +124,40 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('announces committed append and repair ranges without coupling listener failures to writes', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = []
ctx.on('session/persisted', (header, change) => {
observed.push({ headerId: header.id, change: structuredClone(change) })
header.createdAt = -1
return Promise.reject(new Error('derived read model failed'))
})
try {
const m = meta('notifications', WORK)
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
])
await expect(ctx.sessionPersistence.load(m.id)).resolves.toMatchObject({ meta: { createdAt: m.createdAt } })
await Promise.resolve()
await Promise.resolve()
expect(observed).toEqual([
{ headerId: m.id, change: { kind: 'append', fromSeq: 0, toSeq: 5 } },
{ headerId: m.id, change: { kind: 'append', fromSeq: 6, toSeq: 7 } },
{ headerId: m.id, change: { kind: 'repair', fromSeq: 8, toSeq: 9 } },
])
expect((await ctx.sessionPersistence.load(m.id)).meta.createdAt).toBe(m.createdAt)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
await fix.cleanup()
}
})
it('round-trips the seed boundary (seedLength) through persistence', async () => {
// A forked child records how many leading events were inherited via the
// seed; the boundary must survive a reload (so a resume/replay can tell the
@@ -368,6 +403,10 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Crash-tail a torn fragment past the (open) committed turn, then reload.
await first.dispose()
if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK)
const repairs: SessionPersistedChange[] = []
ctx.on('session/persisted', (_header, change) => {
if (change.kind === 'repair') repairs.push(structuredClone(change))
})
const second = await fix.mount(ctx)
// The live session is still the authority: it appends the REAL step/turn
// end. Adoption must truncate the torn tail but NOT synthesize closers.
@@ -378,6 +417,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
expect(repairs).toEqual([])
await second.dispose()
} finally {
await ctx.fiber.dispose()
@@ -385,6 +425,34 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('a query-side load preserves the existing live owner binding', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
let session!: Session
const liveFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('load-owner'), { meta: { cwd: WORK } })
send(session, oneTurnLog())
}, { inject: ['sessions'] }))
try {
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(session.id)
await liveFiber.dispose()
let replacement!: Session
await ctx.plugin(Object.assign((inner: Context) => {
replacement = inner.sessions.create(session.id, {
seed: loaded.events,
meta: { cwd: WORK, createdAt: loaded.meta.createdAt },
})
}, { inject: ['sessions'] }))
await expect(inits(ctx.sessionPersistence).get(replacement)).rejects.toThrow(/different live session|id collision/)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
await fix.cleanup()
}
})
// --- collision / id reuse ---
it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => {