Files
deepseek-harness/packages/session-projection/session-projection-cache/src/index.ts
imccyu b4bc4f382e refactor(session-projection): compact checkpoint row fields to ver/seq/val
The persisted row (sessionId, key, stateVersion, observedSeq, state)
becomes (sessionId, key, ver, seq, val) — the cache medium repeats these
three names for every unit of every session, so the long forms dominated
the JSON payload. ProjectionCheckpointRow and the checkpointRow zod spec
rename together; the domain spec bumps to v3 (cache semantics: the old
medium is discarded, not migrated). The unit-facing declaration keeps
stateVersion — only the persisted/checkpoint row shape changes.
2026-07-28 22:45:35 +08:00

301 lines
14 KiB
TypeScript

/**
* Persisted projection cache (`ctx.sessionProjectionCache`): durable
* checkpoints of every registered projection unit's state, one record per
* session on the domain data form (`session_projcache` domain — the shipped
* json backend lands it beside `workspace.json`). The cache is a fold
* shortcut, never an authority: a row is possibly stale (its `seq`
* says how stale) but never wrong, so every write path is fail-soft (a lost
* write costs a longer tail replay on the next cold read) and a
* `ver` mismatch discards the row instead of migrating it. Design
* authority: the session-projection RFC
* (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
* @module @deepseek-ai/dsh-session-projection-cache
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
// Empty type import: applies the package's cordis Context merge
// (`ctx.sessionPersistence`), which this service reads on the cold path.
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
import { projectionCacheDomainSpec } from './spec.ts'
import type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts'
export type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
declare module 'cordis' {
interface Context {
sessionProjectionCache: SessionProjectionCache
}
}
/**
* Plugin config. Both throttle triggers are deployment choices with no
* universally correct value, so the composition states them explicitly
* (cordis.yml); the two mandatory write points (`turn/end` and session
* disposal) are policy, not tunables, and always fire.
*/
export interface Config {
/** Committed events per session that force a durable checkpoint write between mandatory points. */
writeEveryEvents: number
/** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
writeIntervalMs: number
}
export const Config: z<Config> = z.object({
writeEveryEvents: z.natural().min(1).required(),
writeIntervalMs: z.natural().min(1).required(),
})
/** Per-session write-behind bookkeeping (live sessions only; dropped at retire). */
interface DirtyState {
/** Committed events since the last durable write. */
pending: number
/** Interval trigger armed at the first dirty event after a clean write. */
timer: ReturnType<typeof setTimeout> | undefined
}
/**
* The persisted projection cache service. Opens the `session_projcache`
* domain at init, checkpoints live sessions on a throttled write-behind
* (count/interval triggers from {@link Config}) plus two mandatory points —
* `turn/end` and session disposal (the live-to-cold moment) — and serves the
* cold-read ladder: cached row, persistence `readFrom` tail, registry
* `restore`, durable write-back. Every durable write is fail-soft: failures
* log a warning and the cache self-heals on the next write or cold read.
*/
export class SessionProjectionCache extends Service {
static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions']
static Config: z<Config> = Config
private table?: KvTable<SessionId, CheckpointRecord>
private readonly dirty = new Map<Session, DirtyState>()
constructor(ctx: Context, public config: Config) {
super(ctx, 'sessionProjectionCache')
}
/** Open the domain and install the write-behind listeners. */
protected async [Service.init](): Promise<void> {
const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec)
this.ctx.effect(() => () => domain.close(), 'sessionProjectionCache.domainClose')
this.table = domain.table('sessions')
this.installWritePath()
}
/**
* The stored record for one session, accepted only when its bound log
* identity matches `expected`. A session id names a slot, not a lifecycle:
* a recreated id or a persistence store swapped under a surviving cache
* must not let an old record seed state folded from an unrelated log.
* Synchronous from the domain's in-memory state.
* @param id - the session whose record is read.
* @param expected - the log identity the caller holds (live or stored header).
* @returns the identity-matching record, or `undefined` (absent or unrelated).
*/
private recordFor(id: SessionId, expected: CheckpointIdentity): CheckpointRecord | undefined {
const record = this.requireTable().get(id)
if (record === undefined) return undefined
return identityMatches(record.identity, expected) ? record : undefined
}
/**
* The zero-I/O listing read: whole values viewed straight from the stored
* rows (version-matching keys only), each cut carried with its watermark
* so a client value store can seed under its higher-seq-wins rule — as
* stale as the last durable checkpoint but never wrong, and never from an
* unrelated log (the caller's header is the identity witness). Fresher
* paths (the history tail baseline, {@link coldSnapshot}) supersede these
* values whenever a session is actually opened.
* @param meta - the listed session's header (identity witness; no log read).
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
* `undefined` when no usable row exists for this lifecycle.
*/
cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined {
const record = this.recordFor(meta.id, identityOf(meta))
if (record === undefined) return undefined
const values = this.ctx.sessionProjections.viewCheckpoint(record.rows)
const keys = Object.keys(values)
if (keys.length === 0) return undefined
// The block carries ONE cut: the lowest served watermark is the seq every
// value is at least current as of (under-claiming is safe under
// higher-seq-wins; over-claiming would let a stale value outrank pushes).
const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq))
return { asOfSeq, values }
}
/**
* Durably checkpoint one live session NOW (both mandatory points call
* this; tests and carriers may too). The registry cut is snapshotted at
* this boundary (states are live references), then the whole record is
* replaced. NOT fail-soft — callers on the fail-soft paths contain it.
* @param session - the live session to checkpoint.
* @returns resolution after durability and event emission.
*/
async write(session: Session): Promise<void> {
const rows = this.ctx.sessionProjections.checkpoint(session)
this.markClean(session)
// Durability barrier: the checkpoint cut was taken above, so flushing
// AFTER it guarantees every event inside the cut is durably logged
// before the cache row lands — a crash can leave the cache behind the
// log (longer tail replay) but never ahead of it (phantom values folded
// from events no stored log contains). At detach the store entry is
// already gone; persistence's own retirement drain covers that path and
// any residual overreach is caught by the cold read's anchored floor.
if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session)
await this.put(session.id, identityOf(session.header), rows)
}
/**
* Cold-read one persisted session's projections with zero full-log load:
* cached rows + a persistence `readFrom` tail from the registry's restore
* floor, refolded by the registry and written back (fail-soft) so the next
* cold read starts closer. A cache row invalidated by a shrunk log
* (crash-repair truncation) triggers one full re-read from seq 0 — the
* ladder's slow rung, still no crash. Rejects when the session has no
* persisted log (`not found` from the persistence seam).
* @param id - the persisted session to read.
* @param signal - optional cancellation for the persistence reads.
* @returns the snapshot cut at the stored log end.
*/
async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
const record = this.requireTable().get(id)
const cached = record?.rows ?? {}
const floor = this.ctx.sessionProjections.restoreFloor(cached)
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 }
const tail = await persistence.readFrom(id, floor, signal)
// The tail's stored header is the identity witness: a record bound to a
// different lifecycle (recreated id, swapped store) is discarded whole
// before any of its rows can seed a fold.
const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
try {
if (!related) throw new Error('unrelated log identity')
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
} catch {
// The recoverable restore failures: an unrelated record, or a row
// overreaching the stored log end (or predating the floor). Both imply
// floor > 0 (baseSeq-0 restores never throw and an unrelated record
// still carried a usable watermark), so the full log is a fresh read.
const whole = await persistence.readFrom(id, 0, signal)
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
}
await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
return restored.snapshot
}
// --- write-behind (throttle + mandatory points) ---
private installWritePath(): void {
// Every committed event advances the dirty counter; turn/end is a
// mandatory point (the durable value most reads want is the turn-final
// one), count/interval throttle the in-turn stream.
this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type === 'turn/end') {
void this.flushSoft(session, 'turn/end')
return
}
const state = this.dirty.get(session) ?? { pending: 0, timer: undefined }
this.dirty.set(session, state)
state.pending += 1
if (state.pending >= this.config.writeEveryEvents) {
void this.flushSoft(session, 'count threshold')
return
}
state.timer ??= setTimeout(() => {
void this.flushSoft(session, 'interval')
}, this.config.writeIntervalMs)
})
// Detach (the live-to-cold moment): the second mandatory point. After
// this write the cold-read ladder serves the session from the cache.
// flushSoft's synchronous prefix reads and resets the dirty state, so
// dropping it (timer already cleared by markClean) right after is safe.
this.ctx.on('session/disposed', (session: Session) => {
void this.flushSoft(session, 'detach')
this.markClean(session)
this.dirty.delete(session)
})
// Clear pending timers with the plugin (their sessions outlive the cache).
this.ctx.effect(() => () => {
for (const state of this.dirty.values()) {
if (state.timer !== undefined) clearTimeout(state.timer)
}
this.dirty.clear()
}, 'sessionProjectionCache.timers')
}
/**
* One fail-soft durable checkpoint. Every caller has work by construction:
* the throttle triggers only fire dirty (markClean clears the timer with
* the counter) and the two mandatory points write unconditionally.
*/
private async flushSoft(session: Session, trigger: string): Promise<void> {
try {
await this.write(session)
} catch (error) {
this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`)
}
}
/** Reset one session's dirty bookkeeping (its checkpoint is being written). */
private markClean(session: Session): void {
const state = this.dirty.get(session)
if (state === undefined) return
state.pending = 0
if (state.timer !== undefined) {
clearTimeout(state.timer)
state.timer = undefined
}
}
/** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
private async put(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise<void> {
const detached = snapshotJsonValue(rows)
if (detached === undefined) {
throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)')
}
await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] })
}
/** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
private async putSoft(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint, what: string): Promise<void> {
try {
await this.put(id, identity, rows)
} catch (error) {
this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`)
}
}
private requireTable(): KvTable<SessionId, CheckpointRecord> {
/* v8 ignore next -- Service.init assigns the table before the service becomes injectable */
if (this.table === undefined) throw new Error('session projection cache is not initialized')
return this.table
}
}
/** Project a header onto the identity fields a record is bound to. */
function identityOf(header: SessionHeader): CheckpointIdentity {
return { createdAt: header.createdAt, ...header.cwd === undefined ? {} : { cwd: header.cwd } }
}
/** Whether a stored record's bound identity names the caller's lifecycle. */
function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean {
return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd
}
export default SessionProjectionCache