|
|
@@ -148,6 +148,18 @@ const mark = (session: Session, marks: string[]): SessionEvent =>
|
|
|
const endTurn = (session: Session): SessionEvent =>
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
|
|
+/** Resolve after this Session's next durable cache replacement. */
|
|
|
+function whenWritten(ctx: Context, id: SessionId): Promise<void> {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const dispose = ctx.on('domain/changed', (change) => {
|
|
|
+ if (change.domain !== projectionCacheDomainSpec.name
|
|
|
+ || change.table !== 'sessions' || change.key !== id || change.operation !== 'put') return
|
|
|
+ dispose()
|
|
|
+ resolve()
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
/** The stored record for one session id (undefined = absent or unreadable). */
|
|
|
async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
|
|
|
try {
|
|
|
@@ -189,18 +201,20 @@ afterEach(async () => {
|
|
|
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'))
|
|
|
+ // The interval cannot substitute for the mandatory turn/end trigger.
|
|
|
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
|
+ const id = SessionId('turn-end')
|
|
|
+ const created = whenWritten(ctx, id)
|
|
|
+ const session = ctx.sessions.create(id)
|
|
|
mark(session, ['a'])
|
|
|
- // Creation already wrote the init cut; the mark is throttled, so the
|
|
|
- // stored row is still the creation-time cut (no marks folded).
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1)
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ // The mark is throttled, so the creation cut has no marks folded.
|
|
|
+ await created
|
|
|
+ expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1)
|
|
|
+ const written = whenWritten(ctx, id)
|
|
|
const end = endTurn(session)
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect((await storedRows(root, session.id))?.['cache-test/marks'])
|
|
|
- .toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ await written
|
|
|
+ expect((await storedRows(root, session.id))?.['cache-test/marks'])
|
|
|
+ .toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
|
|
|
})
|
|
|
|
|
|
it('writes a checkpoint at session creation, capturing the seed-derived cut', async () => {
|
|
|
@@ -208,44 +222,50 @@ describe('SessionProjectionCache write policy', () => {
|
|
|
// A forked child seeded with its ancestor's title-like event: no
|
|
|
// conversation follows, yet the creation write must capture the fold so
|
|
|
// a crash or a live-held fork still lists the derived value.
|
|
|
- const session = ctx.sessions.create(SessionId('seeded'), {
|
|
|
+ const id = SessionId('seeded')
|
|
|
+ const created = whenWritten(ctx, id)
|
|
|
+ const session = ctx.sessions.create(id, {
|
|
|
seed: [{ type: 'cache-test/mark', seq: 0, time: 1, data: { marks: ['seed'] } }] as SessionEvent[],
|
|
|
})
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.val)
|
|
|
- .toEqual({ marks: ['seed'] })
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ await created
|
|
|
+ expect((await storedRows(root, session.id))?.['cache-test/marks']?.val)
|
|
|
+ .toEqual({ marks: ['seed'] })
|
|
|
})
|
|
|
|
|
|
it('writes at session disposal (detach, the live-to-cold moment)', async () => {
|
|
|
const { ctx, root } = await harness()
|
|
|
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
|
+ const id = SessionId('detach')
|
|
|
+ const created = whenWritten(ctx, id)
|
|
|
// 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'))
|
|
|
+ session = inner.sessions.create(id)
|
|
|
}, { inject: ['sessions'] }))
|
|
|
if (session === undefined) throw new Error('session was not created')
|
|
|
+ await created
|
|
|
mark(session, ['live'])
|
|
|
+ const written = whenWritten(ctx, id)
|
|
|
await owner.dispose()
|
|
|
- const detached = session
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect((await storedRows(root, detached.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ await written
|
|
|
+ expect((await storedRows(root, 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'))
|
|
|
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
|
+ const id = SessionId('count')
|
|
|
+ const created = whenWritten(ctx, id)
|
|
|
+ const session = ctx.sessions.create(id)
|
|
|
mark(session, ['1'])
|
|
|
mark(session, ['2'])
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect((await storedRows(root, session.id))?.['cache-test/marks'])
|
|
|
- .toEqual({ ver: 1, seq: -1, val: null }) // still the creation cut
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ await created
|
|
|
+ expect((await storedRows(root, id))?.['cache-test/marks'])
|
|
|
+ .toEqual({ ver: 1, seq: -1, val: null }) // still the creation cut
|
|
|
+ const written = whenWritten(ctx, id)
|
|
|
mark(session, ['3'])
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ await written
|
|
|
+ expect((await storedRows(root, id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
|
|
|
})
|
|
|
|
|
|
it('flushes on the configured interval when the count threshold is not reached', async () => {
|
|
|
@@ -320,16 +340,15 @@ describe('SessionProjectionCache write policy', () => {
|
|
|
await vi.waitFor(() => {
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
|
|
|
}, { timeout: 5_000 })
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect(await storedRows(root, session.id)).toBeUndefined()
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ expect(await storedRows(root, session.id)).toBeUndefined()
|
|
|
// Self-heal: once the blocker clears, the next mandatory point writes.
|
|
|
await rm(recordPath(root, session.id), { recursive: true })
|
|
|
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
|
mark(session, ['y'])
|
|
|
+ const written = whenWritten(ctx, session.id)
|
|
|
endTurn(session)
|
|
|
- await vi.waitFor(async () => {
|
|
|
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
|
|
|
- }, { timeout: 5_000 })
|
|
|
+ await written
|
|
|
+ expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -665,14 +684,6 @@ describe('SessionProjectionCache cold-read seeding', () => {
|
|
|
})
|
|
|
const { cache, ctx } = await harness({ root })
|
|
|
const apply = vi.fn((_state: number, _event: SessionEvent) => 1)
|
|
|
- const whenWritten = (id: SessionId): Promise<void> => new Promise((resolve) => {
|
|
|
- const dispose = ctx.on('domain/changed', (change) => {
|
|
|
- if (change.domain !== projectionCacheDomainSpec.name
|
|
|
- || change.table !== 'sessions' || change.key !== id || change.operation !== 'put') return
|
|
|
- dispose()
|
|
|
- resolve()
|
|
|
- })
|
|
|
- })
|
|
|
ctx.sessionProjections.register({
|
|
|
key: 'cache-test/count',
|
|
|
stateSchema: z.number().int().nonnegative(),
|
|
|
@@ -684,7 +695,7 @@ describe('SessionProjectionCache cold-read seeding', () => {
|
|
|
const events = Array.from({ length: 5 }, (_, seq) => ({
|
|
|
type: 'cache-test/mark', seq: SessionSeq(seq), time: seq, data: { marks: [`m${seq}`] },
|
|
|
})) as SessionEvent[]
|
|
|
- const refreshed = whenWritten(meta.id)
|
|
|
+ const refreshed = whenWritten(ctx, meta.id)
|
|
|
const snapshot = cache.coldSnapshot(meta, SessionLogOffset(0), events)
|
|
|
// The full log was traversed, but the fold applied only seqs 3 and 4.
|
|
|
expect(apply).toHaveBeenCalledTimes(2)
|
|
|
@@ -698,7 +709,7 @@ describe('SessionProjectionCache cold-read seeding', () => {
|
|
|
// No cached row yet: the first cold read folds from init over the full
|
|
|
// log and creates the cache row (the `?? {}` seed path).
|
|
|
const fresh = headerOf(SessionId('cold-fresh'), 10)
|
|
|
- const created = whenWritten(fresh.id)
|
|
|
+ const created = whenWritten(ctx, fresh.id)
|
|
|
cache.coldSnapshot(fresh, SessionLogOffset(0), events)
|
|
|
expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
|
|
|
await created
|