# pi_append_log 追加日志技术设计提案
> **状态说明:**当前 crate 已实现默认 V1 codec、默认文件 Layout 和文件存储;网络存储和对象存储尚未实现。
## 1. 目标与非目标
### 目标
- 定义与具体格式和存储介质无关的只追加数据块接口。
- 以完整 block 为提交单位,明确成功、失败、持久化、轮转和归档语义。
- 将历史恢复限制在一次性的 Builder 初始化阶段,运行期只允许追加及生命周期操作。
- 允许文件、网络和对象存储后端在共同契约下采用不同物理策略。
- 提出可正向、反向恢复,并能识别半写与垃圾尾部的默认 V1 envelope。
### 非目标
- 当前阶段不实现默认 V1 codec 的行为或任何具体存储后端。
- 当前阶段不实现文件、网络、对象存储或其他具体后端。
- 不提供运行期读取、随机读取、查询、删除或原地更新。
- 不规定具体轮转阈值、归档介质、并发调度器或运行时。
- 不在 `append` 内自动轮转。
- 在数值 Magic、CRC32 参数和 flags 分配冻结前,不承诺 V1 的字节级稳定性。
## 2. Trait 与公共类型决策
公共 API 分为 `src/storage/mod.rs` 与 `src/format/mod.rs`,由 crate 根重新导出。领域名称固定为 `AppendLog`、`AppendLogBuilder`、`AppendLogVisitor`、`Layout`、`BlockEncoder` 和 `BlockDecoder`;`Closed` 是 `AppendLog` 的关联类型。不使用 WAL、Journal 或 BlockStorage,以免把通用抽象绑定到某种用途或介质。
`AppendLog: Send + Sync`。关联类型 `Block` 满足 `AsRef<[u8]> + Clone + Send + Sync + 'static`:`AsRef<[u8]>` 提供稳定字节视图;`Clone` 允许具体实现按自身缓冲或异步边界策略持有值,但接口不强制克隆;线程和生命周期约束允许 block 安全跨任务或后端边界。
错误统一使用 `io::Result`,使本地与远端 I/O 错误可以保留来源。运行期 `AppendLog` 的方法使用 `&self`,具体实现必须在内部协调并发追加、轮转和归档。
`AppendLog::Closed` 是具体实现定义的已封闭结构句柄。上层只保存并回传该句柄,不依赖文件名、路径、对象 key 或其他物理表示。这样可以把轮转时机与归档时机解耦,也能在多个 AppendLog 实例之间保存对应的待归档结构。
`Layout` 只负责稳定结构身份与活动、已封闭、归档资源名称之间的映射,以及从资源名称解析结构身份。它不负责资源列举、写入、轮转或决定归档时机,因此文件、网络和对象存储后端都可以使用同一抽象;文件后端可以使用路径作为 Name,对象存储可以使用对象键,网络后端可以使用远端流名称。
`AppendOptions` 只有公共字段 `durable: bool`,默认值为 `true`。`ReadOrder` 使用 `Forward` 和 `Backward`,分别表示从旧到新和从新到旧访问已提交历史。`BuildResult` 只返回恢复完成的 storage 与重启发现的未归档 Closed,不返回 BlockSeq。
## 3. 静态泛型分发与不使用 BoxFuture
Traits 使用 Rust 原生 `async fn`。Builder 的 visitor 以泛型 `V` 静态分发,返回的 storage 由关联类型静态确定。这样无需 `BoxFuture`、堆分配、手写 Future 生命周期或异步 trait 宏依赖,也让实现可由编译器单态化。
代价是这些包含原生异步方法的 traits 不以 trait object 动态分发为当前目标;具体异步方法返回 Future 的额外 `Send` 能力由实现和调用环境决定。crate 使用 edition 2024,并把最低 Rust 版本设为 1.85,满足原生 trait async fn 和 edition 需求。
## 4. Builder 一次性恢复
`AppendLogBuilder::build(self, decoder, order, visitor)` 消耗 Builder。历史扫描、损坏识别、有效尾部定位、必要修复以及活动物理结构初始化必须在 build 阶段完成。成功后返回 `BuildResult { storage, recovered_closed }`;运行期 `AppendLog` 不提供 read,避免恢复读取与并发写入交错,并使“可写”成为初始化完成后的明确状态。未归档已封闭结构参与恢复,归档结构不参与正常恢复。
`ReadOrder::Forward` 从最旧到最新访问;`Backward` 从最新到最旧访问。访问顺序只影响 visitor 的观察顺序,不减少 Builder 完成初始化所需的工作。Visitor 通过 `BlockVisitContext` 获得当前 block 在所属物理结构中的首尾位置。
## 5. Visitor 中断语义
`AppendLogVisitor::visit` 接收完整 envelope 的借用字节切片和 `BlockVisitContext`。block 切片只在一次 `visit` 调用期间有效;需要长期保存的 Visitor 必须自行复制。Builder 按完整 block 边界逐个解码并立即调用 Visitor,不构造 `Vec<Vec<u8>>` 作为历史结果。`is_first_in_structure` 和 `is_last_in_structure` 描述当前 block 在所属物理结构中的事实位置,不随 Forward 或 Backward 读取顺序改变:
- 第一个 block 为 `(true, false)`;
- 中间 block 为 `(false, false)`;
- 最后一个 block 为 `(false, true)`;
- 单 block 结构为 `(true, true)`。
例如物理结构为 `A1, A2` 和 `B1, B2` 时,Forward 访问顺序为 `A1(false), A2(true), B1(false), B2(true)`;Backward 访问顺序为 `B2(false), B1(true), A2(false), A1(true)`。
Visitor 返回值含义如下:
- `Ok(false)`:继续向 visitor 交付历史 block。
- `Ok(true)`:停止初始历史读取,但 Builder 必须继续完成初始化并返回 storage;这不是 build 取消。
- `Err(error)`:中止 build,不返回部分初始化的 storage,并把错误交给调用方。
即使 visitor 提前停止,实现也必须通过独立的尾部扫描、元数据或后端索引完成恢复,不能把“visitor 是否消费全部历史”等同于“初始化是否完成”。`BlockVisitContext` 只描述访问序列中的物理结构边界,不改变 `Ok(true)` 作为停止读取请求的语义。
BlockSeq 由具体 Visitor 或上层写入协调器维护,不进入 BuildResult。需要恢复下一序号的调用方应使用一个持续返回 `Ok(false)` 的 Visitor,严格解码完整 envelope 后统计最大 BlockSeq。有效 block 在成功追加顺序上必须严格递增,但允许空洞;AppendLog 只追加完整字节块,不分配或解释序号。
## 6. append、rotate 与 archive 契约
### append
`append(block, options) -> io::Result<u64>` 以整个 block 为操作单位。`Ok(active_size)` 表示完整 block 已按照选项成功接受,返回值是该次追加完成时接收此 block 的活动物理目标大小。append 不自动调用 rotate,也不包含轮转阈值;上层根据返回大小及自身策略决定是否以及何时轮转。
返回大小是该次 append 在线性化完成时的瞬时值,不是轮转承诺。返回后,并发 append 或 rotate 可以立即改变当前活动物理目标及其大小;需要把阈值判断与轮转组成更强原子操作的上层必须自行协调。
`AppendOptions::default().durable == true`。durable 为 true 时,成功必须跨越实现文档声明的持久化屏障:文件后端通常需要同步数据和恢复所需元数据;远端后端需要取得其耐久模型定义的确认。此保证不超过底层介质或服务的耐久能力。durable 为 false 时,实现可在完整 block 被易失缓冲接受后返回,但不能放宽完整 block 的逻辑原子性。
`Err` 表示结果不确定:上层不可确认 block 是否已提交,也不能仅凭错误断言“没有写入”。超时、断连、进程崩溃或同步失败都可能发生在后端已经接受数据之后。
### rotate
`rotate` 显式结束当前非空活动物理结构,并创建或选择新的活动结构,返回精确指向刚刚封闭结构的 `Some(AppendLog::Closed)`。当前活动结构为空时返回 `Ok(None)` 并保持该空活动结构,避免产生无意义的空结构链。成功后,后续 append 指向活动结构。rotate 不隐式归档旧结构;失败时轮转是否已经发生可能不确定,后续 build 必须能恢复一致状态。
### active 尾部恢复与截断
具体后端 Builder 在 `build()` 的独占初始化阶段负责恢复 active。Builder 必须先正向严格验证从文件头到候选边界的连续 block 序列;仅当无效数据是 active 物理尾部连续的半写或垃圾字节时,才可截断到最后一个完整、连续且 CRC 校验通过的 block 结束偏移。截断和必要元数据持久化必须在返回可写 storage 前完成。
active 只有半写首块时截断到零;active 中间损坏、已封闭结构的半写、CRC 错误或结构错误都必须使 build 失败,不能静默修复。archive 结构不参与普通恢复。BlockDecoder 只做解析和校验,AppendLogVisitor 只消费完整 envelope 的借用切片,两者都不修改后端资源;截断仍由 Builder 在恢复阶段负责。
### archive
`archive(closed)` 只归档调用方传入的、由本实例此前 `rotate` 返回的 `AppendLog::Closed` 结构,绝不根据“最后一个结构”隐式选择目标,也绝不归档当前活动结构。归档时机由上层业务决定,失败时归档是否已经完成同样可能不确定。
这种设计避免了 `archive_last` 的歧义。例如在一次轮转后又发生下一次轮转时,上层仍可以准确归档第一次轮转返回的句柄,而不会误归档最近一次关闭的结构。
## 7. 多业务场景复用同一文件实现
三个典型使用方的差异主要在于“何时允许归档”和“如何命名文件”,不应复制三套追加、恢复和轮转实现。
### 数据库 WAL 的双日志
数据库可能同时维护数据日志和索引日志。一次 checkpoint 可以分别轮转两个日志并保存两个 `Closed` 句柄:
`data_closed = data_log.rotate()`,`index_closed = index_log.rotate()`。只有当该 checkpoint 对应的索引全部落地后,才能分别调用 `data_log.archive(data_closed)` 和 `index_log.archive(index_closed)`。等待期间即使某个日志再次轮转,也不会因为使用“最后一个”语义而归档错误文件。
### B+ 树快照和更新日志
B+ 树可以使用同一个文件实现保存空块、根块快照和更新日志。先轮转并保存已封闭句柄,再持久化快照、发布根元数据;只有重启能够从新快照和更新日志恢复后,才归档对应的旧句柄。`rotate` 建立写入边界,`archive` 则等待快照和根元数据稳定后执行。
### LSM 日志
LSM 日志通常要等 memtable 刷成 SSTable 并成功提交 manifest 后才能归档。flush 成功本身不等于可以归档;manifest 发布成功后,旧日志才不再是恢复所必需。文件实现只负责执行 `archive(closed)`,不把 LSM 的 manifest 状态硬编码进存储层。
## 8. Layout 资源命名策略
结构身份必须与 envelope 中的 `BlockSeq` 分开。`BlockSeq` 是逻辑数据块序号,写入块格式并参与恢复;StructureId 是后端内部用于命名、排序和发现物理结构的稳定身份,不能混用。
`Layout` 至少需要提供以下能力:
`active_name(structure_id)` 生成活动资源名称;`closed_name(structure_id)` 生成已封闭资源名称;`archive_name(structure_id)` 生成归档资源名称;三个 parse 方法从资源名称解析稳定身份。具体 Builder 负责列举后端资源后交给 Layout 解析,Layout 不决定何时轮转或归档。
数据库 WAL、B+ 树和 LSM 可以分别注入不同 Layout 实现,例如使用不同前缀、扩展名、目录或对象键命名规则;如果差异只是固定前缀和扩展名,也可以先使用配置结构,只有命名规则出现行为差异时再实现独立 Layout 类型。
## 9. 默认 V1 格式
默认 V1 字节布局全部使用小端序:
`BodyLen:u32 LE + Magic:"pial" + Version:u16 LE + Flags:u16 LE + BlockSeq:u64 LE + Payload + BodyLen:u32 LE + CRC32:u32 LE`
规则如下:
- `BodyLen = 16 + payload_len`,其中 16 字节来自 Magic、Version、Flags 和 BlockSeq。
- `encoded_len = BodyLen + 12`,额外 12 字节来自前置 BodyLen、后置 BodyLen 和 CRC32。
- Magic 固定为 ASCII 字节 `pial`;公共常量同时提供四字节数组和按小端解释的 u32 值。
- Version 固定为 1。
- BlockSeq 从 1 开始;0 永久保留,不能表示有效 block。成功追加顺序必须严格递增,但允许空洞。BlockSeq 由具体 Visitor 或上层写入协调器维护;AppendLog 不解析或维护该序号。
- CRC32 使用 `crc32fast::Hasher` 的标准实现,覆盖从前置 BodyLen 开始,到末尾 CRC32 字段之前为止的所有字节,因此包含两份 BodyLen、全部 envelope 字段和 Payload。
- V1 暂不支持任何 Flags,严格 decoder 遇到非零 Flags 必须返回错误。
- DefaultBlockCodec 已实现 BlockEncoder 和 BlockDecoder;DecodedBlock 借用输入。
## 10. 正向解析
1. 读取前置 BodyLen;先验证其不小于 16,且推导出的 payload 不超过配置的最大值。
2. 使用 checked arithmetic 计算 `payload_len = BodyLen - 16` 和 `encoded_len = BodyLen + 12`,拒绝下溢、加法溢出以及转换为平台 `usize` 时的失败。
3. 读取完整候选记录;校验 Magic、Version、Flags 和非零 BlockSeq。
4. 校验后置 BodyLen 与前置 BodyLen 完全一致。
5. 按冻结的参数计算并校验 CRC32。
6. 只有所有检查成功后才向 visitor 交付 Payload 对应的 block,并前进 encoded_len。
解析器不能在长度和上限检查之前按外部长度分配内存。
## 11. 反向解析
从一个已知候选尾端向前解析时,先读取 CRC32 之前的后置 BodyLen,通过 `encoded_len = BodyLen + 12` 反推出候选起点。随后必须重新校验前置 BodyLen、Magic、Version、Flags、非零 BlockSeq 和 CRC32;不能只因长度看似合理就认定记录边界。
校验成功后,反向 visitor 按从新到旧的物理顺序交付。实现还可检查相邻有效记录的 BlockSeq 是否符合冻结的单调或连续策略,以提高损坏识别能力。
## 12. 半写记录与垃圾尾部
物理尾部可能包含半条记录、旧扇区内容或任意垃圾,不能直接信任最后四或八个字节。恢复器应从物理尾部逐字节向前回退候选尾端。对每个候选都执行:长度下限与上限、checked arithmetic、双 BodyLen、Magic、Version、Flags、非零及合理的 BlockSeq、CRC32 校验。只有全部通过后才能确定有效尾部。
逐字节回退是正确性基线。未来可增加有界窗口、索引或向量化搜索优化,但不得减少验证项目。发现有效尾部后,文件实现可按其恢复策略截断其后的垃圾;截断和元数据持久化仍属于未来实现。
## 13. 最大 Payload 与 checked arithmetic
DefaultBlockCodec 通过 `DefaultBlockCodec::new(max_payload_len)` 接收调用方指定的最大 Payload。`DefaultBlockCodec::default()` 使用 4 MiB,即 `4 * 1024 * 1024` 字节。Encoder 和 decoder 都必须使用同一配置上限;Decoder 在分配或读取大块数据前先检查上限。Encoder 必须拒绝超过实现上限或 `u32::MAX - 16` 的 payload。
所有 `u32` 到 `usize/u64` 的转换、`BodyLen - 16`、`BodyLen + 12`、文件偏移加减和候选起点计算都必须使用 checked arithmetic。任何溢出、下溢或不可表示的转换都作为格式错误处理。
## 14. Flags 未知位
V1 必须冻结已知 flags 掩码。默认严格 decoder 遇到未知位应拒绝记录,避免把压缩、加密或未来变换后的字节误当作普通明文 Payload。若未来定义“可安全忽略”的位,也必须由特定 Version 明确其语义,不得默认静默忽略。
## 15. 版本演进与旧格式多 Decoder
Magic 用于快速排除非目标格式,Version 选择具体 decoder。新版本可以改变 envelope、校验算法或扩展字段。写入端在一个物理结构中应产生明确版本,迁移可选择轮转边界。
兼容旧 CRC32 格式时,应由探测层根据 Magic、Version 或外部元数据选择多个 decoder,而不是让单个 decoder 猜测字段含义。每个 decoder 必须具有独立、完整且可验证的边界规则。当前没有实现任何 decoder。
## 16. 不设置 HeaderLen 的权衡
V1 不设置 HeaderLen,可缩短固定头、简化正反解析,并使反向定位公式明确。代价是同一 Version 不能自然追加固定头字段;任何 envelope 结构变化都需要提升 Version,或仅通过已冻结的 Flags 表示 Payload 变换。
该选择偏向严格、可验证的版本边界,而不是在单一版本内提供任意扩展。若未来确定需要大量可选 envelope 元数据,应在新版本中重新评估 HeaderLen 或 TLV。
## 17. BlockSeq 位于 Envelope 的原因
BlockSeq 是恢复、物理顺序和去重相关元数据,不是业务 Payload。把它放在 envelope 中,可以在不理解 Payload、压缩内容或加密明文的情况下检测零值、重复、倒退、缺口和随机尾部碰撞,也便于不同存储后端共享恢复规则。
若 BlockSeq 位于 Payload,存储层必须理解业务格式,破坏介质抽象;加密或压缩还会阻止恢复器在完成变换前使用序号。
## 18. 文件、网络与对象存储扩展
### 文件存储
文件后端把一个物理结构映射为一个固定八位编号资源,状态后缀依次为 `.active`、`.closed` 和 `.archive`。`durable=true` 会在完整 block 写入后同步文件;轮转和归档使用严格的状态转换,恢复时 active 尾部可截断,closed 损坏直接失败,archive 不参与普通恢复。当前文件实现使用标准库阻塞文件 I/O,Tokio 只用于测试。
### 网络存储、不确定结果与幂等
网络断开或超时会产生不确定结果:服务端可能已经提交,而客户端没有收到确认。因此 `Err` 不能等同于“未提交”。安全重试需要协议携带稳定幂等键。BlockSeq 可以参与去重或冲突检查,但只有在日志身份、会话和序号分配规则也被冻结时才足够。服务端应对同一幂等键和相同内容返回原结果,对同一键但不同内容返回冲突。
### 对象存储不可原地 append
对象存储通常不能原地 append。实现可把每个 block 或一批 block 写成不可变对象,再通过 manifest 或索引对象发布逻辑顺序;轮转可对应完成一个批次并切换新前缀,归档可对应复制、改变存储层级并更新 manifest。发布流程必须使用条件写、版本号或等价并发控制,并处理崩溃后的孤儿对象,不能用普通覆盖写假装原子 append。当前不实现对象存储。
## 19. 压缩、加密 Flags 与 CRC 顺序
Flags 可在未来表示 Payload 已压缩或加密。建议写入顺序为:业务明文 -> 可选压缩 -> 可选认证加密 -> envelope 编码 -> 对最终编码且不含末尾 CRC 字段的全部字节计算 CRC32。
读取顺序相反:先验证长度、envelope 和 CRC32,再按 Flags 解密并验证认证标签,最后解压。CRC32 用于快速识别随机介质损坏,不能替代认证加密的完整性标签;认证失败必须视为记录无效。
若加密需要 nonce、key id 或 tag,V1 固定头没有扩展区。应由新 Version 明确定义新布局,或冻结一种位于 Payload 内部的自描述加密封装,不能临时改变 V1 envelope。
## 20. 当前交付边界与待冻结事项
本阶段已交付公共 trait、公共类型、中文 rustdoc、默认 V1 codec、默认文件 Layout、文件存储和 Tokio 集成测试。网络与对象存储后端仍未实现。
后续网络和对象存储实现仍需冻结各自的幂等协议、资源发布协议和故障恢复规则。最大 Payload 由 DefaultBlockCodec::new 的调用方配置,默认值为 4 MiB。Magic 已固定为 ASCII pial,CRC32 已固定为 crc32fast::Hasher 覆盖末尾 CRC32 之前的完整 block,Version 已固定为 1,V1 Flags 已固定为零,BlockSeq 已固定为成功追加顺序严格递增但允许空洞。