DSHarness 系统拆解 固定基线 47f943859b · 36 已复核 / 0 撰写中 / 36 章
English
会话与持久化·第 11 章

持久化与数据库设计

JSONL、SQLite、通用 Storage 与数据所有权

已复核上游 47f943859b范围: 分析 session persistence、SQLite schema/migrations、JSONL、KV storage、domain form、附件与工作区。

结论:持久化是一组数据所有权合同,而不是一个笼统的“数据库”

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. 所有权地图阻止意外级联

所有者耐久数据对什么具有权威性明确不拥有什么
SessionPersistenceSessionHeader 与连续 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 日志
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 义务

共享 backend 合同要求 Header 与首个 batch 原子 materialize、提供不透明 torn-tail marker 与 source-qualified revision,并要求 appendBatch 只有在耐久后返回。它明确允许 repair 非原子,从而让文件 backend 可以用两个已同步步骤完成 truncate 与追加 closers。PERSIST-BACKEND-CONTRACT

3. Coordinator 才是真正的可移植层

1

快照式接纳

Header 与 event batch 在等待 chain 前先做 lossless JSON snapshot。

2

按 ID 串行化

同一 Session ID 的每个操作进入同一条 Promise chain。

3

校验并提交

调用 provider 的单个耐久 primitive 前,先检查格式与 seq 连续性。

4

发布状态

只有耐久成功后,内存 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 返回后才更新 materializedcursorPERSIST-COORDINATOR

读取面

inspect 可以复用 immutable prepared graph 而不 repair;loadprepare 会 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 会被拒绝,而不是猜测。

文件 Identity

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
RepairTruncate + 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。

物理 Repair 范围

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 被误判为相同
sessionsHeader columns、incarnation UUID、revision counterRow 是否存在就是 lazy materialization 标志
events每 event 一行,主键 (session_id, seq)Foreign-key cascade 与 strict columns

数据库同时携带 application_iduser_version。Pristine database 会初始化并 stamp;非空未版本化 database、外来 application identity,或 SCHEMA_VERSION = 15 之外的任何版本都会拒绝。尽管字段名叫“version”,这个 pre-release 实现没有 migration path。

Schema 所有权

初始化持有 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。

Row Repair

scanRows 会拒绝位于最后一个有效 turn/end 及其之前的 unparsable row 或 sequence gap,但把更晚的缺陷当作 torn tail。commitRepair 原子删除该 suffix 并插入 closers;marker 之前的有效 committed row 不会被改写。PERSIST-SQLITE-SCANPERSIST-SQLITE-REPAIR

同一策略也存在于 Row 粒度

SQLite row 可以已被 transaction 完整提交,却包含坏 JSON 或错误 sequence。当缺陷位于最后一个有效 turn/end 之后时,scanRows 会把该 row 与后续 rows 标成 never-committed torn tail,变更型 load 可以删除它们。这是合理的可用性选择,但“位于最后一个闭合 Turn 之后”属于语义恢复规则,并不是 rows 曾被物理撕裂的证据。PERSIST-SQLITE-SCANPERSIST-SQLITE-REPAIR

Seek 语义

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 写序

每 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。

Replace 协议

临时文件与 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

静态 Root 缺口

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。

静态 Lifecycle 缺口

如果 unit.close() 拒绝,disposing 会保持 true,closed 保持 false,onClosed() 不会释放 name,而 disposal 永远保留那个 rejected Promise。结果是 handle 拒绝所有新写入,却仍允许读取;同名 Domain 也无法 reopen。PERSIST-DOMAIN-CLOSE-GAP

13. Attachment 使用 Commit-before-reference 与内容寻址 Ownership

1

接纳

限制 encoded bytes,完整解码 raster,验证格式、尺寸与 pixel count。

2

寻址

对 exact encoded bytes 做 hash,生成不透明 sha256: attachment ID。

3

发布

同步 private staging file,以 exclusive hard link 发布,再同步目录祖先。

4

引用

只有此后 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

Binary Ownership

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

存储 Shape

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 parametercreate(path, title?) 仍是 public,但源码 TODO 说明其最后一个生产 caller 已删除README/API surface 仍在宣传发布 consumer 已不再使用的分支
宽松 scalar schemasWorkspace 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 pathreadSessionHeader 直接返回 cached Header,不重新 list,也不重新 stat cwd外部 cwd damage 只有在其他路径碰巧重新索引该 Header 或 restart 时才会被观察
Title 与 Schema 缺口

实现允许 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

Refresh 缺口与 README 过度表述

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 自行归档。