fix(session-persistence): invalidate stale preparations

This commit is contained in:
imccyu
2026-08-06 03:45:22 +08:00
parent 5e317371e5
commit ede74b1926
25 changed files with 353 additions and 101 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence-jsonl/README.md
README.md: 55b802c57c27c771e0259451924501e5fb367911
README.zh.md: a5e71f27990938576d5a77bf182dbd6919c047f1
README.md: cd087539bde2433fcdb70b2c511ff30880a877e1
README.zh.md: 144b404e04f8a5fd3623d9329e4fbcafd328524d

View File

@@ -44,7 +44,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without truncating an incomplete tail or changing the lightweight revision.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. It forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another.
- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. A full-prefix read requires the same identity before and after reading the bytes, and `readStoredRevision()` uses that identity to validate retained preparations without loading the log. Snapshot listing forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another.
## Write path

View File

@@ -44,7 +44,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷属于损坏,会被拒绝。
- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer但不会截断不完整尾部或更改轻量修订。
- **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝非 JSON 可序列化 `event.data`,同时命名违规事件类型。
- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。
- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。完整前缀读取要求读取字节前后的身份一致,`readStoredRevision()` 使用同一身份校验保留的 preparation而不加载日志。快照列表通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。
## 写入路径

View File

@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto'
import {
DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type SessionInspection, type StoredPrefix,
type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
import {
@@ -66,6 +66,25 @@ interface JsonlTornMarker {
recoveredEvents: SessionEvent[]
}
interface FileRevisionIdentity {
readonly dev: bigint
readonly ino: bigint
readonly size: bigint
readonly mtimeNs: bigint
readonly ctimeNs: bigint
}
/** Build the source-qualified revision shared by full and lightweight reads. */
function fileRevision(identity: FileRevisionIdentity): PersistenceRevision {
return SessionPersistenceRevision([
identity.dev,
identity.ino,
identity.size,
identity.mtimeNs,
identity.ctimeNs,
].join(':'))
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
@@ -167,6 +186,24 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.readPrefix(path, id, signal)
}
/** Read one log's stat-derived revision without loading its event bytes. */
async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<PersistenceRevision | undefined> {
signal?.throwIfAborted()
await this.ensureRootEncoding()
signal?.throwIfAborted()
const path = await this.findLog(id, signal)
if (path === undefined) return undefined
try {
const identity = await stat(path, { bigint: true })
signal?.throwIfAborted()
return fileRevision(identity)
} catch (error: unknown) {
signal?.throwIfAborted()
if (isENOENT(error)) return undefined
throw error
}
}
/**
* Read a stored prefix and convert torn-tail state to the opaque marker the
* coordinator can round-trip without knowing the physical encoding.
@@ -176,9 +213,20 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
expectedId?: SessionId,
signal?: AbortSignal,
): Promise<StoredPrefix<JsonlTornMarker>> {
const buffer = await readFile(path, { signal })
signal?.throwIfAborted()
let prefix: StoredPrefix<JsonlTornMarker>
let buffer: Buffer
let revision: PersistenceRevision
for (;;) {
signal?.throwIfAborted()
const before = fileRevision(await stat(path, { bigint: true }))
buffer = await readFile(path, { signal })
signal?.throwIfAborted()
const after = fileRevision(await stat(path, { bigint: true }))
if (before === after) {
revision = after
break
}
}
let prefix: Omit<StoredPrefix<JsonlTornMarker>, 'revision'>
if (this.compression === 'zstd') {
prefix = await this.readZstdPrefix(buffer, signal)
} else {
@@ -196,14 +244,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
signal?.throwIfAborted()
await this.assertStoredIdentity(path, prefix.meta, expectedId, signal)
signal?.throwIfAborted()
return prefix
return { ...prefix, revision }
}
/** Decode complete frames and retain complete JSONL records from a torn final frame. */
private async readZstdPrefix(
buffer: Buffer,
signal?: AbortSignal,
): Promise<StoredPrefix<JsonlTornMarker>> {
): Promise<Omit<StoredPrefix<JsonlTornMarker>, 'revision'>> {
signal?.throwIfAborted()
const { frames, tornStart } = scanZstdFrames(buffer)
signal?.throwIfAborted()
@@ -307,13 +355,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
signal?.throwIfAborted()
snapshots.push({
header: artifact.header,
revision: SessionPersistenceRevision([
identity.dev,
identity.ino,
identity.size,
identity.mtimeNs,
identity.ctimeNs,
].join(':')),
revision: fileRevision(identity),
})
} catch (error: unknown) {
signal?.throwIfAborted()

View File

@@ -255,6 +255,17 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
await otherCtx.fiber.dispose()
})
it('binds a full stored prefix to the same revision as a lightweight read', async () => {
const m = meta('stored-prefix-revision')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const persistence = ctx.sessionPersistence as SessionPersistenceJsonl
const stored = await persistence.loadStored(m.id)
expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
})
it('omits a snapshot artifact removed after discovery', async () => {
const m = meta('vanishing-snapshot')
await ctx.sessionPersistence.create(m)