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-sqlite/README.md
README.md: de70eb80559611f0409e05d3b2a6f69777a781a6
README.zh.md: 53df3ef835ebc01b4a1ef7109190c4b64863a5ba
README.md: d01ba6ebfa1f59a9e4d58f3032bbe1d016970290
README.zh.md: c11bef5467a3401948b58d5fd8e3301b72ff3d03

View File

@@ -22,7 +22,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di
- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row).
- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor.
- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without deleting a torn tail row, appending recovery rows, or changing the lightweight revision.
- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. A full-prefix read captures that revision and its event rows in one read transaction, while `readStoredRevision()` queries only the session row to validate retained preparations. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
## Configuration (schemastery)

View File

@@ -22,7 +22,7 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 实现(见[
- **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。
- **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。
- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer但不会删除撕裂尾部行、追加恢复行或更改轻量修订。
- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id以及在每个变更事务中递增的每会话计数器。它在不解析事件行的情况下保持未变观察稳定并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。
- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id以及在每个变更事务中递增的每会话计数器。完整前缀读取在同一个读事务中捕获该 revision 及其事件行,`readStoredRevision()` 则只查询 session 行来校验保留的 preparation。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。
## 配置schemastery

View File

@@ -16,7 +16,8 @@ import { dirname, resolve } from 'node:path'
import {
DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type SessionInspection, type StoredPrefix, type StoredSuffix,
type SessionInspection, type SessionPersistenceRevision as PersistenceRevision,
type StoredPrefix, type StoredSuffix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
import {
@@ -38,6 +39,13 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] {
]
}
/** Build the source-qualified revision shared by full and lightweight reads. */
function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
return SessionPersistenceRevision(
`${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
)
}
/**
* Exclusively create a missing database file with owner-only permissions.
* Existing files retain their modes, and errors other than `EEXIST` propagate.
@@ -187,6 +195,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.readPrefix(id, signal)
}
/** Read one row's revision without loading its events. */
async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<PersistenceRevision | undefined> {
signal?.throwIfAborted()
await this.ready
signal?.throwIfAborted()
const row = this.rowFor(id)
return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row)
}
/**
* Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the
* read scales with the suffix, not the log. Torn rows past the preserved
@@ -216,15 +233,33 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
signal?.throwIfAborted()
await this.ready
signal?.throwIfAborted()
const row = this.rowFor(id)
if (row === undefined) return undefined
const meta = rowToMeta(row)
const eventRows = this.db
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
.all(id) as unknown as EventRow[]
this.db.exec('BEGIN')
let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined
try {
const row = this.rowFor(id)
if (row !== undefined) {
const eventRows = this.db
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
.all(id) as unknown as EventRow[]
snapshot = { row, eventRows }
}
this.db.exec('COMMIT')
} catch (error: unknown) {
/* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */
this.db.exec('ROLLBACK')
throw error
/* v8 ignore stop */
}
signal?.throwIfAborted()
if (snapshot === undefined) return undefined
const { row, eventRows } = snapshot
const { preserved, tornFrom } = scanRows(eventRows)
return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} }
return {
meta: rowToMeta(row),
events: preserved,
revision: sqliteRevision(this.storeIdentity, row),
...tornFrom !== undefined ? { tornMarker: tornFrom } : {},
}
}
/**

View File

@@ -576,6 +576,19 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
await b.dispose()
})
it('binds a full stored prefix to the same revision as a lightweight read', async () => {
const b = await backend()
const m = meta('stored-prefix-revision')
await b.ctx.sessionPersistence.create(m)
await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
const persistence = b.ctx.sessionPersistence as SessionPersistenceSqlite
const stored = await persistence.loadStored(m.id)
expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
await b.dispose()
})
it('changes revisions when a deleted session id is materialized again in the same database', async () => {
const path = await freshDbPath()
const m = meta('recreated-revision')