Files
deepseek-harness/packages/session/session-projection-cache/tests/cache.spec.ts
T
_Kerman 89321489db refactor(session-projection-cache): own the cache tree under a config root
Store each session's projection_cache.json under the cache's own root tree
(<root>/<session-id>/projection_cache.json, wired to dshHomePath('projections')
in the base bundle) instead of beside the session log via
sessionPersistence.locate(). The cache owns its directory layout, keys
directories by the code-generated session id, and never consults the
persistence layer; the service now injects only sessionProjections and
sessions.

Drop the coldSnapshot method and its readFrom-tail fold ladder: every cold
consumer refolds from the log itself, so the cache only serves the listing
read (cachedSnapshot, one async file read per session) and the write side.
Fail-soft durability, per-path write serialization, in-flight drain, and
atomic 0600 writes are unchanged; the chain cleanup now observes its own
rejection so a failed write cannot surface as an unhandled error.

dsh-session-persistence leaves peer/dev dependencies and the tsconfig
reference; dsh-atomic-write moves to peerDependencies. Config gains a
required root.
2026-08-19 22:25:54 +08:00

281 lines
12 KiB
TypeScript

/**
* SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
* count/interval throttling between them, fail-soft durability (a failed
* write logs and stays stale, never throws into the event path), and the
* cached listing read. The durable medium is one `projection_cache.json`
* per session under the cache's own configured root
* (`<root>/<session-id>/projection_cache.json`); the cache never consults
* the persistence layer.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import SessionProjectionCache from '../src/index.ts'
import { checkpointRecord } from '../src/spec.ts'
import type { CheckpointRecord } from '../src/spec.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'cache-test/marks': MarksState
'cache-test/marks2': Map<string, string>
}
interface SessionProjectionMap {
'cache-test/marks': { marks: string[] }
}
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
'cache-test/mark': { marks: string[] }
}
interface OutOfBandSessionEventMap {
'cache-test/mark': true
}
}
type MarksState = { marks: string[] } | null
const marksUnit = (stateVersion = 1) => ({
key: 'cache-test/marks',
stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
init: () => null,
apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
wire: {
viewSchema: z.object({ marks: z.array(z.string()) }),
view: state => state ?? { marks: [] },
},
stateVersion,
}) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
/** One session's cache file under the cache's own root. */
const cachePath = (root: string, id: Session['id']): string =>
join(root, String(id), 'projection_cache.json')
/** Header shape for cachedSnapshot calls. */
const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
interface HarnessOptions {
root?: string
config?: { writeEveryEvents: number; writeIntervalMs: number }
stateVersion?: number
}
const contexts: Context[] = []
const roots: string[] = []
async function harness(options: HarnessOptions = {}) {
const root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(marksUnit(options.stateVersion))
const fiber = await ctx.plugin(SessionProjectionCache, {
root,
...options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 },
})
return { ctx, root, fiber, cache: ctx.sessionProjectionCache }
}
const mark = (session: Session, marks: string[]): SessionEvent =>
session.append('cache-test/mark', { marks })
const endTurn = (session: Session): SessionEvent =>
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
/** The stored record for one session id (undefined = absent or unreadable). */
async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
try {
return checkpointRecord.parse(JSON.parse(await readFile(cachePath(root, id), 'utf8')))
} catch {
return undefined
}
}
/** The stored rows for one session id (undefined = absent or unreadable). */
async function storedRows(root: string, id: Session['id']): Promise<CheckpointRecord['rows'] | undefined> {
return (await storedRecord(root, id))?.rows
}
/** Pre-seed one session's cache file with a stored checkpoint record. */
async function seedRecord(
root: string,
id: string,
rows: CheckpointRecord['rows'],
identity: CheckpointRecord['identity'] = { createdAt: 0 },
): Promise<void> {
await mkdir(join(root, id), { recursive: true })
await writeFile(cachePath(root, SessionId(id)), JSON.stringify({ identity, rows }))
}
/** Wait until queued fail-soft writes (event-listener fire-and-forget over real fs I/O) drain. */
const settle = () => new Promise(resolve => setTimeout(resolve, 40))
afterEach(async () => {
vi.useRealTimers()
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe('SessionProjectionCache write policy', () => {
it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
const { ctx, root } = await harness()
const session = ctx.sessions.create(SessionId('turn-end'))
mark(session, ['a'])
expect(await storedRows(root, session.id)).toBeUndefined() // throttled: no write yet
const end = endTurn(session)
await settle()
const rows = await storedRows(root, session.id)
expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
})
it('writes at session disposal (detach, the live-to-cold moment)', async () => {
const { ctx, root } = await harness()
// Sessions dispose with their owning fiber: create in a child plugin.
let session: Session | undefined
const owner = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('detach'))
}, { inject: ['sessions'] }))
if (session === undefined) throw new Error('session was not created')
mark(session, ['live'])
await owner.dispose()
await settle()
expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
})
it('flushes when the in-turn event count reaches the configured threshold', async () => {
const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
const session = ctx.sessions.create(SessionId('count'))
mark(session, ['1'])
mark(session, ['2'])
await settle()
expect(await storedRows(root, session.id)).toBeUndefined()
mark(session, ['3'])
await settle()
expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
})
it('flushes on the configured interval when the count threshold is not reached', async () => {
const { ctx, root } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
const session = ctx.sessions.create(SessionId('interval'))
mark(session, ['slow'])
await new Promise(resolve => setTimeout(resolve, 10)) // before the interval
expect(await storedRows(root, session.id)).toBeUndefined()
await settle() // past the interval; the fire-and-forget write lands
expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
})
it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
const { ctx, root } = await harness()
// Never dirtied: no events — write() still lands the init-derived cut.
const clean = ctx.sessions.create(SessionId('clean-write'))
await ctx.sessionProjectionCache.write(clean)
expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
// A unit whose state violates the plain-JSON contract fails the write loud.
ctx.sessionProjections.register({
key: 'cache-test/marks2',
stateSchema: z.custom<Map<string, string>>(() => true),
init: () => new Map<string, string>(),
apply: state => state,
stateVersion: 1,
})
await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
})
it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
vi.useFakeTimers()
const { ctx, root, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
const armed = ctx.sessions.create(SessionId('armed'))
const cleaned = ctx.sessions.create(SessionId('cleaned'))
mark(armed, ['pending']) // timer armed, no write yet
mark(cleaned, ['done'])
endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map
await vi.advanceTimersByTimeAsync(0)
await fiber.dispose()
// The armed timer died with the plugin: advancing time writes nothing.
await vi.advanceTimersByTimeAsync(10_000)
expect(await storedRows(root, armed.id)).toBeUndefined()
})
it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register(marksUnit())
await ctx.plugin(SessionProjectionCache, { root, writeEveryEvents: 100, writeIntervalMs: 60_000 })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const session = ctx.sessions.create(SessionId('fail-soft'))
// A directory where the cache file must land makes the atomic rename
// fail on the first write...
await mkdir(cachePath(root, session.id), { recursive: true })
mark(session, ['x'])
endTurn(session)
await settle()
expect(await storedRows(root, session.id)).toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
// Self-heal: once the blocker clears, the next mandatory point writes.
await rm(cachePath(root, session.id), { recursive: true })
mark(session, ['y'])
endTurn(session)
await settle()
expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
})
})
describe('SessionProjectionCache listing read', () => {
it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
roots.push(root)
await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } })
const { cache } = await harness({ root })
const id = SessionId('listed')
// Matching header: values plus the watermark the client seeds under.
expect(await cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
// A recreated id (different createdAt): the record is unrelated — no block.
expect(await cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
// Unknown id: no block.
expect(await cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
})
it('returns undefined when every stored row is version-mismatched', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
roots.push(root)
await seedRecord(root, 'all-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } })
const { cache } = await harness({ root })
expect(await cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
})
it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
roots.push(root)
await seedRecord(root, 'homed', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['w'] } } }, { createdAt: 0, cwd: '/work' })
const { cache } = await harness({ root })
const id = SessionId('homed')
expect((await cache.cachedSnapshot(headerOf(id, 0, '/work')))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
expect(await cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
expect(await cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
})
it('returns undefined for a malformed cache file (refold from the log on the caller side)', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
roots.push(root)
await mkdir(join(root, 'malformed'), { recursive: true })
await writeFile(cachePath(root, SessionId('malformed')), 'not json at all')
const { cache } = await harness({ root })
expect(await cache.cachedSnapshot(headerOf(SessionId('malformed')))).toBeUndefined()
})
})