fix(persistence): tighten crash repair and dispose semantics
This commit is contained in:
@@ -8,8 +8,7 @@
|
||||
* line, verbatim including `assistant/chunk` so `seq` stays contiguous) plus
|
||||
* a small atomic `.summary.json` sidecar for the mutable `SessionSummary`.
|
||||
* Lazy materialization (no file until the first `append`), atomic first
|
||||
* write, and truncation-repair of a never-committed crash tail on the first
|
||||
* `append` after a `load`.
|
||||
* write, and load-time repair of a never-committed crash tail.
|
||||
*
|
||||
* 2. **The write path** — the `session/event` → buffer → `session/flush` drain
|
||||
* that generalizes the example `session-jsonl.ts`: snapshot each event when
|
||||
@@ -24,7 +23,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { open, mkdir, readFile, readdir, rename, link, rm, truncate } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session'
|
||||
@@ -111,6 +110,15 @@ function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unknown[]> {
|
||||
const settled = await Promise.allSettled([...promises])
|
||||
const errors: unknown[] = []
|
||||
for (const result of settled) {
|
||||
if (result.status === 'rejected') errors.push(result.reason)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSONL persistence backend. Load as a plugin; it registers as
|
||||
* `ctx.sessionPersistence` and installs the write-path listeners.
|
||||
@@ -397,8 +405,8 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
// sidecar is best-effort) — but if we mutated state.meta first, a later
|
||||
// touchSummary() on a successful append would persist the rejected
|
||||
// title/firstPrompt, making a failed update durable after the fact.
|
||||
const nextMeta: SessionMeta = { ...state.meta, ...summary }
|
||||
await this.writeSidecar(nextMeta)
|
||||
const nextMeta: SessionMeta = { ...state.meta, ...summary, updatedAt: summary.updatedAt ?? Date.now() }
|
||||
if (state.materialized) await this.writeSidecar(nextMeta)
|
||||
state.meta = nextMeta
|
||||
}
|
||||
|
||||
@@ -407,7 +415,10 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
/** Atomically write the header line + first batch (temp-write, fsync, rename). */
|
||||
private async materialize(state: SessionState, events: readonly SessionEvent[]): Promise<void> {
|
||||
const dir = sessionDir(this.root, state.meta.cwd)
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
await this.syncDir(dirname(this.root))
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await this.syncDir(this.root)
|
||||
const finalPath = logPath(this.root, state.meta.cwd, state.meta.id)
|
||||
// Never rename over an existing committed log: materialize is the FIRST
|
||||
// write of a session the backend believes is new. A file here means a
|
||||
@@ -571,8 +582,9 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
try {
|
||||
const raw = await readFile(sidecarPath(this.root, cwd, id), 'utf8')
|
||||
return JSON.parse(raw) as SessionSummary
|
||||
} catch {
|
||||
return undefined
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,9 +687,14 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
// Dispose must reach quiescence: await every session's init + final drain
|
||||
// BEFORE returning, so no write lands after teardown (orphan rename/ENOENT).
|
||||
ctx.effect(() => async () => {
|
||||
await Promise.allSettled([...this.inits.values()])
|
||||
await Promise.allSettled([...this.buffers.keys()].map(s => this.flush(s)))
|
||||
await Promise.allSettled([...this.chains.values()])
|
||||
const errors = [
|
||||
...await settledErrors(this.inits.values()),
|
||||
...await settledErrors([...this.buffers.keys()].map(s => this.flush(s))),
|
||||
...await settledErrors(this.chains.values()),
|
||||
]
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, 'session-persistence-jsonl dispose failed')
|
||||
}
|
||||
}, 'session-persistence-jsonl write path')
|
||||
|
||||
// HMR: a hot reload does not replay session/created, so seed existing live
|
||||
|
||||
@@ -644,25 +644,40 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
expect(loaded.meta.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delete removes the sidecar of a lazy session that has no log', async () => {
|
||||
// update() before the first append() writes a .summary.json sidecar but no
|
||||
// .jsonl log (lazy create). delete() must still remove that sidecar.
|
||||
const m = meta('lazy-del', '/a')
|
||||
it('update before the first append keeps summary in memory and writes no orphan sidecar', async () => {
|
||||
const m = meta('lazy-update', '/a')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.update(m.id, { title: 'secret', firstPrompt: 'sensitive' })
|
||||
const sidecar = sidecarPath(root, '/a', m.id)
|
||||
expect((await stat(sidecar)).isFile()).toBe(true) // sidecar exists, no log
|
||||
await expect(stat(logPath(root, '/a', m.id))).rejects.toThrow() // no log
|
||||
await ctx.sessionPersistence.delete(m.id)
|
||||
await expect(stat(sidecar)).rejects.toThrow() // sidecar gone
|
||||
await expect(stat(sidecar)).rejects.toThrow()
|
||||
await expect(stat(logPath(root, '/a', m.id))).rejects.toThrow()
|
||||
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const loaded = await ctx.sessionPersistence.load(m.id)
|
||||
expect(loaded.meta.title).toBe('secret')
|
||||
expect(loaded.meta.firstPrompt).toBe('sensitive')
|
||||
expect((await stat(sidecar)).isFile()).toBe(true)
|
||||
})
|
||||
|
||||
it('delete removes a cwd-bucket sidecar even after a restart loses the in-memory cwd', async () => {
|
||||
// A lazy session writes a sidecar under cwd /a (no log). Restart the backend
|
||||
// (fresh instance, empty state) and delete: the in-memory cwd is gone and
|
||||
// there is no log to recover it from, so delete must scan every bucket for
|
||||
// the sidecar rather than only the _no-cwd bucket.
|
||||
it('a lazy update leaves no sidecar that can leak into a future same-id session after restart', async () => {
|
||||
await ctx.sessionPersistence.create(meta('restart-lazy', '/a'))
|
||||
await ctx.sessionPersistence.update(SessionId('restart-lazy'), { title: 'secret' })
|
||||
await expect(stat(sidecarPath(root, '/a', SessionId('restart-lazy')))).rejects.toThrow()
|
||||
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
const m2 = meta('restart-lazy', '/a')
|
||||
await ctx2.sessionPersistence.create(m2)
|
||||
await ctx2.sessionPersistence.append(m2.id, oneTurnLog())
|
||||
const loaded = await ctx2.sessionPersistence.load(m2.id)
|
||||
expect(loaded.meta.title).toBeUndefined()
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('delete removes a materialized cwd-bucket sidecar after a restart', async () => {
|
||||
await ctx.sessionPersistence.create(meta('restart-del', '/a'))
|
||||
await ctx.sessionPersistence.append(SessionId('restart-del'), oneTurnLog())
|
||||
await ctx.sessionPersistence.update(SessionId('restart-del'), { title: 'secret' })
|
||||
const sidecar = sidecarPath(root, '/a', SessionId('restart-del'))
|
||||
expect((await stat(sidecar)).isFile()).toBe(true)
|
||||
@@ -671,7 +686,8 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
await ctx2.sessionPersistence.delete(SessionId('restart-del'))
|
||||
await expect(stat(sidecar)).rejects.toThrow() // sidecar gone despite no in-memory cwd
|
||||
await expect(stat(sidecar)).rejects.toThrow()
|
||||
await expect(stat(logPath(root, '/a', SessionId('restart-del')))).rejects.toThrow()
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user