cache.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  1. /**
  2. * SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
  3. * count/interval throttling between them, fail-soft durability (a failed
  4. * write logs and stays stale, never throws into the event path), and the
  5. * synchronous cached listing read. The durable medium is the
  6. * `session_projcache` storage domain in per-record layout: one
  7. * version-stamped document per session under the json backend root at
  8. * `<root>/session_projcache/sessions/<id>.json`. Reads never touch the
  9. * medium — they come from the domain's in-memory tables, which writes mutate
  10. * only after durability.
  11. */
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  14. import { tmpdir } from 'node:os'
  15. import { dirname, join } from 'node:path'
  16. import { Context } from '@deepseek-ai/cordis'
  17. import { z } from 'zod'
  18. import SessionStore, {
  19. SESSION_FORMAT_VERSION,
  20. Session,
  21. SessionId,
  22. SessionLogOffset,
  23. SessionSeq,
  24. } from '@deepseek-ai/dsh-session'
  25. import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  26. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  27. import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
  28. import Storage from '@deepseek-ai/dsh-storage'
  29. import {
  30. apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
  31. } from '@deepseek-ai/dsh-storage-json'
  32. import {
  33. apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
  34. } from '@deepseek-ai/dsh-storage-domain'
  35. import SessionProjectionCache from '../src/index.ts'
  36. import { checkpointRecord, projectionCacheDomainSpec } from '../src/spec.ts'
  37. import type { CheckpointRecord } from '../src/spec.ts'
  38. declare module '@deepseek-ai/dsh-session-projection/types' {
  39. interface SessionProjectionStateMap {
  40. 'cache-test/marks': MarksState
  41. 'cache-test/secondary-marks': MarksState
  42. 'cache-test/marks2': Map<string, string>
  43. 'cache-test/count': number
  44. 'cache-test/secret': string
  45. 'cache-test/marks3': MarksState
  46. }
  47. interface SessionProjectionMap {
  48. 'cache-test/marks': { marks: string[] }
  49. 'cache-test/secondary-marks': { marks: string[] }
  50. 'cache-test/marks3': { marks: string[] }
  51. }
  52. }
  53. declare module '@deepseek-ai/dsh-session/types' {
  54. interface SessionEventMap {
  55. 'cache-test/mark': { marks: string[] }
  56. }
  57. interface OutOfBandSessionEventMap {
  58. 'cache-test/mark': true
  59. }
  60. }
  61. type MarksState = { marks: string[] } | null
  62. const marksUnit = (stateVersion = 1) => ({
  63. key: 'cache-test/marks',
  64. stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
  65. init: () => null,
  66. apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
  67. wire: {
  68. viewSchema: z.object({ marks: z.array(z.string()) }),
  69. view: state => state ?? { marks: [] },
  70. },
  71. stateVersion,
  72. }) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
  73. const marks3Unit = {
  74. key: 'cache-test/marks3',
  75. stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
  76. init: () => null,
  77. apply: state => state,
  78. wire: {
  79. viewSchema: z.object({ marks: z.array(z.string()) }),
  80. view: state => state ?? { marks: [] },
  81. },
  82. stateVersion: 1,
  83. } satisfies ProjectionDefinition<'cache-test/marks3', MarksState>
  84. const secretUnit = {
  85. key: 'cache-test/secret',
  86. stateSchema: z.string(),
  87. init: () => '',
  88. apply: state => state,
  89. stateVersion: 1,
  90. } satisfies ProjectionDefinition<'cache-test/secret', string>
  91. const secondaryMarksUnit = {
  92. key: 'cache-test/secondary-marks',
  93. stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
  94. init: () => null,
  95. apply: (state, event) => event.type === 'cache-test/mark' ? event.data : state,
  96. wire: {
  97. viewSchema: z.object({ marks: z.array(z.string()) }),
  98. view: state => state ?? { marks: [] },
  99. },
  100. stateVersion: 1,
  101. } satisfies ProjectionDefinition<'cache-test/secondary-marks', MarksState>
  102. /** One session's record document on the per-record medium. */
  103. const recordPath = (root: string, id: Session['id']): string =>
  104. join(root, projectionCacheDomainSpec.name, 'sessions', `${String(id)}.json`)
  105. /** Header shape for cachedSnapshot calls. */
  106. const headerOf = (id: SessionId, createdAt = 0, cwd?: string): SessionHeader =>
  107. ({ version: SESSION_FORMAT_VERSION, id, createdAt, isSeeded: false, ...cwd === undefined ? {} : { cwd } })
  108. interface HarnessOptions {
  109. root?: string
  110. config?: { writeEveryEvents: number; writeIntervalMs: number }
  111. stateVersion?: number
  112. }
  113. const contexts: Context[] = []
  114. const roots: string[] = []
  115. async function harness(options: HarnessOptions = {}) {
  116. const root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  117. roots.push(root)
  118. const ctx = new Context()
  119. contexts.push(ctx)
  120. // The cache opens its domain through the storage stack; the json backend
  121. // lands the per-record tree under this tmp root.
  122. await ctx.plugin(Storage)
  123. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  124. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  125. await ctx.plugin(SessionStore)
  126. await ctx.plugin(SessionProjectionRegistry)
  127. ctx.sessionProjections.register(marksUnit(options.stateVersion))
  128. const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  129. return { ctx, root, fiber, cache: ctx.sessionProjectionCache }
  130. }
  131. const mark = (session: Session, marks: string[]): SessionEvent =>
  132. session.append('cache-test/mark', { marks })
  133. const endTurn = (session: Session): SessionEvent =>
  134. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  135. /** Resolve after this Session's next durable cache replacement. */
  136. function whenWritten(ctx: Context, id: SessionId): Promise<void> {
  137. return new Promise((resolve) => {
  138. const dispose = ctx.on('domain/changed', (change) => {
  139. if (change.domain !== projectionCacheDomainSpec.name
  140. || change.table !== 'sessions' || change.key !== id || change.operation !== 'put') return
  141. dispose()
  142. resolve()
  143. })
  144. })
  145. }
  146. /** The stored record for one session id (undefined = absent or unreadable). */
  147. async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
  148. try {
  149. const document = JSON.parse(await readFile(recordPath(root, id), 'utf8')) as { record: unknown }
  150. return checkpointRecord.parse(document.record)
  151. } catch {
  152. return undefined
  153. }
  154. }
  155. /** The stored rows for one session id (undefined = absent or unreadable). */
  156. async function storedRows(root: string, id: Session['id']): Promise<CheckpointRecord['rows'] | undefined> {
  157. return (await storedRecord(root, id))?.rows
  158. }
  159. /** Pre-seed one session's record document with a stored checkpoint record. */
  160. async function seedRecord(
  161. root: string,
  162. id: string,
  163. rows: CheckpointRecord['rows'],
  164. identity: CheckpointRecord['identity'] = {
  165. formatVersion: SESSION_FORMAT_VERSION,
  166. createdAt: 0,
  167. isSeeded: false,
  168. inheritedEventCount: SessionLogOffset(0),
  169. },
  170. ): Promise<void> {
  171. const path = recordPath(root, SessionId(id))
  172. await mkdir(dirname(path), { recursive: true })
  173. await writeFile(path, JSON.stringify({ version: projectionCacheDomainSpec.version, record: { identity, rows } }))
  174. }
  175. afterEach(async () => {
  176. vi.useRealTimers()
  177. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  178. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })))
  179. })
  180. describe('SessionProjectionCache write policy', () => {
  181. it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
  182. const { ctx, root } = await harness()
  183. // The interval cannot substitute for the mandatory turn/end trigger.
  184. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
  185. const id = SessionId('turn-end')
  186. const created = whenWritten(ctx, id)
  187. const session = ctx.sessions.create(id)
  188. mark(session, ['a'])
  189. // The mark is throttled, so the creation cut has no marks folded.
  190. await created
  191. expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1)
  192. const written = whenWritten(ctx, id)
  193. const end = endTurn(session)
  194. await written
  195. expect((await storedRows(root, session.id))?.['cache-test/marks'])
  196. .toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
  197. })
  198. it('writes a checkpoint at session creation, capturing the seed-derived cut', async () => {
  199. const { ctx, root } = await harness()
  200. // A forked child seeded with its ancestor's title-like event: no
  201. // conversation follows, yet the creation write must capture the fold so
  202. // a crash or a live-held fork still lists the derived value.
  203. const id = SessionId('seeded')
  204. const created = whenWritten(ctx, id)
  205. const session = ctx.sessions.create(id, {
  206. seed: [{ type: 'cache-test/mark', seq: 0, time: 1, data: { marks: ['seed'] } }] as SessionEvent[],
  207. })
  208. await created
  209. expect((await storedRows(root, session.id))?.['cache-test/marks']?.val)
  210. .toEqual({ marks: ['seed'] })
  211. })
  212. it('writes at session disposal (detach, the live-to-cold moment)', async () => {
  213. const { ctx, root } = await harness()
  214. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
  215. const id = SessionId('detach')
  216. const created = whenWritten(ctx, id)
  217. // Sessions dispose with their owning fiber: create in a child plugin.
  218. let session: Session | undefined
  219. const owner = await ctx.plugin(Object.assign((inner: Context) => {
  220. session = inner.sessions.create(id)
  221. }, { inject: ['sessions'] }))
  222. if (session === undefined) throw new Error('session was not created')
  223. await created
  224. mark(session, ['live'])
  225. const written = whenWritten(ctx, id)
  226. await owner.dispose()
  227. await written
  228. expect((await storedRows(root, id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
  229. })
  230. it('flushes when the in-turn event count reaches the configured threshold', async () => {
  231. const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
  232. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
  233. const id = SessionId('count')
  234. const created = whenWritten(ctx, id)
  235. const session = ctx.sessions.create(id)
  236. mark(session, ['1'])
  237. mark(session, ['2'])
  238. await created
  239. expect((await storedRows(root, id))?.['cache-test/marks'])
  240. .toEqual({ ver: 1, seq: -1, val: null }) // still the creation cut
  241. const written = whenWritten(ctx, id)
  242. mark(session, ['3'])
  243. await written
  244. expect((await storedRows(root, id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
  245. })
  246. it('flushes on the configured interval when the count threshold is not reached', async () => {
  247. const { ctx, cache } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
  248. const write = vi.spyOn(cache, 'write').mockResolvedValue()
  249. vi.useFakeTimers()
  250. const session = ctx.sessions.create(SessionId('interval'))
  251. write.mockClear()
  252. mark(session, ['slow'])
  253. await vi.advanceTimersByTimeAsync(19)
  254. expect(write).not.toHaveBeenCalled()
  255. await vi.advanceTimersByTimeAsync(1)
  256. expect(write).toHaveBeenCalledExactlyOnceWith(session)
  257. })
  258. it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
  259. const { ctx, root } = await harness()
  260. // Never dirtied: no events — write() still lands the init-derived cut.
  261. const clean = ctx.sessions.create(SessionId('clean-write'))
  262. await ctx.sessionProjectionCache.write(clean)
  263. expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
  264. // A unit whose state violates the plain-JSON contract fails the write loud.
  265. ctx.sessionProjections.register({
  266. key: 'cache-test/marks2',
  267. stateSchema: z.custom<Map<string, string>>(() => true),
  268. init: () => new Map<string, string>(),
  269. apply: state => state,
  270. stateVersion: 1,
  271. })
  272. await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
  273. })
  274. it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
  275. vi.useFakeTimers()
  276. const { ctx, root, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
  277. const armed = ctx.sessions.create(SessionId('armed'))
  278. const cleaned = ctx.sessions.create(SessionId('cleaned'))
  279. mark(armed, ['pending']) // timer armed, no write yet
  280. mark(cleaned, ['done'])
  281. endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map
  282. await vi.advanceTimersByTimeAsync(0)
  283. await fiber.dispose()
  284. // The armed timer died with the plugin: advancing time writes nothing.
  285. await vi.advanceTimersByTimeAsync(10_000)
  286. // Only the creation cut exists: the armed mark never wrote.
  287. expect((await storedRows(root, armed.id))?.['cache-test/marks']?.seq).toBe(-1)
  288. })
  289. it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
  290. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  291. roots.push(root)
  292. const ctx = new Context()
  293. contexts.push(ctx)
  294. await ctx.plugin(Storage)
  295. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  296. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  297. await ctx.plugin(SessionStore)
  298. await ctx.plugin(SessionProjectionRegistry)
  299. ctx.sessionProjections.register(marksUnit())
  300. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  301. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  302. // A directory where the record document must land makes the atomic
  303. // rename fail — including the creation write, so no row ever lands.
  304. const blocker = recordPath(root, SessionId('fail-soft'))
  305. await mkdir(blocker, { recursive: true })
  306. const session = ctx.sessions.create(SessionId('fail-soft'))
  307. mark(session, ['x'])
  308. endTurn(session)
  309. // The failed creation/turn-end writes are fire-and-forget: wait for the
  310. // warn (the write actually failed), then assert no row landed — the
  311. // property under test is that a failed write leaves no partial row.
  312. await vi.waitFor(() => {
  313. expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
  314. }, { timeout: 5_000 })
  315. expect(await storedRows(root, session.id)).toBeUndefined()
  316. // Self-heal: once the blocker clears, the next mandatory point writes.
  317. await rm(recordPath(root, session.id), { recursive: true })
  318. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
  319. mark(session, ['y'])
  320. const written = whenWritten(ctx, session.id)
  321. endTurn(session)
  322. await written
  323. expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
  324. })
  325. })
  326. describe('SessionProjectionCache listing read', () => {
  327. it('rejects a nonzero inherited cut for an unseeded header', async () => {
  328. const { cache } = await harness()
  329. expect(() => cache.cachedSnapshot(
  330. headerOf(SessionId('invalid-unseeded-cut')),
  331. SessionLogOffset(1),
  332. )).toThrow('unseeded projection-cache identity inherited event count must be 0')
  333. })
  334. it('uses the lowest watermark across every served wire row', async () => {
  335. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  336. roots.push(root)
  337. await seedRecord(root, 'watermark-lower', {
  338. 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['primary'] } },
  339. 'cache-test/secondary-marks': { ver: 1, seq: SessionSeq(2), val: { marks: ['secondary'] } },
  340. })
  341. await seedRecord(root, 'watermark-higher', {
  342. 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['primary'] } },
  343. 'cache-test/secondary-marks': { ver: 1, seq: SessionSeq(6), val: { marks: ['secondary'] } },
  344. })
  345. const { ctx, cache } = await harness({ root })
  346. ctx.sessionProjections.register(secondaryMarksUnit)
  347. expect(cache.cachedSnapshot(headerOf(SessionId('watermark-lower')), SessionLogOffset(0))?.asOfSeq)
  348. .toBe(2)
  349. expect(cache.cachedSnapshot(headerOf(SessionId('watermark-higher')), SessionLogOffset(0))?.asOfSeq)
  350. .toBe(4)
  351. })
  352. it('refuses a checkpoint created for a different inherited cut', async () => {
  353. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  354. roots.push(root)
  355. const id = SessionId('cut-identity')
  356. await seedRecord(
  357. root,
  358. id,
  359. { 'cache-test/marks': { ver: 1, seq: SessionSeq(1), val: { marks: ['seed'] } } },
  360. {
  361. formatVersion: SESSION_FORMAT_VERSION,
  362. createdAt: 0,
  363. isSeeded: true,
  364. inheritedEventCount: SessionLogOffset(2),
  365. },
  366. )
  367. const { cache } = await harness({ root })
  368. const seededHeader = { ...headerOf(id), isSeeded: true }
  369. expect(cache.cachedSnapshot(seededHeader, SessionLogOffset(2))?.values['cache-test/marks'])
  370. .toEqual({ marks: ['seed'] })
  371. expect(cache.cachedSnapshot(seededHeader, SessionLogOffset(1))).toBeUndefined()
  372. expect(() => cache.cachedSnapshot(headerOf(id), SessionLogOffset(1)))
  373. .toThrow('unseeded projection-cache identity inherited event count must be 0')
  374. })
  375. it('serves a creation-time checkpoint at the before-first-event cursor', async () => {
  376. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  377. roots.push(root)
  378. await seedRecord(root, 'before-first-event', {
  379. 'cache-test/marks': { ver: 1, seq: -1, val: null },
  380. })
  381. const { cache } = await harness({ root })
  382. expect(cache.cachedSnapshot(headerOf(SessionId('before-first-event')), SessionLogOffset(0)))
  383. .toEqual({ asOfSeq: -1, values: { 'cache-test/marks': { marks: [] } } })
  384. })
  385. it('refuses a checkpoint folded from another Session format generation', async () => {
  386. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  387. roots.push(root)
  388. const id = SessionId('format-identity')
  389. await seedRecord(
  390. root,
  391. id,
  392. { 'cache-test/marks': { ver: 1, seq: SessionSeq(0), val: { marks: ['stale'] } } },
  393. {
  394. formatVersion: SESSION_FORMAT_VERSION + 1,
  395. createdAt: 0,
  396. isSeeded: false,
  397. inheritedEventCount: SessionLogOffset(0),
  398. },
  399. )
  400. const { cache } = await harness({ root })
  401. expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))).toBeUndefined()
  402. })
  403. it('keeps host-only checkpoint state out of cached wire snapshots', async () => {
  404. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  405. roots.push(root)
  406. await seedRecord(root, 'host-state', {
  407. 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['wire'] } },
  408. 'cache-test/secret': { ver: 1, seq: SessionSeq(4), val: 'private prompt text' },
  409. })
  410. const { ctx, cache } = await harness({ root })
  411. ctx.sessionProjections.register(secretUnit)
  412. const header = headerOf(SessionId('host-state'))
  413. expect(cache.cachedSnapshot(header, SessionLogOffset(0))).toEqual({
  414. asOfSeq: 4,
  415. values: { 'cache-test/marks': { marks: ['wire'] } },
  416. })
  417. expect(JSON.stringify(cache.cachedSnapshot(header, SessionLogOffset(0))))
  418. .not.toContain('private prompt text')
  419. })
  420. it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
  421. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  422. roots.push(root)
  423. await seedRecord(root, 'listed', {
  424. 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['t'] } },
  425. })
  426. const { cache } = await harness({ root })
  427. const id = SessionId('listed')
  428. // Matching header: values plus the watermark the client seeds under.
  429. expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0)))
  430. .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
  431. // A recreated id (different createdAt): the record is unrelated — no block.
  432. expect(cache.cachedSnapshot(headerOf(id, 777), SessionLogOffset(0))).toBeUndefined()
  433. // Unknown id: no block.
  434. expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')), SessionLogOffset(0)))
  435. .toBeUndefined()
  436. })
  437. it('carries ONE cut across multiple served rows: the lowest watermark wins', async () => {
  438. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  439. roots.push(root)
  440. // Equal watermarks: whichever row is visited second cannot lower the cut,
  441. // so the one-cut fold sees both a lowering and a non-lowering row in
  442. // every iteration order.
  443. await seedRecord(root, 'multi-row', {
  444. 'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['a'] } },
  445. 'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } },
  446. })
  447. const { ctx, cache } = await harness({ root })
  448. ctx.sessionProjections.register(marks3Unit)
  449. const block = cache.cachedSnapshot(headerOf(SessionId('multi-row')), SessionLogOffset(0))
  450. expect(block?.values).toEqual({
  451. 'cache-test/marks': { marks: ['a'] },
  452. 'cache-test/marks3': { marks: ['b'] },
  453. })
  454. expect(block?.asOfSeq).toBe(4)
  455. })
  456. it('returns undefined when the stored record version is not accepted', async () => {
  457. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  458. roots.push(root)
  459. // Version 2 is neither current nor declared compatible, so the document
  460. // is discarded at open and the record reads as absent.
  461. const path = recordPath(root, SessionId('all-stale'))
  462. await mkdir(dirname(path), { recursive: true })
  463. await writeFile(path, JSON.stringify({
  464. version: 2,
  465. record: {
  466. identity: {
  467. formatVersion: SESSION_FORMAT_VERSION,
  468. createdAt: 0,
  469. isSeeded: false,
  470. inheritedEventCount: 0,
  471. },
  472. rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['old'] } } },
  473. },
  474. }))
  475. const { cache } = await harness({ root })
  476. expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')), SessionLogOffset(0)))
  477. .toBeUndefined()
  478. })
  479. it('serves a pre-lineage record (accepted old version) to an unseeded caller only', async () => {
  480. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  481. roots.push(root)
  482. // A document stamped with an accepted older version whose identity
  483. // predates the lineage fields: absent lineage reads as unseeded.
  484. const path = recordPath(root, SessionId('pre-lineage'))
  485. await mkdir(dirname(path), { recursive: true })
  486. await writeFile(path, JSON.stringify({
  487. version: 4,
  488. record: {
  489. identity: { formatVersion: SESSION_FORMAT_VERSION, createdAt: 0 },
  490. rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['kept'] } } },
  491. },
  492. }))
  493. const { cache } = await harness({ root })
  494. const id = SessionId('pre-lineage')
  495. // Unseeded caller: the absent lineage is exactly its identity — served.
  496. expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0)))
  497. .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['kept'] } } })
  498. // Seeded caller: the lineage-less record cannot vouch for the cut — refused.
  499. expect(cache.cachedSnapshot({ ...headerOf(id), isSeeded: true }, SessionLogOffset(2)))
  500. .toBeUndefined()
  501. })
  502. it('refuses an accepted predecessor record without a Session format generation', async () => {
  503. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  504. roots.push(root)
  505. const id = SessionId('pre-format-identity')
  506. const path = recordPath(root, id)
  507. await mkdir(dirname(path), { recursive: true })
  508. await writeFile(path, JSON.stringify({
  509. version: 6,
  510. record: {
  511. identity: { createdAt: 0, isSeeded: false, inheritedEventCount: 0 },
  512. rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['unbound'] } } },
  513. },
  514. }))
  515. const { cache } = await harness({ root })
  516. expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))).toBeUndefined()
  517. })
  518. it('returns undefined when every stored row is version-mismatched', async () => {
  519. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  520. roots.push(root)
  521. // A current document whose rows all fail the live unit's stateVersion:
  522. // the listing view is empty, so no block is served.
  523. await seedRecord(root, 'row-stale', {
  524. 'cache-test/marks': { ver: 99, seq: SessionSeq(4), val: { marks: ['old'] } },
  525. })
  526. const { cache } = await harness({ root })
  527. expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')), SessionLogOffset(0)))
  528. .toBeUndefined()
  529. })
  530. it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
  531. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  532. roots.push(root)
  533. await seedRecord(root, 'homed', {
  534. 'cache-test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: ['w'] } },
  535. }, {
  536. formatVersion: SESSION_FORMAT_VERSION,
  537. createdAt: 0,
  538. cwd: '/work',
  539. isSeeded: false,
  540. inheritedEventCount: SessionLogOffset(0),
  541. })
  542. const { cache } = await harness({ root })
  543. const id = SessionId('homed')
  544. expect(cache.cachedSnapshot(headerOf(id, 0, '/work'), SessionLogOffset(0))?.values['cache-test/marks'])
  545. .toEqual({ marks: ['w'] })
  546. expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'), SessionLogOffset(0))).toBeUndefined()
  547. expect(cache.cachedSnapshot(headerOf(id, 0), SessionLogOffset(0))).toBeUndefined()
  548. })
  549. it('returns undefined for a malformed record document (refold from the log on the caller side)', async () => {
  550. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  551. roots.push(root)
  552. const path = recordPath(root, SessionId('malformed'))
  553. await mkdir(dirname(path), { recursive: true })
  554. await writeFile(path, 'not json at all')
  555. const { cache } = await harness({ root })
  556. expect(cache.cachedSnapshot(headerOf(SessionId('malformed')), SessionLogOffset(0)))
  557. .toBeUndefined()
  558. })
  559. })
  560. describe('SessionProjectionCache cold-read seeding', () => {
  561. /** One session's event log: turn/start, one mark per group, turn/end. */
  562. const storedLog = (marks: string[][]): SessionEvent[] => {
  563. const events: SessionEvent[] = [
  564. { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } },
  565. ]
  566. for (const m of marks) {
  567. events.push({
  568. type: 'cache-test/mark',
  569. seq: SessionSeq(events.length),
  570. time: events.length,
  571. data: { marks: m },
  572. })
  573. }
  574. events.push({
  575. type: 'turn/end',
  576. seq: SessionSeq(events.length),
  577. time: events.length,
  578. data: { turn: 1, reason: { kind: 'completed' } },
  579. })
  580. return events
  581. }
  582. it('hydratePrepared seeds from a matching row and retries from the exact log on a malformed one', async () => {
  583. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  584. roots.push(root)
  585. // Records land on disk before the domain opens, so the in-memory table
  586. // picks them up at init.
  587. await seedRecord(root, 'prepared-seeded', {
  588. 'cache-test/marks': { ver: 1, seq: SessionSeq(1), val: { marks: ['cached'] } },
  589. })
  590. await seedRecord(root, 'prepared-fallback', {
  591. 'cache-test/marks': { ver: 1, seq: SessionSeq(1), val: { marks: 'malformed' } },
  592. })
  593. const { cache } = await harness({ root })
  594. const events = storedLog([['fresh']])
  595. // A matching row hydrates the prepared Session without a persistence read.
  596. const seeded = headerOf(SessionId('prepared-seeded'))
  597. const seededSession = Session.create(seeded.id, events, seeded)
  598. expect(cache.hydratePrepared(seededSession, events)).toEqual({
  599. asOfSeq: 2,
  600. values: { 'cache-test/marks': { marks: ['cached'] } },
  601. })
  602. // A malformed row cannot seed the fold; hydration falls back to the
  603. // exact log so a valid Session stays readable.
  604. const fallback = headerOf(SessionId('prepared-fallback'))
  605. const fallbackSession = Session.create(fallback.id, events, fallback)
  606. expect(cache.hydratePrepared(fallbackSession, events)).toEqual({
  607. asOfSeq: 2,
  608. values: { 'cache-test/marks': { marks: ['fresh'] } },
  609. })
  610. // No row at all: hydrate from init over the exact log.
  611. const bare = headerOf(SessionId('prepared-bare'))
  612. const bareSession = Session.create(bare.id, events, bare)
  613. expect(cache.hydratePrepared(bareSession, events)).toEqual({
  614. asOfSeq: 2,
  615. values: { 'cache-test/marks': { marks: ['fresh'] } },
  616. })
  617. })
  618. it('coldSnapshot traverses the full log but applies only the events after each cached watermark', async () => {
  619. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  620. roots.push(root)
  621. // A cached row covering the prefix through seq 2 (three applies folded).
  622. await seedRecord(root, 'cold-snap', {
  623. 'cache-test/count': { ver: 1, seq: SessionSeq(2), val: 3 },
  624. }, {
  625. formatVersion: SESSION_FORMAT_VERSION,
  626. createdAt: 9,
  627. isSeeded: false,
  628. inheritedEventCount: SessionLogOffset(0),
  629. })
  630. const { cache, ctx } = await harness({ root })
  631. const apply = vi.fn((_state: number, _event: SessionEvent) => 1)
  632. ctx.sessionProjections.register({
  633. key: 'cache-test/count',
  634. stateSchema: z.number().int().nonnegative(),
  635. init: () => 0,
  636. apply,
  637. stateVersion: 1,
  638. } satisfies ProjectionDefinition<'cache-test/count', number>)
  639. const meta = headerOf(SessionId('cold-snap'), 9)
  640. const events = Array.from({ length: 5 }, (_, seq) => ({
  641. type: 'cache-test/mark', seq: SessionSeq(seq), time: seq, data: { marks: [`m${seq}`] },
  642. })) as SessionEvent[]
  643. const refreshed = whenWritten(ctx, meta.id)
  644. const snapshot = cache.coldSnapshot(meta, SessionLogOffset(0), events)
  645. // The full log was traversed, but the fold applied only seqs 3 and 4.
  646. expect(apply).toHaveBeenCalledTimes(2)
  647. expect(apply.mock.calls.map(call => call[1].seq)).toEqual([3, 4])
  648. expect(snapshot.asOfSeq).toBe(4)
  649. // Host-only unit: folded but not served; the refreshed row is written
  650. // back (fail-soft, fire-and-forget) once the write lands.
  651. expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
  652. await refreshed
  653. expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
  654. // No cached row yet: the first cold read folds from init over the full
  655. // log and creates the cache row (the `?? {}` seed path).
  656. const fresh = headerOf(SessionId('cold-fresh'), 10)
  657. const created = whenWritten(ctx, fresh.id)
  658. cache.coldSnapshot(fresh, SessionLogOffset(0), events)
  659. expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
  660. await created
  661. expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
  662. })
  663. it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {
  664. const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
  665. roots.push(root)
  666. const ctx = new Context()
  667. contexts.push(ctx)
  668. await ctx.plugin(Storage)
  669. await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
  670. await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
  671. await ctx.plugin(SessionStore)
  672. await ctx.plugin(SessionProjectionRegistry)
  673. ctx.sessionProjections.register(marksUnit())
  674. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
  675. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  676. // A directory where the record document must land makes the write-back
  677. // fail; the cold read itself still succeeds and never throws.
  678. const meta = headerOf(SessionId('cold-fail'))
  679. await mkdir(recordPath(root, meta.id), { recursive: true })
  680. expect(ctx.sessionProjectionCache.coldSnapshot(meta, SessionLogOffset(0), [])).toBeDefined()
  681. // The failed write-back is fire-and-forget: poll for the warn instead of
  682. // assuming a fixed settle window (slow runners exceed it).
  683. await vi.waitFor(() => {
  684. expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "cold-fail" failed'))
  685. }, { timeout: 5_000 })
  686. })
  687. })