fix: batch cancellable title reads

This commit is contained in:
Hypatia May
2026-07-24 18:13:11 +08:00
parent f5fc7ac04a
commit fec4ce52cc
23 changed files with 1152 additions and 150 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
@@ -27,16 +27,31 @@ function eventLog(text = 'hello'): SessionEvent[] {
class TestPersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static listFailure: unknown
static listOverride: ((signal?: AbortSignal) => Promise<SessionHeader[]>) | undefined
static inspectFailure: unknown
static inspectEffect: (() => void) | undefined
static inspectOverride: ((
id: SessionIdType,
signal?: AbortSignal,
) => Promise<{ meta: SessionHeader; events: SessionEvent[] }>) | undefined
static afterList: (() => void) | undefined
static listCalls = 0
static inspectCalls: SessionIdType[] = []
static listSignals: Array<AbortSignal | undefined> = []
static inspectSignals: Array<AbortSignal | undefined> = []
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
this.listFailure = undefined
this.listOverride = undefined
this.inspectFailure = undefined
this.inspectEffect = undefined
this.inspectOverride = undefined
this.afterList = undefined
this.listCalls = 0
this.inspectCalls = []
this.listSignals = []
this.inspectSignals = []
}
locate(_meta: SessionHeader): undefined {
@@ -59,7 +74,15 @@ class TestPersistence extends SessionPersistence {
return this.inspect(id)
}
inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
inspect(
id: SessionIdType,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
TestPersistence.inspectCalls.push(id)
TestPersistence.inspectSignals.push(signal)
if (TestPersistence.inspectOverride !== undefined) {
return TestPersistence.inspectOverride(id, signal)
}
if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure)
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
@@ -69,7 +92,10 @@ class TestPersistence extends SessionPersistence {
return Promise.resolve(result)
}
list(): Promise<SessionHeader[]> {
list(signal?: AbortSignal): Promise<SessionHeader[]> {
TestPersistence.listCalls += 1
TestPersistence.listSignals.push(signal)
if (TestPersistence.listOverride !== undefined) return TestPersistence.listOverride(signal)
if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure)
const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))
TestPersistence.afterList?.()
@@ -192,6 +218,336 @@ describe('session-query exact reads', () => {
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
})
it('batches unique persisted title observations through one cancellable corpus scan', async () => {
const first = header('batch-title-first', 1)
const second = header('batch-title-second', 2)
const titleEvent = (title: string, time: number): SessionEvent => ({
type: 'session/title',
seq: 0,
time,
data: {
title,
messageSeqs: [],
source: { kind: 'fallback' },
},
})
TestPersistence.reset([
{ meta: first, events: [titleEvent('First title', 10)] },
{ meta: second, events: [titleEvent('Second title', 20)] },
])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const signal = new AbortController().signal
const missing = SessionId('batch-title-missing')
const results = await ctx.sessionQuery.readTitleSnapshots(
[second.id, first.id, second.id, missing],
signal,
)
expect(results.map(result => [result.sessionId, result.status])).toEqual([
[second.id, 'fulfilled'],
[first.id, 'fulfilled'],
[missing, 'rejected'],
])
expect(results[0]).toMatchObject({ value: { session: second, title: { title: 'Second title' } } })
expect(results[1]).toMatchObject({ value: { session: first, title: { title: 'First title' } } })
expect(TestPersistence.listCalls).toBe(1)
expect(TestPersistence.inspectCalls).toEqual([second.id, first.id])
expect(TestPersistence.listSignals).toEqual([signal])
expect(TestPersistence.inspectSignals).toEqual([signal, signal])
})
it('bounds persisted title inspection concurrency while preserving ordered results', async () => {
const entries = Array.from({ length: 12 }, (_, index) => {
const meta = header(`bounded-title-${index}`, index)
return { meta, events: eventLog(`title-${index}`) }
})
TestPersistence.reset(entries)
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
let active = 0
let maximum = 0
TestPersistence.inspectOverride = async (id) => {
active += 1
maximum = Math.max(maximum, active)
await new Promise<void>(resolve => setImmediate(resolve))
active -= 1
const entry = TestPersistence.entries.get(id)
if (entry === undefined) throw new Error('missing bounded test session')
return structuredClone(entry)
}
const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id))
expect(maximum).toBe(4)
expect(TestPersistence.listCalls).toBe(1)
expect(TestPersistence.inspectCalls).toEqual(entries.map(entry => entry.meta.id))
expect(results.map(result => result.sessionId)).toEqual(entries.map(entry => entry.meta.id))
expect(results.every(result => result.status === 'fulfilled')).toBe(true)
})
it('folds and discards each completed log before its worker dequeues another inspection', async () => {
const entries = Array.from({ length: 5 }, (_, index) => ({
meta: header(`project-title-${index}`, index),
events: [],
}))
TestPersistence.reset(entries)
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const timeline: string[] = []
const releases = new Map<SessionIdType, () => void>()
TestPersistence.inspectOverride = id => new Promise((resolve) => {
timeline.push(`inspect:${id}`)
releases.set(id, () => {
const marker = `full-log-marker:${id}`
const titleEvent = {
type: 'session/title',
seq: 1,
time: 20,
data: {
title: `Projected ${id}`,
get messageSeqs() {
timeline.push(`project:${id}`)
return []
},
source: { kind: 'fallback' },
},
} as unknown as SessionEvent
resolve({
meta: entries.find(entry => entry.meta.id === id)!.meta,
events: [...eventLog(marker), titleEvent],
})
})
})
const release = (id: SessionIdType): void => {
const settle = releases.get(id)
if (settle === undefined) throw new Error(`inspection ${id} has not started`)
settle()
}
const ids = entries.map(entry => entry.meta.id)
const pending = ctx.sessionQuery.readTitleSnapshots(ids)
await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) })
release(ids[0]!)
await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(5) })
// Heap-retention assertions would depend on nondeterministic GC. This ordering
// is the deterministic guard: a retain-all implementation cannot touch the
// observable title getter until every inspection has completed.
expect(timeline.indexOf(`project:${ids[0]}`))
.toBeLessThan(timeline.indexOf(`inspect:${ids[4]}`))
for (const id of ids.slice(1)) release(id)
const results = await pending
expect(results.map(result => result.sessionId)).toEqual(ids)
expect(JSON.stringify(results)).not.toContain('full-log-marker:')
expect(results.every(result => result.status === 'fulfilled')).toBe(true)
})
it('passes cancellation into a stalled persisted title batch and rejects with its reason', async () => {
const persisted = header('stalled-title', 1)
TestPersistence.reset([{ meta: persisted, events: [] }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('title deadline')
let started!: () => void
const inspectStarted = new Promise<void>((resolve) => { started = resolve })
TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
started()
signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
})
const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
await inspectStarted
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
expect(TestPersistence.listSignals).toEqual([controller.signal])
expect(TestPersistence.inspectSignals).toEqual([controller.signal])
})
it('drains started title inspections after cancellation without starting queued ids', async () => {
const entries = Array.from({ length: 8 }, (_, index) => ({
meta: header(`cancel-queued-title-${index}`, index),
events: eventLog(`queued-${index}`),
}))
TestPersistence.reset(entries)
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('cancel queued title batch')
const releases: Array<() => void> = []
let abortsObserved = 0
let inspectionsSettled = 0
TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => { abortsObserved += 1 }, { once: true })
releases.push(() => {
inspectionsSettled += 1
reject(reason)
})
})
const pending = ctx.sessionQuery.readTitleSnapshots(
entries.map(entry => entry.meta.id),
controller.signal,
)
let batchSettled = false
void pending.then(
() => { batchSettled = true },
() => { batchSettled = true },
)
await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) })
controller.abort(reason)
await vi.waitFor(() => { expect(abortsObserved).toBe(4) })
expect(batchSettled).toBe(false)
expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id))
for (const release of releases) release()
await expect(pending).rejects.toBe(reason)
expect(inspectionsSettled).toBe(4)
expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id))
})
it('passes cancellation into a stalled persisted title listing and rejects with its reason', async () => {
const persisted = header('stalled-title-list', 1)
TestPersistence.reset([{ meta: persisted, events: [] }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('title listing deadline')
let started!: () => void
const listStarted = new Promise<void>((resolve) => { started = resolve })
TestPersistence.listOverride = signal => new Promise((_resolve, reject) => {
started()
signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
})
const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
await listStarted
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
expect(TestPersistence.listSignals).toEqual([controller.signal])
expect(TestPersistence.inspectCalls).toEqual([])
})
it('isolates title read and fold failures while preferring a live owner attached during inspection', async () => {
const attached = header('batch-title-attached', 1)
const failed = header('batch-title-failed', 2)
const malformed = header('batch-title-malformed', 3)
const inspectFailure = new Error('one title inspect failed')
const malformedTitle = {
type: 'session/title',
seq: 0,
time: 30,
data: {
title: 'malformed',
source: { kind: 'fallback' },
},
} as unknown as SessionEvent
TestPersistence.reset([
{ meta: attached, events: eventLog('stale persisted') },
{ meta: failed, events: [] },
{ meta: malformed, events: [malformedTitle] },
])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
TestPersistence.inspectOverride = (id) => {
if (id === failed.id) return Promise.reject(inspectFailure)
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
if (id === attached.id) {
const session = ctx.sessions.create(attached.id, { meta: { createdAt: attached.createdAt } })
session.append('session/title', {
title: 'Attached live title',
messageSeqs: [],
source: { kind: 'fallback' },
})
}
return Promise.resolve(structuredClone(entry))
}
const results = await ctx.sessionQuery.readTitleSnapshots([
attached.id,
failed.id,
malformed.id,
])
expect(results[0]).toMatchObject({
status: 'fulfilled',
value: { session: attached, title: { title: 'Attached live title' } },
})
expect(results[1]).toMatchObject({
sessionId: failed.id,
status: 'rejected',
reason: {
code: 'SESSION_QUERY_PERSISTENCE_FAILED',
cause: inspectFailure,
},
})
expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' })
if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection')
expect(results[2].reason).toBeInstanceOf(TypeError)
})
it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => {
const liveOnly = await liveContext()
const live = liveOnly.sessions.create(SessionId('batch-title-live'))
const missing = SessionId('batch-title-no-persistence')
await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, live.id])).resolves.toEqual([{
sessionId: live.id,
status: 'fulfilled',
value: { session: live.header },
}])
await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, missing])).resolves.toMatchObject([
{ sessionId: live.id, status: 'fulfilled' },
{ sessionId: missing, status: 'rejected' },
])
await expect(liveOnly.sessionQuery.readTitleSnapshot(missing))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
const persisted = header('batch-title-persisted', 1)
const late = header('batch-title-late', 2)
TestPersistence.reset([{ meta: persisted, events: [] }])
const mixed = await liveContext()
const mixedLive = mixed.sessions.create(SessionId('batch-title-mixed-live'))
await mixed.plugin(TestPersistence)
TestPersistence.afterList = () => {
mixed.sessions.create(late.id, { meta: { createdAt: late.createdAt } })
TestPersistence.afterList = undefined
}
await expect(mixed.sessionQuery.readTitleSnapshots([
mixedLive.id,
persisted.id,
late.id,
])).resolves.toMatchObject([
{ sessionId: mixedLive.id, status: 'fulfilled' },
{ sessionId: persisted.id, status: 'fulfilled' },
{ sessionId: late.id, status: 'fulfilled' },
])
TestPersistence.reset()
TestPersistence.listFailure = new Error('title listing failed')
const failedList = await liveContext()
const survivingLive = failedList.sessions.create(SessionId('batch-title-list-live'))
await failedList.plugin(TestPersistence)
await expect(failedList.sessionQuery.readTitleSnapshots([survivingLive.id, missing]))
.resolves.toMatchObject([
{ sessionId: survivingLive.id, status: 'fulfilled' },
{
sessionId: missing,
status: 'rejected',
reason: expectCode('SESSION_QUERY_PERSISTENCE_FAILED'),
},
])
})
it('lists live sessions deterministically and returns detached headers', async () => {
const ctx = await liveContext()
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })