test(session): cover persistence revision races
This commit is contained in:
@@ -266,6 +266,35 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles revision-stat races and errors after log discovery', async () => {
|
||||
const m = meta('stored-revision-race')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
|
||||
const internals = persistence as unknown as {
|
||||
findLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined>
|
||||
}
|
||||
const path = rawLogPath(root, m.cwd, m.id)
|
||||
const findLog = vi.spyOn(internals, 'findLog').mockResolvedValue(path)
|
||||
|
||||
await rm(path)
|
||||
expect(await persistence.readStoredRevision(m.id)).toBeUndefined()
|
||||
|
||||
const invalidPath = `${path}\0`
|
||||
findLog.mockResolvedValue(invalidPath)
|
||||
await expect(persistence.readStoredRevision(m.id)).rejects.toMatchObject({
|
||||
code: 'ERR_INVALID_ARG_VALUE',
|
||||
})
|
||||
|
||||
const reason = new Error('revision read cancelled after discovery')
|
||||
const controller = new AbortController()
|
||||
findLog.mockImplementation(async () => {
|
||||
controller.abort(reason)
|
||||
return invalidPath
|
||||
})
|
||||
await expect(persistence.readStoredRevision(m.id, controller.signal)).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('omits a snapshot artifact removed after discovery', async () => {
|
||||
const m = meta('vanishing-snapshot')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
|
||||
@@ -654,6 +654,21 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
it('resolves the preparation-cache default without schema normalization', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
let persistence!: SessionPersistenceSqlite
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
persistence = new SessionPersistenceSqlite(inner, {
|
||||
path: ':memory:',
|
||||
journalMode: 'wal',
|
||||
})
|
||||
}, { inject: ['sessions'] }))
|
||||
|
||||
expect(await persistence.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses the configured preparation cache through the public service', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -772,6 +772,39 @@ describe('PersistenceCoordinator session preparations', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects preparation when storage disappears after repair', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
const id = SessionId('repair-disappeared')
|
||||
backend.store.set(id, {
|
||||
meta: meta(id),
|
||||
events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
|
||||
})
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
|
||||
try {
|
||||
await coordinator.inspect(id)
|
||||
const readStoredRevision = backend.readStoredRevision.bind(backend)
|
||||
let revisionReads = 0
|
||||
vi.spyOn(backend, 'readStoredRevision').mockImplementation((sessionId, signal) => {
|
||||
revisionReads += 1
|
||||
if (revisionReads === 2) return Promise.resolve(undefined)
|
||||
return readStoredRevision(sessionId, signal)
|
||||
})
|
||||
|
||||
await expect(coordinator.prepare(id)).rejects.toThrow(/disappeared after persistence repair/)
|
||||
expect(backend.repairAttempts).toBe(1)
|
||||
expect(revisionReads).toBe(2)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for an existing reservation and reuses it after release', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
Reference in New Issue
Block a user