本文记录 Docengine 从 TypeMD 后端抽离,到 v0.6 为止的实际开发顺序、模块形成过程、 关键取舍、测试方法和问题复盘。它回答的不是“现在有哪些 API”,而是“代码为什么最终 长成现在这样”。
三份工程文档各有边界:
MODULES.md是当前实现的模块参考和不变量说明;develop.md是完成度评估、目标架构和未来路线图;- 本文是历史与设计决策记录,解释已经发生的演进和背后的原因。
文中版本号表示形成稳定里程碑的 tag;同一阶段可能包含 tag 前的多个提交。项目在 1.0 以前允许破坏性改动,因此早期方案会被完整删除,而不是永久背负兼容层。
原始代码服务于 TypeMD,后端同时知道 Markdown 块、产品元数据、SQLite 搜索、Wails 绑定和编辑器布局。这些能力放在同一个后端里可以快速交付产品,但无法单独验证“文档 存储和编辑内核”是否正确,也无法被其他宿主复用。
抽离工作的第一步不是复制更多代码,而是删除业务认识。Docengine 最终只允许理解:
- 字节和 UTF-8;
- BOM、换行、byte/line/rune 坐标;
- revision、范围替换、Snapshot 和抽象 Source;
- 本地文件的恢复、保存和资源生命周期。
Markdown、JSON、代码语法、DOM、像素布局、渲染命令和产品工作流全部留给宿主。 这条边界决定了后续 Page、Fragment、搜索和多源编排也必须使用格式无关接口。
抽离时宁可暂时留下不能独立工作的接口,也不把 TypeMD 的业务依赖伪装成“通用层”。 如果一开始以编译通过为最高目标,很容易把 Markdown block、应用目录或 Wails 事件重新 包一层后带进内核。先删除,再逐层恢复可验证能力,能让依赖方向从第一天就保持清楚。
底层包不能反向依赖 document.Session,更不能依赖宿主:
document/store ------+
document/save -------+--> document.Session --> host adapters
recovery ------------+
document/coordinate -+
测试也从业务示例转向可证明的不变量:相同输入必须得到相同字节、失败不得发布半个状态、 旧 Snapshot 不得因新编辑失效、磁盘提交状态必须能被明确描述。
这一阶段对应初始提交、Establish module and harden piece tree,以及首个文档版本
v0.1.0。
大型文件不能在每次编辑时完整复制到内存。document/store 因此不保存一份连续字符串,
而是保存 Piece:每个 Piece 只描述某个 io.ReaderAt Source 中的一段物理字节范围。
初始正文引用基础文件,新增正文引用追加式 journal。
树采用持久化随机 treap,而不是普通切片或可变平衡树,原因有三点:
- 子树缓存逻辑字节数,按偏移定位和替换只依赖树高;
- 编辑时只复制修改路径,旧 root 自然成为不可变 Snapshot;
- 随机优先级实现紧凑,不需要把复杂旋转和父指针暴露给上层。
拆分 Piece 时必须继承原节点优先级。早期边界审计发现,如果左右碎片重新生成优先级, 拆分回溯时碎片可能越过祖先,破坏 treap 的 heap 不变量。修复后把“拆分不改变局部优先 关系”写进测试,而不是只比较最终文本。
仅保存不可变 root 不够。root 中的 Piece 仍然引用外部文件或 journal;如果 Session 保存 后关闭旧文件,Snapshot 虽然结构还在,读取却会失败。
因此 Snapshot 捕获 root 与 Source 绑定,sourceGeneration 再用引用计数提供
SnapshotLease。Session 保存后可以切换到新 generation,但旧基础文件和旧 journal
必须等最后一个 lease 关闭后才退役。这里把资源生命周期放在 document,而没有让
store 擅自关闭宿主提供的 io.ReaderAt,保持了包的所有权边界。
最初保留了从 TypeMD 演化来的 recovery v1 frame,包括单次 Replace 和 root 概念。 它让崩溃恢复路径先跑通,但还不能准确表达多操作事务。保存则建立了同目录临时文件、 同步、原子替换和 generation 重绑定的基本顺序。
这一版的意义是建立端到端骨架,不是承诺磁盘格式。1.0 前不做兼容承诺,为后续彻底删除 v1 留出了空间。
- 空树、头尾插入、跨 Piece 删除和无操作替换;
- 负数、越界和
int64溢出; - Snapshot 在后续多轮编辑后保持原字节;
- Source 缺失和短读错误;
- Windows 与 POSIX 的基础打开、替换路径。
这一阶段给上层提供的是“可持久引用、可快照、可按范围编辑的字节地基”。
Harden atomic batches and expand boundary tests 和 v0.2 系列提交把系统从“可以编辑”
推进到“失败时也能说明发生了什么”。
宿主的一次语义操作可能包含多个范围替换。如果逐个调用 Replace 并立即发布,中间一步 失败会留下半个操作,Undo、journal 和事件也会看到不同状态。
Session.ApplyBatch 因而采用先准备、后发布:
- 校验 expected revision、操作数量、范围和 UTF-8 边界;
- 从旧 Snapshot 读取需要保存的删除内容;
- 在临时 Tree/暂存 Source 上顺序应用全部操作;
- 把一个 group 的完整批次追加到 recovery;
- 只有所有步骤成功,才切换 Tree、revision、undo 和 pending 状态。
这不是数据库式分布式事务,但在一个 Session 内保证“全有或全无”。journal 的 group 也必须与内存事务一致,否则崩溃恢复会暴露运行时从未公开过的中间态。
Undo 需要保留被删除的大段文本。如果直接把删除内容放进 Go heap,大文件反复编辑会让 内存预算失控;如果引用基础文件,又会在保存切换 generation 后失效。
因此 undo history 只保存范围和 textRef,正文追加到 Session 自己的 undo store。
forward/inverse 操作都引用稳定偏移。Redo 不是重新解释宿主命令,而是应用已记录的逆向
事务,从而保持 revision 和恢复模型一致。
POSIX 使用同目录临时文件、文件同步、rename 和父目录同步;Windows 使用
ReplaceFileW,并带 write-through 语义。两端不能共用一个假想的“rename 就完成”
实现,因为 Windows 的共享标志、替换参数和错误传播完全不同。
保存期间允许新编辑继续发生,所以保存捕获的是一个不可变 Snapshot。磁盘成功后, Session 以已提交内容建立新 base,再把保存期间到达的 pending group 原样复制到新 journal 并重放。这就是后来的 save rebase。
普通成功路径已经不能继续提高可靠性,真正危险的是第 N 个系统调用失败:append 已写但 未发布、临时文件已创建但 replace 失败、replace 已成功但 reopen 失败等。
v0.2 引入可注入 operations 和系统化 fault tests,对 stat、open、write、sync、rename、 reopen、Tree 构造和清理逐点失败。100% statement coverage 不是“没有 bug”的证明, 但它强迫每个错误分支至少被实际执行,并为后来寻找语义死角提供基线。
- Linux 独有错误分支没有被 Windows 测试覆盖,导致 coverage job 失败;v0.2.1 和 v0.2.2 分别补齐 Linux Session 和绝对路径失败测试。
- Go 的 Windows race 构建不能使用 MSVC 或
clang-cl,需要 GCC 兼容的 MinGW-w64。 因此本机普通测试和 race 工具链被明确区分。 - 只检查成功结果的测试无法发现资源泄漏,于是失败测试同时核对临时文件、句柄和目录。
这一阶段形成了后续所有功能共同遵守的发布原则:先构造完整新状态,再一次性使其可见。
v0.3.0 是第一次明确的破坏性版本。旧 recovery v1 不迁移、不识别,也不保留 decoder; 新格式使用独立后缀和 magic,让旧文件自然不进入 v2 命名空间。
v1 把批事务伪装成一组旧 Replace frame,并保留 root frame。继续兼容会让每个新功能都 同时回答两套 revision、原子性和截断语义,测试矩阵也会永久翻倍。
项目尚未到 1.0,没有需要保护的稳定下游,因此选择删除旧导出 API、magic、decoder 和 fixture。这个决定把“原子 batch 是唯一恢复单位”变成磁盘级不变量,而不是运行时约定。
v2 文件头保存版本、头尺寸、基础文件长度、规范化真实路径 hash、完整基础内容 hash、 保留字段和 CRC-32C。批次记录保存首 revision、group、操作数、payload 长度和覆盖整个 批次的 CRC。
操作表与 payload 一起校验,使回放只能看到完整 batch。尾部截断或 CRC 损坏时,只回放 此前完整批次并修复坏尾;文件头、基础 fingerprint 或歧义 journal 出错时,文件会被 隔离并阻止本次打开。这里宁可拒绝自动猜测,也不把错误 journal 应用到错误正文。
所有长度、数量、revision 和偏移先做上限及溢出检查。攻击面不只来自恶意文件,断电和 部分写同样会生成任意字节组合,所以 decoder 必须把输入当成不可信数据。
只检查文件前 64 KiB 会漏掉后部非法 UTF-8,也无法生成完整内容 fingerprint。
OpenContext 因而以固定 256 KiB 缓冲区单次流式扫描:
- 跨块验证 UTF-8;
- 计算包含 BOM 的完整 SHA-256;
- 统计全文换行风格;
- 响应 Context 取消;
- 扫描前后核对文件句柄和路径身份。
缓冲区有界,所以文件大小不会线性增加 heap。BOM 属于磁盘身份,但不属于逻辑正文, 因此 hash 包含 BOM,Piece Tree 的 base Piece 从 BOM 后开始。
打开时解析符号链接一次,并同时保存请求绝对路径与真实路径。Session 后续始终写入这个 固定目标;链接后来重定向不能悄悄改变保存对象。recovery fingerprint 也使用规范化真实 路径,避免同一文件通过别名产生不兼容 journal。
Windows 路径还需要大小写无关和分隔符规范化。v0.3.1 的 CI 暴露了恢复路径在 Windows 别名下不稳定的问题,随后增加平台路径规范化和专门回归测试,而没有把 Windows 规则 污染到 POSIX。
rename/ReplaceFileW 成功表示新正文已成为目标文件,但父目录同步仍可能失败。此时返回 普通错误并保持旧 committed revision,会诱导宿主重试整个替换,甚至覆盖新修改。
因此 POSIX 目录同步失败返回 DurabilityError,CommittedRevision 仍前进,同时设置
DurabilityUncertain。无正文变化的下一次 Save 只重试目录同步。恢复 journal 的后台
Sync 失败则使用独立的 RecoveryDurabilityUncertain,因为它影响的是未提交编辑能否抗
掉电,而不是基础文件是否已经替换。
替换成功后还要 reopen base、创建新 Tree、建立 journal 并重放并发编辑。任何一步失败 都不能假装磁盘没有提交,也不能继续在来源不完整的 Session 上修改。
Session 因此保存原始 cause,标记 PersistenceFaulted 并永久禁止 Apply/Undo/Redo/Save;
读取、Snapshot、Metadata、Fault 和 Close 仍然可用。显式故障状态比尝试隐式回滚一个
已经发生的文件系统替换更诚实,也更容易让宿主恢复。
固定案例无法覆盖 Piece 分裂组合、随机崩溃位置和并发 save rebase。v0.3.2 增加:
- Piece Tree 与内存 byte slice 的参考模型;
- journal decoder、回放韧性和随机 truncate/bit-flip 状态机;
- Session Apply/Undo/Redo/Save/恢复状态机;
- 保存中并发编辑及旧 Snapshot 读取的 race/fuzz;
- Windows ReplaceFileW 参数、瞬态错误重试和清理性质测试。
fuzz 的判断标准不是“不能 panic”而已,还要比较 reference model、revision、journal 原子 批次和 Snapshot 内容,确保随机输入不能暴露半个状态。
v0.3 解决了正文如何安全存在,v0.4 开始解决宿主如何稳定消费 revision、坐标、事件和 资源策略。这个顺序很重要:如果先做虚拟化或搜索,它们会建立在不稳定的 revision 和 生命周期之上。
Piece Tree 的职责是字节范围和 Snapshot,不应同时承担 line/rune 策略。coordinate
因此基于不可变 io.ReaderAt 建立 byte、line、rune checkpoint,查询只读取目标附近的
有界区间。
索引携带 revision 和 opaque lineage。旧 Index 只能通过明确的 ChangeMap 链刷新到同一 Session 的新 Snapshot;来自其他 Session 的结构即使长度相同也不能复用。增量重建只 复用所有编辑之前可证明安全的 checkpoint 前缀,无法证明的后缀重新扫描。
这里选择保守复用而不是激进平移,因为换行或多字节 UTF-8 的一次变化可能使后续全部 坐标偏移。Stats 会报告复用 checkpoint 和实际扫描字节,性能优化因此可以被测试。
每次成功事务返回顺序 ChangeMap,描述每一步替换发生时的坐标空间。Anchor 带前后 affinity,解决插入正好发生在锚点位置时应该粘向哪一侧。Range 和 opaque Annotation 只组合通用区间,不理解高亮、诊断或 Markdown 节点的业务含义。
ChangeMap 支持组合和反转,但不能无限保留。Session 后来加入有界 change history, 提供正反 revision 查询;过期和中间 revision 不可用使用类型化错误,而不是生成猜测 映射。恢复后的 Session 从恢复完成 revision 建立新的历史窗口,因为此前运行时映射并 不存在。
宿主需要监听打开、恢复、编辑、保存进度、WAL Sync 和关闭,但慢订阅者不能持有 Session 锁或阻塞编辑。
事件 hub 为每个订阅者维护有界队列,溢出时丢弃陈旧待处理事件并在下一事件中精确报告
Dropped。消费者看到缺口后必须从匹配 revision 的 Snapshot 重建派生状态,而不是
继续盲目增量应用。AfterSequence 用于续接保留历史,FutureOnly 用于只监听未来。
Close 发布最终事件并形成资源屏障;多个并发 Close 调用者等待同一结果,避免重复关闭 句柄或得到不同错误。
早期零值选项隐含了太多策略。v0.4 将批次、插入、undo、事件、ChangeMap 和 Anchor
上限解析为不可变 SessionConfig。显式目录默认 shared,自动创建目录才是 owned。
owned Session 目录使用持锁 marker。清理器只删除 marker 合法、未持锁、达到时间阈值 且全部内容可识别的目录;遇到符号链接、未知文件、损坏 marker 或仍持锁进程一律保留。 这里刻意不用递归删除,因为运行时目录中出现宿主文件时,数据安全高于“清理干净”。
- Piece compaction 只合并同 Source、物理偏移连续的相邻 Piece,不读取或改写正文;
- undo compaction 重写仍被 undo/redo history 引用的字节,并重映射
textRef; - journal 不能原地压缩未提交 WAL,否则会破坏崩溃原子性和 revision 身份,因此只有显式
CheckpointJournal:先保存选定 revision,再以新基础重建 journal。
旧 Snapshot 继续持有旧 generation,压缩和保存都不能提前删除其 journal。这个约束通过 Snapshot 生命周期测试,而不是依靠实现注释。
- 坐标索引与逐字节 UTF-8 reference model;
- ChangeMap 组合、反转、Anchor affinity 和 history 淘汰状态机;
- 事件溢出精确计数、续接游标、并发退订和关闭屏障;
- owned/shared 目录、marker 锁和保守孤儿回收故障注入;
- Piece/undo/journal checkpoint 压缩与旧 Snapshot;
- Windows 与 WSL 原生 Linux 的 100% coverage、race 和相关 fuzz。
这一阶段形成了可供未来 Page、Fragment、搜索和 Composition 使用的 revision/坐标/事件 地基,但没有提前把任何 Markdown 或像素布局放进内核。
100% statement coverage 只说明每行执行过,不说明跨模块时序正确。v0.4.1 专门寻找 “单个模块都有测试,但组合后可能出错”的场景。
原测试通过手工 unlock/close 模拟崩溃,不能证明 Windows LockFileEx 和 POSIX flock
会在进程未清理 marker 的情况下由操作系统释放。
新测试启动真实子进程,让它持锁并留下 undo 文件;父进程先验证活跃目录不会被回收, 再让子进程不调用 marker close 直接退出,最终确认锁释放且孤儿目录可被安全回收。
已有并发保存 fuzz 比较正文,却没有验证事件中的 target revision、当前 revision 和 committed revision。新测试在 Save 捕获 rev1 后阻塞,期间提交 rev2,再检查事件严格为:
SaveStarted(target=1) -> Changed(current=2) -> SaveProgress(target=1)
-> Saved(current=2, committed=1, dirty=true)
磁盘只能包含 rev1,内存必须包含 rev2。这样事件消费者不会把“保存完成”误解为当前全部 编辑都已落盘。
写入中途失败的测试确认磁盘未替换、Session 仍可写且 progress 不会虚报完成。另一个 测试把 journal checkpoint、并发新编辑、旧 Snapshot、Undo/Redo 放在同一时序中:旧 journal 必须在 Snapshot 关闭前保留,新编辑必须重基到新 journal,history 在压缩后仍 能往返。
Piece compaction 又增加状态 fuzz,随机编辑后验证当前正文、所有旧 Snapshot、Piece 数 单调和二次 Compact 幂等。CI fuzz target 因此增加到十六个。
TestOpenRejectsChangeAtEndOfScan 在最终 stat(path) 时把文件从一组字节改成同长度另一
组字节。旧实现用 size + mtime(ns) + SameFile 比较扫描前后状态,通常因为写入推进
mtime 而返回 ErrExternalChange。
但 SameFile 只证明是同一个文件对象,不证明内容没有变化。如果文件系统时间分辨率、
调度窗口或外部程序让最终 mtime 与初始值相同,旧实现会接受扫描得到的旧 hash,同时
让 Piece Tree 读取已经改变的 base。
在当前 NTFS 上,原测试连续 100 次都通过,所以它看起来稳定;加入写后调用 Chtimes
恢复原 mtime 的确定性测试后,修复前 Windows 20/20、WSL 原生 Linux 5/5 都错误打开
成功。这证明问题不只是理论上的测试抖动。
第二次完整读取可以比较两次 hash,但会把每次打开大文件的磁盘读取近似翻倍。Docengine 的目标正是让大型本地文档保持有界内存和可接受打开成本,因此不能为一个极窄竞态无条件 支付 O(n) 的第二遍 I/O。
最终选择常数时间变更代际:
- Windows 对已打开句柄在扫描前后查询
FILE_BASIC_INFO.ChangeTime; - Linux、Darwin 和 BSD 从已有
stat结果读取ctime; - 原有 size、mtime、SameFile 和完整 SHA-256 全部保留;
- 最终路径 stat 先执行,再读取句柄最终状态和变更代际,使 stat 回调期间发生的原地改写 也被包含在检测窗口内。
Linux/BSD 不增加系统调用;Windows 只增加两次常数时间句柄查询。内容依然只扫描一遍。
专门的性能回归使用超过两个扫描块的文件,断言打开扫描和保存前身份扫描都只发生三个
ReadAt,防止以后无意退化成双遍读取。
Windows 测试验证 FILE_BASIC_INFO 的 class、结构大小、ChangeTime 提取和 API 错误传播; POSIX 测试覆盖系统元数据不可用的兼容路径。公共测试注入初始/最终代际捕获失败,并覆盖 available/unavailable、相等和不等组合。
最终验证包括:
- Windows 与 WSL 原生 Linux 五个 package 100% statement coverage;
- 两端三轮
-race -shuffle=on -count=3; - 两端同 mtime 回归各连续 100 次;
- Darwin、DragonFly、FreeBSD、NetBSD、OpenBSD 和 Linux 交叉编译;
- 全仓
go vet、普通测试和格式检查。
任何有限次数的校验都不能阻止外部程序在“最终检查完成之后”立即原地改写文件;拥有足够 权限的程序也可能主动伪造 change time。要消除这类对抗性写入,只能使用私有基础快照或 强制排他锁,代价分别是额外磁盘 I/O/空间,或破坏与其他编辑器的兼容性。
当前模型面向本地单写者文档内核:变更代际封住扫描期间的 metadata-preserving 改写,
保存前完整 hash 防止覆盖已观察到的外部内容变化;未来文件 watcher 和可注入冲突策略
仍属于 develop.md 中的后续工作。
搜索、渲染调度和多源编排都不能以“整份文档已经在内存”为前提。v0.5 因此先引入
document/virtual:它只依赖 immutable ReaderAt Source,不导入根 document,再由
Session.VirtualPager 转移 Snapshot lease。旧 Pager 在后续 edit/save 后仍读取旧
revision,新 Pager 才观察新 revision,这与 coordinate Index 的生命周期完全一致。
逻辑 Page 在 target 大小之后等待 LF,但绝不越过 maximum;长行在 UTF-8 boundary 强制
切分。空文档也有一个确定的 [0,0) Page。这里没有使用 TypeMD 旧实现的 Markdown block、
float64 height 或 strings.ToValidUTF8:前两者会把格式/像素带回内核,后者会静默修改
正文。
Fragment 只包含 opaque ID/DataKey、byte range 和 Measure int64。同一批范围有序、
不重叠且必须落在 UTF-8 boundary,但允许 gap。IndexedThrough 表达 Provider 已分析到的
前缀,所以水位内 gap 是“已知没有 Fragment”,水位后的 suffix 才是未知;单独一个
Complete bool 无法表达这个差别。
Pager revision 固定正文身份,Fragment generation 固定派生索引身份。Provider 在没有
Pager/Session 锁的情况下构建结果,发布时用 BaseGeneration CAS;慢结果若落后就返回
stale,不能覆盖新结果。所有 key 都 clone 后保存,避免短 substring 让一个一字节 key
长期持有数百 MiB backing allocation;Fragment 数、key bytes、单 Fragment Measure 和
累计 Measure 都有独立检查。
宿主只给出整个 Fragment 的 Measure,没有给 continuation 内部的测量分布。按 byte 比例 拆 Measure 看似方便,却是在内核伪造布局。最终设计让巨型 Fragment 拆成多个有界 I/O Page,每页重复父 Fragment 的原子 Measure interval,并允许按 continuation 定位。 byte、Fragment ID、Measure 三类窗口都携带 revision/generation,支持非对称 overscan, 并同时执行 byte/page/distinct-fragment/Measure 四项硬预算。
零 Measure、连续零值和 Measure 边界必须有确定 affinity:Before 选择左侧,After 选择 右侧;全零序列在同一点分别夹到末端和起点。请求超界、索引尚不可用和 generation 过期 使用不同错误,宿主不需要猜测是等待、重建还是修正参数。
Page payload 使用严格 byte-capacity LRU,命中和返回都复制,调用方不能修改缓存。
CacheBytes 只统计驻留 payload;并发 miss 和返回副本的瞬时上限由
MaximumTasks × Window.Bytes 约束内核同时构造的 Window;调用返回后宿主持有多久,
属于宿主自己的内存预算。任务 semaphore 满时立即返回 ErrBusy,形成明确背压。
Close 先阻止新任务,再等待已接纳任务,所有并发 Close 调用共享同一 barrier 和 Source
release 错误。开发中曾出现第二个 Close 提前返回、巨型 Fragment 因把全部 continuation
当作一个 anchor 而永久无法读取、fragmentIndex + After 整数溢出、gap Start 落入
多字节 rune、EOF 被误判为 LF 等问题;它们都在 100% 行覆盖之外通过语义复审和定向
并发/边界测试发现。
六个 package 继续保持 100% statement coverage。virtual 测试覆盖 Page 重组、UTF-8 跨缓冲区、line/continuation、partial/full watermarks、gap fallback、key/Measure overflow、 三类锚点、四项预算、缓存逐出、Provider stale race、任务背压和并发 Close。四个 fuzz target 分别验证逻辑分区、UTF-8 重组、Fragment 窗口和 generation 状态机,并增加 build、 publish 与 cached-window benchmark。
这层完成后,v0.6 搜索可以复用相同 Page、revision/generation、Context 和预算语义, 不需要重新发明大文件分块或异步结果淘汰协议。
v0.5.0 完成后继续审计跨模块时,发现 Save 与 Session.Close 没有共享同一串行边界:
Save 已捕获旧 generation 的 Snapshot 后,Close 可能先 retire 旧 generation;Save 随后
安装新 generation,却没有任何 Close 再负责回收它。旧 Pager 同时存在时还可能重复释放
旧 generation 的 owner reference。修复让 Close 在整个过程持有 saveMu,并让
generation retirement 幂等、允许后续把“保留 journal”升级为“删除 journal”。确定性
测试在 Save 的 snapshot hook 阻塞,随后启动 Close,验证 Save 发布后 Close 回收最终
generation,而旧 Pager 仍可读且旧 journal 只在 Pager.Close 后删除。
同一轮还修复了两个容易被示例测试漏掉的契约。其一,只有 revision/generation/range 的
PageKey 会被分页相同的另一个 Pager 接受并读取错误 Source;现在 key 带签发 Pager 的
不透明身份,复制有效,伪造或跨 Pager 使用被拒绝。其二,cache hit 的 ReadPage 原先
不会像 miss 一样再次观察 Context;现在任务接纳后统一检查取消。内存文档也从错误的
MaximumTasks × MaximumPageBytes 修正为内核同时构造 Window 的
MaximumTasks × Window.Bytes,宿主持有的返回副本另计。
测试不只增加行覆盖:并发 Publish 同一 base generation 必须恰有一个完整赢家;多 Page
cache 返回值可修改但不能污染缓存;非法配置与扫描中取消不能泄漏 Snapshot lease;
Pager 要跨 undo/redo、save、checkpoint 和恢复重开保持精确旧 revision;新增 stateful
fuzz 随机组合 edit、save、undo/redo、compact、创建/查询/关闭多个历史 Pager。v0.5.1
最终在 Windows 与 WSL 2 Debian 原生 Linux /tmp 两端完成六包 100% statement
coverage、全仓三轮 shuffle race,以及五个相关 fuzz target 各 10 秒验证。
手动 Session.Compact 能证明压缩安全,却不能保证长期运行的宿主会在正确时间调用它。
直接在每次编辑后扫描全树又会把 Piece Tree 的平均对数编辑退化成 O(n)。因此
document/store 增加可配置的 Piece 数阈值:零值在 4,096 Piece 触发,只合并同 Source
且物理连续的逻辑邻居;若一次扫描没有收益,下次触发点后移一个完整阈值,Piece 数跌回
基础阈值以下时再复位。宿主可以显式关闭自动策略,但默认 Session 不再无限依赖人工维护。
自动压缩发生在 replacement root 完整构建之后、after Snapshot 发布之前。before
Snapshot、压缩前已发出的任意 Snapshot 及其 Source map 都保持不变。Tree.Stats 把
正文长度、Piece 数、换行元数据、有效阈值、下次触发点和自动压缩次数放在同一读锁快照
中,测试和宿主不必组合多个可能跨编辑的查询。
测试覆盖阈值精确触发、第二轮触发、无收益退避、关闭策略、非法配置、阈值饱和、已知/
未知换行统计,以及多 goroutine Snapshot 读取穿过反复自动压缩。新的 stateful fuzz 为
每个输入随机选择小阈值,与 byte slice 参考模型逐步比较 before/after/current Snapshot,
并长期保留旧 root。四类 benchmark 分别固定顺序追加、随机单字节替换、16K 碎片随机
读取和 16K Piece 压缩,防止以后只凭覆盖率声称 Piece Tree 已经成熟。最终 Windows 与
WSL 2 Debian 原生 Linux /tmp 两端均保持六包 100% statement coverage、通过全仓三轮
shuffle race,四个 Piece Tree fuzz target 分别运行 30 秒;自动压缩边界连续运行
100 次,四类 benchmark 也在两端实际执行。
横向审计 Recovery/Save 时发现,原并发保存顺序存在一个真实崩溃窗口:Save 先把 target revision 的 Snapshot 原子替换成新基础,随后才在内存锁内读取旧 journal, 为保存期间到达的新编辑建立新 journal。普通测试、故障注入和 100% coverage 都能通过, 但若进程恰好在基础替换成功后、重基完成前退出,磁盘已经是新基础,较新的编辑却只存在于 绑定旧基础 fingerprint 的 journal 中,下一次 Open 无法安全应用它们。
把 pending 操作压成一个“净替换”被否决,因为会折叠 revision 和 group;把完整 content hash 放进文件名前缀也被否决,因为那会让真正的外部基础改写把脏 journal 隐藏起来, 弱化原有的冲突阻断。最终采用可证明的两状态协议:
- 长时间流式写 Snapshot 时仍允许编辑继续到达旧 generation;
- 临时文件完成后取得短写屏障,重新校验基础文件强身份;
- 为临时文件内容计算新 fingerprint,把 target 之后的完整 group 写入新 journal;
Sync新 journal,再同步 RecoveryDir 目录项;- 保持屏障完成基础文件原子替换,随后只接管已经耐久的新 journal。
即使没有并发编辑,也会准备一个只有 96 字节头的空 journal。这样进程在替换前退出时, 当前基础只与旧 journal 匹配;替换后退出时,当前基础只与新 journal 匹配。Open 不再把 路径前缀下所有候选直接视为歧义,而是逐个读取完整 fingerprint:恰好一个匹配时选择它并 隔离已证明退役的候选;零个匹配仍视为外部基础变化,多个匹配仍视为真正歧义。这个变化 没有降低“不能猜测恢复来源”的原则。
增长策略同时补齐。SessionLimits.MaxJournalBytes 默认 4 GiB,编辑在创建或追加 journal
之前用 recovery.BatchEncodedSize 精确预检;超限时 revision、正文和文件系统都不变化。
自动 checkpoint 不能默认开启,因为它等价于授权内核写回基础文件;宿主只有显式设置
AutoCheckpointJournalBytes 才会启用。后台请求使用单槽合并,失败后下次触发点后移
一个完整阈值,避免外部冲突或文件系统故障造成保存热循环。RecoveryStats 把物理字节、
硬上限、当前/下次自动阈值、排队状态和完成次数放在一个锁快照中。
自动 checkpoint 还暴露了 Close 的锁顺序风险:若 Close 先持有 saveMu 再等待后台循环,
而后台正准备进入 CommitAtLeast,两者会永久互等。新的顺序先在 Session 锁内标记关闭并
停止调度,等待循环退出后再取得 saveMu,同时保留“已经开始的 Save 安装最终 generation,
Close 再统一退役”的语义。确定性测试会阻塞自动 Save 的 snapshot hook,确认 Close 在释放
前不会提前返回,释放后也不会死锁。
测试不再把正常 Close 冒充崩溃。真实子进程分别在 WAL append 后、准备完成但替换前、
替换后退出,并覆盖有/无并发新编辑;父进程核对磁盘正文、恢复正文、Recovered 状态、
候选隔离和句柄释放。故障矩阵还覆盖新 journal 的 open/read/append/file Sync/目录 Sync、
意外尺寸、清理和原子替换契约。新的 quota stateful fuzz 逐批与 byte slice 比较,并在
每个可接受前缀后重开验证。Recovery/Save benchmark 又发现 Replay 曾为每个 batch
分配 64 KiB CRC 缓冲;改为一次 replay 复用后,Windows 的 4,096 batch 单次基准从约
180 ms、269 MiB 分配降到约 46–49 ms、1.2 MiB 分配。
这里的子进程退出仍不等于真实断电。准备 journal 的文件和父目录已经在基础替换前同步,
但未来仍需可控掉电/系统调用级 crash harness、更多本地文件系统和长期磁盘增长 soak。
最终 Windows 本机与 WSL 2 Debian 原生 Linux /tmp 两端均保持六包 100% statement
coverage、通过全仓三轮 shuffle race;四个 recovery fuzz、并发保存、崩溃恢复和 quota
fuzz 各运行 30 秒。真实子进程矩阵每端连续通过 20 次,checkpoint/配额边界连续通过
100 次,Recovery/Save/Session benchmark 也在两端实际执行。
Session 原来保证单个 sourceGeneration 会等最后一个 lease 才关闭,但没有限制宿主能
持有多少 lease。宿主若在每次 Save 前保留一个 Snapshot、coordinate Index 或 virtual
Pager,就能让任意多个退役 generation 同时保留 base/journal 句柄;单个模块各自正确,
整个进程的文件句柄和磁盘生命周期仍然无界。普通 Close 又只能无限等待最后一个 lease,
Save/Commit/Undo/Redo 除 Apply 外没有 Context 版本,后台自动 checkpoint 也没有独立于
调用 goroutine 的取消域。
按 generation 分别限额被否决,因为宿主可以在每次保存后重新获得一整份预算;让
Close 强行关闭尚存 Snapshot 也被否决,因为这会破坏已经发布的不可变读取契约。最终
预算放在 Session:所有宿主可获得的 SnapshotLease、coordinate Index 和 virtual
Pager 在所有当前/退役 generation 间共享 MaxSnapshotLeases,默认 1,024。精确饱和
直接返回 ErrLimitExceeded,不会先加 generation 引用再回滚。内部 Save Snapshot 不占
宿主 permit,因此泄漏的派生消费者不能阻止宿主把当前内容持久化。包装 lease 使用
sync.Once 释放 permit,重复 Close 不会重复扣减。
LifecycleStats 把 active/peak lease、硬上限、等待/执行中的 Save、自动 checkpoint 和
closing/closed 放进一个锁快照。Save 的串行边界从不可取消的 mutex 改为单 permit gate:
等待者可被自己的 Context 或 Session 关闭唤醒;已经取得 gate 的宿主 Save 仍由调用方
Context 决定,避免 Close 在未知时刻擅自取消用户要求的提交。自动 checkpoint 明确传入
Session 生命周期 Context,因此关闭会取消它。流式写、最终强身份扫描和替换 journal
准备阶段都检查 Context;原子替换一旦成功,后续取消不能伪装成“未提交”。
CloseContext 也没有在 deadline 时放弃清理。第一次有效调用只启动一次独立关闭流程,
调用者超时只是不再等待;后台仍同步 journal、等待 lease、关闭 generation/undo/marker、
发布最终事件并完成同一个 closeDone。后续 Close 得到完全相同的 joined cleanup
错误。所有尚未取得 save gate 的调用会在关闭时一起醒来,不必逐个等前一个排队者释放
permit;已开始的宿主 Save 完成后再由关闭流程退役最终 generation。
实现过程中出现两个容易被标准 Context 测试漏掉的问题。第一,测试使用了合法但
Done()==nil、只在 Err() 中推进取消点的 Context;直接 WithCancel 包装会屏蔽其
动态 Err,使本应失败的 Pager 构建成功并泄漏 lease,最终让 Close 永久等待。合并
Context 因此保留父 Context 的直接 Err() 轮询,同时用派生 Done 合并 Session 关闭。
第二,为覆盖最终身份锁前后的取消点,Save 的私有阶段 hook 增加第二个调用位置;并发
fuzz 随即通过 race detector 发现 hook 指针被另一 goroutine 清空。Save 现在在持锁捕获
Snapshot 时同时捕获一次 hook,整个尝试只读这个局部快照。
测试按资源和时序两条轴展开:精确 lease 饱和、64 goroutine 同时抢 permit、旧/新
generation 混合消费者、派生对象失败自动归还、排队 Save 取消不发布尝试事件、所有
等待保存被 Close 同时唤醒、流式写及 prepared journal 每个检查点取消、Undo/Redo 中途
取消原子性、CloseContext deadline 后继续清理并保留最终错误、自动 checkpoint 被关闭
取消后由 journal 完整恢复。新的 FuzzSessionLifecycleBudgets 把 lease 获取/释放、
编辑、保存、取消和派生消费者与有界参考状态比较;Snapshot permit 热路径和 4 MiB Save
都纳入 benchmark。这里仍不实现 watcher,也不声称已经具备进程级内存/句柄总量采样。
最终 Windows 本机与 WSL 2 Debian 原生 Linux /tmp 两端均保持六包 100% statement
coverage,并通过全仓三轮 shuffle race;五个 Session 生命周期相关 fuzz target 各运行
30 秒。核心并发边界连续通过 100 轮,详细逐检查点矩阵通过 30 轮。Snapshot lease 获取
在 Windows 约 447–467 ns、Linux 约 347–353 ns,均为 368 B/4 alloc;4 MiB Session Save
分别约 49–50 ms 与 10–11 ms。
首版 Coordinate 增量重建只做了一件绝对安全的事:找到所有顺序编辑中最早的 start,
复用它之前的 checkpoint,然后解码新 Source 直到 EOF。它不会产生错误坐标,但一个
4 MiB 文档中部只改一个字节也要重新解码后半份文档。查询同一个坐标窗口时同样会重复
分配并读取约 CheckpointBytes+UTFMax 的缓冲。ChangeHistory 的另一个问题更隐蔽:
between 每取得一个保留 map 就调用一次二元 Compose,而二元组合每次都复制已经累积
的 edit。保留历史接近上限时,时间和分配会从线性退化为二次增长。
后缀复用首先要回答“凭什么知道后缀没有变”。为每次 Rebuild 再从两端比较或 hash
完整后缀被否决:它减少 UTF-8 解码,却没有减少 Source I/O,还会把一次增量构建变成
隐蔽的第二遍内容扫描。把 Piece Tree 的节点身份塞进 coordinate.Source 也被否决,
因为 Coordinate 应继续只依赖通用 io.ReaderAt,不能反向绑定 Store 实现。最终使用
已经存在但此前只用于前缀的公开契约:传入的新 Source 必须正是顺序 ChangeMap 的结果。
既然每个 edit 之外的字节按定义未变,就可以在不断变化的坐标空间里维护一对
oldSuffix/newSuffix 边界:
- edit 完全位于当前稳定后缀之前时,只按长度差移动新边界;
- edit 触及稳定后缀时,旧边界推进到删除范围之后,新边界落到插入范围之后;
- 全部 edit 结束后,两侧剩余长度必须相同,否则 map 不是合法证明。
旧后缀边界本身不一定落在 rune 或 checkpoint 上,因此实现寻找它之后第一个旧 checkpoint。若该 checkpoint 已是 EOF,复用它不能少解码一个字节,直接回退到扫尾且 不虚报后缀复用。否则从最后一个稳定前缀 checkpoint 扫描新 Source 到映射后的 seam。 这个实际扫描得到的新状态是校准点:后续 byte 和 rune 用差值平移;仍在 seam 原始行上 的 checkpoint 叠加列差;已经越过换行的 checkpoint 平移 line/rune,但保持原 column。 这样后缀状态不是从长度猜出来的,而是从“精确映射契约 + 新 seam 实测状态”推导出来。 映射 seam 若落进 UTF-8 rune,明确返回 Source 不一致错误。
查询缓存由 Index 自己拥有,而不是放进 Session 全局表。Index 已经绑定一个 revision 和
Source lease,窗口内容天然不可变,Close 也正好是清理缓存的唯一所有权边界。Options
增加默认 1 MiB、最大 256 MiB 的 CacheBytes 与互斥的 DisableCache;LRU 在独立锁下
按精确驻留字节淘汰,查询仍持 Index 读锁,所以 Close 不会与命中窗口竞争生命周期。
缓存不向宿主返回 byte slice,不需要复制命中值。并发 miss 可以暂时各自持有读取缓冲,
这部分明确不伪装成驻留预算;只有发布进 LRU 的窗口计入 Stats。
ChangeMap 侧把单图上限固定为 1,048,576 个 edit,正好覆盖 Session 最坏的
4,096 history × 256 operations。ComposeAll 先完整验证 revision/length 链和总 edit
数,再做一次分配和一次顺序复制;二元 Compose 只是它的兼容包装。批量 Anchor 变换
最多执行 16,777,216 个 edit×anchor 步骤,Range 按两个端点计数。Context 版本在外层
edit 和每 1,024 个 anchor 间轮询取消,任何错误仍返回 nil 而不是部分结果。Session 的
既有批量上限直接引用同一常量,避免两个层次的预算日后漂移。
测试从正确性、资源和性能三条线展开。增量索引把 UTF-8 多字节、换行数变化、同一行
列差、顺序编辑、EOF 无收益和“插入后立即删除”的净零 seam 与全量 Index 逐坐标比较;
原有 FuzzIncrementalIndexMatchesFullBuild 不做特判,因而直接进入新的后缀路径。缓存
测试覆盖精确字节淘汰、重复发布、禁用、窗口大于预算、取消命中不触碰统计,以及
64 goroutine 竞争下永不越界。ChangeMap 测试覆盖链中部 revision/length 错误、精确
最大 edit 数、超过上限、变换工作量饱和和取消发生在不同轮询位置;Document 还实际
构造并组合完整 1,048,576-edit 历史,验证上边界可用。
基准编写时也出现了一个值得保留的测试教训:最初把查询 offset 选在文档正中央,而它
恰好是 checkpoint,缓存/非缓存两组都直接返回,错误地显示零分配和十几纳秒。偏移改到
checkpoint 窗口内部后才真正测到缓存路径。Windows 三轮数据中,缓存命中保持 0
allocation,未缓存查询每次分配约 72 KiB;4 MiB 中部单字节编辑的全量构建约
27–29 ms、4.27 MiB/77 alloc,前缀+后缀重建约 0.39–0.47 ms、136 KiB/9 alloc;
256-map ComposeAll 约 4.2–5.4 µs、1 alloc,逐段组合约 0.38–0.47 ms、256 alloc。
性能数字不是跨机器承诺,但缓存命中零分配、增量只扫描中段和线性组合分配次数由测试
与 Stats 固定为回归边界。
最终 Windows 本机与 WSL 2 Debian 原生 Linux /tmp 两端均保持六包 100% statement
coverage,并通过全仓三轮 shuffle race;三个 coordinate fuzz 每端各运行 30 秒,核心
缓存/后缀/取消/最大历史边界通过 100 轮普通重复与 10 轮 race 重复。Linux 的缓存命中
同样为零分配;4 MiB 中部编辑增量重建约 0.323–0.339 ms,全量约 20.6–21.9 ms;
256-map ComposeAll 约 3.6–4.1 µs、1 alloc,逐段组合约 0.34–0.36 ms、256 alloc。
事件流在 v0.4 已经做到“每个订阅队列有界”,但继续横向审计后发现,调用方可以不断
创建订阅。单个队列最多 4,096 个事件并不能限制所有队列的总内存;这和只限制单次插入、
却不限制 journal 总增长是同一种错误。另一个缺口在压缩:旧实现虽然用候选文件重写
undo store,但 Session.Compact 在持锁期间没有把 Context 取消贯穿复制过程,而且先后
步骤的提交边界没有公开表达。若 Piece 结构已经压缩而 undo 候选随后失败,调用方只能
看到一个 error,却无法判断结构维护是否部分发生。
事件侧没有选择“全局按字节精确计量每个 channel”。Go channel 的槽位之外还包含字符串、
ChangeMap 和 Metadata 引用,假装一个精确字节数反而会给出错误承诺。最终使用两级明确
上限:单订阅 Buffer 仍有 4,096 硬上限,Session 新增默认 128、最大 4,096 的
MaxSubscriptions。这样最坏队列槽位数是可计算的,默认也不会因为遗忘 Close 无限
增长。EventStats 同时报告历史占用、存活订阅、物理丢弃的待投递事件和历史缺口。
这里区分“队列里真正被替换的 delivery”与“从续接游标到最早历史之间的 gap”,否则
一个数字无法解释损失发生在哪一层。
长期运行还要求计数器不能静默回绕。丢弃计数和历史缺口使用饱和加法;订阅 ID 回绕时
跳过零和仍存活的 ID;事件 sequence 若真的达到 uint64 上限,hub 会关闭全部订阅并在
Stats 标记耗尽,而不是复用一个已经代表过旧事件的因果 ID。压缩事件种类被追加在已有
EventJournalSyncRestored 之后,没有插入枚举中部,以免改变既有事件数值并把当前已知
Windows journal-sync 测试问题扩大成协议变化。
压缩侧把提交顺序固定为:
- 校验并去重所有存活 undo 引用,检查累计字节是否溢出;
- 以 64 KiB 固定缓冲复制到同目录候选文件,每块前检查 Context;
- 完整复制且最后一次取消检查通过后,切换活动 undo store;
- 用确定映射同时更新 undo/redo 两栈;
- 最后执行不会读取 Source、不会失败的 Piece 结构合并。
这个顺序使“候选提交前”成为真正的零影响边界:取消、创建失败、读写错误和非法引用都
不会改变活动 store、history 或 Tree。候选切换之后,旧文件 Close/Remove 仍可能失败;
这时新 store 和映射已经是权威状态,所以 CompactionResult.Committed 与失败事件必须
为 true。把这种错误伪装成未执行,会诱使宿主重试并错误理解操作结果。journal
checkpoint 仍位于压缩之前,使用原有 Save 提交语义;压缩自身不尝试原地改写未提交
WAL。
进度总量取“去重后的存活 undo 字节”,而不是旧 store 文件大小。事件以 operation ID 关联,开始事件给出精确 total,复制每跨 4 MiB 发布一次进度,最终事件给出 Piece before/after 与 committed。没有把复制移到完全无锁后台:history 引用与活动 store 必须属于同一个 Session revision 状态,先引入后台两阶段协议会扩大当前阶段范围。 当前实现选择持有 Session 写边界,但 64 KiB 取消粒度和 4 MiB 观测粒度分别约束停止 延迟与事件频率;后续若基准证明锁时长不可接受,再以明确 generation/CAS 协议演进。
测试专门覆盖了覆盖率数字之外的提交语义:
- 候选创建失败、复制中取消和最终切换前取消都验证旧路径、字节、历史和 Piece 数不变, 且 Undo 继续正确;
- 旧文件清理失败验证 result 与事件都为 committed,Undo 使用新映射继续正确;
- 内部复制器依赖收窄为
ReadAt/Write最小接口,注入真实os.File不会产生的负 计数、超长计数、零进展、短写和错误组合,防御分支因此不是不可验证的死代码; - 事件测试覆盖订阅精确饱和、释放后再订阅、ID/sequence 边界、统计与 Session Close;
- 事件 state-machine fuzz 在随机发布、续接、溢出、消费、退订和关闭后持续核对序列及 Stats;新的 undo fuzz 用磁盘 store 与内存引用模型比较成功重写和不同取消点,保证 不暴露半个候选。
Windows 与 WSL 2 Debian 两端六包都保持 100% statement coverage;新增路径在两端通过 100 轮普通重复和 10 轮 race 重复,两项 fuzz 每端各运行 30 秒。Linux 全仓三轮 shuffle race 无排除通过。Windows 全仓 race 再次确定性地暴露了已经登记的 journal-sync 事件超时与未关闭句柄清理失败;按路线图它不混入本次压缩改动,而留到所有 v0.5.x 模块结束后单独修复和发布。基准显示零订阅发布约 29 ns,128 个积压订阅约 12.5–13.2 µs,二者均零分配;4 MiB 存活 undo 重写在 Windows 约 3.43–3.51 ms、 Linux 约 3.41–3.72 ms。数字不作为跨机器承诺,但订阅总量硬上限、发布零分配和复制 字节吞吐现在有了可重复基线。
v0.5.1 已经解决 PageKey 不能跨 Pager 伪造、缓存命中也必须观察取消等组合问题,但横向
审计仍留下三类缺口。第一,MaximumTasks 只限制 goroutine 数;如果每个 Window 都使用
大预算,活跃 payload 上界仍是任务数乘单请求预算。第二,旧 Close 只能等待 Provider
返回,不能通知正在执行的 Provider 或读取任务停止;一个遵守 Context 的 Provider 也收
不到 Pager 生命周期取消。第三,Pager 固定 revision 虽然保证旧读稳定,却没有一条受信
任路径把同一资源策略带到 Session 当前 Snapshot,宿主容易手工复制 Options、漏掉新字段
或误把无关 Pager 当作同一来源历史。
最终没有选择“把默认 MaximumTasks 降到 1”或“按实际返回后再记账”。前者牺牲并发却
仍不能表达内存预算;后者在分配完成后才发现超限,已经失去背压意义。实现新增独立
MaximumInflightBytes:每个 Window 在读取前预留其完整硬预算,ReadPage 预留精确 Page
长度,任何并发组合都不能超过总量。预留完整预算比预测实际裁剪结果更保守,但检查为
O(1),失败发生在 I/O 和分配之前,而且调用者可以用请求 Budget 明确换取更高并发。
窗口策略本身也补上 bytes/pages/fragments/Measure 绝对硬上限,防止配置把“有界”退化为
平台整数最大值附近的事实无界。
关闭协议复用了 Session v0.5.4 的核心思想,但所有权更窄。Pager 创建独立生命周期
Context;任务接纳在同一把锁下完成第二次调用方取消检查、占用 task permit、安装
生命周期监听,因而 Close 无法插进“任务已计数但还收不到取消”的缝隙。
CloseContext 第一次调用只启动一个清理 goroutine:先标记 closed、取消生命周期,再
等待全部 permit 归还,清空缓存并且只调用一次 owned Source Close。等待方 Context
超时只停止等待,后台清理不被放弃;后续 Close 读取同一个终态错误。实现初稿在解锁后
才安装生命周期监听,覆盖率审计暴露了一个只能靠竞态命中的补救分支;调整为持锁安装后,
该分支从状态空间中被删除,而不是编写概率测试粉饰它。
Provider 构建 Fragment 时需要可观察,但进度不能变成第二套可变索引。每次 Refresh 都
收到非 nil、仅在本次调用期间有效的 Report;它只接受不倒退且不超过 byte/Fragment
硬上限的候选水位。Provider 若忽略一次非法 Report 的错误,错误仍会粘滞,最终结果不能
发布。完成结果还必须不落后于最后一次报告。Publish 与 Refresh 共享单调 operation ID,
产生 started/advanced/completed/failed 四类状态;只有 generation CAS 成功才标记
published。Session 创建 Pager 时包装 Observer,把这些状态映射到现有有界事件流,再
调用宿主 Observer。回调不持 Pager 或 Session 锁,可以查看 Stats,但不能同步重入同一
Pager 的任务型 API 或 Close;否则调用方会把同步进度通道变成自身等待环。
跨 revision 刷新没有在内核内偷偷监听文件或猜测何时重建。Lineage 是只比较指针身份
的 opaque token;Session 强制覆盖调用方 Options 中的 lineage。Rebuild 复制上一个
Pager 已解析后的全部 Page、Fragment、task、key、cache、window、in-flight、Observer
策略,RebuildOwned 在 Build、Provider 或发布任一点失败时关闭新 Source。
Session.RefreshVirtualPager 只接受自身 lineage,取得当前 Snapshot 后建立新 Pager;
旧 Pager 和旧 Snapshot lease 保持独立可读。Provider 可为 nil,此时新 Pager 明确停在
generation 0 的逻辑 Page fallback。没有实现“自动把旧 Fragment 字节范围平移到新
revision”,因为格式无关内核无法证明任意宿主 Fragment 在 ChangeMap 后仍有同一语义;
需要增量策略时,应由宿主 Analyzer 基于新 Snapshot 决定。
本阶段测试分为五层:
- 确定性生命周期测试阻塞可取消与拒绝取消的 Provider,分别证明 Close 主动终止和 CloseContext 超时后继续清理;多个等待方共享释放错误,关闭中拒绝新任务;
- 资源测试同时占满 task 与 in-flight bytes,覆盖 byte、Fragment、Measure Window 和 ReadPage,不允许任何错误路径泄漏计数;
- 进度测试覆盖重复报告、倒退、越界、调用方取消、Provider 错误、最终结果倒退、迟到 Report、operation ID 耗尽和 generation 竞争;
- Session 综合测试串起编辑、旧 Pager、当前 Snapshot 重建、用户 Observer、事件流、 foreign/closed Pager、Provider 失败及 Snapshot lease 回收;
- 新的独立窗口参考模型不用生产查询辅助函数计算预期值,而是穷举 Page/Fragment/ Measure 选择和四维预算;第二个状态机随机组合 Refresh、非法报告、Provider 失败、 Publish、Stats 与 Close,验证 operation 阶段和 generation 永远原子。
Windows 与 WSL 2 Debian 两端继续保持六包 100% statement coverage。Linux 全仓三轮 shuffle race 无排除通过;受影响路径在 Windows 通过 100 轮普通重复和 10 轮 race 重复,在 Linux 通过 10 轮 race 重复。六个 Virtual fuzz 每端各运行 30 秒;两个新增 模型在 Windows 约执行 810 万输入、Linux 约执行 500 万输入。缓存 64 KiB Window 在 Windows 约 12.5–17.0 µs、Linux 约 21.3–22.5 µs,约 66 KiB/9 alloc;Refresh progress 在两端约 1.9–2.3 µs、1,288 B/22 alloc,安装 Observer 不增加分配。这些数字不是跨机器 承诺,但并发 payload 总量、关闭取消边界、进度状态和刷新身份已经成为可回归的契约。 已登记的 Windows journal-sync 事件超时与 undo store 打开句柄失败没有混入本提交, 按计划留给 v0.5.x 模块收口后的独立补丁版本。
v0.5.7 发布后,GitHub Actions 的 windows-test 间歇性失败在
TestSaveRestoresFailedJournalDurabilityState:测试期待第三个事件是
EventSaveProgress,实际先收到了 EventJournalSyncRestored。失败事件携带的
CommittedRevision 仍为 0,证明它不是 Save 完成后发布的恢复,而是后台 sync loop
已经捕获旧 journal,随后恰好在 Save 写入期间完成同步。两项操作分别保持自身的顺序,
但共享事件流允许它们合法交错;把全局顺序写死,实际上是在测试 Windows runner 的调度
速度,而不是 Session 契约。
这里没有通过延长超时或忽略恢复事件来掩盖问题。需要证明“Save 自身会恢复 durability
状态”的测试把 JournalSyncInterval 设为一小时,使后台 ticker 不参与该因果链;另一个
新增测试则故意阻塞 Save 的正文写入,让已捕获旧 journal 的同步在阻塞窗口内成功,
确定性验证
SaveStarted → JournalSyncRestored → SaveProgress → Saved。它同时检查恢复事件仍携带
提交前 revision、最终 Saved 携带提交后 revision,并且不会重复发布恢复或保存完成。
这种写法把原本依赖窄调度窗口的概率失败,变成可重复的并发协议测试。
审计还发现同组 sync-loop 测试在 Apply 后才订阅事件。5 ms ticker 足够在订阅建立前完成
失败和恢复,所以它同样依赖机器速度。订阅现在先于 Apply 建立,并明确消费
EventChanged 后再验证失败/恢复转换。所有 Session 与 Subscription 都在取得资源后立刻
用 t.Cleanup 注册释放;即使中途 Fatal,Windows 上打开的 undo store 句柄也不会逃出
测试并阻止 TempDir 清理。
修复没有改变公开 API、journal v2 格式或生产事件语义。Windows 与 WSL 原生 Linux 均 通过全仓三轮普通测试、三轮 race、六包 100% statement coverage;五个相关测试在两端 各通过 100 轮 race 重复。CI 另外加入 Windows 50 轮定向门禁,让这种跨操作交错与资源 释放约束持续受检。
v0.5 完成后,底层已经能够稳定提供 revision、不可变 Snapshot、ChangeMap 和有界 Page。 搜索因此不需要发明新的正文模型;它要解决的是如何在大型 Source 上减少候选读取,同时 保证任何索引损坏、过期或预算不足都不能制造漏检却标为完整的答案。
开发顺序首先是 document/search.Scan,而不是 SQLite schema。它固定了公开语义:
- Source 是不可变 UTF-8
ReaderAt,结果只包含 revision、[start,end)和 opaque source key; - exact literal 用固定缓冲区流式扫描,carry 只保留跨缓冲区所需字节;
- Unicode 不区分大小写使用 simple-fold canonical rune 和 KMP,返回的仍是原始字节坐标;
- regex 使用 Go RE2。标准库不暴露可续接自动机,因此只缓存调用方硬预算允许的前缀;
- result、scan、pattern 和 read-buffer 都有上限,
Report明确记录 completeness 和原因; - Term 查询不偷偷假定“单词”含义,只能交给后续注入的 Analyzer。
测试先覆盖空文件、跨缓冲区匹配、跨缓冲区多字节 rune、非法/截断 UTF-8、非重叠结果、 simple-fold 环、空 regex、取消、短读、长度变化、扫描/结果预算和整数上限。独立参考模型 fuzz 不复用生产 KMP,用简单 matcher 比较随机输入,避免“测试和实现共享同一个错误”。
自研方案表面上可以只写 trigram → block posting,但很快还要补页格式、校验、WAL、原子 发布、崩溃恢复、读写并发、压缩和 schema 迁移。这些工作不会改善 Docengine 的搜索 语义,却会新增一套低层数据库。
最终选择纯 Go modernc.org/sqlite 与 FTS5 contentless trigram:
- 不需要 CGO,普通测试和 Linux race 不引入第二套 C 工具链;
- SQLite 负责事务、B-tree/FTS 页和一致性检查;
- contentless、
detail=none表不保存可读正文,只保存候选能力; - exact 与 simple-fold 使用独立表,宿主 Analyzer term 使用普通关系表;
- 公开类型完全不暴露 SQL、rowid 或 SQLite 连接。
关键限制是:SQLite 不是正确性边界。候选必须回读同 revision Snapshot,再用与 Scan
一致的 literal/RE2 或 Analyzer 逻辑验证。索引缺失、损坏、过期、候选过多、短 literal、
含 NUL trigram、regex 有上下文断言或最大长度不可证明时,默认走流式 fallback。调用方
显式禁用 fallback 时,返回的是带原因的不完整报告,而不是猜测完整。
索引块按目标字节数切分,但 core 起止和两侧 overlap 都落在 UTF-8 边界。FTS 可以在 overlap 中找到跨块 trigram;Analyzer 也能读取边界上下文,但只有 token 起点属于 core 的块才能发布该 term,避免重复所有权。
Build 的发布顺序是:
- 获取跨进程 build lock 并分配单调 generation;
- 在私有临时目录建立 SQLite schema;
- 一个 transaction 写 blocks、两张 trigram 表、Analyzer terms 和唯一 state;
- 同步数据库及目录项,再把完整临时目录 rename 为 generation;
- 建立 generation lease 文件并计算完整数据库 SHA-256;
- 最后原子发布带 envelope SHA-256 的
DOCSEARCH01manifest; - 以只读、
quick_check通过的连接重新打开发布结果。
manifest 同时绑定 Source 全文 SHA-256、数据库全文 SHA-256、revision、长度、schema、
generation、块策略、预算和 Analyzer identity。Open 不仅比较 manifest,还验证 SQLite
state 和 block count。磁盘索引始终可删除重建,不影响文档或 recovery journal。
首版增量原型有两个值得记录的问题。第一,它先调用 hashSource,之后又按块读取正文,
导致新 Source 完整读取两次。第二,它只根据 ChangeMap 推导脏块;如果调用方错误地把
另一份 Source 与该 map 配对,可能漏掉未声明变化。
最终实现只在每个 edit 都保持长度且新旧块几何完全相同时尝试增量。它单次遍历所有新块:
- overlap-inclusive data hash 与旧块比较,得到“事实变化集”;
- core 按顺序送入 SHA-256,得到完整正文身份;
- ChangeMap 扩展到所有相交 overlap,得到“允许变化集”;
- 事实变化必须是允许集的子集,否则不信任声明并全量 Build。
通过证明后才复制上一不可变 SQLite 文件,在一个 transaction 中删除并重建实际变化块的
exact/folded/term 行、块 hash 和 state。任一位移 edit、块边界变化、历史缺口、Source
不一致或数据库异常都会全量构建。旧 Index 在整个过程保持可查询。stateful fuzz 随机
组合编辑、重建、重开与查询,并把增量结果和全量 Scan 比较;计数 Source 测试还把
增量正文读取限制在一次完整 pass,而不是只检查结果相等。
一个进程内 mutex 不能保护两个 CLI/宿主同时发布相同 generation,因此 build lock 是
真实系统文件锁。每个打开 Index 还持有 generation 共享 lease;Collect 只有取得独占
非等待 lease 后才把退役 generation rename 到临时命名空间并删除。
Windows 实现早期把 lease 文件放在 generation 目录内。即使 SQLite 已关闭,打开的 lease 句柄也会让目录 rename 失败。最终把 lease 文件放到索引根目录,名称仍绑定 generation; 这样活跃共享锁阻止 GC,而 collector 释放独占锁后可以删除目录和对应 lease 文件。 真实并发 builder、活跃旧 Index、关闭后回收和子进程退出测试覆盖这条生命周期。
继续审计时还发现 collector 最初把任何 generation- 前缀都视为可删除对象,这与“未知
宿主文件不动”的承诺冲突。现在只接受固定二十位十进制 generation、分隔符和非空后缀,
损坏或相似命名一律保留。
Windows/POSIX 的锁重试最初各自包含一个难以确定触发的 timer drain 分支。重构后的
waitLockRetry 用函数级 timer 生命周期表达取消;Win32 LockFileEx 非竞争错误和
CloseHandle 结果也收敛到可注入的小边界。生产仍调用真实系统 API,测试则能确定覆盖
非竞争错误、取消、等待、共享/独占冲突、重复关闭和清理失败。
BuildOwned/OpenOwned 把 Source 生命周期交给 Index。CloseContext 先拒绝新查询,
取消已经接纳的查询,再让唯一后台屏障依次关闭数据库、generation lease 和 Source;
调用方超时只停止等待,不会放弃清理。
Session.SearchIndex 从当前 Snapshot 建立或打开索引,强制覆盖宿主传入的 lineage,并
把 Snapshot lease 交给 Index。RefreshSearchIndex 在 Session 锁内同时取得当前 Snapshot
与 ChangeMap 历史副本,拒绝外部 Index 和已淘汰历史,再调用 RebuildOwned。上一 Index
及其旧 Snapshot 仍然独立可读。测试覆盖 lease 精确释放、失败关闭一次、并发 Close、
关闭超时后继续清理、历史缺口和跨 Session lineage 伪造。
候选验证范围会合并 overlap,但仍可能有多个相距很远的范围。早期实现给每个范围都重新
设置完整 MaxResults,最后才统一截断;结果虽然正确,CPU 和返回切片却可能按范围数
放大。现在每个范围只得到“全局剩余结果预算”,一旦达到上限立即停止。防回归测试让
第二范围的 Source 读取直接报错,从而证明达到上限后不会继续触碰它。
另一个早返回发生在 FTS 没有候选时:代码直接给出 Complete=true,没有执行其他索引
路径都有的最终 Source 长度检查。不可变 Source 是公开契约,但这个廉价防御仍然应该
一致;现在零候选路径也拒绝长度改变。
增量 fuzz 随后在 Linux 找到第三个问题:候选块的 dataStart/dataEnd 本来位于 UTF-8
边界,但按最大匹配字节数继续向外扩展验证窗口后,新起止点可能落在相邻多字节字符
内部。把这个 section 当成独立文本扫描会误报 ErrInvalidUTF8。修复没有增加第二次
全文扫描,而是在真正读取每个候选范围之前把两端向外对齐;用于预算判断的长度预先按
每端最多三个字节保守扩张并使用饱和加法。对齐按范围惰性执行,因此达到全局结果上限
后仍不会探测后续范围。exact、regex、对齐读取故障、多字节边界、极大整数和 fuzz
复现都成为固定回归。
同一次审计还把候选坐标与 Snapshot 全长的关系纳入验证。运行期数值损坏即使绕过基础
非负/顺序检查,也不能构造越过 Source 尾端的 section:默认路径回退到权威扫描,显式
禁用 fallback 时返回 ErrIndexCorrupt。literal 和 Analyzer term 两条路径使用同一
约束。
所有文件/SQLite 操作通过 Context-local operations 表注入,避免全局测试 hook 在并行 测试中竞态。测试逐点覆盖 mkdir、stat、随机数、复制、短写/零进度、Sync、Close、 rename、manifest 发布、SQLite prepare/exec/commit/constraint、重开、hash、lease 与 回收。真实子进程在临时 generation 的多个阶段直接退出,再验证 manifest 仍只指向完整 generation 且下次构建可恢复。
达到 100% statement coverage 时,没有为无法发生的分支保留假代码:固定注册的 SQLite
driver使 sql.Open 本身不会失败,因此删除该伪错误分支;Rows.Err、Close、文件
Sync/Close 和 Win32 分类则提取为最小接口,用确定性 fault object 验证。覆盖率之外还有
并发 builder、随机 state machine、数据库/manifest 位翻转、budget matrix、race、fuzz
和 benchmark。
最初的 exact scanner 每个读取块都重新分配 carry window,4 MiB 扫描约分配 4.8 MiB。 改成一个可复用 workspace 后,当前两端均约 139 KiB、3–4 alloc;专门的 allocation 回归用大量小块证明分配次数不会随块数增长。Windows 上 16 个 fuzz worker 同时创建、 Sync 和删除 SQLite generation 时,协调器可能在 30 秒收尾边界等待最后一个磁盘迭代 而报 deadline。增量持久化 fuzz 因此固定四个 worker,并保留真实 fsync/rename 路径, 不是通过 mock 掉持久化来换取通过。
SQLite 依赖的支持矩阵也不能决定整个内核能否编译。驱动注册被拆成平台文件:
modernc.org/sqlite 支持的目标启用真实后端,其他 GOOS/GOARCH 注册只返回
ErrIndexUnavailable 的驱动。这样 backend-independent Scan、Session 和其余模块在
DragonFly 等目标仍可构建。Darwin、DragonFly、FreeBSD、NetBSD、OpenBSD 和 Linux
七包测试二进制都经过交叉编译。
最终 Windows/WSL 原生 Linux 验证均保持七包 100% statement coverage并通过全仓三轮 shuffle race;两类搜索 fuzz 每端各跑满 30 秒,真实退出与并发发布各重复 20 次。4 MiB literal 扫描约为 Windows 4.57–5.37 ms、Linux 4.90–5.47 ms;索引命中/缺失分别约为 Windows 0.276–0.310/0.151–0.252 ms、Linux 0.322–0.425/0.128–0.303 ms。全量构建约 253/177–181 ms,增量重建约 56/16–17 ms。
这一阶段给 v0.7 的地基不是某种“Markdown 全文搜索”,而是 revision 固定的通用候选与 验证协议。未来 Composition 可以为多个 SourceKey 合并结果,但不能绕过每个 Source 的 Snapshot 身份和字节范围。
内核可以返回 byte range、revision 和通用 annotation,但不能返回 Markdown heading 或 代码 token。只要底层开始理解一种格式,虚拟化、搜索和组合就会被该格式绑定。
保存、坐标构建、搜索和未来 Page 调度都应读取绑定 revision 的 Snapshot,而不是长时间 持有 Session 锁。Source generation 确保结构不可变之外,底层句柄也活得足够久。
ApplyBatch、journal append、save rebase、Index 刷新和 generation 切换都先构造完整结果, 再一次发布。错误路径宁可返回明确失败,也不暴露半个事务。
Tree 不关闭 Source;generation 管理 base/journal;Session 管理 generation、undo、marker 和后台循环;owned/shared 配置决定目录能否由内核删除。模糊所有权最终都会变成崩溃恢复 或并发 Close 的数据丢失。
CommittedRevision、DurabilityUncertain、RecoveryDurabilityUncertain 和
PersistenceFaulted 分别表达不同状态。把它们压成一个 error 会让宿主无法做正确决策。
固定扫描缓冲区、单遍 hash、checkpoint 前缀复用、Piece 合并、undo 重写和有界事件/history 都配套统计或测试。没有约束的缓存和“看起来更快”的增量算法不进入内核。
当前测试层次是:
- 单元和边界测试验证局部不变量;
- fault injection 穿过每个系统调用失败点;
- property test 与内存参考模型比较;
- stateful fuzz 组合编辑、保存、恢复、截断和压缩;
- race 测试验证 Snapshot、事件和保存并发;
- Windows/WSL 原生文件系统验证平台语义;
- 真实子进程测试验证仅靠 mock 无法证明的锁生命周期和保存崩溃窗口。
100% statement coverage 是最低门槛,不是终点。v0.4.1 和 v0.4.2 都是在覆盖率已经 100% 之后发现的语义缺口。
截至 v0.6,Docengine 已经有 Piece Tree、不可变 Snapshot、原子事务、recovery v2、 跨平台保存、显式故障状态、坐标/ChangeMap、事件、资源策略、压缩,以及格式中立的 Page/Fragment/Measure 虚拟化和持久化搜索;v0.5.x 横向维护、Windows journal-sync CI 修复以及 v0.6 搜索的候选/验证/增量/生命周期闭环都已完成。
下一阶段进入 v0.7 持久区间集合与多源 Composition。Composition 只组合不可变 Source/Snapshot 并建立双向 source map;它可以汇总搜索返回的 revision/SourceKey/字节 范围,但不能向内核引入 Markdown、代码语法或其他业务格式。