持久化与数据库设计
JSONL、SQLite、通用 Storage 与数据所有权
结论:持久化是一组数据所有权合同,而不是一个笼统的“数据库”
DeepSeek Harness 刻意拆出了三组持久化体系。SessionPersistence 拥有权威事件日志及其不可 replay 的 Header;通用 Storage/Domain 栈拥有由消费者 schema 赋予含义的 Host 侧记录;Attachment Store 拥有不可变二进制对象,而 Session event 只保留内容寻址引用。Workspace 再把 Domain 状态与 Session Header 组合起来,但它既不拥有项目目录,也不拥有 Session 日志。
这种分离是本章最重要的结论。JSONL 与 SQLite 是同一 Session 合同的替代物理 provider,但通用 JSON/SQLite backend 并不是 Session 数据库。同样,Workspace 的归组记录不等于对 transcript 的外键所有权,attachment reference 也不等于对象本身。
1. 所有权地图阻止意外级联
| 所有者 | 耐久数据 | 对什么具有权威性 | 明确不拥有什么 |
|---|---|---|---|
SessionPersistence | SessionHeader 与连续 SessionEvent | 模型可见 History、Replay、Resume 与 Fork 事实 | Workspace 标签、图片二进制、项目文件 |
| Storage backend | 不透明 JSON KV unit | 原子且耐久的介质操作 | 记录 schema、写入顺序、Domain 含义 |
| Domain | 经 schema 校验的记录与可选 global state | 内存当前视图、串行写入、变化事件 | Session-event 语义或跨 Domain 事务 |
| Attachment store | 不可变的内容寻址字节 | 对象完整性与已验证图片 metadata | 消息顺序与引用生命周期 |
| Workspace | 规范路径、标题、有序候选 ID、Registry 顺序与 Archive 状态 | Host 侧分组与展示顺序 | 目录、其中的文件或被引用的 Session 日志 |
Service Definition 明确要求 backend 把既有 SessionEvent 存为事件溯源日志,并单独保存不可 replay 的 SessionHeader metadata。它的 API 区分 raw artifact 定位、延迟创建、耐久 append、会突变的 load、非突变 inspect、物理 suffix read 与轻量 listing。PERSIST-SEAM-API
2. SessionPersistence 是行为 seam,不是 CRUD repository
create(header) // 预留 identity;可以只存在于内存
append(id, contiguousBatch) // 只在耐久后 resolve
inspect(id) // 平衡的逻辑视图;不做物理修复
load(id) // Cold 时提交修复,再返回平衡视图
prepare(id) // 为 Resume 独占预留 exact unpublished Session
readFrom(id, seq) // 分离、非突变的 stored suffix
list / listSnapshots // 只列已 materialize 的日志
这个 seam 把崩溃语义写进了接口。完整且有效、但中断的最后一个 Turn 会被保留,并以确定性 closers 闭合;provider 返回连续前缀,以及它判定为不可恢复后缀的不透明 marker。这个抽象比字面上的“部分物理记录”更宽;第 6、8 节会审计具体分类。已 create、从未 append 的 Session 可以完全不留下 artifact。因此不能用普通增删改查替代 SQLite 实现——provider 必须实现 append 连续性、identity、repair 与静止关闭。
共享 backend 合同要求 Header 与首个 batch 原子 materialize、提供不透明 torn-tail marker 与 source-qualified revision,并要求 appendBatch 只有在耐久后返回。它明确允许 repair 非原子,从而让文件 backend 可以用两个已同步步骤完成 truncate 与追加 closers。PERSIST-BACKEND-CONTRACT
3. Coordinator 才是真正的可移植层
快照式接纳
Header 与 event batch 在等待 chain 前先做 lossless JSON snapshot。
按 ID 串行化
同一 Session ID 的每个操作进入同一条 Promise chain。
校验并提交
调用 provider 的单个耐久 primitive 前,先检查格式与 seq 连续性。
发布状态
只有耐久成功后,内存 cursor 才前进,prepared view 才失效。
PersistenceCoordinator 拥有延迟 identity 冲突检查、write-behind controller、按 ID 串行化、prepared-session cache、live adoption、crash-repair sequencing、revision retry 与 disposal。Provider 只贡献介质 primitive,无需重写这套状态机。
Coordinator 按 Session ID 保存 backend bookkeeping,按 exact live Session 保存 lifecycle/write-behind 状态,并拥有 retirement drain、四阶段 preparation pool 与每 ID 一条 Promise chain。已提交 append 只有在 appendBatch 返回后才更新 materialized 与 cursor。PERSIST-COORDINATOR
inspect 可以复用 immutable prepared graph 而不 repair;load 与 prepare 会 reserve 并提交 repair;readFrom 绕过该 cache,且永远不 repair。它们是刻意不同的 ownership 操作。PERSIST-READ-FACES
4. Write-behind 把热路径接纳与耐久静止点分开
session/event → structuredClone → pending queue
首个 item 启动固定 deadline
deadline → stable batch → backend append
failure → batch 恢复到队首;暂停自动重试
flush → 取消 timer → 加入 active write → 重试并排空
后续 event 不会延长当前 batching deadline。一次写入期间接纳的 event 会形成 follow-up batch。后台失败会被报告,同时 exact batch 仍被保留;显式 flush 是重试与 quiescence barrier。Backend disposal 会先排空 controller,再关闭介质。
SessionWriteBehind 会复制已接纳 event,让并发 flush caller 共享一个 barrier,并把失败 batch 放回比新 pending event 更前的位置。队列不会静默确认失败的耐久写。PERSIST-WRITE-BEHIND
5. JSONL 是一种逻辑事件流,对应两种物理编码
| 属性 | Raw .jsonl | 默认 .jsonl.zstd |
|---|---|---|
| Header | 第一条以换行结束的 record | 一个带 checksum 的 Header frame |
| Append batch | 新增 JSONL lines | 一个独立的 checksummed frame |
| Chunk packing | 符合条件的 delta run 可变成 lossless packed row;load 时再展开为 events | |
| Suffix seek | 没有:readFrom 仍扫描物理前缀,然后只返回 suffix | |
| Raw export | 返回精确 logical JSONL text,保留 packed row 与序列化细节 | |
配置 root 必填,并在 constructor 中只 resolve 一次,因此后续 process.cwd() 变化不会拆散同一 backend。Session ID 在成为路径 segment 前会做 injective escape。Repair 或 append 前会检查 Header identity 以及由 cwd/id 推导的路径;混用 compression suffix 的 root 或已退役 flat layout 会被拒绝,而不是猜测。
Provider 只 resolve 一次 root,公开 absolute location,并刻意不实现 seek-capable loadStoredFrom。Format 使用一条专用 Header record,后跟 verbatim 或 packed storage rows;未验证的 branded Session ID 会先转成 path-safe 编码。PERSIST-JSONL-ROOTPERSIST-JSONL-FORMAT
6. JSONL 的耐久性很强,但 Repair 刻意分成两个阶段
| 操作 | 耐久边界 | 失败行为 |
|---|---|---|
| 首次 append | 同步临时文件;无覆盖发布;POSIX 下同步父目录 | 同 ID 发布冲突会拒绝,不会替换日志 |
| 后续 append | 追加编码 batch 并 fsync 文件 | 写入/同步失败会 truncate 回此前 byte length |
| Repair | Truncate + fsync,再 append recovered records 与 synthetic closers + fsync | 两个步骤之间崩溃可能留下更短、但仍可恢复的 open tail |
Raw mode 只容忍不完整的最后一条 record,或严格位于最后一个 committed turn/end 之后的缺陷。Zstandard mode 会拒绝损坏的完整 frame,但可以从结构不完整的 final frame 中抢救已经输出的完整 JSONL records。它从该 frame 起点截断,只重写 recovered records 与 closers,不会改写更早的 committed prefix。
Reader 返回 byte-offset marker,以及从不完整 final frame 中恢复出的完整 event。commitRepair 先 fsync truncation,再通过正常同步 append path 追加 recovered events 与 closers。Coordinator 合同允许这个非原子序列。PERSIST-JSONL-REPAIR
Raw scanner 也接收完整、以换行结束的 records。如果这样的 row 不是合法 JSON、storage shape 无效,或展开后出现 seq gap,scanner 会记录问题,但只有在后续 decoded row 闭合 Turn 时才抛错;否则 finish() 返回缺陷前的 byte offset。因此 repair 可能丢弃一条物理完整的 row,以及最终 open Turn 中位于其后的所有 rows。这个策略保护了每个已闭合 Turn,却不能证明该 suffix 从未耐久提交。PERSIST-JSONL-SCANPERSIST-JSONL-REPAIR
7. Session SQLite 把同一日志合同映射为 STRICT rows
| 表 | 作用 | Identity / Durability 细节 |
|---|---|---|
persistence_state | 单例 store UUID | 避免不同 store 的本地 counter 被误判为相同 |
sessions | Header columns、incarnation UUID、revision counter | Row 是否存在就是 lazy materialization 标志 |
events | 每 event 一行,主键 (session_id, seq) | Foreign-key cascade 与 strict columns |
数据库同时携带 application_id 与 user_version。Pristine database 会初始化并 stamp;非空未版本化 database、外来 application identity,或 SCHEMA_VERSION = 15 之外的任何版本都会拒绝。尽管字段名叫“version”,这个 pre-release 实现没有 migration path。
初始化持有 BEGIN IMMEDIATE,检查 schema ownership,创建三个 STRICT tables,分配 store identity,并在应用指定 journal mode 前 stamp application/schema version。既有的不兼容介质会被拒绝,不会被改成猜测出来的 shape。PERSIST-SQLITE-SCHEMA
8. SQLite Append 与 Repair 获得事务原子性和真正的 Suffix Read
首次 materialization 与其 batch 中的每个 event 在一个 transaction 内提交。后续 append batch 同样如此,并只让 Session revision 增加一次。Load 在同一事务快照中扫描完整 row set,找出第一个 invalid tail seq,再用一个 transaction 删除 seq >= tornMarker、插入确定性 closers,并递增 revision。
scanRows 会拒绝位于最后一个有效 turn/end 及其之前的 unparsable row 或 sequence gap,但把更晚的缺陷当作 torn tail。commitRepair 原子删除该 suffix 并插入 closers;marker 之前的有效 committed row 不会被改写。PERSIST-SQLITE-SCANPERSIST-SQLITE-REPAIR
SQLite row 可以已被 transaction 完整提交,却包含坏 JSON 或错误 sequence。当缺陷位于最后一个有效 turn/end 之后时,scanRows 会把该 row 与后续 rows 标成 never-committed torn tail,变更型 load 可以删除它们。这是合理的可用性选择,但“位于最后一个闭合 Turn 之后”属于语义恢复规则,并不是 rows 曾被物理撕裂的证据。PERSIST-SQLITE-SCANPERSIST-SQLITE-REPAIR
loadStoredFrom 执行 WHERE session_id = ? AND seq >= ? ORDER BY seq,只扫描选中区域。它明确非突变:错误的 selected tail 只会缩短返回视图,不会被 repair。PERSIST-SQLITE-SUFFIX
9. 文档中的 WAL 默认值存在 Direct-constructor 陷阱
Plugin schema 把 journalMode 默认成 wal,但 SqliteSessionPersistence constructor 只为 cache size 与 batch delay 补 fallback。它把仍为 optional 的 journalMode 强制转换成 required 后继续传递,而 openDatabase 会调用 journalMode.toUpperCase()。因此 new SqliteSessionPersistence(ctx, { path }) 不会使用 WAL,而会异步失败。Direct-constructor test 通过显式传入 journalMode: 'wal' 避开了该缺陷。PERSIST-SESSION-SQLITE-CONSTRUCTORPERSIST-SESSION-SQLITE-CONSTRUCTOR-TESTPERSIST-SQLITE-SCHEMA
通用 SQLite storage backend 也出现相同形状:Schemastery 承诺 WAL 默认值,但 public constructor 强制转换一个缺失的 optional value,随后 open path 解引用它。经 Loader/schema normalization 的 plugin 构造是安全的;没有传 journalMode 的直接程序化构造则不是。
SqliteStorageBackend({ path }) 因同样原因把 undefined journal mode 交给后续 toUpperCase()。属于 public config 合同的默认值,应在 constructor 侧完成 normalization,或让 constructor 类型要求 already-normalized config。PERSIST-STORAGE-SQLITE-CONSTRUCTORPERSIST-STORAGE-SQLITE-OPEN
10. 通用 Storage 刻意比 SessionPersistence 知道得更少
Storage hub
├─ backend registry: name → 一个介质与可选 facets
└─ mounted forms: 当前为 domain
KV backend contract
open(unit descriptor) → loadAll / putRecord / deleteRecord / setGlobal / close
Domain form
spec + zod schemas + route → authoritative memory + 单写入 chain + change events
对 backend 而言,KV value 是不透明 JSON。Unit 保证每次单独调用原子且耐久,但明确不串行化并发调用。Domain 层补上这一顺序,在 open 时校验数据,先完成 backend durability,再修改内存 Map,只有两者一致后才发出 domain/changed。
Backend 合同把介质 ownership 与单次调用 durability 交给 KvUnit,把 schema 与并发顺序留给 caller。Backend unregister 不会关闭介质;provider plugin 拥有该 lifecycle。PERSIST-STORAGE-SEAMPERSIST-STORAGE-REGISTRY
每 Domain 一条 Promise chain,让 update function 看到自己 queue slot 时的 value。Backend 写失败会让内存保持不变;写成功则先更新内存,再发送受控 notification。PERSIST-DOMAIN-ORDER
通用 SQLite provider 在 units 中为每个 unit 保存一行,在 unit_globals 中保存可选 global row,并为每个声明表创建一个 STRICT u_<unit>_<table> table。它的物理 user_version 为 1;其他 stamped layout 会拒绝,而不是 migration。每次 KV mutation 是一条 prepared statement,因此 provider 提供单次调用原子性,Domain 仍负责写序。PERSIST-STORAGE-SQLITE-SCHEMAPERSIST-STORAGE-SQLITE-UNIT
11. JSON KV 发布认真处理了耐久性,但相对 Root 仍是动态的
每次 JSON KV mutation 都会重新发布整份 human-readable unit file。Writer 创建同目录、owner-only 的临时文件,写入并 fsync,以 atomic rename 覆盖 target,然后在 POSIX 下 fsync 父目录。Windows 通过 libuv 获得 atomic replacement,但没有显式 write-through flag。实现也没有跨进程 writer lock。
临时文件与 target 位于同一目录,因此 rename 是 publication point;此前先同步文件,之后在 POSIX 上同步目录。任何失败路径都会删除临时文件并传播 error。PERSIST-JSON-ATOMIC
rename() 发生在父目录 fsync 之前。如果后者拒绝,writeAtomic 会在 target 可能已经暴露新 bytes 后返回失败。JsonKvUnit 随即回滚自身 state,Domain 也因 backend call 失败而保持旧内存。于是 caller 收到 failure,但 reopen 可能读到新文件,crash durability 同样未知。Backend 需要明确 post-publication policy——例如 poison/reload unit,或保留足够状态完成 reconcile——而不能把每个 rejection 都当作尚未发布。PERSIST-JSON-ATOMICPERSIST-JSON-UNIT-ROLLBACKPERSIST-DOMAIN-ORDER
Config 正确要求显式 root,但 schema 接受 relative string,JsonStorageBackend 也原样保存该 string。mkdir(root) 与 join(root, unit.json) 在以后运行,因此 process-wide cwd change 可以把后续 unit open 指向别处。Session JSONL provider 通过在 constructor 中只 resolve 一次 root,避开了完全相同的问题。PERSIST-JSON-ROOT-GAPPERSIST-JSONL-ROOT
12. Domain Close 存在一种单向失败状态
Happy path 是可靠的:close() 立刻拒绝新写入,排空已经 settle 的 write-chain tail,关闭 backend unit,把读取标记为 closed,最后释放 facility 的 name reservation。重复 close 会共享同一个 Promise。
如果 unit.close() 拒绝,disposing 会保持 true,closed 保持 false,onClosed() 不会释放 name,而 disposal 永远保留那个 rejected Promise。结果是 handle 拒绝所有新写入,却仍允许读取;同名 Domain 也无法 reopen。PERSIST-DOMAIN-CLOSE-GAP
13. Attachment 使用 Commit-before-reference 与内容寻址 Ownership
接纳
限制 encoded bytes,完整解码 raster,验证格式、尺寸与 pixel count。
寻址
对 exact encoded bytes 做 hash,生成不透明 sha256: attachment ID。
发布
同步 private staging file,以 exclusive hard link 发布,再同步目录祖先。
引用
只有此后 Session event 才能保存 ID、media type、byte length、dimensions 与 sanitized display name。
Deduplication 是安全的,因为接受既有对象前会重新读取并 hash。Read 会重新 hash bytes,把存储 metadata 与日志 reference 对比,并保留 cancellation。由于 resumed/forked Session 可能共享对象,所有对象会无限期保留;删除 Session 或 Workspace 都不会隐式 garbage-collect 它们。PERSIST-ATTACH-LIMITS
Attachment seam 公开 immutable reference,而不是 path 或 provider URL。Local provider 在 versioned root 下保存 owner-private objects,只有完成 content 与 namespace durability 检查后才发布 durable reference。PERSIST-ATTACH-SEAMPERSIST-ATTACH-STORE
14. Attachment 的 Read-time Decode 文档不正确
Local-backend README 声称 write admission 与 read 都会完整解码 raster。生产源码则明确相反:admission 调用 image.raw().toBuffer() 完整 decode;通过 digest 校验的 read 调用 probeImage(),只向 Sharp 请求 Header metadata。源码给出的理由是 digest 已证明它们正是先前接纳的 exact bytes,因此无需在 replay 时再次引入 pixel amplification。PERSIST-ATTACH-DECODE-DOCPERSIST-ATTACH-DECODE-SOURCEPERSIST-ATTACH-DECODE-IMPL
实现仍然验证 integrity 与 reference metadata;漂移涉及的是计算工作和 malformed-payload revalidation,而不是跳过 digest verification。文档应改成“admission 时完整 decode;read 时先验证 digest,再 probe Header”。
15. Workspace 是 Domain State 之上的可恢复两次写 Registry
workspace domain(version 2)为每个 Workspace 保存一条 record——canonical path、title、有序 candidate Session IDs 与 timestamps——并以 global state 保存 initialized、Workspace order、archive IDs 与可选 pending create/delete marker。首次启动只列 Session Header,规范化有效 cwd 目录,对其分组,并把 initialized marker 放到最后写。PERSIST-WORKSPACE-INITPERSIST-WORKSPACE-BOOTSTRAP
Create/delete 无法成为一个通用 cross-record transaction,因此 Registry 会在 record/order pair 可能分叉前先写 pending marker。启动时只完成被 marker 点名的 mutation;无法解释的 order/table、duplicate path 或 duplicate-session ownership 仍会 fail loud。删除 Workspace 只删除注册与 account,绝不删除目录、文件、live Session、persisted log 或 attachment。PERSIST-WORKSPACE-MUTATIONPERSIST-WORKSPACE-OWNERSHIP
Zod spec 通过 Domain storage 投影出一个 workspaces table 与一个 global state slot。Runtime validation 另行检查 order 完整性、path 唯一性与每 Session 只归一个 Workspace。PERSIST-WORKSPACE-SCHEMAPERSIST-WORKSPACE-INVARIANTS
16. Workspace 的 Title、Schema 与 Refresh 合同存在可见缺口
| 发现 | 源码行为 | 后果 |
|---|---|---|
| 陈旧的 title parameter | create(path, title?) 仍是 public,但源码 TODO 说明其最后一个生产 caller 已删除 | README/API surface 仍在宣传发布 consumer 已不再使用的分支 |
| 宽松 scalar schemas | Workspace ID、path、title、Session ID 与 timestamps 都只是 z.string() | 空白 title、非 UUID ID、非 ISO timestamp 与非规范 stored path 都能通过 schema |
| 部分补偿 | 启动时另行检查 duplicate order ID、missing/orphan row、duplicate path 与 duplicate Session accounting | 跨记录完整性强于 Zod shape,但 scalar semantics 仍未检查 |
| 没有 public full refresh | 只有启动会 clear/replace Header index;未缓存 lookup 调用 list() 并增量索引返回 Header | 被外部删除的 cached Session 不会被该增量过程移除;只有 restart 保证完整清理 |
| Cached fast path | readSessionHeader 直接返回 cached Header,不重新 list,也不重新 stat cwd | 外部 cwd damage 只有在其他路径碰巧重新索引该 Header 或 restart 时才会被观察 |
实现允许 create 接受 optional raw title,setTitle 接受任何 string;耐久 record schema 没有 nonblank、timestamp、UUID 或 path refinement。源码自己也把 create-time title parameter 标记为待删除。PERSIST-WORKSPACE-TITLE-GAPPERSIST-WORKSPACE-TITLE-SETTERPERSIST-WORKSPACE-SCHEMA
README 称外部 deletion 或 cwd damage 会在“下次 refresh 或 restart”后出现,但生产实现没有 public full-refresh operation。Runtime miss 只调用增量 indexHeaders,不会清掉缺失 ID;cached hit 更完全不 refresh。Restart 是唯一明确的 full replacement path。PERSIST-WORKSPACE-REFRESHPERSIST-WORKSPACE-REFRESH-DOC
17. 一个有用的 Codex 对照:介质相似,权威性拓扑不同
对 Codex 用户来说,文件名很容易造成误解。在 Codex 官方公开仓库提交 66919805ea080053d1933b6b43afeb0d8bf70c91 上,rollout module 明确说 JSONL Session rollout 会被持久化,以便 replay 或后续 inspection;recorder 会把 canonical rollout items 写入 JSONL。公开 config 把 sqlite_home 定义为 SQLite state DB 的目录;App Server 的 thread/list 合同说明默认路径可以扫描 JSONL rollout 来修复 metadata,而 useStateDbOnly 会关闭该行为。
DeepSeek Harness 让 JSONL 与 SQLite 成为一个行为 seam 后面的可互换、权威 Session-log provider。它的通用 SQLite state、Workspace records 与 attachment objects 仍属于分开的 ownership domains。可迁移的教训不是“JSONL 对 SQLite”,而是明确哪种介质可以重建哪些事实、哪种介质只是 index/projection,以及哪些 repair 可以修改权威事实。
18. 审计结论:合同扎实,并有七个具体后续项
- 在 Constructor 边界 Normalize SQLite Defaults。修复两个 direct-constructor
journalMode路径,或要求 normalized config type。 - 准确命名 Torn-tail Policy。明确两种 provider 都可能丢弃最后一个闭合 Turn 后、物理完整但异常的 records;在无法确认物理撕裂时,考虑保留诊断信息或要求显式 repair。
- 解决 JSON KV 发布后失败。目录 sync 在 rename 后失败时,让 Domain、unit memory 与已发布 target 进入定义清楚的状态。
- 冻结 JSON KV Root Identity。像 Session JSONL 一样,只 resolve 一次显式 relative root。
- 定义失败的 Domain Closure。明确 retry 与 name-release 语义,避免 close error 留下 readable-but-unwritable handle。
- 修正 Attachment 文档。明确 admission 时完整 decode,read 时执行 digest + Header verification。
- 收紧或明确 Workspace 边界。删除已无 caller 的 create-time title 分支,按意图校验用户可见 title/timestamp/ID,并提供 runtime full refresh,或停止承诺它。
系统的中心设计仍然连贯:耐久事实有明确 owner,provider 共享同一份 Session 合同。Recovery 对最后一个闭合 Turn 以内的历史保持保守,而最终 open tail 遵循更宽的策略,应该被准确命名。静态缺口集中在 repair classification、发布后失败、configuration、lifecycle、documentation 与 cache refresh 边界,并不是对 event-log 所有权认识混乱。
我的学习体会
内容仅自动保存到当前浏览器,不上传、不进入仓库。你可以导出 Markdown 自行归档。