# Dirty-Page Checkpoint Refactor Design

## 1. 目标与非目标

### 目标

1. 将持久化单元从当前的 `arena/frame` 切换为 `dirty resident page`
2. 只将当前仍有效的 page image 刷入 data file，不再把前台 compact 和 delta 演化过程原样刷盘
3. 让 `link/update/delete/compact/split/merge` 只修改 resident page，不再直接制造 durable 中间产物
4. 通过 `dirty_page_set swap + checkpoint barrier` 打通从前台写入到 WAL 截断、磁盘垃圾清理的完整闭环
5. 保持 `data-first, meta-last`、`pending_retire`、`flush FIFO` 等现有 crash-closure 不变量
6. 在 `get/scan` 热路径上尽量复用当前 `page -> base + delta` 的访问形态，不引入 RocksDB 式多层 run 查找

### 非目标

1. 本方案不尝试保留 `arena` 作为前台 runtime allocator
2. 本方案不尝试把 compact 做成原地修改 durable image
3. 本方案不修改 `CommitTree::compact`
4. 本方案不改变 `Options::sync_on_write` 语义
5. 本方案不把 flush 改成非 FIFO 调度

## 2. 当前实现的关键问题

当前实现的问题不是单个 `fsync` 慢，也不是某个参数调得不对，而是**持久化单位选错了**。

当前路径里：

1. 前台 `Tree::link()` 会直接分配 hot durable delta 并发布到页状态  
   见 [src/index/tree.rs](/home/workspace/gits/github/mace/src/index/tree.rs) 和 [src/index/publish.rs](/home/workspace/gits/github/mace/src/index/publish.rs)
2. `Node::try_compact()` 会把当前页重新 materialize 成新的 packed base frame  
   见 [src/types/node.rs](/home/workspace/gits/github/mace/src/types/node.rs#L560) 和 [src/types/base.rs](/home/workspace/gits/github/mace/src/types/base.rs#L39)
3. flush 直接遍历 flush unit 中的 frame 全量写文件  
   见 [src/map/flush.rs](/home/workspace/gits/github/mace/src/map/flush.rs#L151)

结果是：

1. compact/SMO 生成的大量 soon-to-die frame 会进入 flush 输入集
2. data file 承接的是“前台内存演化过程”，不是“当前仍有效的 page”
3. 对随机写 workload，compact 越频繁，data file 越大，file 数越多
4. WAL 清理当前绑定在旧 checkpoint/flush 路径上，不能自然跟随“只刷当前有效 page”一起前推

根因可以表述为：

`前台运行时修改 -> durable frame 产生 -> flush 全量写 frame`

只要这条链不切断，compact 的每次重建都会放大持久化垃圾。

## 3. 必守不变量

### 持久化不变量

1. 如果某轮 checkpoint 推进到了 `ckpt_lsn = X`，那么 checkpoint 开始时从 dirty set 交换出来的那一批 dirty pages 的对应 page image 必须已经 data/blob durable 且 manifest publish 成功
2. WAL checkpoint record 只能在该轮 page checkpoint 的 data/blob durable + manifest publish 成功后写入，且写入的 checkpoint 不得超过该轮 `ckpt_lsn`
3. manifest publish 之前，checkpoint 不得前推
4. WAL 删除边界必须小于等于：
   `min(active_txns.min_file_id, last_ckpt.file_id)`
5. WAL checkpoint 写入位置必须先经过 `active_txns.min_lsn` 裁剪；有活跃事务时不得把 checkpoint 推进到其最小活跃 LSN 之后
6. 任一 `MapUpdate(pid -> new_addr)` 若覆盖旧 durable addr，必须在同一 manifest txn stage 对应 `pending_retire`
7. `pending_retire` 只能在对应 stat apply 与 meta commit 成功后清理
8. `pending_retire` key 必须是 `(bucket_id, kind, retire_id)`，其中 `kind ∈ {Data, Blob}`，`retire_id` 在 `(bucket_id, kind)` 下唯一
9. 单个 bucket 的单轮 page checkpoint 可写入多个 data/blob file，但 page-map/interval/stat publish 只能有一次 manifest 更新
10. 单个 bucket 的单轮 page checkpoint 只允许一次 WAL checkpoint 更新

### resident/runtime 不变量

1. 前台修改只作用于 resident page，不直接生成 durable page image
2. live `dirty_page_set` 只表示“该 pid 当前 resident state 相对 stable image 已经变脏”，不表示具体有多少次更新
3. checkpoint swap 冻结的是 `pid` 快照集合
4. checkpoint 期间同一 `pid` 的新写入必须留在/重新进入当前 live dirty set
5. page checkpoint 完成后的 clean 标记必须通过 `NodeState` 上的 dirty-CAS 完成（`dirty=true -> false`）；CAS 失败表示该 pid 已出现并发更新，不得把它当作 clean
6. 每个 dirty page version 必须携带 `page_lsn: Position`（`Position = {wal_file_id, wal_file_offset}`）；`page_lsn` 挂在 page image header（`DeltaView/BaseView` 的 `BoxHeader` 扩展字段）并随新版本创建写入，旧版本不做原地修改
7. clean page 才允许无条件 evict；dirty page 驱逐前必须先 durable 或明确返回 `Again`

### recovery 不变量

1. recovery 只认 manifest 中最新的 durable page mapping
2. checkpoint 之后的变更通过 WAL replay 恢复
3. `pending_retire` 的 recover/apply/clear 必须幂等

## 4. 控制模型总览

### 新核心对象

1. `resident page`
   
   - 当前内存中的可变页对象
   - 维持 `base + delta` 读模型
   - 对外是 append-only version，不做原地覆写
   - flush 持久化的是 page image（`DeltaView/BaseView` 物化结果），不是 `Node/NodeState` 本身
   - 包含 `pid`, `addr`, `dirty` 等 runtime 元数据；`page_lsn` 存放在 page image header

2. `dirty_page_set`
   
   - bucket 级 dirty page 索引
   - live 集合成员是 `pid`
   - checkpoint 开始时通过 `swap(dirty_page_set, empty_set)` 冻结出一批 `frozen_pids`
   - `page checkpoint` 的轮次定义为 bucket 级别，每个 bucket 独立触发和完成

3. `checkpoint barrier`
   
   - 每轮 checkpoint 先从 `frozen_pids` 读取 `snapshot_lsn = page_lsn@collect` 并收集 dirty pages（仅保留 `snapshot_lsn > last_checkpoint_lsn`）
   - 对收集结果按 `Position` 排序，取最小 `Position` 作为 `tmp_last_checkpoint_lsn`
   - `Position` 比较规则：先比 `wal_file_id`，再比 `wal_file_offset`
   - 若本轮未收集到可推进项，则不写 WAL checkpoint
   - 该轮只要求 durable 在 swap 时刻冻结出来的 dirty page 集合

4. `stable mapping`
   
   - manifest 中 `pid -> durable addr`
   - recovery 时以它为准

### 控制思想

系统从“flush arena 中的 frame”改成“flush dirty resident page 的当前有效 image”：

1. 前台把修改写入 WAL，然后生成新的 append-only resident page version
2. 页第一次变脏时把 `pid` 放入当前 live `dirty_page_set`
3. checkpoint 开始时直接 `swap(dirty_page_set, empty_set)`，冻结出本轮 page batch
4. 后台只 flush 这批 frozen dirty pages 对应的 page image
5. data file 只是多个有效 page image 的容器，不再是 arena 内容镜像

这条模型能关闭已知失败模式，因为 durable 输入集不再包含 compact 产生的中间 frame，只包含当前仍被 `PageMap(pid)` 表示的有效 page 状态。

## 5. 关键时序

### 5.1 正常写入时序

1. 事务生成 WAL entry，拿到 `lsn: Position`
2. 找到 leaf/intl resident page
3. 对 resident page 执行 `link/update/delete/compact/smo`
4. 对逻辑更新生成新的 append-only page version，并更新 `PageMap(pid)` 指向它
5. 更新 `page_lsn`：
   - clean -> dirty：新 page version 的 header 写入 `page_lsn = lsn: Position`
   - dirty -> dirty：通过创建更新后的新 page version 前移 `page_lsn`（不后退）
6. 若该页原来不在当前 live dirty set，则把 `pid` 加入 `dirty_page_set`

这里的关键点是：

1. resident page 的修改不直接产生 durable frame
2. 一个页被写 100 次，dirty 管理上仍然只是“1 个 dirty page”
3. compact 不产生新的 WAL LSN；它只是生成新的 resident page version
4. `page_lsn` 随 page version 前移且不能后退；旧版本 header 不会被原地改写

### 5.2 checkpoint 选页时序

这个步骤很简单，根据全局的 lastest_checkpoint_lsn 来过滤 dirty page set 中的 page id 中 lsn >= latest_checkpoint_lsn 的page即可 (abby)

1. 直接执行 `frozen_pids = swap(dirty_page_set, empty_set)`
2. 若 frozen 为空，本轮结束（不写 WAL checkpoint）
3. 对每个 `pid` 读取 `Node`，固定本轮 `snapshot_lsn = page_lsn@collect`，目前来看，应该是 `NodeState::latest_lsn`
4. 仅收集 `snapshot_lsn > last_checkpoint_lsn` 的 dirty page
5. 本轮 page checkpoint 只处理收集出来的 dirty pages
6. 按 `Position` 排序并取最小 `snapshot_lsn` 生成 `tmp_last_checkpoint_lsn`
7. swap 之后产生的新 dirty pid 自动进入新的 live dirty set

这里要求的是：

1. 本轮收集出的 dirty pages 全部满足完成条件后，才能写 WAL checkpoint record
2. 写 checkpoint 前先裁剪：`ckpt_pos = min(tmp_last_checkpoint_lsn, active_txns.min_lsn)`（若存在活跃事务），然后写 WAL checkpoint；写入后 `last_checkpoint_lsn = ckpt_pos`

### 5.3 flush/build 时序

1. 对每个收集到的 dirty page，读取当前 `Node` 视图并 materialize page image
2. materialize 出该页当前有效 image
3. 若 leaf 有 multi-version sibling，则 sibling/base 相关 page image 在本轮 checkpoint 内一并刷盘
4. 将多个 page image 合并进固定大小 data file （这里可以做一个优化：相同pid的page存放在一起，这样可以加速 load）
5. 生成：
   - `MapUpdate(pid -> durable addr)`
   - file relocation
   - interval
   - stat
   - `pending_retire stage`（`(bucket_id, kind, retire_id) -> retired_addrs[]`，当前实现 `retire_id` 来自 flush batch id）

这里的关键区别是：

1. flush 输入是 page，不是 arena/frame
2. page image 是 checkpoint 冻结时刻的有效闭包，不是历史演化过程

### 5.4 publish/checkpoint 时序

1. data/blob file write（可写多个 data/blob file）
2. data/blob sync
3. manifest publish commit（该 bucket 本轮仅一次）：
   - page map
   - interval/stat
   - pending retire stage
4. orphan marker stage 与 `pending_retire` 记录属于辅助元数据流程，不计作额外 manifest publish 轮次
5. retire 的 stat apply/clear 由 recover/gc 路径幂等执行：`load_pending_retire -> apply_stats -> clear_pending_retire`，并覆盖 `retire_after_apply_before_clear` 崩溃窗口
6. manifest commit 成功后，对涉及的 pid 执行 `NodeState` dirty-CAS（`dirty=true -> false`）
   - CAS 成功：该 pid 标记为 clean，并更新其 checkpoint frontier
   - CAS 失败：表示该 pid 在 checkpoint 期间已有并发更新，不得清 dirty
7. 完成条件：
   - 默认路径：本轮收集出的 dirty pages 已完成 durable publish（允许 clean-CAS 失败）
   - 可选优化（映射变化直接跳过 build）：从本轮 frozen snapshot 删除该 pid；判定条件固定为 `current_lsn > snapshot_lsn`，且不重算本轮 frontier
8. 当本轮全部满足完成条件时，执行该 bucket 本轮唯一一次 WAL checkpoint 更新（写入 `ckpt_pos`）
9. WAL checkpoint 写入成功后，原子更新 `last_checkpoint_lsn`

### 5.5 WAL 清理时序

1. WAL recycle 只依赖最近成功 WAL checkpoint record 的边界
2. WAL recycle 边界保持：
   `min(active_txns.min_file_id, last_ckpt.file_id)`
3. 活跃事务存在时，checkpoint/WAL recycle 都必须保留其 rollback 所需 WAL
4. 只要 checkpoint 轮次能持续把 frozen dirty batch 刷完并成功写 WAL checkpoint，WAL 就能继续前推

### 5.6 evict 时序

1. clean page:
   - 可直接丢 resident
   - `PageMap` 回到 tagged stable addr
2. dirty page:
   - 必须先被某轮 checkpoint durable
   - 或同步 page flush 成功后才能驱逐
   - 否则返回 `Again`

### 5.7 GC 时序

1. resident GC:
   - compact/split/merge 产生的旧 resident 结构在 epoch 安全后回收
   - 这层不参与 manifest
2. durable GC: (abby)
   - 在 `BucketState` 中增加一个 `stablized_addr` 用于记录已经持久化的最大的 addr，目的是过滤 compact 产生的 Junk 只有 junk addr <= `stablized_addr` 才需要生成 junk page
   - 对于 junk 和  unmap 记录
      - 对于 junk 可以在 BaseHeader 中增加一个 `junk_addr` 用来存放 junk page 的地址, 这个需要注意的是 Node 在 checkpoint 前可能会经过多次 `replace` 因此，每次 `replace` 新的 Node 需要继承旧的 `junk_addr` 如果有新的 junk page 那么需要插入到这个链中
         - **但问题是：何时清理这个链条呢？**
      - 对于 unmap 可以将 unmap 的 pid 记录到 `Pool::unmap_pid` 中，在完成 checkpoint 后再将 page table 执行 unmap
   - 上一步（特别是 junk）依赖于 SysTxn::replace 必须成功，好在所有的 replace 都被 Mutex 保护，这个是成立的
   - 有了以上几步，就可以复用当前的 Data/Blob stat 了

`stablized_addr` 不一定能拦住所有的新的 stale addr (真的吗？) 因此在 `apply_junk` 时可能会出现 `ivl_map` 通过 addr 找不到 file_id 的情况 （如果真的拦不住的话）


### 5.8 崩溃窗口矩阵

#### after_data_sync

1. data file 已 durable
2. manifest 未 commit
3. recovery 只认旧 mapping
4. 新文件按 orphan 处理
5. frozen dirty batch 不得视为已 checkpoint 完成

#### before_manifest_commit

1. 与 `after_data_sync` 相同
2. `pending_retire` 尚未 publish，不得清理旧 durable 数据

#### after_manifest_commit

1. 新 mapping 已 durable 可见
2. recovery 应加载新 stable image
3. 对应 retire 元数据必须与本次 commit 同步存在

#### after_manifest_commit_before_wal_checkpoint

1. page checkpoint 结果已 durable 且 manifest 已 commit
2. WAL checkpoint 尚未写入
3. crash 后仍不得错误推进 `last_ckpt`，WAL 回收边界必须保持在旧 checkpoint

#### retire_after_apply_before_clear

1. `pending_retire` 已 apply 到 stat，但 clear 尚未完成
2. recovery 必须幂等重放，不允许重复删除出错
3. `NotFound` 只能在 bucket 确认物理删除时忽略

## 6. 代码接入点

### `src/index`

1. [src/index/tree.rs](/home/workspace/gits/github/mace/src/index/tree.rs)
   - `link/update/delete` 改为只生成新的 resident page version
   - 不再调用 runtime durable alloc path
2. [src/index/publish.rs](/home/workspace/gits/github/mace/src/index/publish.rs)
   - `TreeBuildCtx/TreePublishCtx` 从“生产 durable frame”改成“生产 resident mutation 和 retire intent”

### `src/types`

1. [src/types/node.rs](/home/workspace/gits/github/mace/src/types/node.rs)
   - `try_compact` 改成 resident compact
   - 输出 resident page 新状态和 runtime junk，不输出 durable frame
2. [src/types/base.rs](/home/workspace/gits/github/mace/src/types/base.rs)
   - `try_new_leaf/try_new_intl` 从前台路径后移到 checkpoint page-image builder

### `src/map`

1. `BucketContext`
   - 增加 `dirty_page_set`
   - 增加 `swap(dirty_page_set, empty_set)` 的 checkpoint 冻结入口
2. `PageMap`
   - 保持 `pid -> swip`
   - 但 dirty/stable 元数据不再从 arena 派生
   - page entry 需要承载 append-only resident page version 语义
3. `flush`
   - 从“flush arena contents”改成“flush dirty pages”
   - `collect_flush_entries` 改为 `collect_dirty_pages`

### `src/store`

1. `StoreFlushObserver`
   - publish 输入单位改成 page batch
   - checkpoint 推进绑定 `ckpt_lsn`
2. `gc`
   - WAL recycle 继续依赖 `last_ckpt.file_id`
   - 但 checkpoint 推进点改为 dirty-page batch publish 成功之后

### `src/cc`

1. [src/cc/log.rs](/home/workspace/gits/github/mace/src/cc/log.rs)
   - `Logging` 的 checkpoint frontier 以 `Position{file_id, offset}` 表示
   - page checkpoint 完成后调用 `update_checkpoint(pos)` 写入 WAL checkpoint record

### `src/meta`

1. manifest page map 仍保持 `pid -> durable addr`
2. `pending_retire` contract 保持不变，只是生产点改成 page checkpoint 路径
3. `pending_retire` key 第三段是 `retire_id`；`kind` 维持 `Data/Blob` 两类

## 7. 与现有机制关系

### 与 WAL 的关系

1. WAL 仍是前台 durability 首要保障
2. checkpoint 的职责从“flush frame”变成“把 frozen dirty page batch stable 化，并在 commit 后推进 WAL truncation frontier”
3. WAL 在所有 bucket 间共享；各 bucket checkpoint 轮次独立，但写入 WAL checkpoint 时只做 frontier 单调前推，不回退

### 与 `pending_retire` 的关系

1. 语义不变
2. retire 的来源从 arena junk 改成“durable page addr 被新 image 替换”
3. stage 与 page map publish 必须同 txn 提交，禁止“新 mapping 可见但 retire intent 丢失”
4. pending key 采用 `(bucket_id, kind, retire_id)`，其中 `kind` 的意义仅是区分 `Data/Blob`
5. 当前实现 `retire_id` 由 flush batch id 提供（唯一且单调，不复用）；唯一性语义保持不变
6. recover/apply/clear 维持现有幂等 contract：按 bucket+kind 聚合去重后 apply，`NotFound` 仅在 bucket 已删除（`pending_del` 或 bucket meta 缺失）时允许 clear

### 与 `pending_sibling` 的关系

1. 本方案移除 `pending_sibling`
2. 原因：dirty page 按 `pid` 收集并在同轮 checkpoint 一起刷盘，不再存在 base/delta 的 blob addr 指向“未刷盘 sibling 数据”的窗口
3. 前提：单轮 checkpoint 只做一次 manifest publish 与一次 WAL checkpoint 更新

### 与现有 cache/evictor 的关系

1. evictor 从“重写并产出 durable frame”改成“驱逐 resident dirty page 之前要求它先 durable”
2. cache 仍缓存 resident page

## 8. 配置项建议

建议仅新增与 checkpoint 触发相关的硬约束，不新增可绕开安全语义的旁路开关。

1. `checkpoint_dirty_bytes_high_watermark`
   - 内存压力触发 cleaner
2. `checkpoint_wal_bytes_high_watermark`
   - WAL 长度触发 cleaner
3. `checkpoint_period_ms`
   - 定时触发
4. `checkpoint_target_file_size`
   - 单个 data file 的目标大小
5. 禁止提供“跳过 manifest commit 后直接清 dirty”的调试开关

## 9. 可观测性

建议新增但默认不作为设计成立前提：

1. `dirty_page_count`
2. `checkpoint_frozen_page_count`
3. `checkpoint_pages_per_batch`
4. `checkpoint_bytes_per_batch`
5. `pending_retire_backlog`
6. `wal_pinned_by_checkpoint`

## 10. 性能影响与约束

### 热路径

1. `link/update/delete`
   - 少了 durable frame alloc
   - 多了 dirty bit 更新
2. `get/scan`
   - 目标保持当前 `base + delta` 访问形态
   - 不允许引入多层 run merge

### 慢路径

1. checkpoint 会承担 page image materialization 成本
2. cleaner 需要维护 `dirty_page_set` 与 checkpoint batch swap

### 预期收益

1. data file 只含当前有效 page image
2. compact 生成的中间产物不再直接进入 durable 路径
3. file 数量和 data bytes 应主要由有效 page 数决定，而不是由 compact 次数决定

### 验收阈值

1. 随机写 workload 下，data file 数量不得随着 compact 次数线性上升
2. `get/scan` p99 回退不超过 5%
3. checkpoint 轮次在持续写入下仍能稳定 drain frozen dirty batch，不允许出现 checkpoint 永远无法完成

## 11. 失败与边界场景

1. checkpoint 期间同一 `pid` 再次写脏
   - 必须生成新的 append-only page version
   - 并重新进入当前 live dirty set
   - 该 pid 的 `page_lsn` 不得后退
2. frozen pid 在 build 前发现当前 `page_lsn <= last_checkpoint_lsn`
   - 允许直接从本轮 frozen snapshot 删除，不参与本轮 build
3. frozen pid 在 build/publish 前发现当前 `page_lsn` 已被并发写前移
   - 可选优化：当且仅当 `current_lsn > snapshot_lsn` 时允许跳过该 pid，并从本轮 frozen snapshot 删除；不重算本轮 frontier，按新 `page_lsn` 参与后续轮次
4. data sync 成功但 manifest commit 失败
   - checkpoint 不前推
   - 新文件作为 orphan 处理
5. dirty page unload/delete
   - unload 前必须保证该页已 durable 或返回 `Again`
   - delete 仍遵守两阶段 bucket delete
6. long txn 钉住 WAL
   - 仍使用现有 `max_ckpt_per_txn` 约束
   - dirty-page checkpoint 不能绕过事务可见性边界
7. swap 与并发写交错
   - 并发新写对应更大的 `page_lsn`，不会导致已确认数据丢失
   - 本轮只保证 frozen snapshot 闭包；并发新增 dirty 由后续轮次推进
8. `pending_retire` 回放遇到 `NotFound`
   - bucket 已进入 `pending_del` 或 bucket meta 缺失：允许 clear
   - bucket 仅 unload/暂时不在内存：不得 clear，必须重试

## 12. 分阶段落地计划

### phase A: metadata and checkpoint barrier

1. 为 resident page 引入 `dirty`, `page_lsn`
2. 增加 `dirty_page_set`
3. checkpoint 增加 `swap(dirty_page_set, empty_set)` 与 `tmp_last_checkpoint_lsn` barrier
4. 即使仍保留旧 flush 路径，也必须先把 checkpoint 推进顺序改成“manifest commit 之后再写 WAL checkpoint”

退出门槛：

1. dirty tracking 和 checkpoint barrier 可验证
2. checkpoint 只在 manifest commit 后推进

### phase B: page-based flush path

1. 新增 page-image builder
2. flush 从 arena 枚举改成 dirty page 枚举
3. `pending_retire` 从 page publish 路径产生，`pending_sibling` 路径移除

退出门槛：

1. 新 publish 路径闭合
2. 旧 arena-based publish 不能再绕开新 barrier

### phase C: resident-only foreground mutations

1. `link/update/delete/compact/smo` 不再产生 durable frame
2. `BaseView::try_new_*` 从前台路径移出，只保留给 checkpoint image builder

退出门槛：

1. 前台无 direct-to-durable 旧路径
2. dirty evict 与 recovery 正确

### phase D: retire old arena model

1. 清理旧 arena runtime 依赖
2. 保留仅必要的 durable file builder 组件

退出门槛：

1. performance/correctness 均过验收
2. 旧路径删除，不再并存

## 13. 测试矩阵建议

### unit

1. dirty page 首次写入只入 live dirty set 一次
2. checkpoint swap 后，新写脏的同一 `pid` 会重新进入 live dirty set
3. clean 标记必须走 `NodeState` 的 dirty-CAS（`dirty=true -> false`），CAS 失败不得清 dirty
4. `page_lsn` 规则正确：写入发生在新 page version header，dirty 周期内不后退，旧版本不被原地改写
5. `tmp_last_checkpoint_lsn` 生成正确（按 `Position` 排序后取最小值）
6. `Position` 跨文件比较正确（`wal_file_id` 优先于 `wal_file_offset`）
7. compact 不生成新的 WAL LSN，也不会改变 checkpoint 语义
8. `pending_retire` 幂等 apply/clear
9. `pending_retire` key 唯一性正确（`bucket_id + kind + retire_id`），不同 bucket/kind 不得互相覆盖
10. `pending_retire` 的 `NotFound` 清理守卫正确（仅 deleted bucket 可 clear）

### integration

1. random update-heavy workload 下，checkpoint 后 data file 只包含当前有效 page
2. checkpoint 轮次能稳定 drain frozen dirty batch 并推进 WAL 回收
3. swap 与并发写交错时，`last_checkpoint_lsn` 不得超过 live dirty set 的最小 `page_lsn`
4. frozen pid 采用 skip 优化时，被 skip 的 pid 必须满足 `current_lsn > snapshot_lsn`，且本轮不重算 frontier
5. clean/dirty evict 行为正确
6. unload/delete 与 dirty page 的交互正确
7. `last_checkpoint_lsn` 单调推进且不回退
8. `pending_retire` 在 bucket unload 与 bucket delete 两类 `NotFound` 场景下行为不同：前者重试、后者可清理
9. 存在活跃事务时，`ckpt_pos` 按 `active_txns.min_lsn` 裁剪，WAL 不得推进到其 rollback 之后
10. 单个 bucket 的单轮 checkpoint 即使写入多个 data/blob file，也只执行一次 manifest publish 与一次 WAL checkpoint 更新

### failpoint/crash

1. `after_data_sync`
2. `before_manifest_commit`
3. `after_manifest_commit`
4. `after_manifest_commit_before_wal_checkpoint`（`mace_flush_after_manifest_commit_before_wal_checkpoint`）
5. `retire_after_apply_before_clear`

### recovery

1. checkpoint 前 crash
2. checkpoint 中 crash
3. checkpoint 后 crash
4. `pending_retire` 重放幂等
5. `pending_retire` 的 `NotFound` 分支符合 `pending_del/meta-missing` 判定

## 14. 与替代方案对比

### 对比 arena 优化路线

1. arena 优化只能减少中间垃圾进入 flush 的概率
2. 无法改变 durable 单元仍然是 frame 的事实
3. 因此前台 compact 仍会放大 data file

### 对比 resident-snapshot 路线

1. resident-snapshot 的主要问题是内存受限时会把 page snapshot 批次打碎
2. 本方案不要求维护 page-level lsn frontier，也不要求 oldest dirty page 选择
3. 它只在 checkpoint 开始时交换 dirty set，并刷完 frozen batch

### 对比“全量刷所有 dirty page”

1. 全量同步阻塞式 flush 所有 dirty page 会放大前台停顿
2. 本方案通过 dirty set swap 把“当前批次”和“后续新脏页”隔离开
3. 前台可继续写入，下一轮 checkpoint 再处理新的 dirty set

## 15. 实施顺序建议

1. 先引入 `dirty_page_set + append-only page version + ckpt_lsn barrier`
2. 再实现 page-based checkpoint publish
3. 然后把前台 compact/link 从 durable frame 路径切走
4. 最后移除旧 arena runtime 依赖

原因是：

1. 先立 barrier 和 dirty contract，保证 crash-closure 不先破
2. 再切 publish 单位，保证 data file 语义先对齐
3. 最后再切前台热路径，避免“新脏页语义 + 旧持久化语义”并存过久
