refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
432
packages/session/session-persistence/tests/contract.ts
Normal file
432
packages/session/session-persistence/tests/contract.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Reusable contract test for any {@link SessionPersistence} backend. A backend
|
||||
* package imports {@link runPersistenceContract} and calls it with a factory
|
||||
* that yields a fresh, empty backend (and a teardown), so every backend is held
|
||||
* to the same append-only / contiguous-seq / lazy-materialization / crash
|
||||
* semantics. The JSONL backend's own spec adds file-specific tests on top.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-persistence/tests/contract
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
|
||||
/** A backend under test plus its teardown. */
|
||||
export interface ContractBackend {
|
||||
persistence: SessionPersistence
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Build a minimal {@link SessionHeader} for a session id. */
|
||||
export function meta(id: string, cwd?: string): SessionHeader {
|
||||
return {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId(id),
|
||||
createdAt: 1000,
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** A well-formed one-turn event log (contiguous seqs from 0). */
|
||||
export function oneTurnLog(): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: freezeMessage({
|
||||
id: MessageId('one-turn-user'),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 3, time: 4, data: {
|
||||
turn: 1, step: 1,
|
||||
message: freezeMessage({
|
||||
id: MessageId('one-turn-assistant'),
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Append recorded events to a live session while forwarding surface metadata verbatim. The broad
|
||||
* `SessionEvent` union makes the typed marker optional, but the runtime guard must still reject a
|
||||
* surface event whose fixture omitted it; this helper never synthesizes a default.
|
||||
*/
|
||||
export function appendLog(session: Session, events: readonly SessionEvent[]): void {
|
||||
for (const e of events) {
|
||||
const se = e as SessionEvent<SurfaceEventType>
|
||||
if (se.surfaceOp !== undefined) {
|
||||
const intent: SurfaceIntent = {
|
||||
surfaceOp: se.surfaceOp,
|
||||
...se.sourceEventSeqs !== undefined ? { sourceEventSeqs: se.sourceEventSeqs } : {},
|
||||
}
|
||||
session.append(e.type, e.data, intent)
|
||||
} else {
|
||||
session.append(e.type, e.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the backend-agnostic contract suite. `make()` MUST return a fresh, empty
|
||||
* backend each call.
|
||||
*/
|
||||
export function runPersistenceContract(name: string, make: () => Promise<ContractBackend>): void {
|
||||
describe(`SessionPersistence contract: ${name}`, () => {
|
||||
it('round-trips a session: create + append → load returns identical meta and byte-identical events', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s1', '/work')
|
||||
const log = oneTurnLog()
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' })
|
||||
expect(loaded.events).toEqual(log)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a fractional creation timestamp without reserving its session id', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = { ...meta('fractional-created-at'), createdAt: 1.5 }
|
||||
await expect(persistence.create(m))
|
||||
.rejects.toThrow('session metadata createdAt must be a non-negative safe integer')
|
||||
|
||||
const valid = meta('fractional-created-at')
|
||||
await persistence.create(valid)
|
||||
await persistence.append(valid.id, oneTurnLog())
|
||||
expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('interrupted')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5)
|
||||
// A second turn that crashed mid-flight: turn/start + step/start were
|
||||
// durably written, but no step/end / turn/end ever arrived.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
])
|
||||
const beforeRepair = (await persistence.listSnapshots())
|
||||
.find(snapshot => snapshot.header.id === m.id)?.revision
|
||||
|
||||
const inspected = await persistence.inspect(m.id)
|
||||
const afterInspect = (await persistence.listSnapshots())
|
||||
.find(snapshot => snapshot.header.id === m.id)?.revision
|
||||
expect(afterInspect).toBe(beforeRepair)
|
||||
expect(inspected.events.map(e => e.type)).toEqual([
|
||||
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end',
|
||||
'turn/start', 'step/start', 'step/end', 'turn/end',
|
||||
])
|
||||
|
||||
// load PRESERVES the interrupted turn's events (a turn can be huge — they
|
||||
// must not be truncated) and closes the orphaned turn with synthetic
|
||||
// boundary events: step/end (the step was open) then turn/end {interrupted}.
|
||||
const loaded = await persistence.load(m.id)
|
||||
const afterRepair = (await persistence.listSnapshots())
|
||||
.find(snapshot => snapshot.header.id === m.id)?.revision
|
||||
expect(afterRepair).not.toBe(beforeRepair)
|
||||
expect(loaded.events.map(e => e.type)).toEqual([
|
||||
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
|
||||
'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers
|
||||
])
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||
const last = loaded.events.at(-1)!
|
||||
expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
|
||||
|
||||
// The closed log is durable and continuable: a fresh append continues at
|
||||
// the balanced length (seq 10), and a reload round-trips identically.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await persistence.load(m.id)
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('interrupted-toolcall')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5)
|
||||
// Turn 2 crashed AFTER the assistant message asked for a tool call but
|
||||
// BEFORE the tool/result was written (the loop runs tools after logging
|
||||
// the assistant message — a process killed mid-tool lands exactly here).
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 8, time: 9, data: {
|
||||
turn: 2, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
])
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
// The orphaned call is answered by a synthetic error tool/result BEFORE
|
||||
// step/end + turn/end {interrupted}, so the step (and turn) are balanced
|
||||
// and a resumed session derives a valid transcript (no dangling call).
|
||||
expect(loaded.events.map(e => e.type)).toEqual([
|
||||
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
|
||||
'turn/start', 'step/start', 'assistant/message', 'tool/result', 'step/end', 'turn/end', // turn 2
|
||||
])
|
||||
const synthetic = loaded.events.find(e => e.type === 'tool/result')
|
||||
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
|
||||
message: {
|
||||
source: { kind: 'tool', callId: CallId('call-x') },
|
||||
content: [{ type: 'tool-result', toolCallId: CallId('call-x'), isError: true }],
|
||||
},
|
||||
error: { code: TOOL_NOT_STARTED },
|
||||
})
|
||||
// The synthetic result carries the SAME callId as the orphaned tool-call,
|
||||
// so deriveMessages() pairs them — no provider-invalid dangling call.
|
||||
const call = loaded.events.findLast(e => e.type === 'assistant/message')
|
||||
const callId = call?.type === 'assistant/message'
|
||||
&& call.data.message.content.find(b => b.type === 'tool-call')
|
||||
expect(callId && callId.type === 'tool-call' && callId.id).toBe(CallId('call-x'))
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: a recorded tool call with no result tells the model to assess retry risk', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('unknown-tool-outcome')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 3, data: {
|
||||
turn: 1, step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' },
|
||||
],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, surfaceOp: 'append' },
|
||||
{ type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } },
|
||||
])
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
const synthetic = loaded.events.find(e => e.type === 'tool/result')
|
||||
expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({
|
||||
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
|
||||
})
|
||||
if (synthetic?.type !== 'tool/result' || synthetic.data.message.content[0].content[0]?.type !== 'text') {
|
||||
throw new Error('expected a text tool result')
|
||||
}
|
||||
expect(synthetic.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
|
||||
expect(synthetic.data.message.content[0].content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
|
||||
const resumed = Session.create(m.id, loaded.events, loaded.meta)
|
||||
const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
|
||||
expect(resumedResult?.content[0]).toMatchObject({
|
||||
type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,
|
||||
})
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
await persistence.create(meta('empty'))
|
||||
expect((await persistence.list()).map(m => m.id)).not.toContain(SessionId('empty'))
|
||||
expect((await persistence.listSnapshots()).map(snapshot => snapshot.header.id))
|
||||
.not.toContain(SessionId('empty'))
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects pre-aborted observation reads with the exact cancellation reason', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const reason = new Error('persistence observation cancelled')
|
||||
const controller = new AbortController()
|
||||
await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([])
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(persistence.list(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
|
||||
.rejects.toBe(reason)
|
||||
await expect(persistence.readFrom(SessionId('cancelled-read-from'), 0, controller.signal))
|
||||
.rejects.toBe(reason)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('readFrom returns exactly the stored suffix from the requested seq, without mutating the log', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('read-from', '/work')
|
||||
const log = oneTurnLog()
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const whole = await persistence.readFrom(m.id, 0)
|
||||
expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' })
|
||||
expect(whole.events).toEqual(log)
|
||||
|
||||
const suffix = await persistence.readFrom(m.id, 3)
|
||||
expect(suffix.events).toEqual(log.slice(3))
|
||||
expect(suffix.events[0]?.seq).toBe(3)
|
||||
|
||||
// At/past the stored end: an empty tail, never an error.
|
||||
await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] })
|
||||
await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] })
|
||||
|
||||
// Non-mutating: an interrupted-turn log is served as stored, no closers.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
])
|
||||
const tail = await persistence.readFrom(m.id, 6)
|
||||
expect(tail.events.map(event => event.type)).toEqual(['turn/start'])
|
||||
|
||||
await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found')
|
||||
await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer')
|
||||
await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer')
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('lists stable lightweight revisions that change after an append', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s2')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog())
|
||||
expect((await persistence.list()).map(x => x.id)).toContain(m.id)
|
||||
const first = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
|
||||
const repeated = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
|
||||
expect(first).toBeDefined()
|
||||
expect(repeated?.revision).toBe(first?.revision)
|
||||
|
||||
await persistence.append(m.id, [{
|
||||
type: 'turn/start',
|
||||
seq: 6,
|
||||
time: 7,
|
||||
data: { turn: 2 },
|
||||
}])
|
||||
const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
|
||||
expect(changed?.revision).not.toBe(first?.revision)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('append rejects a batch whose first seq does not match the stored next-seq', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s3')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog()) // seqs 0..5, next-seq = 6
|
||||
// A re-append of an already-stored seq must be rejected, not duplicated.
|
||||
const restated = oneTurnLog()
|
||||
await expect(persistence.append(m.id, restated)).rejects.toThrow()
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('append rejects a mid-batch seq gap', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s4')
|
||||
await persistence.create(m)
|
||||
const gapped: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1
|
||||
]
|
||||
await expect(persistence.append(m.id, gapped)).rejects.toThrow()
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('append rejects non-JSON-serializable event data, naming the event type', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
// Every value `isJsonValue` rejects must be rejected by the backend, not just BigInt —
|
||||
// otherwise a backend could pass this contract while still accepting values that
|
||||
// corrupt the durable round-trip. Each value is carried in a plugin-added field on one
|
||||
// user message so the contract covers the complete JSON-value boundary.
|
||||
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
|
||||
cyclic['self'] = cyclic
|
||||
const badValues: unknown[] = [
|
||||
1n, // BigInt
|
||||
undefined, // dropped by JSON.stringify
|
||||
Infinity, // → null
|
||||
() => 0, // function
|
||||
Symbol('s'), // symbol
|
||||
new Map(), // exotic object
|
||||
cyclic, // circular ref
|
||||
]
|
||||
for (const [i, bad] of badValues.entries()) {
|
||||
// A fresh session per value isolates each rejection (a rejected append
|
||||
// must leave no state behind, but isolating keeps the assertion clean).
|
||||
const mi = meta(`s5-${i}`)
|
||||
await persistence.create(mi)
|
||||
const events = [
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: {
|
||||
id: MessageId(`invalid-json-${i}`),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'x' }],
|
||||
source: { kind: 'user' },
|
||||
extra: bad,
|
||||
},
|
||||
},
|
||||
] as unknown as SessionEvent[]
|
||||
await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/)
|
||||
}
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
1432
packages/session/session-persistence/tests/coordinator-contract.ts
Normal file
1432
packages/session/session-persistence/tests/coordinator-contract.ts
Normal file
File diff suppressed because it is too large
Load Diff
1915
packages/session/session-persistence/tests/persistence.spec.ts
Normal file
1915
packages/session/session-persistence/tests/persistence.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
360
packages/session/session-persistence/tests/preparations.spec.ts
Normal file
360
packages/session/session-persistence/tests/preparations.spec.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
/** Unit coverage for unpublished Session preparation ownership and sharing. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { observeQueuedAbort, SessionPreparations } from '../src/preparations.ts'
|
||||
|
||||
interface PreparedSource {
|
||||
readonly session: Session
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
function prepared(label: string): PreparedSource {
|
||||
return { session: Session.create(SessionId(label)), label }
|
||||
}
|
||||
|
||||
function committed(source: PreparedSource): Promise<{ source: PreparedSource; state: string }> {
|
||||
return Promise.resolve({ source, state: source.label })
|
||||
}
|
||||
|
||||
describe('SessionPreparations inspection', () => {
|
||||
it('shares in-flight and ready sources, then invalidates them', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(2)
|
||||
const id = SessionId('shared-inspection')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const load = vi.fn(() => gate.promise)
|
||||
const first = preparations.inspect(id, load)
|
||||
const second = preparations.inspect(id, load, new AbortController().signal)
|
||||
const source = prepared(id)
|
||||
|
||||
expect(preparations.has(id)).toBe(true)
|
||||
gate.resolve(source)
|
||||
await expect(first).resolves.toBe(source)
|
||||
await expect(second).resolves.toBe(source)
|
||||
await expect(preparations.inspect(id, load)).resolves.toBe(source)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
|
||||
preparations.invalidate(id)
|
||||
preparations.invalidate(id)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a shared load alive when its first observer cancels', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('cancelled-first-observer')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const load = vi.fn(() => gate.promise)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('first observer cancelled')
|
||||
const first = preparations.inspect(id, load, controller.signal)
|
||||
const joined = preparations.inspect(id, load)
|
||||
|
||||
controller.abort(reason)
|
||||
await expect(first).rejects.toBe(reason)
|
||||
const source = prepared(id)
|
||||
gate.resolve(source)
|
||||
await expect(joined).resolves.toBe(source)
|
||||
await expect(preparations.inspect(id, load)).resolves.toBe(source)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('evicts completed loads whose observers cancelled before readiness', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const firstId = SessionId('cancelled-ready-first')
|
||||
const secondId = SessionId('cancelled-ready-second')
|
||||
const firstGate = Promise.withResolvers<PreparedSource>()
|
||||
const secondGate = Promise.withResolvers<PreparedSource>()
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const first = preparations.inspect(firstId, () => firstGate.promise, firstController.signal)
|
||||
const second = preparations.inspect(secondId, () => secondGate.promise, secondController.signal)
|
||||
|
||||
firstController.abort(new Error('first observer cancelled'))
|
||||
secondController.abort(new Error('second observer cancelled'))
|
||||
await expect(first).rejects.toThrow('first observer cancelled')
|
||||
await expect(second).rejects.toThrow('second observer cancelled')
|
||||
|
||||
firstGate.resolve(prepared(firstId))
|
||||
await firstGate.promise
|
||||
secondGate.resolve(prepared(secondId))
|
||||
await secondGate.promise
|
||||
await Promise.resolve()
|
||||
|
||||
expect(preparations.has(firstId)).toBe(false)
|
||||
expect(preparations.has(secondId)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes failed and invalidated in-flight loads without changing their observers', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const failedId = SessionId('failed-inspection')
|
||||
const failure = new Error('load failed')
|
||||
await expect(preparations.inspect(failedId, () => Promise.reject(failure))).rejects.toBe(failure)
|
||||
expect(preparations.has(failedId)).toBe(false)
|
||||
|
||||
const invalidatedId = SessionId('invalidated-inspection')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const inspection = preparations.inspect(invalidatedId, () => gate.promise)
|
||||
preparations.invalidate(invalidatedId)
|
||||
const source = prepared(invalidatedId)
|
||||
gate.resolve(source)
|
||||
await expect(inspection).resolves.toBe(source)
|
||||
expect(preparations.has(invalidatedId)).toBe(false)
|
||||
|
||||
const rejectedId = SessionId('invalidated-rejection')
|
||||
const rejectedGate = Promise.withResolvers<PreparedSource>()
|
||||
const rejected = preparations.inspect(rejectedId, () => rejectedGate.promise)
|
||||
preparations.invalidate(rejectedId)
|
||||
rejectedGate.reject(failure)
|
||||
await expect(rejected).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('removes a load that throws before returning its promise', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('synchronous-load-failure')
|
||||
const failure = new Error('synchronous load failure')
|
||||
|
||||
await expect(preparations.inspect(id, () => { throw failure })).rejects.toBe(failure)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('evicts ready entries while leaving reserved entries alone', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const reservedA = await preparations.reserve(
|
||||
SessionId('reserved-a'),
|
||||
() => Promise.resolve(prepared('reserved-a')),
|
||||
committed,
|
||||
)
|
||||
const reservedB = await preparations.reserve(
|
||||
SessionId('reserved-b'),
|
||||
() => Promise.resolve(prepared('reserved-b')),
|
||||
committed,
|
||||
)
|
||||
expect(reservedA).toBeDefined()
|
||||
expect(reservedB).toBeDefined()
|
||||
|
||||
await preparations.inspect(SessionId('ready-c'), () => Promise.resolve(prepared('ready-c')))
|
||||
preparations.release(reservedA!, true)
|
||||
expect(preparations.has(SessionId('reserved-b'))).toBe(true)
|
||||
expect(preparations.has(SessionId('ready-c'))).toBe(false)
|
||||
expect(preparations.has(SessionId('reserved-a'))).toBe(true)
|
||||
|
||||
preparations.discard(reservedB!)
|
||||
preparations.invalidate(SessionId('reserved-a'))
|
||||
})
|
||||
|
||||
it('discards only the exact ready source and retains exclusive reservations', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const ready = prepared('discard-ready')
|
||||
expect(preparations.discardReady(ready.session.id, ready)).toBe('missing')
|
||||
await preparations.inspect(ready.session.id, () => Promise.resolve(ready))
|
||||
expect(preparations.discardReady(ready.session.id, prepared('different'))).toBe('missing')
|
||||
expect(preparations.discardReady(ready.session.id, ready)).toBe('discarded')
|
||||
|
||||
const reserved = await preparations.reserve(
|
||||
ready.session.id,
|
||||
() => Promise.resolve(ready),
|
||||
committed,
|
||||
)
|
||||
expect(preparations.discardReady(ready.session.id, ready)).toBe('retained')
|
||||
preparations.release(reserved!, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPreparations reservation', () => {
|
||||
it('waits for an existing reservation, republishes the exact Session, and attaches once', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(2)
|
||||
const id = SessionId('reservation-wait')
|
||||
const source = prepared(id)
|
||||
const first = await preparations.reserve(id, () => Promise.resolve(source), committed)
|
||||
expect(first).toBeDefined()
|
||||
expect(preparations.reservationFor(source.session)).toBe(first)
|
||||
expect(() => preparations.reservationFor(Session.create(id))).toThrow(/cannot publish/)
|
||||
expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
|
||||
|
||||
let secondSettled = false
|
||||
const secondPromise = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
|
||||
.then((reservation) => {
|
||||
secondSettled = true
|
||||
return reservation
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(secondSettled).toBe(false)
|
||||
|
||||
preparations.release(first!, true)
|
||||
const second = await secondPromise
|
||||
expect(second?.source).toBe(source)
|
||||
preparations.attach(second!)
|
||||
expect(preparations.reservationFor(source.session)).toBeUndefined()
|
||||
expect(() => { preparations.attach(second!) }).toThrow(/no longer reserved/)
|
||||
preparations.discard(second!)
|
||||
preparations.release(second!, true)
|
||||
expect(() => { preparations.assertWritable(id) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('supports abortable reservation waits without cancelling the held reservation', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('abortable-reservation-wait')
|
||||
const first = await preparations.reserve(id, () => Promise.resolve(prepared(id)), committed)
|
||||
const controller = new AbortController()
|
||||
const reason = { kind: 'cancelled' }
|
||||
const waiting = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed, controller.signal)
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
controller.abort(reason)
|
||||
await expect(waiting).rejects.toBe(reason)
|
||||
expect(preparations.reservationFor(first!.source.session)).toBe(first)
|
||||
preparations.release(first!, false)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('removes a failed commit and wakes another waiter as invalidated', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('failed-commit')
|
||||
const commitStarted = Promise.withResolvers<undefined>()
|
||||
const commitGate = Promise.withResolvers<{ source: PreparedSource; state: string }>()
|
||||
const source = prepared(id)
|
||||
const failure = new Error('commit failed')
|
||||
const first = preparations.reserve(id, () => Promise.resolve(source), () => {
|
||||
commitStarted.resolve(undefined)
|
||||
return commitGate.promise
|
||||
})
|
||||
await commitStarted.promise
|
||||
expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
|
||||
const second = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
|
||||
|
||||
commitGate.reject(failure)
|
||||
await expect(first).rejects.toBe(failure)
|
||||
await expect(second).resolves.toBeUndefined()
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns a post-commit cancellation to the ready pool', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('post-commit-cancel')
|
||||
const source = prepared(id)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel after commit')
|
||||
|
||||
await expect(preparations.reserve(id, () => Promise.resolve(source), async (value) => {
|
||||
controller.abort(reason)
|
||||
return { source: value, state: value.label }
|
||||
}, controller.signal)).rejects.toBe(reason)
|
||||
|
||||
expect(preparations.takeReady(id)).toBe(source)
|
||||
expect(preparations.takeReady(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not revive an invalidated commit after post-commit cancellation', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('invalidated-commit-cancel')
|
||||
const source = prepared(id)
|
||||
const commitStarted = Promise.withResolvers<undefined>()
|
||||
const commitGate = Promise.withResolvers<undefined>()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel invalidated commit')
|
||||
const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
|
||||
commitStarted.resolve(undefined)
|
||||
await commitGate.promise
|
||||
return { source: value, state: value.label }
|
||||
}, controller.signal)
|
||||
|
||||
await commitStarted.promise
|
||||
preparations.invalidate(id)
|
||||
controller.abort(reason)
|
||||
commitGate.resolve(undefined)
|
||||
await expect(reservation).rejects.toBe(reason)
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not reserve an entry invalidated while its commit succeeds', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('invalidated-successful-commit')
|
||||
const source = prepared(id)
|
||||
const commitStarted = Promise.withResolvers<undefined>()
|
||||
const commitGate = Promise.withResolvers<undefined>()
|
||||
const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
|
||||
commitStarted.resolve(undefined)
|
||||
await commitGate.promise
|
||||
return { source: value, state: value.label }
|
||||
})
|
||||
|
||||
await commitStarted.promise
|
||||
preparations.invalidate(id)
|
||||
commitGate.resolve(undefined)
|
||||
|
||||
await expect(reservation).resolves.toBeUndefined()
|
||||
expect(preparations.has(id)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns undefined when a load is invalidated before reservation', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('invalidated-reservation')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const reservation = preparations.reserve(id, () => gate.promise, committed)
|
||||
preparations.invalidate(id)
|
||||
gate.resolve(prepared(id))
|
||||
await expect(reservation).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips pending adoption and accepts a ready source exactly once', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const id = SessionId('take-ready')
|
||||
const gate = Promise.withResolvers<PreparedSource>()
|
||||
const inspection = preparations.inspect(id, () => gate.promise)
|
||||
expect(preparations.takeReady(id)).toBeUndefined()
|
||||
const source = prepared(id)
|
||||
gate.resolve(source)
|
||||
await inspection
|
||||
expect(preparations.takeReady(id)).toBe(source)
|
||||
expect(preparations.takeReady(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects publication while only an inspection exists', async () => {
|
||||
const preparations = new SessionPreparations<PreparedSource, string>(1)
|
||||
const source = prepared('inspection-publication')
|
||||
await preparations.inspect(source.session.id, () => Promise.resolve(source))
|
||||
expect(() => preparations.reservationFor(source.session)).toThrow(/cannot publish/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('observeQueuedAbort', () => {
|
||||
it('relays fulfillment and rejection exactly', async () => {
|
||||
const signal = new AbortController().signal
|
||||
await expect(observeQueuedAbort(Promise.resolve('value'), signal)).resolves.toBe('value')
|
||||
const failure = { kind: 'failed' }
|
||||
const rejected = Promise.withResolvers<never>()
|
||||
rejected.reject(failure)
|
||||
await expect(observeQueuedAbort(rejected.promise, signal)).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('rejects promptly with an exact abort reason and ignores later settlement', async () => {
|
||||
const operation = Promise.withResolvers<string>()
|
||||
const controller = new AbortController()
|
||||
const reason = { kind: 'aborted' }
|
||||
const observed = observeQueuedAbort(operation.promise, controller.signal)
|
||||
controller.abort(reason)
|
||||
await expect(observed).rejects.toBe(reason)
|
||||
operation.resolve('late')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('observes a pre-aborted signal through the default start predicate', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('pre-aborted')
|
||||
await expect(observeQueuedAbort(new Promise<never>(() => {}), controller.signal))
|
||||
.rejects.toBe('pre-aborted')
|
||||
})
|
||||
|
||||
it('lets an operation that already started own cancellation settlement', async () => {
|
||||
const operation = Promise.withResolvers<string>()
|
||||
const controller = new AbortController()
|
||||
const observed = observeQueuedAbort(operation.promise, controller.signal, () => true)
|
||||
controller.abort(new Error('too late'))
|
||||
operation.resolve('owned')
|
||||
await expect(observed).resolves.toBe('owned')
|
||||
})
|
||||
})
|
||||
275
packages/session/session-persistence/tests/write-behind.spec.ts
Normal file
275
packages/session/session-persistence/tests/write-behind.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SessionWriteBehind } from '../src/write-behind.ts'
|
||||
|
||||
/** Minimal ordered event fixture; batching does not interpret event vocabulary. */
|
||||
function event(seq: number): SessionEvent<'turn/start'> {
|
||||
return {
|
||||
type: 'turn/start',
|
||||
seq,
|
||||
time: seq,
|
||||
data: { turn: seq + 1 },
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('SessionWriteBehind', () => {
|
||||
it('uses one fixed window from the first queued event and owns its copy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const batches: SessionEvent[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => { batches.push(structuredClone(events) as SessionEvent[]) },
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
const first = event(0)
|
||||
|
||||
controller.enqueue(first)
|
||||
first.data.turn = 99
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(49)
|
||||
expect(batches).toEqual([])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(batches).toEqual([[
|
||||
expect.objectContaining({ seq: 0, data: { turn: 1 } }),
|
||||
expect.objectContaining({ seq: 1 }),
|
||||
]])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
|
||||
it('coalesces twenty events admitted ten milliseconds apart into one 200 ms batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => { batches.push(events.map(item => item.seq)) },
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
for (let seq = 1; seq < 20; seq += 1) {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
controller.enqueue(event(seq))
|
||||
}
|
||||
expect(batches).toEqual([])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(batches).toEqual([Array.from({ length: 20 }, (_, seq) => seq)])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('makes concurrent flushes one immediate barrier that drains admitted tails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) await gate.promise
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
const first = controller.flush()
|
||||
const second = controller.flush()
|
||||
expect(second).toBe(first)
|
||||
await Promise.resolve()
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
controller.enqueue(event(1))
|
||||
gate.resolve(true)
|
||||
await first
|
||||
expect(batches).toEqual([[0], [1]])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('starts a new window for work admitted after an already-quiescent barrier', async () => {
|
||||
vi.useFakeTimers()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => { batches.push(events.map(item => item.seq)) },
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
const barrier = controller.flush()
|
||||
controller.enqueue(event(0))
|
||||
await barrier
|
||||
expect(batches).toEqual([])
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0]])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
|
||||
it('starts an over-budget tail immediately after the active write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) await gate.promise
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0]])
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
gate.resolve(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(batches).toEqual([[0], [1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('keeps a tail deadline that has not expired when the active write finishes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) await gate.promise
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
gate.resolve(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
expect(batches).toEqual([[0]])
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(batches).toEqual([[0], [1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('pauses automatic retries after failure and preserves order for new work', async () => {
|
||||
vi.useFakeTimers()
|
||||
const failure = new Error('storage unavailable')
|
||||
const report = vi.fn()
|
||||
const batches: number[][] = []
|
||||
let attempt = 0
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (++attempt === 1) throw failure
|
||||
},
|
||||
reportBackgroundFailure: report,
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(report).toHaveBeenCalledWith(failure)
|
||||
expect(controller.hasWork).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(batches).toEqual([[0]])
|
||||
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
expect(batches).toEqual([[0]])
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(batches).toEqual([[0], [0, 1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('observes an overlapping background failure and retries it inside flush', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const report = vi.fn()
|
||||
const batches: number[][] = []
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (batches.length === 1) {
|
||||
await gate.promise
|
||||
throw new Error('transient')
|
||||
}
|
||||
},
|
||||
reportBackgroundFailure: report,
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
const first = controller.flush()
|
||||
const second = controller.flush()
|
||||
gate.resolve(true)
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined])
|
||||
expect(batches).toEqual([[0], [0]])
|
||||
expect(report).toHaveBeenCalledOnce()
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces a barrier failure without detached logging and retains its batch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const failure = new Error('durability failed')
|
||||
const report = vi.fn()
|
||||
const batches: number[][] = []
|
||||
let attempt = 0
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
batches.push(events.map(item => item.seq))
|
||||
if (++attempt === 1) throw failure
|
||||
},
|
||||
reportBackgroundFailure: report,
|
||||
})
|
||||
|
||||
controller.enqueue(event(0))
|
||||
await expect(controller.flush()).rejects.toBe(failure)
|
||||
expect(report).not.toHaveBeenCalled()
|
||||
expect(controller.hasWork).toBe(true)
|
||||
|
||||
controller.enqueue(event(1))
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(batches).toEqual([[0], [0, 1]])
|
||||
await controller.flush()
|
||||
})
|
||||
|
||||
it('retains a failed batch larger than the engine call-argument limit', async () => {
|
||||
const failure = new Error('durability failed')
|
||||
const batchSize = 150_000
|
||||
const sizes: number[] = []
|
||||
let attempt = 0
|
||||
const controller = new SessionWriteBehind({
|
||||
maxDelayMs: 200,
|
||||
write: async (events) => {
|
||||
sizes.push(events.length)
|
||||
if (++attempt === 1) throw failure
|
||||
},
|
||||
reportBackgroundFailure: vi.fn(),
|
||||
})
|
||||
|
||||
for (let seq = 0; seq < batchSize; seq += 1) controller.enqueue(event(seq))
|
||||
await expect(controller.flush()).rejects.toBe(failure)
|
||||
expect(controller.hasWork).toBe(true)
|
||||
|
||||
await controller.flush()
|
||||
expect(sizes).toEqual([batchSize, batchSize])
|
||||
expect(controller.hasWork).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user