index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. /**
  2. * Persisted projection cache (`ctx.sessionProjectionCache`): durable
  3. * checkpoints of every projection unit's state, one record per session on
  4. * the `session_projcache` domain (`per-record` layout — the shipped json
  5. * backend stores one document per session under its root). Reads and writes
  6. * share ONE coherent state: the domain's in-memory tables serve every read
  7. * synchronously, and each write lands on the domain's write chain (durability
  8. * first, then memory), so a read can never observe a disk write the memory
  9. * has not applied, or a memory value the disk does not hold. The cache is a
  10. * fold shortcut, never an authority: a row
  11. * is possibly stale (its `seq` says how stale) but never wrong, so every
  12. * write path is fail-soft (a lost write costs a longer tail replay on the
  13. * next cold read) and a `ver` mismatch discards the row instead of migrating
  14. * it. Design authority: the session-projection RFC
  15. * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
  16. * @module @deepseek-ai/dsh-session-projection-cache
  17. */
  18. import { Context, Service } from '@deepseek-ai/cordis'
  19. import z from '@deepseek-ai/schemastery'
  20. import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
  21. import { SessionLogOffset } from '@deepseek-ai/dsh-session'
  22. import type {
  23. Session,
  24. SessionEvent,
  25. SessionHeader,
  26. SessionId,
  27. } from '@deepseek-ai/dsh-session'
  28. import type {
  29. ProjectionCheckpoint,
  30. ProjectionSnapshot,
  31. SessionProjectionMap,
  32. } from '@deepseek-ai/dsh-session-projection'
  33. import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
  34. import { projectionCacheDomainSpec } from './spec.ts'
  35. import type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
  36. /** Complete identity written by the current cache generation. */
  37. type CurrentCheckpointIdentity = CheckpointIdentity & {
  38. formatVersion: number
  39. isSeeded: boolean
  40. inheritedEventCount: SessionLogOffset
  41. }
  42. const PREDECESSOR_TITLE_KEY = 'title' as Extract<keyof SessionProjectionMap, string>
  43. export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts'
  44. export type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
  45. declare module '@deepseek-ai/cordis' {
  46. interface Context {
  47. sessionProjectionCache: SessionProjectionCache
  48. }
  49. }
  50. /**
  51. * Plugin config. Both throttle triggers are deployment choices with no
  52. * universally correct value, so the composition states them explicitly
  53. * (cordis.yml); the three mandatory write points (session creation,
  54. * `turn/end`, and session disposal) are policy, not tunables, and always
  55. * fire.
  56. */
  57. export interface Config {
  58. /** Committed events per session that force a durable checkpoint write between mandatory points. */
  59. writeEveryEvents: number
  60. /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
  61. writeIntervalMs: number
  62. }
  63. export const Config: z<Config> = z.object({
  64. writeEveryEvents: z.natural().min(1).required(),
  65. writeIntervalMs: z.natural().min(1).required(),
  66. })
  67. /** Per-session write-behind bookkeeping (live sessions only; dropped at retire). */
  68. interface DirtyState {
  69. /** Committed events since the last durable write. */
  70. pending: number
  71. /** Interval trigger armed at the first dirty event after a clean write. */
  72. timer: ReturnType<typeof setTimeout> | undefined
  73. }
  74. /**
  75. * The persisted projection cache service. Opens the `session_projcache`
  76. * domain at init, checkpoints live sessions on a throttled write-behind
  77. * (count/interval triggers from {@link Config}) plus three mandatory points —
  78. * session creation, `turn/end`, and session disposal (the live-to-cold
  79. * moment) — and serves the
  80. * cached rows for a session header. Every durable write is fail-soft:
  81. * failures log a warning and the cache self-heals on the next write.
  82. */
  83. export class SessionProjectionCache extends Service {
  84. static inject = ['storageDomain', 'sessionProjections', 'sessions']
  85. static Config: z<Config> = Config
  86. private table?: KvTable<SessionId, CheckpointRecord>
  87. private readonly dirty = new Map<Session, DirtyState>()
  88. constructor(ctx: Context, public config: Config) {
  89. super(ctx, 'sessionProjectionCache')
  90. }
  91. /** Open the domain and install the write-behind listeners. */
  92. protected async [Service.init](): Promise<void> {
  93. const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec)
  94. this.ctx.effect(() => () => domain.close(), 'sessionProjectionCache.domainClose')
  95. this.table = domain.table('sessions')
  96. this.installWritePath()
  97. }
  98. /**
  99. * The stored record for one session, accepted only when its bound log
  100. * identity matches `expected`. A session id names a slot, not a lifecycle:
  101. * a recreated id or a persistence store swapped under a surviving cache
  102. * must not let an old record seed state folded from an unrelated log.
  103. * Synchronous from the domain's in-memory state — the same state every
  104. * write mutated, so a read can never go around the write chain to the
  105. * medium.
  106. * @param id - the session whose record is read.
  107. * @param expected - the log identity the caller holds (live or stored header).
  108. * @returns the identity-matching record, or `undefined` (absent or unrelated).
  109. */
  110. private recordFor(id: SessionId, expected: CurrentCheckpointIdentity): CheckpointRecord | undefined {
  111. const record = this.requireTable().get(id)
  112. if (record === undefined) return undefined
  113. return identityMatches(record.identity, expected) ? record : undefined
  114. }
  115. /**
  116. * The zero-I/O listing read: whole values viewed straight from the stored
  117. * rows (version-matching keys only), each cut carried with its watermark so
  118. * a client value store can seed under its higher-seq-wins rule — as stale
  119. * as the last durable checkpoint but never wrong, and never from an
  120. * unrelated log (the caller's header is the identity witness). Fresher
  121. * paths (the history tail baseline) supersede these values whenever a
  122. * session is actually opened.
  123. * @param meta - the listed session's header (identity witness; no log read).
  124. * @param inheritedEventCount - exact inherited prefix length that completes
  125. * the checkpoint identity.
  126. * @param keys - optional projection keys required by the caller's audience.
  127. * @returns the cut (`asOfSeq` = lowest served-row watermark), or
  128. * `undefined` when no usable row exists for this lifecycle.
  129. */
  130. cachedSnapshot(
  131. meta: SessionHeader,
  132. inheritedEventCount: SessionLogOffset,
  133. keys?: readonly Extract<keyof SessionProjectionMap, string>[],
  134. ): ProjectionSnapshot | undefined {
  135. const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount))
  136. if (record === undefined) return undefined
  137. return this.viewRecord(record, keys)
  138. }
  139. /**
  140. * Read only a predecessor checkpoint's title as a zero-I/O listing hint.
  141. *
  142. * The authoritative Session header supplies the lifecycle identity. A cache
  143. * checkpoint can lag that log but cannot lead it because writes flush the
  144. * log first, so a matching predecessor title is a genuine (possibly stale)
  145. * fact from this Session. The registry still requires the current title
  146. * projection's row version and schema. No other predecessor projection is
  147. * exposed: format normalization can change their current meaning, and the
  148. * strict {@link cachedSnapshot} / hydration paths continue to reject them.
  149. * @param meta - authoritative listed Session header.
  150. * @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
  151. * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined`
  152. * when the record is current, newer, unrelated, missing, or incompatible
  153. * with the title unit. The sentinel avoids reusing a sequence that a
  154. * cardinality-changing Session migration may have remapped.
  155. */
  156. cachedPredecessorTitle(
  157. meta: SessionHeader,
  158. inheritedEventCount: SessionLogOffset,
  159. ): ProjectionSnapshot | undefined {
  160. const expected = identityOf(meta, inheritedEventCount)
  161. const record = this.requireTable().get(meta.id)
  162. if (record === undefined || !predecessorIdentityMatches(record.identity, expected)) return undefined
  163. const title = this.viewRecord(record, [PREDECESSOR_TITLE_KEY])
  164. return title === undefined ? undefined : { ...title, asOfSeq: -1 }
  165. }
  166. /** View selected wire rows and bind them to their lowest served watermark. */
  167. private viewRecord(
  168. record: CheckpointRecord,
  169. keys?: readonly Extract<keyof SessionProjectionMap, string>[],
  170. ): ProjectionSnapshot | undefined {
  171. const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys)
  172. const servedKeys = Object.keys(values)
  173. if (servedKeys.length === 0) return undefined
  174. // The block carries ONE cut: the lowest served watermark is the seq every
  175. // value is at least current as of (under-claiming is safe under
  176. // higher-seq-wins; over-claiming would let a stale value outrank pushes).
  177. const firstKey = servedKeys[0] as string
  178. let asOfSeq = (record.rows[firstKey] as ProjectionCheckpoint[string]).seq
  179. for (const key of servedKeys.slice(1)) {
  180. const row = record.rows[key] as ProjectionCheckpoint[string]
  181. if (row.seq < asOfSeq) asOfSeq = row.seq
  182. }
  183. return { asOfSeq, values }
  184. }
  185. /**
  186. * Hydrate projection cells for an already-prepared Session without another
  187. * persistence read. The cache seeds matching rows; the supplied exact log
  188. * advances every unit to the observation cut. No checkpoint is written
  189. * because the logical observation may contain recovery events not yet durable.
  190. * @param session - exact unpublished Session retained by persistence.
  191. * @param events - exact logical event prefix represented by the observation.
  192. * @returns all projection values at the event cut.
  193. */
  194. hydratePrepared(
  195. session: Session,
  196. events: readonly SessionEvent[],
  197. ): ProjectionSnapshot {
  198. const record = this.recordFor(
  199. session.id,
  200. identityOf(session.header, session.inheritedEventCount),
  201. )
  202. if (record === undefined) {
  203. return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0))
  204. }
  205. try {
  206. return this.ctx.sessionProjections.hydrate(
  207. session,
  208. record.rows,
  209. events,
  210. SessionLogOffset(0),
  211. )
  212. } catch {
  213. // Cached rows are disposable derived data. Retry from the exact log so a
  214. // stale schema cannot make a valid Session unreadable.
  215. return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0))
  216. }
  217. }
  218. /**
  219. * Durably checkpoint one live session NOW (all mandatory points call
  220. * this; tests and carriers may too). The registry cut is snapshotted at
  221. * this boundary (states are live references), then the session's record is
  222. * replaced on the domain's write chain. NOT fail-soft — callers on the
  223. * fail-soft paths contain it.
  224. * @param session - the live session to checkpoint.
  225. * @returns resolution after durability and event emission.
  226. */
  227. async write(session: Session): Promise<void> {
  228. const rows = this.ctx.sessionProjections.checkpoint(session)
  229. this.markClean(session)
  230. // Durability barrier: the checkpoint cut was taken above, so flushing
  231. // AFTER it guarantees every event inside the cut is durably logged
  232. // before the cache row lands — a crash can leave the cache behind the
  233. // log (longer tail replay) but never ahead of it (phantom values folded
  234. // from events no stored log contains). At detach the store entry is
  235. // already gone; persistence's own retirement drain covers that path and
  236. // any residual overreach is caught by the cold read's anchored floor.
  237. if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session)
  238. await this.put(
  239. session.id,
  240. identityOf(session.header, session.inheritedEventCount),
  241. rows,
  242. )
  243. }
  244. /**
  245. * Cold-read one session's projections from its complete log. Each unit is
  246. * seeded from the identity-checked cached rows — the registry skips `apply`
  247. * for the already-folded prefix (events at or below the row's `seq`) — and
  248. * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
  249. * the first cold read creates the cache row and later ones seed from it.
  250. * The caller supplies the complete log in seq order: this service never
  251. * consults the persistence layer.
  252. * @param meta - the stored session header (identity witness).
  253. * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity.
  254. * @param events - the session's complete log, in seq order.
  255. * @returns the projection cut at the log end.
  256. */
  257. coldSnapshot(
  258. meta: SessionHeader,
  259. inheritedEventCount: SessionLogOffset,
  260. events: readonly SessionEvent[],
  261. ): ProjectionSnapshot {
  262. const identity = identityOf(meta, inheritedEventCount)
  263. const restored = this.ctx.sessionProjections.restore(
  264. this.recordFor(meta.id, identity)?.rows ?? {},
  265. events,
  266. SessionLogOffset(0),
  267. meta,
  268. inheritedEventCount,
  269. )
  270. // Refresh the row so the next cold read seeds from it; fail-soft and
  271. // fire-and-forget — a failed write-back only costs a longer tail replay.
  272. void this.put(meta.id, identity, restored.checkpoint).catch((error: unknown) => {
  273. this.ctx.logger.warn(`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`)
  274. })
  275. return restored.snapshot
  276. }
  277. // --- write-behind (throttle + mandatory points) ---
  278. private installWritePath(): void {
  279. // Every committed event advances the dirty counter; turn/end is a
  280. // mandatory point (the durable value most reads want is the turn-final
  281. // one), count/interval throttle the in-turn stream.
  282. this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
  283. if (event.type === 'turn/end') {
  284. void this.flushSoft(session, 'turn/end')
  285. return
  286. }
  287. const state = this.dirty.get(session) ?? { pending: 0, timer: undefined }
  288. this.dirty.set(session, state)
  289. state.pending += 1
  290. if (state.pending >= this.config.writeEveryEvents) {
  291. void this.flushSoft(session, 'count threshold')
  292. return
  293. }
  294. state.timer ??= setTimeout(() => {
  295. void this.flushSoft(session, 'interval')
  296. }, this.config.writeIntervalMs)
  297. })
  298. // Creation is the FIRST mandatory point: a session that never talks (a
  299. // forked child seeded with its ancestor's title, say) would otherwise
  300. // get its first row only at detach — so a crash, or a fork held live in
  301. // the store, would leave the seed-derived values (the title) unreadable
  302. // on the cold list. The creation write captures the seed-derived cut.
  303. this.ctx.on('session/created', (session: Session) => {
  304. void this.flushSoft(session, 'create')
  305. })
  306. // Detach (the live-to-cold moment): the final mandatory point. After
  307. // this write the cold-read ladder serves the session from the cache.
  308. // flushSoft's synchronous prefix reads and resets the dirty state, so
  309. // dropping it (timer already cleared by markClean) right after is safe.
  310. this.ctx.on('session/disposed', (session: Session) => {
  311. void this.flushSoft(session, 'detach')
  312. this.markClean(session)
  313. this.dirty.delete(session)
  314. })
  315. // With the plugin (their sessions outlive the cache): clear pending
  316. // timers and stop accepting new work. The domain-close effect registered
  317. // in init runs after this disposer and drains already-queued writes, so
  318. // a late flush can never land after disposal (it rejects `closed` into
  319. // flushSoft's warning instead).
  320. this.ctx.effect(() => () => {
  321. for (const state of this.dirty.values()) {
  322. if (state.timer !== undefined) clearTimeout(state.timer)
  323. }
  324. this.dirty.clear()
  325. }, 'sessionProjectionCache.timers')
  326. }
  327. /**
  328. * One fail-soft durable checkpoint. Every caller has work by construction:
  329. * the throttle triggers only fire dirty (markClean clears the timer with
  330. * the counter) and the mandatory points write unconditionally.
  331. */
  332. private async flushSoft(session: Session, trigger: string): Promise<void> {
  333. try {
  334. await this.write(session)
  335. } catch (error) {
  336. this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`)
  337. }
  338. }
  339. /** Reset one session's dirty bookkeeping (its checkpoint is being written). */
  340. private markClean(session: Session): void {
  341. const state = this.dirty.get(session)
  342. if (state === undefined) return
  343. state.pending = 0
  344. if (state.timer !== undefined) {
  345. clearTimeout(state.timer)
  346. state.timer = undefined
  347. }
  348. }
  349. /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
  350. private async put(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise<void> {
  351. const detached = snapshotJsonValue(rows)
  352. if (detached === undefined) {
  353. throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)')
  354. }
  355. await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] })
  356. }
  357. private requireTable(): KvTable<SessionId, CheckpointRecord> {
  358. /* v8 ignore next -- Service.init assigns the table before the service becomes injectable */
  359. if (this.table === undefined) throw new Error('session projection cache is not initialized')
  360. return this.table
  361. }
  362. }
  363. /** Project a header onto the identity fields a record is bound to. */
  364. function identityOf(
  365. header: SessionHeader,
  366. inheritedEventCount: SessionLogOffset,
  367. ): CurrentCheckpointIdentity {
  368. const cut = SessionLogOffset(inheritedEventCount)
  369. if (!header.isSeeded && cut !== 0) {
  370. throw new Error('unseeded projection-cache identity inherited event count must be 0')
  371. }
  372. return {
  373. formatVersion: header.version,
  374. createdAt: header.createdAt,
  375. ...header.cwd === undefined ? {} : { cwd: header.cwd },
  376. isSeeded: header.isSeeded,
  377. inheritedEventCount: cut,
  378. }
  379. }
  380. /**
  381. * Whether a stored record's bound identity names the caller's lifecycle.
  382. * An absent format generation cannot prove the fold semantics and never
  383. * matches. Once the format matches, absent lineage fields (records admitted
  384. * via `compatibleVersions` predate them) read as the unseeded lineage: exact
  385. * for an unseeded caller, while a seeded caller fails the match.
  386. */
  387. function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean {
  388. return stored.formatVersion === expected.formatVersion
  389. && lifecycleIdentityMatches(stored, expected)
  390. }
  391. /** Match one predecessor cache record to the authoritative listed lifecycle. */
  392. function predecessorIdentityMatches(
  393. stored: CheckpointIdentity,
  394. expected: CurrentCheckpointIdentity,
  395. ): boolean {
  396. const predecessor = stored.formatVersion === undefined
  397. || stored.formatVersion < expected.formatVersion
  398. return predecessor && lifecycleIdentityMatches(stored, expected)
  399. }
  400. /** Match the format-independent fields that distinguish one Session lifecycle. */
  401. function lifecycleIdentityMatches(
  402. stored: CheckpointIdentity,
  403. expected: CurrentCheckpointIdentity,
  404. ): boolean {
  405. return stored.createdAt === expected.createdAt
  406. && stored.cwd === expected.cwd
  407. && (stored.isSeeded ?? false) === expected.isSeeded
  408. && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount
  409. }
  410. export default SessionProjectionCache