/** * Persisted projection cache (`ctx.sessionProjectionCache`): durable * checkpoints of every projection unit's state, one record per session on * the `session_projcache` domain (`per-record` layout — the shipped json * backend stores one document per session under its root). Reads and writes * share ONE coherent state: the domain's in-memory tables serve every read * synchronously, and each write lands on the domain's write chain (durability * first, then memory), so a read can never observe a disk write the memory * has not applied, or a memory value the disk does not hold. 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 '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId, } from '@deepseek-ai/dsh-session' import type { ProjectionCheckpoint, ProjectionSnapshot, SessionProjectionMap, } 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' /** Complete identity written by the current cache generation. */ type CurrentCheckpointIdentity = CheckpointIdentity & { formatVersion: number isSeeded: boolean inheritedEventCount: SessionLogOffset } const PREDECESSOR_TITLE_KEY = 'title' as Extract export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' export type { CheckpointIdentity, CheckpointRecord } from './spec.ts' declare module '@deepseek-ai/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 three mandatory write points (session creation, * `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 = 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 | 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 three mandatory points — * session creation, `turn/end`, and session disposal (the live-to-cold * moment) — and serves the * cached rows for a session header. Every durable write is fail-soft: * failures log a warning and the cache self-heals on the next write. */ export class SessionProjectionCache extends Service { static inject = ['storageDomain', 'sessionProjections', 'sessions'] static Config: z = Config private table?: KvTable private readonly dirty = new Map() constructor(ctx: Context, public config: Config) { super(ctx, 'sessionProjectionCache') } /** Open the domain and install the write-behind listeners. */ protected async [Service.init](): Promise { 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 — the same state every * write mutated, so a read can never go around the write chain to the * medium. * @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: CurrentCheckpointIdentity): 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) supersede these values whenever a * session is actually opened. * @param meta - the listed session's header (identity witness; no log read). * @param inheritedEventCount - exact inherited prefix length that completes * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or * `undefined` when no usable row exists for this lifecycle. */ cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract[], ): ProjectionSnapshot | undefined { const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount)) if (record === undefined) return undefined return this.viewRecord(record, keys) } /** * Read only a predecessor checkpoint's title as a zero-I/O listing hint. * * The authoritative Session header supplies the lifecycle identity. A cache * checkpoint can lag that log but cannot lead it because writes flush the * log first, so a matching predecessor title is a genuine (possibly stale) * fact from this Session. The registry still requires the current title * projection's row version and schema. No other predecessor projection is * exposed: format normalization can change their current meaning, and the * strict {@link cachedSnapshot} / hydration paths continue to reject them. * @param meta - authoritative listed Session header. * @param inheritedEventCount - exact inherited cut completing the lifecycle identity. * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined` * when the record is current, newer, unrelated, missing, or incompatible * with the title unit. The sentinel avoids reusing a sequence that a * cardinality-changing Session migration may have remapped. */ cachedPredecessorTitle( meta: SessionHeader, inheritedEventCount: SessionLogOffset, ): ProjectionSnapshot | undefined { const expected = identityOf(meta, inheritedEventCount) const record = this.requireTable().get(meta.id) if (record === undefined || !predecessorIdentityMatches(record.identity, expected)) return undefined const title = this.viewRecord(record, [PREDECESSOR_TITLE_KEY]) return title === undefined ? undefined : { ...title, asOfSeq: -1 } } /** View selected wire rows and bind them to their lowest served watermark. */ private viewRecord( record: CheckpointRecord, keys?: readonly Extract[], ): ProjectionSnapshot | undefined { const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys) const servedKeys = Object.keys(values) if (servedKeys.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 firstKey = servedKeys[0] as string let asOfSeq = (record.rows[firstKey] as ProjectionCheckpoint[string]).seq for (const key of servedKeys.slice(1)) { const row = record.rows[key] as ProjectionCheckpoint[string] if (row.seq < asOfSeq) asOfSeq = row.seq } return { asOfSeq, values } } /** * Hydrate projection cells for an already-prepared Session without another * persistence read. The cache seeds matching rows; the supplied exact log * advances every unit to the observation cut. No checkpoint is written * because the logical observation may contain recovery events not yet durable. * @param session - exact unpublished Session retained by persistence. * @param events - exact logical event prefix represented by the observation. * @returns all projection values at the event cut. */ hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot { const record = this.recordFor( session.id, identityOf(session.header, session.inheritedEventCount), ) if (record === undefined) { return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)) } try { return this.ctx.sessionProjections.hydrate( session, record.rows, events, SessionLogOffset(0), ) } catch { // Cached rows are disposable derived data. Retry from the exact log so a // stale schema cannot make a valid Session unreadable. return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0)) } } /** * Durably checkpoint one live session NOW (all mandatory points call * this; tests and carriers may too). The registry cut is snapshotted at * this boundary (states are live references), then the session's record is * replaced on the domain's write chain. 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 { 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, session.inheritedEventCount), rows, ) } /** * Cold-read one session's projections from its complete log. Each unit is * seeded from the identity-checked cached rows — the registry skips `apply` * for the already-folded prefix (events at or below the row's `seq`) — and * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so * the first cold read creates the cache row and later ones seed from it. * The caller supplies the complete log in seq order: this service never * consults the persistence layer. * @param meta - the stored session header (identity witness). * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity. * @param events - the session's complete log, in seq order. * @returns the projection cut at the log end. */ coldSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[], ): ProjectionSnapshot { const identity = identityOf(meta, inheritedEventCount) const restored = this.ctx.sessionProjections.restore( this.recordFor(meta.id, identity)?.rows ?? {}, events, SessionLogOffset(0), meta, inheritedEventCount, ) // Refresh the row so the next cold read seeds from it; fail-soft and // fire-and-forget — a failed write-back only costs a longer tail replay. void this.put(meta.id, identity, restored.checkpoint).catch((error: unknown) => { this.ctx.logger.warn(`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`) }) 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) }) // Creation is the FIRST mandatory point: a session that never talks (a // forked child seeded with its ancestor's title, say) would otherwise // get its first row only at detach — so a crash, or a fork held live in // the store, would leave the seed-derived values (the title) unreadable // on the cold list. The creation write captures the seed-derived cut. this.ctx.on('session/created', (session: Session) => { void this.flushSoft(session, 'create') }) // Detach (the live-to-cold moment): the final 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) }) // With the plugin (their sessions outlive the cache): clear pending // timers and stop accepting new work. The domain-close effect registered // in init runs after this disposer and drains already-queued writes, so // a late flush can never land after disposal (it rejects `closed` into // flushSoft's warning instead). 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 mandatory points write unconditionally. */ private async flushSoft(session: Session, trigger: string): Promise { 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 { 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'] }) } private requireTable(): KvTable { /* 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, inheritedEventCount: SessionLogOffset, ): CurrentCheckpointIdentity { const cut = SessionLogOffset(inheritedEventCount) if (!header.isSeeded && cut !== 0) { throw new Error('unseeded projection-cache identity inherited event count must be 0') } return { formatVersion: header.version, createdAt: header.createdAt, ...header.cwd === undefined ? {} : { cwd: header.cwd }, isSeeded: header.isSeeded, inheritedEventCount: cut, } } /** * Whether a stored record's bound identity names the caller's lifecycle. * An absent format generation cannot prove the fold semantics and never * matches. Once the format matches, absent lineage fields (records admitted * via `compatibleVersions` predate them) read as the unseeded lineage: exact * for an unseeded caller, while a seeded caller fails the match. */ function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean { return stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected) } /** Match one predecessor cache record to the authoritative listed lifecycle. */ function predecessorIdentityMatches( stored: CheckpointIdentity, expected: CurrentCheckpointIdentity, ): boolean { const predecessor = stored.formatVersion === undefined || stored.formatVersion < expected.formatVersion return predecessor && lifecycleIdentityMatches(stored, expected) } /** Match the format-independent fields that distinguish one Session lifecycle. */ function lifecycleIdentityMatches( stored: CheckpointIdentity, expected: CurrentCheckpointIdentity, ): boolean { return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd && (stored.isSeeded ?? false) === expected.isSeeded && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount } export default SessionProjectionCache