# 页面延迟序列化方案 (Delayed Serialization)

## 1. 目标与非目标

### 目标
- 彻底消除 `CheckpointTask::snapshot` 与前台 `replace/evict/dealloc` 因中间态物理页并发交错导致的缺页、半截快照和生命周期错位问题
- 让 `compact/merge/split` 产生的中间态 `base/sibling/junk` 在发布前不进入 `dirty_pages`，从根上减少 Arena 压力、脏页膨胀和写放大
- 明确 `remote` 在 `delta/base/sibling` 三处出现时的可达性和所有权边界，避免继续依赖 `dirty_pages` 清理时序或 `Loader::pinned` 兜底
- 保持现有 `data_junk/blob_junk -> apply_*_junks -> GC rewrite` 的持久化垃圾回收协议，但 retire metadata 不再编码进 `BaseHeader::junk_addr` 或物理 junk page chain

### 非目标
- 不修改 `Delta` 和 `Remote` 的即时分配格式，`src/types/delta.rs` 里的 large value remote 仍保持即时 `BoxRef` 分配
- 不修改 `Manifest` 的 data-first, meta-last 发布顺序
- 不修改 `data_junk/blob_junk -> DataStat/BlobStat -> GC rewrite` 的持久化协议，只调整前端对象何时进入该协议
- 允许删除 `BaseHeader::junk_addr`，并相应删除“retire metadata 必须挂在 base page 上”的旧假设
- 不要求一次性回退所有旧补丁，只有在新模型闭合后才撤销对应的兜底逻辑

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

### 2.1 中间态页过早绑定物理地址

当前 `compact/merge/split` 生成的新 `base/sibling/junk` 会先分配 `addr` 并进入 `dirty_pages`，之后才由 checkpoint 决定是否写盘。这样会产生两个后果：
- `snapshot` 先拿到 `latest_addr`
- 前台又做一次 `replace/evict`，把旧中间态页从 `dirty_pages` 清掉
- `snapshot` 再沿链访问时遇到缺页，旧实现是 panic，后来的兜底则容易形成不完整闭包

这就是 `src/map/data.rs` 中各种 `must exist`、`continue`、`Again` 补丁的根因。

### 2.2 `dirty_pages` 的清理条件和 live 可达性脱钩

当前 `CheckpointTask::done` 对 `dirty_pages` 的删除依据主要是 checkpoint 前沿，而不是“是否仍被 live Node 间接引用”。  
这对 `remote` 和 `sibling` 尤其危险，因为它们未必出现在本轮 `dirty_pid -> latest_addr` 主链上，但仍可能被：
- `Iter`
- `compact/merge_to_base`
- leaf rebuild
- large value load

间接访问。

### 2.3 `remote` 的“编码位置”和“生命周期拥有者”被混淆

按当前实现：
- `delta` 里的 remote 在 [src/types/delta.rs](/home/workspace/gits/github/mace/src/types/delta.rs) 里即时分配
- `base/sibling` 里的 remote 会把逻辑 `addr` 编码进 value；leaf base 页尾的 sibling/remote hints 只是进程内附加 payload，flush dump 时会被截断，不进入文件，见 [src/types/base.rs](/home/workspace/gits/github/mace/src/types/base.rs) 和 [src/types/refbox.rs](/home/workspace/gits/github/mace/src/types/refbox.rs)
- `Node::save` 只保证 delta 及其 remote 在 delta 仍 live 时被 pin，见 [src/types/node.rs](/home/workspace/gits/github/mace/src/types/node.rs)

一旦 remote 从“delta owner”迁移到“resident base/sibling owner”，如果没有新的内存 owner，仅保留 `addr` 不足以保证未持久化 remote 的存活。

### 2.4 现有补丁方向复杂且热路径代价高

围绕这个问题已经出现过几类补丁：
- `pin_leaf_pages`
- `aux_refs`
- `collect_aux_addrs`
- sibling/remote hint 补洞
- `dirty_pages` 清理兜底

这些补丁都在试图修复“中间态物理页既要可访问，又可能被 checkpoint/replace 并发清理”的旧模型。  
如果继续沿这个方向演进，复杂度和热路径成本会持续上升。

### 2.5 当前 cache 记账口径会在 resident 化后失真

当前 bucket 上层 cache 的记账来自 `Page.size()`，见 [src/map/buffer.rs](/home/workspace/gits/github/mace/src/map/buffer.rs) 和 [src/types/node.rs](/home/workspace/gits/github/mace/src/types/node.rs)。  
这个口径近似等于“当前 Node 挂着的物理页大小”，但 delayed-serialization 引入 resident base/sibling 与 runtime retire owner 后，真正占用上层 cache 的将变成一批 heap 对象。

如果继续沿用现有口径：
- resident heap buffer 不会计入 `cache_capacity`
- evictor 会系统性低估上层 cache 占用
- `cache_capacity` 这个配置项会部分失效

因此，`NodeCache` 记账切换到 retained-bytes 不是优化项，而是 delayed-serialization 成立的必要条件。

## 3. 必守不变量

- `PageMap` 指向 `Pointer Swip` 时，Node 的全部 live leaf 辅助对象必须由 `Arc<Node>` 可达，不能再依赖 `dirty_pages` 保活
- `PageMap` 指向 `Tagged Swip` 时，该 `addr` 对应的 `base/sibling` 闭包必须已经全部 materialize 到 Pool；退休地址不再挂在 base 上，而是由与该 artifact 绑定的 runtime retire owner 托管
- `remote` 若尚未 durable，则必须有明确的内存 owner 持有对应 `BoxRef`；仅有逻辑 `addr` 不算闭合
- `remote` 若已经 durable，则只保留逻辑 `addr` 即可，不需要继续由 live Node 持有 `BoxRef`
- evict 只要求把 live Node 转成“脱离 Node 后仍可通过 `Tagged Swip` + `Loader` 访问”的 `Addressable` 闭包，不要求当场 durable
- 一旦 evict 完成并开始回收旧 `Node`，后续 reload 不能再依赖旧 `loader.pinned`
- resident -> addressable 不能原地修改一个对外可见的 live `Node`；读者只能看到“旧 resident snapshot”或“新的 addressable snapshot”
- checkpoint 若只拿 resident snapshot 去 flush，而 live `Node` 继续保留 resident 形态，则成功发布后必须有显式 ack 把“这次已经 durable 的 remote/junk/fallback”回写到未变更的 live `Node`
- 只有已经 durable 的退休对象才进入 `data_junk/blob_junk` 协议；未 durable 的临时对象只能靠最后一个内存 owner drop 回收
- checkpoint 只能发布完整闭包，不能发布“base 已写、部分 sibling/remote/junk 未写”的半截快照
- evictor 的内存控制必须针对“随 `Node` 一起释放的上层内存”，不能只统计物理页大小
- `cache_capacity` 必须继续对上层 cache 有效，因此 `NodeCache` 的计费口径必须从物理页大小切换到 retained-bytes
- 必须继续保持 data-first, meta-last

## 4. 控制模型总览

### 4.0 两层 cache 与 evictor 的真实职责

当前运行时实际上有两层 cache：
- 下层 cache：bucket 上下文的 LRU，保存从文件读取回来的 `BoxRef`
- 上层 cache：`PageMap` 指向的 `Pointer Swip` 对应的 live `Node`，以及这些 `Node.loader.pinned` 持有的 `BoxRef`

evictor 只负责控制上层 cache 的内存占用。  
它的目标不是“把 Node 持久化”，而是：
- 在回收 `Handle<Node>` 之前，先准备好一个不依赖该 `Node` 的 `Addressable` fallback
- 这样 `Node` 析构后，`loader.pinned` 随之释放，上层内存才能真正下降

dirty page 最终总会由 checkpoint 处理，因此 evictor 不承担 durable publish 责任。

这里还要补一条显式记账规则：

- `Loader::pinned` 只表示“当前 live Node 额外持有了一份引用”，不单独形成新的预算维度
- 同一个 `BoxRef` 只能归属一条预算线，不能因为同时被 `pinned` 再重复计费
- 若一个 `pinned` 页同时存在于 `Pool.pages`
  - 只计入 `active_bytes`
  - 不计入 `cache_capacity`
- 若一个 `pinned` 页同时存在于下层 LRU/file cache
  - 只计入下层 cache 的记账
  - 不计入 `cache_capacity`
- `cache_capacity` 只统计“没有被 `Pool.pages` 或下层 cache 独立计费，且在 evict/drop live Node 后会随之下降”的 retained bytes

换句话说：

- `pinned` 决定生命周期可达性
- `Pool.pages` / 下层 cache / resident heap owner 决定计费归属

### 4.0.1 三条独立的内存控制线

delayed-serialization 落地后，运行时内存不再由单一阈值控制，而是由三条独立控制线协同：

- `cache_capacity`
  约束上层 cache，也就是 live `Node` 及其 retained resident memory
- `max_mem_size` / `max_log_size`
  约束 `dirty_pages` 与 WAL backlog，并触发 checkpoint
- `backpressure`
  在 flush debt 累积时减慢前台写入，防止 checkpoint 长期追不上

这三条线的职责必须保持分离：
- resident memory 压力先交给 evictor
- dirty backlog 压力交给 checkpoint
- flush 队列压力交给 backpressure

不能把“resident heap 变大”直接等同于“必须 checkpoint”，否则会让 checkpoint 被错误地当成上层 cache 回收器。

### 4.1 三种对象状态

本方案将“是否有物理地址”和“是否已经 durable”分开：

| 状态 | 是否有 `addr` | 是否可通过 `Loader` 访问 | 是否已经 durable |
| :--- | :--- | :--- | :--- |
| `Resident` | 否 | 否，必须通过 `Arc<Node>` 访问 | 否 |
| `Addressable` | 是 | 是，命中 `dirty_pages` 或 `pinned` | 否 |
| `Durable` | 是 | 是，命中文件或缓存 | 是 |

### 4.2 对象分类

| 对象 | 旧模型 | 新模型 |
| :--- | :--- | :--- |
| delta page | 直接 `Addressable` | 保持不变 |
| remote page | 直接 `Addressable` | 保持不变 |
| compact 产物 base | 直接 `Addressable` | 改为先 `Resident`，checkpoint/evict 时再 materialize |
| compact 产物 sibling | 直接 `Addressable` | 改为先 `Resident`，checkpoint/evict 时再 materialize |
| retire metadata | 直接挂在 `BaseHeader::junk_addr` / junk page chain | 改为先在 Node / runtime artifact 内存中积累退休意图，checkpoint 时直接产出 `data_junk/blob_junk` |

### 4.3 Node 的所有权边界

`Node` 需要显式区分 durable 可寻址对象和 resident 内存对象，同时还要兼容当前 [src/types/page.rs](/home/workspace/gits/github/mace/src/types/page.rs) 的 `Page::ref_node()` / `Node::reference()` 按值复制模型。概念上应扩展为：

```rust
enum BaseStorage {
    resident(ResidentRef),
    addressable(BoxRef),
}

struct ResidentRef(Arc<ResidentNode>);

struct ResidentNode {
    base: ResidentBase,
    aux: ResidentLeafAux,
}

struct ResidentLeafAux {
    resident_siblings: Vec<ResidentSibling>,
}

struct ResidentRuntime {
    resident_remotes: Vec<BoxRef>,
    durable_remote_hints: Vec<u64>,
    resident_retired_addrs: Vec<u64>,
}

struct NodeState {
    resident_runtime: ResidentRuntime,
}

struct PinnedSet {
    map: DashMap<u64, BoxRef>,
    bytes: AtomicUsize,
}

struct ArtifactRetireState {
    retired_addrs: Vec<u64>,
}
```

其中：
- `resident_siblings` 只保存尚未 materialize 的 sibling
- `resident_remotes` 只保存“尚未 durable、但已经被 resident base/sibling 引用”的 remote `BoxRef`
- `durable_remote_hints` 保存“当前进程内已经确认 durable 的 remote addr 缓存”，它不是磁盘格式的一部分
- `resident_retired_addrs` 只保存“已经 durable、但当前 resident generation 已不再引用，且尚未被本次 checkpoint 提交进 `data_junk/blob_junk`”的退休地址，其中 remote 继续沿用 tagged addr 语义
- `ArtifactRetireState` 是 addressable runtime artifact 的退休 sidecar，它是纯内存元数据，不进入 page dump，不要求恢复后保留
- `ResidentRef` 必须是共享只读 backing，`Node::reference()` 只能克隆引用，不能深拷贝 resident payload
- 任何会修改 resident 内容的路径都必须生成新的 `ResidentRef`，不能原地修改当前 backing
- `resident_remotes` / `durable_remote_hints` / `resident_retired_addrs` 属于 live runtime owner，而不是 `ResidentRef` 的一部分
- `PinnedSet` 是 `Loader::pinned` 的运行时实体，live write-path 的 `copy_with_pin()` 可以继续共享它
- `Node::reference()` / `Page::ref_node()` 保持轻量 reader snapshot 语义，只共享 resident backing 与 live addressable pin-set，不在 hot path 上重建整节点 closure
- checkpoint / evict 需要独立冻结 source 时，必须走显式 `freeze_for_materialize()` 一类 helper：先 `copy_without_pin()`，再只为 materialize 重建独立 pinned closure

### 4.4 `remote` 的可达性规则

`remote` 可能出现在三处：
- `delta`
- `base`
- `sibling`

但谁拥有它，取决于它当前是否 durable，而不是它编码在哪一页里。

规则如下：
- `delta` 里新建的 remote：先由 delta owner 持有，沿用 `Node::save`
- 当 compact/merge 把该版本折叠进 resident base/sibling 时，如果 remote 还未 durable，必须把 `BoxRef` 转移到 `resident_remotes`
- 如果 remote 已经 durable，则 resident base/sibling 只保留 `addr`，并可选择把它记入 `durable_remote_hints` 作为进程内缓存
- leaf materialize 时，内存中的 addressable base 页仍可写入页尾 sibling/remote hints，以加速同进程内的后续访问
- 但这些 hints 不会持久化到文件；冷加载后的 durable leaf 若需要恢复 remote/sibling 可达性，必须回到已持久化的 `Val` 编码与 sibling 链本身去重建

另外还需要兼容当前 [src/types/data.rs](/home/workspace/gits/github/mace/src/types/data.rs) 的 `Val::get_record()` 路径。  
当前 remote 读取只有：
- `addr -> Loader::load_remote()`
- `addr -> Loader::load_remote_uncached()`

因此对“尚未 durable，但已被 resident base/sibling 引用”的 remote，不能只把 `BoxRef` 放在 `resident_remotes` 里，还必须同时插入共享 `PinnedSet`：
- 这样现有 `Val::get_record()` 无需新增第三条 resident-remote 读取路径
- evict 释放旧 `Node` 时，只有在新的 addressable fallback 已经准备好后，旧 `PinnedSet` 才允许随之释放

### 4.5 `addr_head` 与 delta 链语义

当前 [src/types/node.rs](/home/workspace/gits/github/mace/src/types/node.rs) 的 `NodeState.addr` 语义是“当前物理链头”，`Delta.link = state.addr` 也建立在这个前提上。  
一旦 base 允许进入 resident 无地址状态，这个语义就必须重构。

概念上应拆成：

```rust
struct NodeState {
    addr_head: u64,      // head of addressable prefix, NULL when no addressable prefix
    total_size: usize,
    max_txid: u64,
    group: u8,
    latest_lsn: Position,
    delta: ImTree<DeltaView>,
}
```

并配套以下规则：
- `addr_head` 只表示 addressable 前缀链的头，不再等价于“整个 Node 的 head”
- 若当前 base 是 resident，且还没有 addressable delta 前缀，则 `addr_head = NULL_ADDR`
- `Node` 需要额外提供 `addressable_anchor()` 语义：
  - 若存在 addressable delta 前缀，则返回 `addr_head`
  - 否则若存在 `sealed_head`，则返回 `sealed_head`
  - 否则返回 `NULL_ADDR`
- 新 delta 插入时：
  - `delta.link = addressable_anchor()`
  - 然后 `addr_head = delta.addr`
- `latest_addr()` 这种默认返回 `u64` 的接口不能继续沿用旧语义，应拆成：
  - `addressable_head() -> Option<u64>`
  - 或 `is_fully_addressable() + head_addr()`
- `base_addr()` 只对 addressable/durable base 有意义
- `collect_junk()` 不能再默认把 resident base 的 `base_addr()` 作为退休地址；resident 路径的 durable retire 应来自 `resident_retired_addrs`

### 4.5.1 `sealed_head` 与 checkpoint ack

仅有 `addr_head` 还不够。  
因为按 [src/map/data.rs](/home/workspace/gits/github/mace/src/map/data.rs) 和 [src/store/store.rs](/home/workspace/gits/github/mace/src/store/store.rs) 的现有流程，checkpoint 可以先把 resident node materialize 成一条一次性的 flush snapshot，再把其 `PageTable` 写入 manifest，而 live `PageMap` 里的 `Pointer Swip` 仍然保持不变。

这意味着 live `Node` 还需要一份“最近一次已经 durable 的完整 fallback”元数据，例如：

```rust
struct MaterializedSeal {
    seq: u64,
    head_addr: u64,
}
```

规则如下：
- `seq` 对应 resident backing 的 generation，任何会改变 resident base/sibling 的操作都必须递增 generation，并清空旧 `sealed_head`
- checkpoint manifest commit 之后的 runtime-owner shrink 只会修改 `resident_remotes` / `durable_remote_hints` / `resident_retired_addrs` / `sealed_head`，不会推进 `seq`
- checkpoint/evict 生成 `MaterializePlan` 时带出当前 `seq`
- checkpoint manifest commit 成功后，只有当：
  - `PageMap[pid]` 仍指向 `ack.pointer_swip`
  - 且 live `Node` 的当前 `seq` 仍与 plan 一致
  才允许安装新的 `sealed_head`
- 若 live resident backing 仍被其他 reader/shared snapshot 持有，ack 不得原地修改这份 backing；必须替换成新的 resident backing（例如 copy-on-write），再收缩当前 generation 的 runtime owner
- 安装 `sealed_head` 时，要一并把本次已经 durable 的 `resident_remotes` 转入 `durable_remote_hints`，并把已经提交到本次 manifest 的 `resident_retired_addrs` / `ArtifactRetireState.retired_addrs` 从 live runtime owner 中清掉
- 若 `seq` 已变化，说明 live `Node` 在 flush 期间又被改过；此时只保留本次 flush 产物作为“恢复用的旧 durable snapshot”，但不回写 live `Node`

因此：
- `addr_head` 继续表示 live addressable delta 前缀
- `sealed_head` 表示“当前 resident snapshot 最近一次已经 durable 的完整 fallback”

后续 `simple_evict` 可以复用 `sealed_head`，不必因为 live node 仍是 resident 就每次重新 materialize。

### 4.6 “是否已经 durable”的判定

对一个有 `BoxHeader` 的 page，可复用当前 checkpoint 里的 flush 判定：

- 若 `h.lsn < last_chkpt_lsn[group]`，则它已经 durable
- 若 `h.lsn == last_chkpt_lsn[group] && h.addr <= stablized_addr`，则它已经 durable
- 其他情况都视为未 durable

这与 [src/map/data.rs](/home/workspace/gits/github/mace/src/map/data.rs) 里 `collect_remote_page` 的 flush 条件一致。  
delayed-serialization 落地后，这个判定只用于“owner 迁移时要不要保留 `BoxRef`”，不再用于补 `dirty_pages` 生命周期漏洞。

这里还要固定一条更强的热路径约束：

- 对已经确认是 live reachable 的现有 logical addr，owner transfer / live reachability / checkpoint materialize 不允许为了判定 durable 再走文件读取
- 这些路径只需要区分两类状态：
  - `addr <= stablized_addr`
    - 直接视为 durable
  - `addr > stablized_addr`
    - 直接视为 undurable，并且必须只能从本地 live owner 取页
    - 来源只允许是 `resident_remotes`、共享 `PinnedSet`、或 `Pool.pages`
    - 不允许退回到 `Loader::load*()` 的文件路径去“确认一下”

换句话说：

- `last_chkpt_lsn + stablized_addr` 仍是带 `BoxHeader` 时的等价断言
- 但对热路径上的“已有 addr，要不要继续持有 BoxRef”这个问题，`addr <= stablized_addr` 已经足够，不需要额外 load page header

另外，leaf rebuild / resident materialize 在重写一个已经编码成 remote 的 `Val` 时，必须直接复用它已有的 `{flags, len, addr}` 元数据：

- 不允许为了把同一个 remote addr 再写回 base/resident value，就先把 remote payload 整体 `load_remote()`
- 这条约束同样适用于 resident leaf build、sibling rebuild 和 checkpoint materialize
- 大 value 的主流路径必须保持“重写引用，不重读 payload”

### 4.6.1 `resident_retired_addrs` 的编码与 durable 来源

`resident_retired_addrs` 是 resident generation 持有的“待持久化退休集”，它只记录已经 durable 的对象：

- data/base/sibling page 使用原始 `addr`
- remote/blob page 使用 `RemoteView::tag(addr)`
- 建议实现为有序去重的 `Vec<u64>`，便于 compact/checkpoint collect 时线性 merge，避免引入额外哈希热路径

一个地址是否有资格进入 `resident_retired_addrs`，只允许来自以下来源：

- 来自磁盘回读的 durable base/sibling/remote
- 当前 generation 已安装的 `sealed_head` 所代表的完整 durable fallback
- 旧 generation 继承下来的 `resident_retired_addrs`
- 仍处于 addressable 前缀中的旧页，但经 `last_chkpt_lsn + stablized_addr` 判定已 durable

反过来，以下对象不得进入 `resident_retired_addrs`：

- 仅存在于 resident heap 的新 base/sibling
- 仍只存在于 `Pool.pages`、尚未 durable 的 delta/remote
- 任何只能依赖最后一个内存 owner drop 回收的未发布或未持久化地址

### 4.6.2 `resident_retired_addrs` 的维护公式

对一次 `old -> new` 的 compact/merge/split，新的 resident generation 应按下面的方式计算退休集：

```text
inherited_retired = old.resident_retired_addrs
old_durable_reachable = collect_durable_reachable(old)
new_durable_reachable = collect_durable_reachable(new)
newly_retired = old_durable_reachable - new_durable_reachable
next_retired = dedup_sort(inherited_retired ∪ newly_retired)
```

其中：

- `collect_durable_reachable(old)` 只收集 old generation 当前仍引用的 durable 地址
- `collect_durable_reachable(new)` 只收集新 generation 仍保留的 durable 地址
- 对刚构造出来的 resident generation，新的 resident base/sibling 本身还没有 `addr`，因此绝不能出现在 `new_durable_reachable` 里
- 对 resident generation 来说，`new_durable_reachable` 通常只来自：
  - 继承保留的 `durable_remote_hints`
  - 或对当前保留的 durable leaf/sibling payload 做一次显式扫描后得到的 remote/sibling 地址
  - 仍留在 addressable 前缀里且已 durable 的旧 delta/base
- 对“发生了逻辑修改而新建出来的 generation”，旧 `sealed_head` 必须先被清空，因此默认不应把它算进 `new_durable_reachable`
- 只有在“未发生逻辑修改，只是复用已有 sealed resident snapshot”的场景下，`sealed_head` 才可被视为 `new_durable_reachable` 的一部分
- `old` 和 `new` 都按同一种编码收集：
  - data/base/sibling 用原始 `addr`
  - remote 用 `RemoteView::tag(addr)`

需要特别注意两点：

- 这个集合不追踪“未 durable 但已被替换”的地址；这类对象只能走 owner drop/reclaim
- 对 branch node 不存在 remote，因此 `collect_durable_reachable()` 只需要处理 data page 地址
- 对 leaf split，继承下来的 `resident_retired_addrs` 与本次 `old_durable_reachable - new_durable_reachable` 统一挂到 `lhs/original pid` 这一侧
  - 因为 split 之后旧 pid 继续承载 lhs，rhs 是新 pid
  - 这样退休意图只有一份 owner，不需要在两个 child 之间复制或做额外去重协议

### 4.6.3 resident / artifact 通用 retire owner 继承公式

上面的公式还需要推广成“对所有 live runtime owner 都成立”，而不是只对 resident leaf 成立。

对一次任意 `old_owner -> new_owner` 的重写，真正的继承源应定义为：

```text
inherited_retired =
    old.resident_retired_addrs
  ∪ old.artifact_retire_state.retired_addrs

newly_retired = old_durable_reachable - new_durable_reachable
next_retired = dedup_sort(inherited_retired ∪ newly_retired)
```

其中：

- `old.resident_retired_addrs`
  - 只在 `old` 是 resident generation 时存在
- `old.artifact_retire_state.retired_addrs`
  - 只在 `old` 是 live addressable artifact 时存在
  - 它不是 leaf 专属，也不是 resident 专属
  - internal compaction / replace / evict 之后的 live addressable artifact 同样可以持有它
- `old_durable_reachable` / `new_durable_reachable`
  - 继续按 node 类型区分：
    - leaf 处理 data + tagged remote
    - internal 只处理 data page 地址

必须把下面 4 条转移链都写死：

1. resident -> resident
   - `next_retired` 进入新的 `resident_retired_addrs`
2. resident -> artifact
   - `next_retired` 进入新 `ArtifactRetireState.retired_addrs`
3. artifact -> resident
   - reload/compact/split/replace 前，必须先把旧 `ArtifactRetireState.retired_addrs` 并入 `inherited_retired`
   - 然后把 `next_retired` 写入新的 `resident_retired_addrs`
4. artifact -> artifact
   - 旧 `ArtifactRetireState.retired_addrs` 必须在 old artifact retire/reclaim 前并入新 artifact
   - 不能依赖 old artifact drop 后“反正下轮 checkpoint 会重新猜出来”

如果这一条没落地，删掉 `junk_addr/junk chain` 之后，addressable artifact 在 checkpoint 前再次被改写时，退休信息仍然会丢。

## 5. 关键时序

### 5.1 写入与 compact

1. `insert/update`
   - delta 和 remote 仍即时分配为 `Addressable`
   - `Node::save` pin 住 delta 和 remote
2. `compact/merge/split`
   - 不再为新 `base/sibling` 分配物理页，也不再为 retire metadata 分配 junk page
   - 直接构建 resident base/resident siblings
   - 对每个被保留的 remote：
     - 若已 durable，只记录 `addr` 到 `durable_remote_hints`
     - 若未 durable，把 `BoxRef` 放入 `resident_remotes`
   - 对被淘汰的旧 durable page/remote，地址并入 `resident_retired_addrs`
   - 对被淘汰的旧未 durable page/remote，不进 `resident_retired_addrs`，只在最后一个 owner drop 时回收

#### `resident_retired_addrs` 的增删改查

它的维护需要显式区分 4 类操作：

- 新增
  - compact/merge/split 生成新 resident generation 时，按 `old_durable_reachable - new_durable_reachable` 增量加入
- 继承
  - 旧 generation 已经积累的 `resident_retired_addrs` 直接并入新 generation
- 查询
  - checkpoint 只读取当前 generation 的 `resident_retired_addrs` / `ArtifactRetireState.retired_addrs`，直接产出本轮 `data_junk/blob_junk`
- 删除
  - 只有 manifest commit 成功，且 `pointer swip + seq` 命中当前 live generation 时，才从 live resident 状态里删除对应地址

这里的删除不能提前到“flush 成功但 metadata 未提交”之前。  
原因是 `resident_retired_addrs` 的职责本质上是“尚未 durably 进入 `data_junk/blob_junk` 协议的退休地址集合”，只有 manifest 已提交，这批退休记录才算真正落盘。

#### 多次 compact 后旧 durable 部分如何退休

这是 delayed-serialization 必须显式闭合的关键时序：

1. `G0`
   - 某个 leaf 已经 durable，可能来自冷加载，也可能来自上一轮 checkpoint 后安装的 `sealed_head`
2. `G0 -> G1`
   - 前台 compact 生成新的 resident generation `G1`
   - `G0` 中已经 durable、但 `G1` 不再引用的地址，进入 `G1.resident_retired_addrs`
3. `G1 -> G2`
   - 再次 compact 时，`G1.resident_retired_addrs` 直接继承到 `G2`
   - 同时把 `G1` 相对 `G2` 新退休掉的 durable 地址继续并入
4. checkpoint 处理 `G2`
   - 直接把 `G2.resident_retired_addrs` 收集到本轮 `data_junk/blob_junk`
   - manifest commit 成功后，这批地址才真正进入 `data_junk/blob_junk -> stat -> GC` 协议

因此在“多次 compact 后才 checkpoint”的场景里，退休信息不会丢，只会在 resident generation 之间传递并累积。

#### 为什么崩溃后不会丢退休记录

如果在 `replace cursor_ptr`、compact、甚至 checkpoint flush 完成之后但 manifest 提交之前崩溃：

- `resident_retired_addrs` 作为纯内存状态会丢失
- 但这并不会造成错误回收
- 因为恢复只能看到上一次已经 durable 的 manifest/page table
- 对恢复视角来说，那些旧 durable 地址仍然属于旧 snapshot 的 live 数据，而不是垃圾

也就是说：

- 崩溃发生在“退休记录 durable 之前”
  - 结果只是延后 GC
  - 不会把仍然可能被恢复路径引用的地址提前回收
- 崩溃发生在“manifest 已提交之后”
  - retire apply 与新的 page table/interval/stat 一起 durable
  - 恢复后退休记录已经体现在 durable stat/mask 中

所以这个模型保证的是：

- 最多漏回收一次 checkpoint 之前刚形成的垃圾
- 不会因为 resident 内存状态丢失而把 durable live 地址误当成垃圾，或把已经 durable 的退休记录丢成不一致状态

### 5.2 scan / Iter

`Iter` 和 leaf rebuild 必须改成显式区分两条路径：
- `Resident` leaf：从 `Node` 内存里的 resident siblings 继续遍历，不能再假设 `Loader::load(addr)` 一定能找到中间 sibling
- `Addressable/Durable` leaf：沿用当前 `base slot -> sibling addr -> loader.load(addr)` 路径

也就是说，`Iter` 的正确实现应该建立在“resident/durable 两态”上，而不是建立在“地址缺了就跳过或重试”上。

### 5.3 resident -> addressable materialize helper

`resident -> addressable` 应实现成 checkpoint 和 evict 共享的 helper，而不是各自拼一套逻辑。  
它的职责只是把当前 Node 闭包转成可通过 `Loader` 命中的地址链，不负责写文件。

处理 resident leaf 时，需要先冻结一个只读 snapshot，再在锁外做 materialize。

当前 checkpoint 实现的冻结方式不是“从 live node 提取一个共享 helper plan”，而是：

1. `snapshot()` 先在 live node lock 保护下对 live pointer-swip 调用 `Page::freeze_node_for_materialize()`
2. `Node::freeze_for_materialize()` 在释放 live node lock 之前复制当前 `NodeState` 并闭合独立 owner
3. resident backing 继续以 `ResidentRef` 共享
4. 随后的 `collect_resident_target()` / `build_checkpoint_base()` 都只读取这个 detached snapshot node

这条路径之所以安全，是因为：

- 已发布的 resident backing 不会再原地修改
- 前台后续写入只会在 live `NodeState` 上追加 delta / 更新 `addr_head`
- 因此 checkpoint snapshot node 看到的是一个自洽的 generation，而不是 live node 的半途状态

如果后续真的把 checkpoint/evict 收敛成“直接从 live node 提取 shared materialize helper”，那时才必须满足下面这条更强约束：

1. 在 node lock 保护下，从旧 `Node` 提取一个只读 `MaterializePlan`
2. `MaterializePlan` 只持有不可变 snapshot：
   - `ResidentRef`
   - 当前 `addr_head`
   - 当前 addressable 前缀所需的不可变页快照
   - pid / group / lsn / flags 等发布元数据
3. 锁外根据 plan 分配 base/sibling 物理页
4. 用 sibling addr 回填 base 的 sibling slot 和 sibling hints
5. 把 `durable_remote_hints` 与 `resident_remotes.addr()` 合并，写入当前内存 base 页的 remote hints
6. 若目标是 `live_pool`，把 `resident_retired_addrs` 转移到新 artifact 对应的 `ArtifactRetireState`
7. 若目标是 `checkpoint_scratch`，直接把 `resident_retired_addrs` 收集进 snapshot 的 `data_junk/blob_junk`
8. 若 plan 里存在 addressable delta 前缀，则不能原地修改旧 delta frame 的 `link`
9. 必须为需要回填 link 的 delta 前缀构造一条新的私有 publish chain，再把它接到新 base 之上
10. materialize 的产物至少是一个新的 addressable 链头 `head_addr`
11. 若目标是 checkpoint scratch，还要额外产出一份只针对该 snapshot 的 `ack token`
12. `ack token` 至少要包含：
   - pid
   - live pointer swip
   - resident generation `seq`
   - 本次写出的 `head_addr`
   - 本次被 durable 化的 remote addr 集合
   - 本次已经提交进 `data_junk/blob_junk` 的 `retired_addrs`

这里 `MaterializePlan` 不能只保存一个 `addr_head`。  
因为一旦离开冻结边界，前台 `replace/evict` 就可能把旧 addressable 前缀从 `Pool.pages` 里摘掉。  
因此 plan 至少还要带上以下两类不可变输入之一：

```rust
enum AddressablePrefixSnapshot {
    none,
    boxed(Vec<BoxRef>),     // cloned immutable frames needed by this plan
    delta_views(Vec<DeltaView>),
}
```

也就是说：
- resident backing 负责解决 resident base/sibling 与 retire owner 的锁外可见性
- `AddressablePrefixSnapshot` 负责解决旧 delta/base addressable 前缀的锁外可见性
- 锁外 materialize 不能再回头依赖 `addr_head -> Pool.pages` 重新查旧页

还需要明确一点：`AddressablePrefixSnapshot` 不能只表达“有哪些地址”，还必须表达“这些地址当前属于哪种来源”。

因为一个 pointer-swip/addressable node 的可达链在运行时可以天然混合：
- 前缀是本轮尚未 durable 的 live delta，仍在 `Pool.pages`
- 尾部是更早已经 durable 的 base / sibling / remote，不在 `Pool.pages`

因此对 checkpoint/materialize 来说，地址解析至少要区分 3 类：

```rust
enum ReachableSource {
    live_pool(BoxRef),   // 本轮仍未 durable，成功 checkpoint 后才允许进入 durable_pool_addrs
    durable_addr(u64),   // 已 durable，可通过 Loader/file/sealed snapshot 继续解析
    resident_owner,      // 仅存在于 resident/artifact owner 中，必须先 materialize 或直接从 owner 收集
}
```

约束是：
- `live_pool` 缺失才是“闭包不完整”，可以进入 retry
- `durable_addr` 不命中 `Pool.pages` 是正常态，不能直接当成 retry
- `resident_owner` 也不能退化成“再去 `Pool.pages`/文件里碰碰运气”，必须走 owner/materialize 正规路径

否则一旦把“已 durable 但不在 `Pool.pages`”误判成缺页重试，就会在随机写入场景下形成 checkpoint livelock：
- snapshot 永远返回 retry
- `done()` 永远不执行
- flush debt 不释放
- backpressure 会把前台持续压住
- `active_bytes` 却继续增长

这也是 delayed-serialization 必须补上的 source-closure contract，而不是只补 frontier replay。

同一个 helper 需要支持两个 target：

```rust
enum MaterializeTarget {
    checkpoint_scratch,
    live_pool,
}
```

- `checkpoint_scratch`
  - 只给本轮 checkpoint 构造一次性 flush artifact
  - 逻辑地址仍然必须从 bucket `next_addr` 单调保留出来
  - 分配出的页只放进 `Snapshot.pages`
  - 不插入 `Pool.pages`
  - 不增加 `active_bytes`
  - publish 失败或完成后，直接随 task-local artifact 一起释放
- `live_pool`
  - 给 evict 或其他需要发布 live addressable fallback 的路径使用
  - 分配出的页进入 `Pool.pages`
  - 计入 `active_bytes`
  - 必须同时接管这份 addressable artifact 所需的运行时 owner
  - 后续由正常 checkpoint 或 owner drop 回收

也就是说，checkpoint 与 evict 复用的是“如何从 resident 生成 addressable 链”的逻辑，
不是复用同一份 `Pool` 生命周期。

因此还需要一个独立于 `Pool.pages` 的地址分配接口，例如：

```rust
trait AddrAlloc {
    fn next_addr(&self) -> u64;
}
```

- `live_pool` target 用它拿地址，然后把页注册进 `Pool.pages`
- `checkpoint_scratch` target 也用它拿地址，但只生成 task-local `BoxRef`
- 两者共享同一个 bucket 逻辑地址空间，不能各自维护独立计数器

这样可以保证：
- `Tagged Swip` 指向的地址链不再依赖旧 `Node` 或旧 `loader.pinned`
- 对 `live_pool` target，remote 即使还未 durable，也已经作为 `Addressable` page 存在于 `dirty_pages`
- 对 `checkpoint_scratch` target，flush 闭包完整，但不会把一次性 artifact 污染到 `dirty_pages`
- 读者不会观察到“半 resident 半 addressable”的原地中间态
- 当前进程内仍可复用 base 页尾 hints 加速访问，但恢复正确性不能依赖它们，因为 dump 不会把它们写进文件

这里还必须补一条 owner 转移合同：

- 对 `checkpoint_scratch`
  - 只复制数据，不转移 live owner
  - live `Node` 仍继续持有 `resident_remotes` / `resident_retired_addrs`
- 对 `live_pool`
  - 必须把“evict 后 reload 仍需要的运行时 owner”一起挂到新 artifact 上
  - 对未 durable remote，owner 需要转成新 addressable artifact 可达的 `BoxRef` / pinned owner
  - 对 `resident_retired_addrs`，owner 需要转成新 addressable artifact 绑定的 `ArtifactRetireState`
  - 但这只是运行时 owner 转移，不是 durable 消费
  - 不能因为 `live_pool` materialize 成功就提前删除 live resident 里的 `resident_retired_addrs`
  - 不能因为 `live_pool` materialize 成功就提前释放“只有 manifest commit 后才能清”的 resident remote owner

这里的关键约束是：
- 不允许直接修改当前对外可见 `Node.inner` / resident backing
- 不允许原地补写一个可能仍被旧 `Page::ref_node()` 持有的 delta frame `link`
- materialize 必须输出新的 publish artifact，旧 `Node` 只在 epoch/reclaim 之后才退出

### 5.4 evict

evict 的目标是释放上层 cache，而不是触发 durable publish。  
它应只保留两条路径：

- `simple_evict`
  当前 Node 已经有完整 `Addressable` 闭包，或者 resident snapshot 已经有匹配当前 generation 的 `sealed_head`，且相应 `ArtifactRetireState` 已经就位，可以直接把 `Pointer Swip` 改成 `Tagged(addr)`
- `materialize_then_evict`
  当前 Node 仍含 resident base/sibling，且没有可复用的 `sealed_head`，先调用 shared materialize helper，再改成 `Tagged(addr)`

这里需要显式约束：
- evictor 不再做 `compact`
- 删除 `src/map/evictor.rs` 中的 `compact_once`
- 删除 `COMPACT_TMO`
- 删除 `safe_txid/oracle` 在 evictor 中的用途
- 不再根据 `delta_len` 选择“先 compact 再 evict”

evict 过程仍应保持当前并发保护模式：
- 先 `try_lock`
- 再复检 `table.get(pid) == old.swip()`
- 再执行 `CAS(pointer -> tagged)`

另外还需要补一条时序约束：
- 若 `materialize_then_evict` 新产生了 addressable dirty pages，evict 完成后仍要走一次 `mark_dirty_pid + try_checkpoint`
- 原因不是 evict 要负责持久化，而是这些新物理页已经进入 `dirty_pages`，必须重新参与正常 checkpoint 调度
- 否则这批由 evict materialize 出来的新 dirty memory 可能要等到下一次前台 publish 才触发 checkpoint，可见内存会滞留过久
- 若 `simple_evict` 复用的是已经 durable 的 `sealed_head`
  - 只允许在 live resident 已经没有 addressable prefix 时复用
  - 仍要把当前 `checkpoint_retired_addrs()` 绑定到目标 tagged head 的 runtime catalog
  - 不应再把该 pid 重新标成 dirty
  - 不应再走 `try_checkpoint`
  - 因为 manifest page table 理论上已经指向同一个 durable head，evict 只是内存态从 pointer 切回 tagged

### 5.5 checkpoint

checkpoint 在需要处理 pointer swip 的 resident node 时，先冻结一个 resident snapshot，再把这个 snapshot materialize 成供 flush 使用的 `Addressable` 闭包，然后再：

1. 收集本轮需要 flush 的 data/blob pages
2. 写 data/blob 文件
3. 按现有协议提交 manifest 元数据

所以 checkpoint 负责的是：
- `resident -> addressable`
- `addressable -> durable`

而 evict 只负责：
- `resident -> addressable`
- `drop Node`

需要注意，checkpoint 不要求把 live pointer-swip 直接改成新的 pointer page。  
结合当前 [src/map/data.rs](/home/workspace/gits/github/mace/src/map/data.rs) 的实现，checkpoint 更准确的做法是：
- 先在 live node lock 下用 `Page::freeze_node_for_materialize()` / `Node::freeze_for_materialize()` 得到一个 detached snapshot node
- 生成一条新的 addressable publish chain 供 snapshot/flush 使用
- live `Node` 仍可继续保持 resident 形态，直到后续写入、evict 或新的 materialize 发生

这里还要把 freeze 之后和 evict 之间的 handoff 约束写死：

- `Page::freeze_node_for_materialize()` 只有在 live node lock 保护下完成 detached source capture 后，checkpoint materialize 的 source 才算真正切换成
  - snapshot 自己持有的 resident backing
  - 以及 `Node::freeze_for_materialize()` 根据冻结 `NodeState` 重建出来的独立 pinned remote/data owner
- 换句话说，不能先拷一份旧 `NodeState` 再锁外按 addr 回头补 pin；独立 owner 必须在释放 live node lock 之前闭合
- 因此前台 `materialize_then_evict` 若先完成 `pointer -> tagged`，再把旧 live delta/remote 从 `Pool.pages` 里摘掉，是允许的
- checkpoint 在锁外 collect/materialize 时不能再把“旧地址仍留在 `Pool.pages`”当作 source-closure 前提
- 这些旧地址最多只作为 `durable_pool_addrs` 的 best-effort exact-remove token 带到 `done()`
- 换句话说，freeze 之后 `Pool.pages` 对 checkpoint 的语义只剩 cleanup metadata，不再是 source owner
- live `checkpoint ack` 后续若对 runtime owner 调用 `unpin()`，也只会影响 live loader，不会反向破坏已经冻结出来的旧 snapshot

也就是说，checkpoint 使用的是“为 flush 准备一个地址链快照”，而不是要求原地替换当前 live `Node`。

但 checkpoint 不能停在这里。  
当 manifest commit 成功后，还需要有一个“只在 generation 未变化时生效”的 ack 步骤：

1. 若：
   - `PageMap[ack.pid] == ack.pointer_swip`
   - 且 live `Node.seq == ack.seq`
   - 安装 `sealed_head = ack.head_addr`
   - 将 `ack.remote_addrs` 从 `resident_remotes` 转入 `durable_remote_hints`
   - 将 `ack.retired_addrs` 从 live resident 状态中移除
   - 整个 ack 只允许修改 live `NodeState` 里的 runtime owner，不能修改共享 `ResidentRef` backing
2. 若 pointer swip 或 `seq` 任一不匹配
   - 说明 flush 期间该 pid 已经指向了别的 live node，或当前 node 已有新修改
   - 不更新 live resident state，也不安装 `sealed_head`
   - 保留 live generation 里的 `resident_retired_addrs`
   - 等下一轮 dirty checkpoint 针对新 generation 重新 materialize

publish 侧执行这一步时，还必须沿用 pointer-swip 正常读路径的 epoch 保护：

- `apply_checkpoint_acks()` 在读取 `PageMap[pid]`、构造 `Page::from_swip(raw)`、以及二次确认 `PageMap[pid]` 的整个过程中都必须持有同一个 epoch pin
- 这样即使旧 pointer page 已经被并发 replace 并进入 defer reclaim，也不会在 ack 路径里解引用悬空 swip

这样做有三个目的：
- 避免已经 durable 的 remote 继续长期占着 `resident_remotes` 和 `PinnedSet`
- 避免同一批 `resident_retired_addrs` 在后续 checkpoint 时被重复送入 `data_junk/blob_junk`
- 让 clean resident node 在后续 evict 时直接复用 `sealed_head`

即使 `ack` 命不中，后续再次写出同一批 retired 地址也是安全的：

- live resident state 不会被错误清空
- 这批地址后续最多被重复送入 `apply_data_junks` / `apply_blob_junks`
- 当前 `src/meta/mod.rs` 的 `apply_junks()` 已基于 stat mask 做幂等过滤，因此重复 apply 只会带来额外开销，不会破坏正确性

checkpoint 的实现应再进一步落到两个显式产物上：

```rust
struct Snapshot {
    start_chkpt_lsn: [Position; N],
    pages: Vec<BoxRef>,              // actual frames to flush
    durable_pool_addrs: Vec<u64>,    // pool addrs removable after manifest commit
    unmap_pid: HashSet<u64>,
    retire_tick: u64,
    data_junk: Vec<u64>,
    blob_junk: Vec<u64>,
    acks: Vec<CheckpointAck>,
}

struct CheckpointAck {
    pid: u64,
    pointer_swip: u64,
    seq: u64,
    head_addr: u64,
    remote_addrs: Vec<u64>,
    retired_addrs: Vec<u64>,
}
```

其中：
- `pages` 是真正写文件的页镜像，可能来自 live pool，也可能来自 checkpoint scratch
- `durable_pool_addrs` 是“在本轮 checkpoint 成功后，可以从 `Pool.pages` 删除的旧地址集合”
  - 它表示逻辑内容已 durable
  - 不要求这些地址对应的 `BoxRef` 就是实际写进文件的那一份
- `retire_tick` 是本轮 checkpoint 的单调 tick
  - 它必须来自持久化的全局 `Numerics.next_tick`
  - manifest commit 时必须和其它 numerics 一起持久化，不能只靠 flusher 本地计数器
  - data file 初始 `MemDataStat.up1/up2` 与 data GC 的 `now` 必须共用这条 tick 轴
  - 因此它必须独立于 `data_ivl/blob_ivl` 和 `file_id`
  - 这样即使本轮没有新 data file，`data_junk` 也能独立 apply
- `acks` 只针对 pointer-swip resident node

`CheckpointTask::snapshot` 具体应这样执行：

1. `swap_snapshot()` 拿走当前 `dirty_pid/unmap_pid`
2. 对每个 dirty pid 读取当前 `PageMap`
3. 若是 `Tagged Swip`
   - 不能直接假设 `tagged addr` 必须仍在 `Pool.pages`
   - snapshot 必须把“当前 tagged head”与“该 head 对应的 runtime retire owner”作为一个带复检的稳定观察单元
   - 具体要求是：
     - 先读取当前 tagged swip
     - 再读取该 `addr` 的 runtime retire owner
     - 最后复检 `PageMap[pid]` 仍然是同一个 tagged swip
     - 若复检失败，必须按新的 page-map 状态重新处理该 pid，而不是冻结旧 head 搭配新的 owner 视图
   - 必须先区分：
     - `live_pool artifact`
       - 地址仍由当前 runtime artifact / `Pool.pages` 托管
       - 参与本轮 durable 的 live pool 地址进入 `durable_pool_addrs`
       - 若该 artifact 带有 retire owner，也必须把其 retired addrs 收集进 `data_junk/blob_junk`
     - `ordinary durable head`
       - 地址已经 durable，只是 `PageMap` 仍指向 tagged addr
       - snapshot 直接把它视为 durable frontier，不再继续沿旧地址链扫描
       - 不要求命中 `Pool.pages`，也不进入 `durable_pool_addrs`
4. 若是 `Pointer Swip`
   - 必须先冻结一个自洽 snapshot，再在锁外 materialize
   - 当前实现通过 `Page::freeze_node_for_materialize()` / `Node::freeze_for_materialize()` 在 live node lock 下完成这一步
   - 若未来改成直接读取 live node 的 shared helper，则冻结点必须放在 `node lock` 下
   - snapshot / `MaterializePlan` / `AddressablePrefixSnapshot` 必须把可达对象先分类成 `live_pool | durable_frontier | resident_owner`
   - 尤其要允许“dirty delta 前缀 + durable base tail”这种混合闭包
   - 用 `checkpoint_scratch` target 生成私有 publish chain
   - scratch chain 的页放入 `pages`
   - 本轮被 durable 化的 live pool 地址放入 `durable_pool_addrs`
   - 对 plan 中原本就是 `durable_frontier` 的对象，只作为 reachability 边界，不进入 `durable_pool_addrs`
   - 直接把该 live generation / artifact 的 retire owner 收集进 `data_junk/blob_junk`
   - 生成 `CheckpointAck`
5. 若在 materialize / scan / collect 闭包过程中遇到 `Pool.pages` miss
   - 对 addressable data/remote/sibling/junk，一律解释成“已经 durable，到此为止”
   - 不允许把它建模成正常 retry 分支
   - 不允许返回半截 `Snapshot`
6. 只有 manifest commit 成功后，`durable_pool_addrs` 和 `acks` 才允许生效
9. 在当前实现收敛里，manifest commit 的成功边界由 `observer.on_flush()` 返回来代表
   - `CheckpointTask::done()` 只能在 `on_flush()` 成功返回后执行
   - `done()` 先按 `durable_pool_addrs` 精确删除 `Pool.pages`
   - 然后再做 `unmap` 等纯运行时收敛
10. `ack` 的 best-effort 安装与 `durable_pool_addrs` 删除解耦
   - `ack` 命不中 live generation 只影响 resident owner 收敛
   - 不影响 `durable_pool_addrs` 的删除

这里有一个关键约束：
- pointer-swip resident node 的 checkpoint artifact 不进入 `Pool.pages`
- 因此当前 [src/map/data.rs](/home/workspace/gits/github/mace/src/map/data.rs) 里 `CheckpointTask::done()` 那种按 `start_chkpt_lsn` 全表 `retain()` 的做法要删除
- checkpoint 不再通过“前沿扫描”清理 `dirty_pages`
- 改成只对 `durable_pool_addrs` 做按地址精确删除

`dirty_pages` 的清理应收敛成 3 条显式路径：

1. `checkpoint commit` 清理
   - `observer.on_flush()` 成功返回后，由 `CheckpointTask::done()` 逐个 `remove(durable_pool_addrs)`
   - 同步精确扣减 `active_bytes`
   - 这是唯一允许把“已 durable 的 live addressable 页”从 `Pool.pages` 移出的 checkpoint 路径
2. `owner drop/reclaim` 清理
   - 某批 addressable 页从未 durable，但也不再属于任何 live artifact
   - 例如旧 node 被 replace 后残留的 undurable delta/remote，或失败 publish 之后留给旧 owner 的地址
   - 这类页在最后一个 owner 真正 drop/reclaim 时按地址删除
3. `rollback/abort` 清理
   - 某次 build/materialize 分配了一批地址，但在 publish 前就失败
   - 调用方必须立即按地址回滚
   - 不能等下一轮 checkpoint 再靠前沿顺带清掉

要让这三条路径真正落到代码上，还必须补一个显式的 pool owner 模型：

```rust
struct PoolAddrOwner {
    addrs: Vec<u64>,
}

enum RuntimeArtifact {
    build(BuildArtifact),          // unpublished allocs
    published(AddressableArtifact),// live tagged/pointer-visible artifact
    scratch(ScratchArtifact),      // not in Pool.pages
}
```

归属规则必须明确：

- `BuildArtifact`
  - 持有本次 build/materialize 新分配、但尚未 publish 的全部 `Pool.pages` 地址
  - publish 成功前若失败，`Drop/abort` 必须逐个 `remove(addr)` 并按实际移除结果扣 `active_bytes`
- `AddressableArtifact`
  - publish 成功后，从 `BuildArtifact` 接管这些地址
  - 它代表当前 live runtime artifact 对这批 undurable addressable 页的唯一 owner
  - 当 artifact 被 replace/unmap/reclaim 且这些地址尚未被 checkpoint durable 掉时，`Drop/reclaim` 必须逐个精确删除
- `ScratchArtifact`
  - 只持有 checkpoint-scratch 页
  - 由于它根本不进入 `Pool.pages`，只需要在 task 结束时直接释放 `BoxRef`

同时还要补一条去重/幂等规则：

- `checkpoint commit` 先删除 `durable_pool_addrs`
- 后续 `AddressableArtifact::drop()` 再清理同一批地址时，`remove(addr)` 返回 `None` 必须被视为正常情况
- `active_bytes` 只能按实际成功移除的地址扣减，避免双扣

也就是说，新的清理模型不能只写“按 owner 精确删除”，还必须明确：

- 哪批地址在 build 阶段归谁
- publish 成功后 ownership 转移给谁
- 谁在 abort/drop/reclaim 时负责 exact remove
- checkpoint commit 与 owner drop 之间如何幂等衔接

因此 delayed-serialization 落地后，`Pool.pages` 的语义应收紧为：
- 只保存 live 的、尚未 durable 的 addressable 页
- resident base/sibling 与 retire owner 不进入 `Pool.pages`
- checkpoint scratch artifact 不进入 `Pool.pages`

`active_bytes` 也随之只统计 `Pool.pages`：
- 不统计 resident heap
- 不统计 checkpoint scratch artifact
- resident 内存交给 evictor 和记账后的 `cache_capacity`
- checkpoint scratch 只存在于单个 in-flight task，且当前每 bucket 已经由 `flush_in != flush_out` 限制为单并发

这也意味着 `dirty_pid` 的语义要同步收紧：
- 它表示“该 pid 的 durable 映射可能需要在本轮 checkpoint 更新到 manifest”
- 不是“只要内存里有动过就一定重新 flush”
- 复用 `sealed_head` 的 `simple_evict` 不应再标 dirty pid
- 生成了新的 live pool fallback 的 `materialize_then_evict` 仍然要标 dirty pid

### checkpoint trigger policy

delayed-serialization 之后，旧的 `active_bytes >= data_file_size * 2` 只能算历史实现里的经验阈值，不能再当成方案级语义。  
checkpoint 何时触发，必须显式收敛成下面 3 类来源：

1. 内存压力触发
   - 由一个明确配置的“总内存上限”驱动，而不是继续把 `data_file_size * 2` 当作隐含阈值
   - 这个预算至少覆盖：
     - dirty-side bytes：`Pool.active_bytes`
     - 上层 live cache bytes：resident/addressable `Node` 的 retained-bytes
   - checkpoint scratch artifact 不计入这个预算
   - resident/cache 压力不能把 checkpoint 重新变成“上层 cache 回收器”，因此顺序是：
     - resident/cache 部分优先交给 evictor
     - 若 dirty-side 仍超阈，或 evict 后总内存仍高于上限，则再触发 checkpoint
2. WAL backlog 触发
   - 由 `log_size` 单独驱动，目标是尽快回收日志并缩短崩溃恢复
   - 即使内存压力不高，只要 WAL backlog 超阈也必须 checkpoint
3. 定时触发
   - 目的是避免“压力不大但长期不落盘”
   - 定时器不能无脑刷空 checkpoint，必须要求存在未 checkpoint 的脏状态，例如：
     - `dirty_pid/unmap_pid` 非空
     - 或自上次 durable checkpoint 以来 WAL / map 状态有推进

因此职责边界应明确为：
- resident/cache 压力先交给 evictor
- dirty backlog 压力交给 checkpoint
- WAL backlog 压力独立触发 checkpoint
- timer 只做“有脏状态时”的兜底

`data_file_size` 在这个模型里继续保留为 flush unit / file build 基线，但不再承担“何时一定要 checkpoint”的方案语义。

#### 冷加载与后续重写

从磁盘回读的 leaf/branch 必须遵守更简单的规则：

- `Tagged Swip` 命中的 base/sibling 已经 durable
- leaf base 页尾的 sibling/remote hints 不能作为恢复依据，因为它们不会持久化到文件
- 冷加载后的 durable leaf 若需要恢复 remote/sibling 可达性，必须从已持久化的 `Val` 编码与 sibling 链本身显式扫描重建
- 这些对象的可达性来自文件与下层 cache，不再依赖 `dirty_pages` 或 live `Node` owner

一旦该 node 后续又经历 compact/merge/split：

- 新 generation 重新变成 resident
- 旧 durable snapshot 中不再被引用的地址通过 `collect_durable_reachable(old)` 并入 `resident_retired_addrs`
- 下次 checkpoint 时，再把这批退休地址直接送入 `data_junk/blob_junk`

如果 `Tagged Swip` 重新加载命中的是本进程内仍存活的 `live_pool artifact`，还需要额外满足：

- `Node::load(tagged head)` 先查 runtime artifact catalog
- 若命中 `ArtifactRetireState`
  - 说明这不是“纯磁盘 durable head”，而是尚未 checkpoint commit 的 live runtime artifact
  - reload 后的新 `Node` 必须继续继承对应的 retire owner
  - tagged reload 只是 owner transfer，不是 owner retirement
  - 因此 reload 成功把 `Tagged Swip` 变成 `Pointer Swip` 后，旧 `addr` 的 runtime catalog 记录也不能立刻删除
  - 否则并发 checkpoint 可能已经观察到旧 tagged head，但随后读到空的 retire owner，最终把同一代 head durable 下去却漏掉 retire batch
- 若未命中 `ArtifactRetireState`
  - 说明这是普通 cold/durable load
  - 不应凭空恢复任何 retire owner，因为这类 retire 信息若已 commit，应该已经体现在 manifest stat/mask 中

在 `checkpoint ack / sealed_head` 还没有完整接入前，允许 runtime retire owner 在 `Tagged Swip` catalog 和已 reload 的 pointer node 上暂时重复保留。
这只会带来后续 checkpoint 的重复观察与幂等 apply 成本，不会破坏正确性。

所以“冷加载 -> 多次 resident 重写 -> 再次 durable”的过程里，durable 可达性和退休传播始终是闭合的：

- 已 durable 的旧地址靠 `sealed_head` / 磁盘回读路径识别
- 若缺少进程内 `durable_remote_hints` 缓存，则通过扫描 durable leaf payload 恢复 remote/sibling reachability
- 新形成但尚未 durable 的 resident 状态只在内存里存在
- 真正进入 GC 协议的边界，始终是“materialize 后 manifest commit 成功”

### 5.5.1 backpressure

backpressure 继续只针对 flush debt 工作，不直接针对 resident cache bytes 工作。

它的输入保持不变：
- checkpoint enqueue 时用估计字节数增加 debt
- flush build 完成时用实际字节数修正 debt
- flush publish / done 后释放 debt

因此 delayed-serialization 下的正确组合是：

- resident memory 高、dirty backlog 低
  - 先 evict
  - 不应仅因 resident 高就直接对前台写入做 backpressure
- dirty backlog 高、resident memory 低
  - 触发 checkpoint
  - debt 升高时对前台写入做 backpressure
- 两者都高
  - evictor 和 checkpoint 同时工作
  - backpressure 负责限制新写入继续放大 flush debt

换句话说，backpressure 的作用不是“限制总内存”，而是“限制 flush 落后速度”。

### 5.6 durable 之后的垃圾回收

持久化后的退休协议保持不变：
- 普通 data page 退休：把原始 `addr` 送入 `data_junk`
- remote/blob page 退休：把 `RemoteView::tag(addr)` 拆到 `blob_junk`
- checkpoint 直接从 live retire owner 产出 `data_junk` 和 `blob_junk`
- `Manifest::apply_data_junks` / `apply_blob_junks` 更新 stat
- `GC::rewrite_data` / `rewrite_blob` 最终回收旧文件内容

未 durable 的对象不走这条链路。

对 delayed-serialization 来说，关键边界只有一条：

- `resident_retired_addrs` 只是“待进入 `data_junk/blob_junk` 协议”的内存退休集
- 只有当它在本轮 checkpoint 中被直接带入 `data_junk/blob_junk`，且对应 manifest commit 成功后，这批地址才真正进入 durable GC 协议
- 在那之前，即使这些地址从当前 live resident 视角已经是垃圾，恢复视角仍把它们当作旧 durable snapshot 的 live 数据

这也意味着 retire metadata 的 carrier 应明确分成两类：

- resident carrier
  - `ResidentLeafAux.resident_retired_addrs`
  - 只服务 live resident generation 之间的继承与 merge
- addressable runtime carrier
  - `ArtifactRetireState.retired_addrs`
  - 只服务已经 evict/materialize 成 `Tagged Swip`、但尚未 checkpoint commit 的 live runtime artifact

对 addressable runtime carrier 还要再加一条生命周期约束：

- tagged reload 不是 safe removal point
- 只要某个 live tagged head 仍可能被并发 checkpoint 观察到，其对应的 runtime retire owner 就必须继续可见
- 在当前实现里，这意味着 catalog 允许跨 tagged reload 暂时重复保留，直到后续 checkpoint publish / ack 边界再收敛

二者都只是运行时 owner，不是恢复来源。

当前实现里的实际闭包路径必须固定为：

1. 前台 rewrite/evict 在 `Publish::commit()` 中重新 `mark_dirty_pid(pid)`，保证被替换的 live head 会再次进入 checkpoint
2. `CheckpointTask::snapshot()` 对 addressable target 冻结 `{addr, retired_addrs}`，其中 `retired_addrs` 来自 live `Node` 或 `ArtifactRetireState`
3. flush build 把这批 retired addrs 变成 `FlushResult.data_junk/blob_junk`
4. `StoreFlushObserver::publish()` 在同一个 manifest txn 中同时提交 `Map`、`data_junk/blob_junk` 对应的 stat 更新、以及 interval/numerics

因此 crash-safety 的原子边界不是“某个 data/blob page 字节已经写进文件”，而是“引用这些页的 manifest txn 已提交”：

- manifest commit 前，新 head 还不属于 recovery-visible state，丢失 `ArtifactRetireState` 是允许的
- manifest commit 后，新 head 与 retire batch 一起变成 recovery-visible state，之后 runtime owner 是否还在只影响内存收敛，不影响恢复正确性

### 5.6.1 delayed-serialization 的 crash-safety 合同

方案要成立，必须先把“恢复依赖什么”与“运行时只是缓存什么”彻底分开。

恢复后的唯一真相只能来自：

- manifest txn 已提交的 `PageTable`
- manifest txn 已提交的 `data/blob interval`
- manifest txn 已提交的 `DataStat/BlobStat`
- manifest txn 已提交的 orphan marker / numerics
- 已经 sync 到磁盘且被上述 metadata 引用的数据文件和 blob 文件
- WAL 及其 checkpoint 边界

反过来，下面这些状态都必须被视为纯运行时缓存，崩溃后允许全部丢失：

- resident `Node` backing
- `resident_remotes`
- `resident_retired_addrs`
- `sealed_head`
- `Loader::pinned`
- `Pool.pages`
- `checkpoint_scratch`
- `durable_pool_addrs`
- `CheckpointAck`
- `ArtifactRetireState`

这里还需要明确一个常见误判：

- 不能把“页字节已经写到 data/blob 文件”误当成“新 head 已经 durable”
- delayed-serialization 下，新 head 是否对恢复可见，仍只由 manifest txn 是否提交决定
- 因此 runtime retire owner 是否需要跨崩溃保留，取决于它是否已经被本轮 snapshot 冻结并随同一个 manifest txn 发布，而不是取决于页字节是否已经存在于文件中

因此 delayed-serialization 必须满足 4 条硬约束：

1. 任何会影响恢复可见结果的变化，必须先进入 flush 文件，再进入同一个 manifest txn
2. 任何只影响内存回收/owner 收敛的变化，必须严格放在 manifest commit 之后执行
3. crash recovery 绝不能依赖 `ack`、`sealed_head`、`resident_retired_addrs` 被成功回写
4. evict/materialize 产生的 undurable addressable 页，即使已经替换了内存中的 `PageMap`，崩溃后也只能靠 WAL 重建，不能要求恢复路径看到它们

换句话说，delayed-serialization 只是把“中间态页何时物化”从前台提前分配改成了延迟 materialize，
但它不能改变现有的恢复边界：

- durable 边界仍然是 `data/blob sync -> manifest commit`
- runtime cleanup 边界仍然是 `manifest commit` 之后的 best-effort 内存收敛

### 5.6.1a writer-group durable frontier 补丁

当前实现还有一个额外的恢复缺口：  
`redo` 虽然最终会把已经收集进 `dirty_table` 的 `WalUpdate` 按 `txid` 由旧到新回放，但“哪些记录会被收集进 `dirty_table`”这一步，仍然依赖一个并不成立的前提：

- foreground `compact/merge/split/materialize` 生成的新 node/page 可能吸收多个 writer group 的历史
- 但当前 runtime/page header 只保留了一个“最新”的 `(group, latest_lsn)`
- checkpoint publish 也只按这个单一 `(group, latest_lsn)` 推进 `latest_chkpoint_lsn[group]`
- recovery analyze 再按各个 group 的 checkpoint 起点去扫描 WAL

这意味着：

- 新 page 的 durable 内容可能已经吸收了 `group A` 的旧更新和 `group B` 的新更新
- 但 checkpoint 只会推进 `group B`
- 恢复时 `group A` 的旧记录仍可能落在 replay window 内

`preserve_delete` 只能在 delete tombstone 这一种场景下人为保留一个 latest witness，避免 `Tree::get(raw)` 把“latest is delete”降格成普通 `NotFound`。  
它不是根因修复，根因是：

- WAL group 之间没有全局 durable 顺序
- 但 analyze 的收集判据却在把当前 durable tree 当成一个足够精确的全局 witness

因此 delayed-serialization 需要补上一层显式的 `durable frontier` 协议，作为恢复与 checkpoint 之间真正的闭包边界。

#### 目标

- 不改 data/blob page 磁盘格式
- 不引入“全局总序 WAL”重构
- 保持恢复仍然按 group 扫 WAL
- 但把“这个 bucket 对每个 group 已 durable 到哪里”显式持久化出来

#### 核心不变量

1. 对任意 bucket、任意 writer group `gid`
   - 只有当 manifest 中的 `BucketDurableFrontier[gid] >= pos` 时
   - recovery 才允许把 `gid@pos` 以及更早的记录视为已经 durable
2. `BucketDurableFrontier` 必须和本轮 `Map` / `DataStat` / `BlobStat` / interval 更新一起提交到同一个 manifest txn
3. 全局 `WalCheckpoint[group]` 不再允许直接由“某个 bucket 本轮 flush 到的最大 lsn”推进
   - 它只能是所有未删除 bucket 的 `BucketDurableFrontier[group]` 的下界
4. recovery correctness 必须首先依赖 `BucketDurableFrontier`
   - `Tree::get(raw)` 最多只能作为优化，不能再当 correctness source

#### 运行时状态

为避免 checkpoint 在热路径上重新扫描整棵 node 去猜“它吸收了哪些 group”，需要增加一层只服务未 durable generation 的 runtime frontier：

- `NodeState.dirty_frontier`
  - 建议用稀疏有序 `SmallVec<[(u8, Position); N]>`
  - 只记录“当前 live generation 中，尚未被 manifest frontier 吸收的 writer-group frontier”
- `ArtifactRetireState.dirty_frontier`
  - live tagged artifact 的 addressable runtime owner 也要带同样一份 frontier
  - 否则 `Pointer Swip -> Tagged Swip -> reload/checkpoint` 会在 frontier 上断链

维护规则：

1. 冷加载出来的纯 durable node
   - `dirty_frontier = []`
   - 因为它对应的 durable 历史已经由 manifest `BucketDurableFrontier` 托管
2. 新 delta/link 进入 live node 时
   - 把该 delta 的 `(group, lsn)` merge 到 `dirty_frontier`
3. `compact/merge/split/materialize_then_evict`
   - 新 generation 的 `dirty_frontier = union_max(所有 source 的 dirty_frontier, 本轮新增 delta 的 group/lsn)`
4. checkpoint ack 命中当前 generation 时
   - 允许 best-effort 清理掉已经被本轮 manifest frontier 覆盖的项
   - 但恢复 correctness 不能依赖这一步一定成功

这里有一条关键语义需要固定：

- `dirty_frontier` 只负责“自上次 durable frontier 之后，这个 live generation 又吸收了哪些 group/lsn”
- 已经 durable 的旧历史不再要求逐个 node 持续携带
- 它们的唯一恢复来源转移给 manifest 里的 `BucketDurableFrontier`

这样可以避免为冷加载 node 引入新的磁盘格式，也避免每次从 durable page 回读时重新扫描整页去重建全量 frontier。

#### snapshot / publish

`CheckpointTask::snapshot()` 除了现有的 `pages / durable_pool_addrs / data_junk / blob_junk / acks`，还需要额外产出：

```rust
bucket_frontier_delta: [Position; Options::MAX_CONCURRENT_WRITE as usize]
```

其来源不是 page header 的单个 `(group, lsn)`，而是：

- resident target 的 `node.dirty_frontier`
- addressable live pointer target 的 `node.dirty_frontier`
- tagged live artifact 的 `ArtifactRetireState.dirty_frontier`
- checkpoint scratch / live_pool materialize 过程中本轮新建页自身的 `(group, lsn)` 只作为调试断言，不再当唯一 frontier source

合并规则：

- `snapshot.bucket_frontier_delta = pointwise_max(本轮所有 dirty target 的 dirty_frontier)`
- `publish_frontier = pointwise_max(manifest.bucket_frontier_old, snapshot.bucket_frontier_delta)`

`StoreFlushObserver::publish()` 必须把下面这些内容放进同一个 manifest txn：

- `Map`
- `data_junk/blob_junk` 对应的 stat apply
- `data/blob interval`
- `Numerics`
- 新的 `BucketDurableFrontier`

crash-safety 边界因此变成：

- manifest commit 前
  - page bytes 即使已写盘，也不能视为 `BucketDurableFrontier` 已推进
- manifest commit 后
  - 新 map 和新 frontier 一起对 recovery 可见
  - runtime `dirty_frontier` / `ack` 是否已经回写，只影响内存收敛，不影响恢复正确性

#### 全局 `WalCheckpoint` 的派生规则

补上 bucket frontier 之后，现有“某个 bucket flush 完成就直接 `update_checkpoint(result.latest_chkpoint_lsn[group])`”的语义必须降级为过渡实现。

正式语义应改为：

```text
WalCheckpoint[group]
  = min(
      min_frontier_across_live_buckets[group],
      active_txns.min_lsn(group)
    )
```

其中：

- `min_frontier_across_live_buckets[group]`
  - 是所有未删除 bucket 的 `BucketDurableFrontier[group]` 的 pointwise min
- `active_txns.min_lsn(group)`
  - 保持现有 WAL recycle / active txn 约束

这样 recovery 从 `WalCheckpoint[group]` 开始扫描时，只会多扫，不会漏扫：

- 某个 bucket 的 frontier 更高
  - recovery 会多看到一些其实已经 durable 的 WAL
  - 但可以在 analyze 阶段按 bucket frontier 精确跳过
- 某个 bucket 的 frontier 更低
  - 全局 checkpoint 会被它拉低
  - 从而保证这一 bucket 的旧 WAL 不会被错误跳过

实现上不要求每次发布都全量遍历所有 bucket 才算正确，可以用内存 cache/堆优化；但方案级语义必须固定成“全局 checkpoint 是所有 bucket frontier 的下界”，不能再退回到“由单个 flush task 的 max lsn 直接推进”。

#### recovery analyze

补丁落地后，`Recovery::handle_update()` 的判据要改成两层：

1. correctness gate
   - 先读取该 `bucket_id` 的 `BucketDurableFrontier`
   - 若 `loc.pos <= frontier[group_id]`
     - 直接跳过，不进入 `dirty_table`
   - 否则视为“可能未 durable”
2. optional optimization
   - 之后仍可保留 `Tree::get(raw)` / `latest ver` 之类的二级优化，减少无意义 redo
   - 但即使完全删掉这层优化，恢复 correctness 仍然成立

这样恢复语义就不再依赖“当前 durable tree 是否还能从 key 空间里推断出所有 group 的 durable 顺序”。

#### 与 `preserve_delete` 的关系

- 在这个补丁落地之前，`preserve_delete` 仍可作为临时止血
- 但它只能覆盖 delete witness，不覆盖 put/update/多 group compact 的一般情形
- 一旦 `BucketDurableFrontier + dirty_frontier + global WalCheckpoint 下界派生` 落地：
  - `preserve_delete` 应删除
  - recovery correctness 不再依赖 latest tombstone 必须继续留在 page 里

因此这份补丁是 delayed-serialization 针对 recovery 正确性的正式闭环，`preserve_delete` 只是过渡期 workaround。

### 5.6.2 checkpoint / evict 的崩溃边界

checkpoint pointer-swip resident node 时，新的 addressable fallback 可能有两种落点：

- `checkpoint_scratch`
- `live_pool`

它们的 crash 语义必须完全不同：

- `checkpoint_scratch`
  - 只服务本轮 flush
  - 不进入 `Pool.pages`
  - 若崩溃发生在 manifest commit 前，文件靠 orphan marker 清理
  - 若崩溃发生在 manifest commit 后，恢复直接使用新 metadata + 新文件
- `live_pool`
  - 只服务运行时 evict / reload
  - 不直接改变 durable metadata
  - 若崩溃发生在 checkpoint 前，这批页整体丢失，恢复只能回到旧 manifest snapshot，再由 WAL 重放补回

因此：

- evict 绝不能消费 `resident_retired_addrs`
- evict 绝不能清理 `resident_remotes`
- evict 绝不能删除旧 durable pool 地址
- 这些动作都只能由“manifest commit 成功后的 checkpoint ack”来做

更具体地说：

- evict 可以做的只有“运行时 owner 转移”
  - 让 evict 之后的 `Tagged Swip -> live_pool artifact` 仍然能访问未 durable remote 和对应的 retire owner
- evict 不能做的是“durable owner 收敛”
  - 不能把某批 retired 地址视为已经进入 durable `data_junk/blob_junk` 协议
  - 不能把某批 resident remote 视为已经 durable 并移出 live owner 集
- 若 evict 完成后在 checkpoint 前崩溃
  - 这次 live_pool materialize 产生的地址整体允许丢失
  - 恢复仍回到上一次 committed manifest snapshot
  - 因此前述 durable owner 收敛动作绝不能提前发生

否则一旦崩溃卡在“内存 owner 已清掉，但 metadata 还没提交”的窗口，就会破坏恢复闭包。

### 5.6.3 逻辑地址在崩溃后的单调性

delayed-serialization 要求 `checkpoint_scratch` 也消耗 bucket `next_addr`。  
这在 crash-safety 上是安全的，但要明确原因：

- 若 scratch/live-pool 地址最终进入了 committed interval
  - 恢复时 `recover_intervals()` 会把 `next_addr` 推到这些地址之后
- 若这些地址只存在于未提交文件或纯内存 `Pool.pages`
  - 崩溃后文件要么由 orphan marker 清理，要么根本不存在 durable interval
  - 恢复时它们不会出现在 `recover_intervals()` 的结果里
  - 后续重新分配同一逻辑地址是允许的，因为旧内容对恢复视角完全不可见

所以“逻辑地址单调递增”只需要对单次运行中的发布顺序成立，不要求对未提交的 scratch/live-pool 地址跨崩溃永久保留。

### 5.7 crash matrix

| 窗口 | 行为要求 | 恢复结果 |
| :--- | :--- | :--- |
| `after_data_sync` | 新 data/blob 文件已刷盘，但 manifest 未提交 | 通过现有 orphan marker 清理孤儿文件，resident 状态随进程消失，由 WAL/恢复重建 |
| `before_manifest_commit` | 同上，允许 checkpoint 结果整体丢弃 | 不会出现 manifest 指向未 durable 文件 |
| `after_manifest_commit` | 新 interval/stat/page-table 已提交 | 恢复后只依赖 durable 文件和 manifest，resident 状态不再重要 |
| `after_manifest_commit_before_wal_checkpoint` | manifest 已提交，但 WAL checkpoint 位置尚未推进 | 恢复后仍以已提交 manifest 为准，flush 文件不能被当作 orphan 或未提交结果 |
| `retire_after_apply_before_clear` | retire/stat apply 已完成，但 runtime owner clear 尚未结束 | 恢复必须能重复 replay retire/apply，不得丢退休记录或重复破坏统计 |

## 6. 代码接入点

### `src/types/node.rs`
- 引入 `BaseStorage` 的 resident/addressable 双态
- 为 leaf 增加 `ResidentLeafAux`
- 将 `resident_retired_addrs` 作为 resident generation 的一部分持有，并提供 merge/dedup helper
- 重构 `NodeState.addr` 为 `addr_head` 语义
- 为 `NodeState` 增加 resident generation、`sealed_head` 和 `apply_checkpoint_ack()` 这类 helper
- 为 addressable 前缀或发布产物增加显式 `PoolAddrOwner`
- 为 addressable runtime artifact 增加 `ArtifactRetireState`
- 增加 `collect_durable_reachable()` 一类 helper，用于在 compact/merge/split 时计算 `old_durable_reachable - new_durable_reachable`
- 当前 checkpoint 路径通过 `Node::freeze_for_materialize()` 在 live node lock 下冻结 resident snapshot
- 若后续引入 shared materialize helper，则需要 `freeze_materialize_plan()` 一类 helper，在 node lock 下把 resident backing 和 addressable 前缀一起快照出来
- `latest_addr()` / `base_addr()` / `collect_junk()` 等接口按新状态语义拆分
- 删除 `pin_leaf_pages` 之前，先让 `reference/load/new` 基于新状态维护真实 owner，而不是 pinned 兜底

### `src/types/page.rs`
- `Page::ref_node()` 继续依赖 `Node::reference()`
- `Page::freeze_node_for_materialize()` 依赖 `Node::freeze_for_materialize()`
- delayed-serialization 必须保证 `reference()` 只共享 resident backing与 live addressable pin-set，不深拷贝、不悬挂
- delayed-serialization 必须保证 `freeze_for_materialize()` 只在 checkpoint/evict 等非 hot path 使用
- `Page::reclaim()` 不能只回收 `Handle<Node>`，还必须连带触发挂在该 page/node 上的 `AddressableArtifact` owner 清理

### `src/index/tree.rs`
- `compact`
- `merge_node`
- `split_node`
- `split_root`

这些路径都应改为产出 resident base/sibling，而不是直接分配物理页。

### `src/types/base.rs`
- 拆分“构造 resident leaf”与“materialize durable leaf”
- materialize 出来的内存 leaf page 继续使用当前 base 页尾 hint 布局
- 但要显式保留“dump 不持久化这些 hints”的语义
- 删除 `BaseHeader::junk_addr`
- resident 形态不再要求通过物理 sibling addr 访问

### `src/map/data.rs`
- `snapshot` 对 pointer swip 的处理从“遍历 dirty page 链”改为“必要时先 materialize resident node，再收集本轮 flush 集合”
- `snapshot()` 继续只返回完整 `Snapshot`
- `snapshot` 必须把 `Pool.pages` miss 解释成 durable frontier，而不是 retry carrier
- 删除 `collect_aux_addrs` 这类为旧模型补生命周期的扫描逻辑
- 但对 cold/durable leaf，若需要恢复 remote/sibling 可达性，仍允许显式扫描已持久化 payload；这属于磁盘格式不变下的正常路径，不再属于生命周期补洞
- `Snapshot` 需要显式携带：
  - `pages`
  - `durable_pool_addrs`
  - `ack token`
- `snapshot` 不再扫描 `BaseHeader::junk_addr`；退休地址改由 resident/artifact retire owner 直接提供
- 为 checkpoint 增加 task-local scratch allocator
- scratch allocator 仍要从 bucket `next_addr` 取唯一逻辑地址
- 删除 `CheckpointTask::done()` 里按 `start_chkpt_lsn` 全表 `retain()` 的逻辑
- 改成只对 `durable_pool_addrs` 做按地址精确删除
- `snapshot` 不再承担“猜哪些旧 durable 地址已经退休”的职责；这件事前移到 resident generation 构造时完成
- `snapshot.retire_tick` 必须直接来自 `Numerics.next_tick.fetch_add(1, ...)`
- `snapshot` 必须把可达来源收敛成 `live_pool | durable_frontier | resident_owner`
- 对 addressable data/remote/sibling/junk，`Pool.pages` miss 直接表示“已经 durable，停止向下遍历”，不能再走 `Loader`/文件 fallback 做常规分类

### `src/map/flush.rs`
- `FlushResult` 需要显式携带 pointer-swip materialize 产出的 `ack token`
- `FlushResult` 需要显式携带 `retire_tick`
- 新 data file 的 `MemDataStat.up1/up2` 必须初始化为 `snapshot.retire_tick`
- checkpoint 线程只负责把 `ack token` 和普通 flush 元数据一起送到 publish 结束点，不在 flusher 线程里直接改 live `Node`
- `durable_pool_addrs` 保持 task-local，不进入 `FlushResult`
- `CheckpointTask::done()` 只能在 `on_flush()` 成功返回后按地址精确清理它们
- `FlushResult.data_junk/blob_junk` 是与 `data_ivl/blob_ivl` 并列的一等输出，不允许隐含“有 junk 就一定有本轮同类型 flush artifact”
- flusher 线程只处理一次性 scratch artifact，不把它们注册到 `Pool.pages`
- flusher 不再维护 checkpoint retry/requeue 旁路；每个 task 只消费一次完整 `Snapshot`

### `src/map/publish.rs`
- `Publish::publish` 只处理已经 materialize 的 data page
- 不再承担 resident sibling/remote 的生命周期托管
- build 失败路径必须能按地址立即回滚未发布分配
- `AllocGuard`/build 路径需要产出显式 `BuildArtifact` owner，不能再只靠调用栈约定“记得 dealloc”
- `simple_evict` 若复用 `sealed_head`，不应再无条件 `mark_dirty_pid`
  - 该路径需要单独的 runtime-only finalizer，不能复用会触发 `try_checkpoint` 的普通 `commit()`

### `src/map/evictor.rs`
- evictor 只保留淘汰职责，不再做后台 compaction
- 只保留 `simple_evict` 和 `materialize_then_evict` 两种路径
- 复用 shared materialize helper 准备 `Addressable` fallback
- `simple_evict(sealed_head)` 只允许命中 `addressable_head() == None` 的 clean resident

### `src/map/buffer.rs`
- `NodeCache` 的记账从 `Page.size()` 调整为 `Node` 的上层 retained bytes
- resident base/sibling/retire owner 这类随 `Node` 或 runtime artifact 释放而释放的堆内存必须计入 evict 压力
- 已经在 Pool/dirty_pages 中独立存在的物理页不应在上层 cache 中重复计费
- `Loader::pinned` 中若引用的是 `Pool.pages` 里的 dirty page，只计入 `active_bytes`
- `Loader::pinned` 中若引用的是下层 LRU/file cache 里的 durable page，只计入下层 cache 记账
- 只有“未被 `Pool.pages` 或下层 cache 独立计费、且 evict 后会随 Node 一起下降”的 bytes，才计入 `cache_capacity`
- 建议在 `Node` 上提供单独的 `retained_bytes()` / `cache_weight()` 接口，避免继续复用 `size()` 造成语义混淆
- `try_checkpoint()` 的触发条件继续保持为 dirty-side 条件，不要改成直接读取 retained-bytes
- 但 `materialize_then_evict` 产出的新 dirty pages 仍要通过现有 `mark_dirty_pid + try_checkpoint()` 接入 checkpoint 调度
- `active_bytes` 只统计 `Pool.pages`
- checkpoint scratch artifact 不进入 `Pool.pages`，因此也不进入 `active_bytes`
- `Pool` 之外还需要一个“只分配逻辑地址、不登记 dirty page”的 scratch addr path

### `src/store/store.rs`
- `StoreFlushObserver::publish` 在 manifest commit 成功后，需要把 `ack token` 回调给仍存活的 live `Node`
- 这个 ack 必须是“best effort + generation checked”的：
  - 命中同一个 live pointer swip 且 generation 一致，才安装 `sealed_head` 并释放相应 resident owners
  - 命不中就直接放弃，不能为了安装 ack 去覆盖 live 新状态
- 这里的 best effort 只针对 identity / generation miss
  - 若 `PageMap[pid]` 已不是同一个 `pointer swip`，或 live generation 已变化，可以直接丢弃 ack
  - 当前实现里，ack 只修改 live runtime state，不依赖 node mutex；因此只要 identity 和 generation 仍匹配，就必须在 publish 路径里完成交付，不能因为 node mutex 被其它线程持有而跳过
  - 若未来把 ack 需要修改的状态重新放回 node mutex 保护域，则必须同时提供等价的 post-commit 交付路径，才能保持同样的 contract
- `publish` 只提供 manifest commit 成功边界，不负责直接清理 `Pool.pages`
- `durable_pool_addrs` 的删除与 `ack` 的成败解耦：
  - 即使 `ack` 因 generation mismatch 被丢弃，`CheckpointTask::done()` 也照常删除对应 `durable_pool_addrs`
- `ack` 命中时，还要从 live generation 的 `resident_retired_addrs` / `ArtifactRetireState` 中删除本次已经 durable 落盘的退休地址
- `apply_data_junks` / `apply_blob_junks` 必须与 `data_ivl/blob_ivl` 解耦：
  - 只要 `FlushResult.data_junk` 非空，就必须独立调用 `apply_data_junks(bucket_id, retire_tick, data_junk)`
  - 只要 `FlushResult.blob_junk` 非空，就必须独立调用 `apply_blob_junks(bucket_id, blob_junk)`
  - 是否存在本轮新 data/blob file，只影响 `add_*_stat + interval`，不影响 retire apply
- 因为 resident/artifact retire owner 可以只退休旧地址，而本轮未必生成同类型的新 flush artifact

### `src/store/gc.rs`
- data GC 在计算 `Score::calc_decline_rate(now)` 时，`now` 必须读取同一个 `Numerics.next_tick`
- 不允许继续使用 `next_data_id` 或 `file_id` 作为 age 轴

### `src/map/flow.rs`
- backpressure 不感知 resident bytes，只继续感知 flush debt
- 不新增“resident bytes 直接折算 debt”这类耦合逻辑
- 文档和实现都要明确：它控制的是 flush 追赶速度，不是单独控制 Node cache

### `src/map/mod.rs`
- `Loader::load_remote` / `load_remote_uncached` 保持先查 `pinned`
- `Loader::pinned` 需要从“只有 map”扩展为“共享 `PinnedSet` + bytes 统计”
- `Tagged Swip` reload 需要先查 runtime artifact catalog，以区分“live_pool artifact reload”与“cold/durable load”
- resident leaf 的 scan/compact 不应再把 `Loader` 当作访问 resident sibling 的主路径
- 但 cold/durable leaf 仍沿用当前 `Val::get_sibling/get_remote -> Loader` 路径恢复辅助对象

### `src/types/data.rs`
- 保持 `Val::get_record()` 的 `addr -> Loader::load_remote()` 读取模型不变
- 方案必须通过 `PinnedSet` 让 undurable resident remote 继续兼容这一路径，而不是引入新的特判读取分支

### `src/store/gc.rs` 与 `src/meta/mod.rs`
- 不改协议
- 继续复用 `data_junk/blob_junk -> stat -> rewrite GC`

## 7. 与现有机制关系

### 7.1 可以被该方案替代的现有补丁

- `4d7332f` / `fdd311b` 这类围绕 `dirty_pages` 生命周期和辅助地址补洞的补丁
- `src/types/node.rs` 中的 `pin_leaf_pages` / `try_pin_leaf_pages`
- 为容忍中间 sibling 缺页而在 `Iter`、`BaseIter`、tree merge/split 上加的各种 `NotFound/Again/skip` 式兜底
- 围绕 `aux_refs`、`collect_aux_addrs`、hintless 扫描、`dirty_pages` 生命周期补洞的整套补丁

### 7.2 不应回退的独立修正

- `Loader::load_remote` / `load_remote_uncached` 先查 `pinned`
- `interval/reloc` 查找改为 `Option/NotFound`，不再假设 interval 命中后 reloc 一定存在
- `reserve_pid` / `map` / `split_root` 的时序收紧

### 7.3 和现有 junk/GC 协议的关系

这个方案不替换当前 GC，只把对象分成两类：
- 未 durable：纯内存生命周期，不进 GC
- 已 durable：退休意图进入 `data_junk/blob_junk`，后续沿用现有 GC

因此它解决的是“何时进入 GC 协议”，而不是“GC 协议本身怎么做”。

## 8. 配置项建议

- Phase A/B 期间增加一个临时开关，例如 `delayed_serialization = off | resident_leaf_only`
- Phase C 起进入默认开启
- 不提供“只开 resident sibling、不接 materialize 闭包”的半开状态；这种模式最容易形成新的生命周期洞
- 不提供跳过 `resident_retired_addrs` 或跳过 remote owner transfer 的调试开关
- `cache_capacity` 继续表示上层 Node cache 的预算
- `data_file_size` 继续是 checkpoint 的 flush unit / file build 基线，不再承担 checkpoint trigger 阈值语义
- 需要单独提供“总内存上限”配置，作为 memory trigger 的正式来源
- `max_log_size` 继续决定 WAL backlog 触发 checkpoint 的阈值
- 需要单独提供 timer trigger 配置，用来约束“长时间不落盘”的窗口

## 9. 可观测性

- `resident_leaf_count`
- `resident_sibling_bytes`
- `resident_remote_count`
- `materialize_data_pages`
- `materialize_blob_pages`
- `materialize_retry_count`
- `materialize_fail_again_count`
- `resident_retired_addr_count`
- `checkpoint_materialize_micros`

至少需要一条失败导向指标：
- `materialize_incomplete_abort_count`

语义是：发现闭包不完整而主动放弃本轮 checkpoint/evict 的次数。

## 10. 性能影响与约束

### 收益
- 大量中间态 `base/sibling` 不再进入 Pool，直接降低 `dirty_pages` 和 `active_bytes`
- 移除 `collect_aux_addrs` 这类为生命周期补洞的扫描后，checkpoint 热路径 CPU 成本下降
- scan 不再为 resident sibling 走 `Loader`，减少无意义的 `dirty_pages`/文件查找
- evictor 删除后台 compact 分支后，职责更单一，不再和前台 compact 重叠
- retire metadata 不再挂在 base page 上，checkpoint 不需要再拼接/扫描物理 junk chain
- resident memory 与 dirty backlog 分层治理后，`cache_capacity`、checkpoint、backpressure 三条控制线的职责更清晰

### 成本
- pointer swip 的 resident node 会占用额外堆内存
- checkpoint/evict materialize 会增加一次复制开销
- 其中 checkpoint 是 “heap/live-pool -> scratch” 的一次性复制
- evict 是 “heap -> live-pool” 的可见发布复制
- 若 resident base 下方已经存在 addressable delta 前缀，materialize 还需要复制一条新的 delta publish chain
- compact 时要做 remote owner transfer 判定
- evict/live_pool reload 需要额外维护 addressable artifact 的 runtime retire sidecar
- `NodeCache` 需要维护新的 retained-bytes 记账口径
- checkpoint publish 完成后还需要一次 generation-checked ack，以释放已 durable 的 resident owner
- 由于磁盘格式不变，cold/durable leaf 在缺少进程内缓存时，仍可能需要扫描持久化 payload 以恢复 remote/sibling reachability
- 但 live rebuild / materialize 不能把这种 cold-path payload 扫描带回热路径；对已编码 remote 的重写必须保持 metadata-only

### 约束
- retained-bytes 必须只统计“evict 后能随 `Node` 一起下降”的内存
- 不能把已经在 `dirty_pages`/Pool 中独立计费的 `BoxRef` 再算进上层 cache，否则会双重计费
- 不能把已经在下层 LRU/file cache 中独立计费、只是额外被 `Loader::pinned` 持有的 `BoxRef` 再算进上层 cache
- 不能继续把 `Node::size()` 当作 cache weight 使用，否则 `cache_capacity` 将无法真实限制 resident 内存
- ack 只能清理“本次确实 durable 且 generation 未变化”的 resident owner，不能跨 generation 误删 live 状态
- `dirty_pages` 清理必须只按显式地址集执行，不能再退回到按 checkpoint frontier 的全表保守删除

### 验收约束
- `gc`、`cc`、`big`、`bench` 相关用例不能再出现 `NotFound`、死循环或半截快照
- 允许 cold/durable leaf 按需扫描已持久化 payload，但 checkpoint CPU 不应再因为生命周期补洞去扫描整个 dirty pool 或无界重试
- evict 触发后，`Handle<Node>` 和其 `loader.pinned` 能按预期释放，上层 cache 内存应可观测下降
- resident memory 单独增长时，evict 应能生效，而不会错误依赖 checkpoint 才能缓解
- dirty backlog 单独增长时，应仍按现有 `active_bytes/log_size` 规则触发 checkpoint
- flush debt 累积时，backpressure 应仍然只跟随 checkpoint backlog，而不是跟随 resident bytes 直接波动

## 11. 失败与边界场景

- materialize 过程中若 `alloc/reserve_pid` 返回 `Again`
  - 保持 Node 仍为 `Resident`
  - 不发布半截 `Tagged Swip`
  - 必须把本轮 `swap_snapshot()` 拿走的 frontier 原样并回
  - 并由 checkpointer 立即重试或重新入队，不能依赖未来偶然写入
- 若 pointer-swip/tagged snapshot 解析到的地址不在 `Pool.pages`
  - 不能直接视为缺页重试
  - 必须先区分“本应仍由 live owner 提供的 undurable 页”与“已经 durable 的合法可达地址”
  - 前者才能进入 retry
  - 后者必须通过 `Loader` / 文件 / sealed snapshot / runtime artifact owner 继续闭包
  - 否则随机写入场景下会出现永久 retry，进而让 checkpoint debt 无法释放，最终把 backpressure 卡死
- 若 resident base 上方已经有 addressable delta 前缀
  - 不允许原地补写旧 delta `link`
  - 必须通过新的私有 publish chain 完成 backfill
- 若 resident node 在 materialize 前被再次修改
  - 以 Node 锁保护，重新冻结最新 resident 视图
  - 不允许基于旧快照发布
- 若 checkpoint flush 成功，但 ack 回写时发现 generation 已变化
  - 允许本次 durable snapshot 只服务恢复与旧版本读取
  - 不允许覆盖 live resident state
  - 后续由新的 dirty generation 重新 materialize
- 若 data/blob 文件已经写完，但 manifest commit 失败
  - checkpoint scratch artifact 直接随 task 释放
  - 不清理任何 `durable_pool_addrs`
  - 继续依赖现有 orphan 清理协议
- 若 remote owner transfer 失败
  - 直接视为逻辑错误，不能退化为“只保留 addr”
- 若 durable leaf 没有 sibling/remote hint
  - 这不代表“确实没有辅助对象”，因为页尾 hints 本来就不持久化
  - 正确做法是回到已持久化的 `Val` 编码和 sibling 链本身去恢复 reachability
- 未 durable 的退休页不允许写入 `resident_retired_addrs`
  - 否则会把不存在于 interval/reloc 的地址错误送入 GC 协议
- 若 resident memory 很高，但 dirty backlog 很低
  - 先依赖 evict 缓解
  - 不应因为 cache 压力直接强行触发 checkpoint
- 若 evict materialize 之后 dirty backlog 被明显抬高
  - 通过现有 `try_checkpoint()` 路径尽快参与 checkpoint
  - 后续是否对前台形成 backpressure，仍由 flush debt 决定
- 若 clean resident node 已经有 `sealed_head`
  - evict 应直接复用它
  - 不应再次 materialize 同一 generation

## 12. 分阶段落地计划

### Phase A
- 引入 resident/addressable/durable 三态文档和数据结构骨架
- 不改 checkpoint，只先把 `Node`、`Iter`、leaf rebuild 的状态表示理顺

退出条件：
- resident leaf 可以在纯内存路径下被正确 scan/compact

### Phase B
- leaf `compact/merge/split` 改为产出 resident base/sibling
- internal-node `split/compact` 继续保持 addressable，不在本阶段 resident 化
- 接入 `resident_remotes` 和 `durable_remote_hints`
- 完成 remote owner transfer

退出条件：
- 不再为 leaf 中间态 `base/sibling` 分配物理页，也不再为 retire metadata 分配 junk page

### Phase C
Phase C 过大，执行时必须继续拆成顺序子阶段，每个子阶段单独提交，不允许跨子阶段混改：

#### Phase C1: checkpoint publish boundary
- `data_junk/blob_junk` 的 apply 与 `data_ivl/blob_ivl` 解耦
- `durable_pool_addrs` 改成 task-local exact-remove，不再按 checkpoint frontier/watermark 清理 `Pool.pages`
- `retire_tick`、新 data file 的 `up1/up2`、data GC 的 `now` 统一到持久化的全局 `Numerics.next_tick`

退出条件：
- checkpoint 成功后，`Pool.pages` 只按 `durable_pool_addrs` 精确减少
- 即使本轮没有新的 `data_ivl/blob_ivl`，非空 `data_junk/blob_junk` 也会独立 apply
- `DataStat.up1/up2` 与 data GC `now` 已在同一条持久化 tick 轴上

#### Phase C1b: bucket durable frontier
- 为每个 bucket 引入持久化的 `BucketDurableFrontier`
- 为 live resident node / live tagged artifact 引入 runtime `dirty_frontier`
- `compact/merge/split/materialize_then_evict` 必须把 source 的 `dirty_frontier` 做 pointwise max 继承
- `CheckpointTask::snapshot()` 额外汇总 `bucket_frontier_delta`
- `StoreFlushObserver::publish()` 必须把新的 `BucketDurableFrontier` 与 `Map` / stat / interval 一起提交到同一个 manifest txn
- 全局 `WalCheckpoint[group]` 改成由所有 live bucket frontier 的下界派生，不能再直接使用单个 flush task 的最大 lsn
- recovery analyze 先按 `BucketDurableFrontier` 判断“这条 WAL 是否已经 durable”，再决定是否进入 `dirty_table`
- 当前 `preserve_delete` 只允许作为过渡期 workaround，Phase C1b 完成后必须删除

退出条件：
- recovery correctness 不再依赖 latest tombstone 必须继续留在 durable page 中
- 同一 bucket 中“一个 dirty generation 吸收多个 writer group 历史”的情况不会再因为单个 `(group, lsn)` 继承而漏推进某些 group 的 durable frontier
- 全局 `WalCheckpoint` 对任意 group 都只是所有 bucket frontier 的下界，不会因单个 bucket flush 完成而跨 bucket 漏扫 WAL
- `Recovery::handle_update()` 的 correctness gate 已切换到 `BucketDurableFrontier`，`Tree::get(raw)` 至多只作为优化

#### Phase C2: checkpoint source closure
Phase C2 的核心不是“给 checkpoint 增加 retry carrier”，而是先把 snapshot 的 source closure 收紧。  
在当前约束下，`Pool.pages` miss 对 addressable live closure 的语义只能是“已经 durable，到此为止”。  
如果继续把 miss 当成可重试失败，就会把合法 durable frontier 误判成 livelock。

##### Phase C2a: checkpoint source closure
- 为 snapshot/materialize 定义统一的地址来源分类：`live_pool | durable_frontier | resident_owner`
- `Tagged Swip` 路径必须区分：
  - `live_pool artifact`
  - `ordinary durable head`
  - 并且必须把“读取 tagged head”和“读取 retire owner”做成带 page-map 复检的一次稳定观察
- `Pointer Swip` 路径必须先冻结一个自洽 snapshot
  - 当前实现通过 `Page::freeze_node_for_materialize()` / `Node::freeze_for_materialize()` 在 live node lock 下冻结 detached snapshot node
  - 若后续改成直接从 live node 提取 helper，则冻结点必须在 `node lock` 下
  - 其中 addressable 前缀允许同时包含：
    - 本轮未 durable 的 live delta/base
    - 已经 durable 的旧 tail
- pointer-swip/tagged path 都不能再把“`Pool.pages` miss”直接等同于闭包不完整
- 对 `durable_frontier`，snapshot 只停止继续向旧 tail/aux 遍历，不再通过 `Loader` / 文件 fallback 去做常规分类
- resident 路径只允许从 live owner 或 checkpoint-local scratch materialize 收集闭包，不引入额外的 durable fallback

退出条件：
- dirty delta 挂在旧 durable base 之上的混合链不会触发 livelock 或半截 snapshot
- `Tagged Swip` 指向已 durable head 时不要求命中 `Pool.pages`
- snapshot 的每个可达对象都能被解释为 `live_pool`、`durable_frontier` 或 `resident_owner` 三者之一

##### Phase C2b: remove retry carrier
- 删除 `SnapshotBuild::retry` / `SnapshotRetry` 这条旁路
- `CheckpointTask::snapshot()` 只返回完整 `Snapshot`
- checkpointer/flusher 不再回放 `dirty_pid/unmap_pid` 重新入队
- “未持久化但缺页”不再被建模成正常重试窗口；若出现，只能视为生命周期不变量被破坏

退出条件：
- flusher 对每个 checkpoint task 只消费一次，不存在 frontier replay
- 合法 durable miss 只会停止遍历，不会形成 checkpoint debt / backpressure livelock
- checkpoint 不会返回半截 `Snapshot`

#### Phase C3: retire owner closure

##### Phase C3a: resident retire carrier
- resident generation 继续用 `resident_retired_addrs` 托管 durable retired addrs
- checkpoint 在处理 resident pointer-swip 时，直接从 `resident_retired_addrs` 产出 `data_junk/blob_junk`
- resident checkpoint retire 路径不再依赖物理 `junk_addr/junk chain`

退出条件：
- resident pointer-swip checkpoint 不再从 `junk_addr` 读取 retire metadata
- resident retired data/blob addrs 能直接进入 `Snapshot.data_junk/blob_junk`
- resident rewrite -> checkpoint 的 retire carrier 已经闭合

##### Phase C3b: artifact retire carrier
- 引入 `ArtifactRetireState`，让 evict/live_pool artifact 托管未提交的 retire owner
- `artifact -> artifact` / `artifact -> resident` 重写必须继承旧 `ArtifactRetireState.retired_addrs`
- `Tagged Swip` reload 需要先查 runtime artifact catalog，以区分 live artifact 与 cold/durable load
- tagged reload 只能复制/继承 retire owner，不能把旧 tagged head 的 catalog 记录当场清掉

退出条件：
- artifact -> artifact / artifact -> resident 的 retire owner 转移不会丢退休记录
- live artifact reload 能继续继承 retire owner

##### Phase C3c: remove physical junk chain
- 删除 `BaseHeader::junk_addr` 和物理 junk page chain
- checkpoint 直接从 resident/artifact retire owner 产出 `data_junk/blob_junk`
- snapshot 改为基于 resident node 和 materialized 闭包收集 flush 集合，不再扫描或拼接物理 junk chain

退出条件：
- retire metadata 已从 page 格式中移出
- checkpoint 不再扫描 `junk_addr` 或物理 junk chain

#### Phase C4: evict/materialize integration

##### Phase C4a: retained-bytes cache accounting
- 为 live `Node` 增加单独的 `retained_bytes()` / `cache_weight()` 口径
- `NodeCache` 的记账从 `Page.size()` 切换到 retained-bytes
- retained-bytes 只统计“随 live `Node` drop/evict 一起下降”的上层内存
- resident `base/sibling`、runtime retire owner、以及只属于 live `Node` 的容器内存必须计入 retained-bytes
- 已经在 `Pool.pages` / 下层 LRU 中独立计费的 `BoxRef` 不得重复计入 retained-bytes

退出条件：
- `BucketContext::cache/warm` 不再使用 `Page.size()` 作为 cache weight
- resident leaf 的 retained-bytes 会对 `cache_capacity` 形成真实压力
- addressable/dirty page 不会因为仍在 `Pool.pages` 中而被上层 cache 重复计费

##### Phase C4b: evictor role shrink
- 删除 evictor 中独立的 timeout 驱动后台 `compact_once` 路径
- 删除 `safe_txid/oracle` 在 `compact_once` 这条后台 compact 路径中的用途
- 本阶段不改 `evict_once` 里 cold candidate 的 addressable fallback；那条路径由 `Phase C4c` 统一替换成 `materialize_then_evict`

退出条件：
- evictor 在“无内存压力、只有 timeout”的情况下不再主动 compact live page
- `src/map/evictor.rs` 不再保留独立的 `compact_once`
- 后续 cold-evict fallback 改造显式留给 `Phase C4c`

##### Phase C4c: materialize_then_evict integration
- checkpoint/evict 接入 shared materialize helper
- 明确 `checkpoint_scratch` 和 `live_pool` 两种 materialize 目标的生命周期边界
- 将 `evict_once` 里 cold candidate 的 addressable fallback 从 `compact_addressable` 改成 `materialize_then_evict`
- 明确 `materialize_then_evict` 产出的新 dirty pages 会重新接入 `try_checkpoint()`
- 明确 freeze 后 checkpoint 不再依赖旧 live delta/remote 继续留在 `Pool.pages`，evict 只会影响 exact-remove token 是否还能命中

退出条件：
- evictor 不再承担任何 compact/persist 相关逻辑
- `cache_capacity` 对 resident Node 内存重新恢复有效约束
- checkpoint 仍只由 dirty-side 条件触发，backpressure 仍只由 flush debt 驱动

#### Phase C5a: checkpoint ack install
- 接入 checkpoint publish 之后的 generation-checked ack
- 只在 generation 匹配时安装 `sealed_head`
- ack 命中时释放对应 resident owner，并把已 durable 的 retire owner 从 live runtime state 中移除
- ack 只允许修改 live runtime state，不能原地修改已发布的 `ResidentRef`
- publish-side ack traversal 必须带 epoch pin 读取 pointer swip
- 在这一步之前，tagged reload 或普通 page-map 变换都不是 safe removal point

退出条件：
- checkpoint ack 命不中不会覆盖 live resident state
- ack 命中时只收缩本次被 durable 的 resident owners，不会误删 live delta owners
- live ack 收缩 owner 后，旧 snapshot 仍能通过自己重建的 pinned closure 访问之前冻结下来的 remote/data page
- `addressable_anchor()` 已接管“clean resident 收到新 delta 后如何接回 durable fallback”的语义

#### Phase C5b: sealed_head evict reuse
- clean resident node 在 evict 时优先复用 `sealed_head`
- `simple_evict(sealed_head)` 不再重复 materialize resident fallback
- `simple_evict(sealed_head)` 不再无条件重新标 dirty pid
- `simple_evict(sealed_head)` 通过 runtime-only publish helper 完成，不再调用 `try_checkpoint`

退出条件：
- 已有 `sealed_head` 的 clean resident node 在 evict 时不会重复 materialize
- `simple_evict(sealed_head)` 不会把纯运行时切换误当成新的 dirty publish
- `simple_evict(sealed_head)` 仍会把当前 retire owner 绑定到目标 tagged head
- cold/durable leaf 按需扫描已持久化 payload 的路径保留为正常恢复/重建语义

Phase C 总退出条件：
- 不再依赖 `collect_aux_addrs` 这类生命周期补洞扫描和 `dirty_pages` 生命周期补丁
- checkpoint source closure 已闭合，合法 durable miss 只会停止遍历，不会形成 retry/backpressure livelock
- `Pool.pages` 的 build/published/drop owner 已落地，exact-remove 三条路径都可执行
- retire metadata 已从 page 格式中移出，checkpoint 不再扫描或拼接物理 junk chain
- `data_junk/blob_junk` 的 apply 已与 `data_ivl/blob_ivl` 解耦，不会因“本轮没有对应 type 的新文件”而漏 apply
- evictor 不再承担任何 compact/persist 相关逻辑
- `cache_capacity` 对 resident Node 内存重新恢复有效约束
- checkpoint 仍只由 dirty-side 条件触发，backpressure 仍只由 flush debt 驱动

### Phase D

#### Phase D1: remove eager leaf pre-pin
- 删除 `pin_leaf_pages`
- `Node::new` / `Node::load` 不再因为 sibling hint 而预先遍历整条 sibling 链

退出条件：
- addressable leaf 的构造/重载不再依赖 eager sibling pin 补丁
- sibling 只会在真正遍历或 materialize freeze 时按需加载

#### Phase D2: remove sibling-miss fallback
- 删除 sibling 缺页回退逻辑
- 删除 scan/leaf iter 上围绕 `Again/NotFound` 的重试式补丁
- addressable sibling 遍历回到普通 lazy `Loader::load()` / durable reload 语义，不再通过 restart 当前 scan 来“恢复”

退出条件：
- scan/iter 不再依赖“遇到 sibling 缺页就重试”来维持正确性

#### Phase D3: remove old dirty-pages lifecycle patches
- 清理旧的 `aux_refs` / 中间态脏页补丁留下的最后兼容壳
- 在当前实现里，这一步主要体现为删除已经失效的 sibling iterator / fallback 兼容接口，而不是再引入新的生命周期机制

退出条件：
- 不再保留围绕 `dirty_pages` 生命周期补洞的兼容分支
- `remote/sibling/retire` 的 live 可达性只剩 delta owner、resident owner、artifact owner 或 durable addr 四条正规路径

### 12.1 read-path closure required before completion

当前 delayed-serialization 还不能只靠“checkpoint/evict/crash window 已闭合”就宣称完成，leaf 读路径本身还必须满足下面 3 条额外约束：

1. resident point read 的 owner 必须 zero-copy 闭合
   - `ValRef` 不能再假设 inline payload 总能由 `base_box()` 保活
   - 对 addressable leaf，inline value 仍可由 `BoxRef` 保活
   - 对 resident leaf，inline value 必须改由 resident backing owner 保活
   - 这里的 owner 抽象必须是 zero-copy 的，不能为了统一 owner 而把 inline value 重新拷贝到新的 `Vec<u8>` / `Box<[u8]>`
2. resident `search/lower_bound` 必须按 prefix-compressed 语义比较
   - resident leaf 内部保存的是去掉当前 leaf prefix 之后的 suffix key
   - point lookup / seek 起点不能再拿完整 raw key 直接和 resident entry 的 suffix 做二分比较
   - 正确实现必须在 resident 路径下按 `prefix + suffix` 的逻辑顺序比较，但不能为比较临时拼接完整 key
3. resident sibling 的可见性必须同时覆盖 scan 和 point lookup
   - resident leaf 中的旧版本链存在 `resident_siblings`，不再依赖 addressable sibling addr chain
   - 因此 point lookup 不能只看 `Val::get_sibling()` 再走 `Loader::load(addr)`
   - resident point lookup 必须直接读取 `resident_siblings`，并与 scan 使用相同的 MVCC/visibility 语义

这 3 条如果有任意一条未闭合，即使 checkpoint/evict 路径本身已经正确，delayed-serialization 仍然会把现有读语义打坏，因此 phase 状态必须保持 `in_progress`。

## 13. 测试矩阵与验收

验收标准不是“局部新增测试通过”，而是：
- 现有单元测试必须全部通过
- 现有集成测试必须全部通过
- `tests/anomalies.rs` 单独通过不构成验收
- 在全量测试通过前，delayed-serialization 只能标记为 `in_progress`

### 单元测试
- resident leaf 的 sibling 遍历不经过 `Loader`
- resident point read 在 addressable / resident 两种 backing 下都不需要拷贝 inline payload
- resident `search/lower_bound` 在 prefix-compressed key 上能给出与 addressable leaf 一致的命中和起始位置
- resident point lookup 能看到 `resident_siblings` 里的旧版本，而不是只覆盖 scan
- delta remote compact 到 resident base 后，旧 delta 释放不影响 remote 访问
- durable remote 不进入 `resident_remotes`
- 未 durable remote 不进入 `resident_retired_addrs`
- 多次 compact 后，`resident_retired_addrs` 能正确继承并去重
- `ArtifactRetireState.retired_addrs` 在 artifact -> resident 重写时会并入新的 `resident_retired_addrs`
- `ArtifactRetireState.retired_addrs` 在 artifact -> artifact 重写时不会丢失或重复
- `Node::retained_bytes()` 覆盖 resident base/sibling/retire owner，且不重复统计独立 dirty pages
- `Node::reference()` 对 resident backing 只共享引用，不深拷贝 resident payload
- `Node::reference()` 只提供轻量 reader snapshot，不再隐式重建 materialize freeze closure
- snapshot 在 live ack/unpin 之后仍能访问自己冻结时 pin 下来的 resident remote/data page
- resident base 下新增 delta 时，`addr_head` / `delta.link` 语义与 addressable 旧模型解耦
- materialize 不会原地修改一个仍可能被旧 reader 持有的 delta frame
- checkpoint ack 只在 generation 匹配时安装 `sealed_head`，且会释放对应 `resident_remotes`
- checkpoint ack 只有在 pointer swip 和 generation 同时匹配时才生效
- 已有 `sealed_head` 的 clean resident node 在 evict 时不会重复 materialize
- checkpoint 只按 `durable_pool_addrs` 删除 `Pool.pages`，不会误删未参与本轮 durable 的地址
- checkpoint 直接从 resident/artifact retire owner 产出 `data_junk/blob_junk`，不再扫描 base `junk_addr`
- 即使本轮没有新的 `data_ivl/blob_ivl`，非空 `data_junk/blob_junk` 也会被独立 apply
- checkpoint scratch artifact 不进入 `Pool.pages` / `active_bytes`
- `simple_evict(sealed_head)` 不会重新标 dirty pid
- 冷加载出来的 durable leaf 即使没有页尾 hint，也能通过扫描已持久化 `Val` 编码恢复 sibling/remote reachability
- `BuildArtifact` abort 和 `AddressableArtifact` drop 只按实际 `remove(addr)` 扣减 `active_bytes`
- evict 后 reload 命中 live_pool artifact 时，`ArtifactRetireState` 能继续托管 retire owner；冷加载 durable leaf 则不会凭空恢复 retire owner
- evict -> tagged reload -> checkpoint 的链路中，retire owner 不会因 `Node` 析构而丢失

### 集成测试
- `tests/cc.rs`
  - merge churn 下 scan 仍有序
- `tests/gc.rs`
  - `gc_data`
  - `gc_blob`
- `tests/big.rs`
  - 大 value 路径下 remote 不丢
- `tests/bench.rs`
  - `try_find_leaf` 不死循环
- 其余现有集成测试必须继续保持通过，不能把 resident 读路径回归留给后续 phase 收尾
- cache pressure 场景下，evict 后 `Pointer Swip` 可安全降为 `Tagged Swip`，且 Node 析构后 pinned pages 可释放
- cache pressure 场景下，resident heap 增长会触发 evict；若只增长 resident 内存而不增长 dirty page，`cache_capacity` 仍然有效
- 仅增长 dirty pages / WAL backlog 时，checkpoint 仍会被 memory/log trigger 命中
- 仅增长 resident heap 时，不应把 checkpoint 当成 cache 回收器；应先由 evict 降低上层 cache，占用仍超总内存阈值时再触发 checkpoint
- 低压力但长期有脏状态时，timer trigger 仍会产生多个 data/blob file，`gc_data/gc_blob` 不能继续依赖旧的 dirty-page 膨胀行为
- evict materialize 导致 dirty pages 增长后，应能通过现有 `try_checkpoint()` 进入 flush 调度
- checkpoint 成功后，`Pool.pages` 只减少本轮 `durable_pool_addrs`，不会因前沿推进误删其它 live 地址
- 同一 node 经历“冷加载 -> 多次 compact -> 一次 checkpoint”后，旧 durable 地址只会延后回收，不会丢退休记录
- checkpoint 因 `Again` / 闭包不完整而中止时，前一轮 `dirty_pid/unmap_pid` 会被完整重放并最终重试成功
- 移除 `junk_addr/junk chain` 后，GC 结果与旧协议一致，不会漏标记已 durable retired addr

### failpoint / crash
- `after_data_sync`
- `before_manifest_commit`
- `after_manifest_commit`
- `after_manifest_commit_before_wal_checkpoint`
- `retire_after_apply_before_clear`

每个窗口都要验证：
- 重启后无 `NotFound`
- manifest 不指向未 durable 文件
- orphan file 可回收
- manifest/page-table/interval/stat 一致
- retire/junk apply 可以幂等重放

## 14. 与替代方案对比

### 对比一：继续补 `aux_refs`

优点：
- 改动局部

缺点：
- 本质上仍承认“中间态物理页先进入地址空间”这个旧模型
- 需要继续维护引用计数、hint、dirty_pages 清理和 snapshot 重试之间的复杂耦合
- checkpoint 仍可能需要扫描 leaf entry 补 remote reachability

### 对比二：`uncommit_pages`

优点：
- 可把“已分配但未发布”的物理页与 `pages` 分离

缺点：
- 仍然没有解决 resident sibling/remote 的真实 owner 问题
- 只是把地址空间再分一层，不能消除“中间态物理页过早出现”的根因

### 本方案的取舍

本方案的核心不是再给物理页多打一层补丁，而是把中间态 `base/sibling` 与 retire metadata 一起从物理页格式里拿出来，直到必须 checkpoint/evict 发布时才分别 materialize/collect。  
这样才能同时解决：
- checkpoint 竞态
- live 可达性
- remote owner transfer
- `dirty_pages` 提前清理

## 15. 实施顺序建议

1. 先定义 resident/addressable/durable 三态和 `ResidentLeafAux`
2. 同步定义 `ResidentRef` / `PinnedSet` / `Node::reference()` 的共享语义
3. 再定义 `addr_head` / `base_storage` 新状态语义，去掉对 `state.addr == 整链物理头` 的依赖
4. 同步定义 `Node::retained_bytes()` / `cache_weight()`，先把上层 cache 记账口径改对
5. 再改 `Iter`、leaf rebuild、`compact/merge/split` 的 resident 路径
6. 再接 remote owner transfer 和 `durable_remote_hints`
7. 再接 checkpoint/evict materialize
8. 最后删除 `pin_leaf_pages`、`aux_refs`、`collect_aux_addrs` 和 sibling 缺页兜底

执行原则：
- 先让 live 可达性闭合，再删旧补丁
- 先让 `reference()` 和 resident backing 的共享语义闭合，再让 resident 对象进入主流程
- 先去掉 `state.addr` 的旧语义，再谈 resident base 上的 delta 插入
- 先让 cache 记账口径闭合，再谈 `cache_capacity` 是否仍有效
- 先让 resident 路径独立正确，再接 checkpoint materialize
- 保持 checkpoint 触发和 backpressure 语义稳定，只把 resident 内存控制交给 evictor
- 只有当 `remote/sibling/retire` 的 owner 边界已明确，才能让 `dirty_pages` 退出生命周期管理角色

## 16. 附录：已落地的实现优化

本附录只记录当前代码中已经保留的优化，不包含后来被回退的尝试性改动。

### 16.1 resident value 存储压缩

- `ResidentLeaf` 与 `ResidentSibling` 不再为每个 key/value/version 单独分配 `Box<[u8]>`
- 当前实现改为：
  - `ResidentLeaf.payload: Box<[u8]>`
  - entry / sibling version 只保存 `{off, len}`
- 这样做的收益是：
  - 显著减少 compact/build resident 时的小对象分配
  - 降低 `malloc/free` 与碎片整理开销
  - 让 resident payload 在内存中更连续，更接近 addressable sst 的布局

### 16.2 resident key 构造去临时分配

- resident build 时不再通过 `LeafSeg::full_raw_vec()` 先构造临时 `Vec<u8>`
- 改为直接把 `prefix + base` 追加写入 resident payload arena
- 这样减少了一次临时 key 分配与复制

### 16.3 resident 结构元数据压缩

- `ResidentLeafEntry.sibling_head` 与 `ResidentSibling.next` 已由 `Option<usize>` 收敛为 `u32 + sentinel`
- 这样 resident entry / sibling 本身更紧凑，降低缓存占用与遍历带宽

### 16.4 resident 查找路径 sst 化

- resident `search / lower_bound / visit_versions` 不再走 `slice::binary_search_by` 闭包
- 当前实现改为手写二分，控制流与 addressable `Sst::search_by / lower_bound` 更接近
- 对共享当前 leaf prefix 的查询 key：
  - 先剥掉公共 prefix
  - 直接对 resident entry 中保存的 tail 做比较
  - 只有 prefix 不匹配时才回退到通用 `cmp_raw_with_prefixed_tail`
- 这样把 resident 查找从“通用字节切片比较”进一步收敛到“更接近 sst 的定制比较”

### 16.5 resident payload 访问去重复边界检查

- resident entry / sibling version 的 payload 访问统一走 `payload_slice()`
- 在 debug 下保留边界断言
- 在 release 下使用单次封装的 unchecked slice 访问
- 这样减少了 resident 查找与遍历中的重复 bounds check

### 16.6 dirty_frontier 固定成本下降

- `dirty_frontier` 已从堆 `Vec` 收敛为小对象内联结构
- 在当前实现里，单节点常见的 1 到 4 个 writer group frontier 不再产生堆分配
- `merge_dirty_frontier_entry()` 也不再每次 `push + sort + dedup`
- 改为基于有序 `binary_search` 的原位更新 / 插入
- 这样可以减少每次 `Node::insert / insert_inplace` 的固定 bookkeeping 成本

### 16.7 retained-bytes 增量记账

- `Node::insert / insert_inplace` 不再构造临时 `NodeState` 后整体重算 retained bytes
- 当前实现改为：
  - resident static bytes 保持不变
  - `dirty_frontier` / sidecar bytes 按增量更新
- 这样减少了写热路径上的对象构造和重复聚合

### 16.8 remote 路径避免无意义 payload load

- resident build / sibling rebuild / checkpoint materialize 在重写 remote `Val` 时，不再 `get_record()` 读取 remote payload
- 当前实现直接复用已编码的：
  - flag
  - data len
  - sibling addr
  - remote addr
- 对 large value，这避免了“为了重写引用而重新读取 payload”的固定成本

### 16.9 hot path remote 可达性判定去文件回退

- owner transfer / live reachability / checkpoint materialize 不再为了判定 remote 是否仍 undurable 而走文件读取
- 当前实现只允许：
  - 从本地 live owner 取 undurable remote
  - 本地 owner 缺失时直接按 durable 处理
- 对 freeze/materialize 路径，已新增只查本地 owner 的 remote 访问接口，避免把文件 fallback 混入热路径

### 16.10 纯 base leaf 读路径去空 delta 扫描

- 对 `delta_len() == 0` 的 leaf，`get_exact()` 与 `traverse()` 会直接走 base/resident 路径
- 不再先跑一遍空的 delta 访问逻辑再回到 sst/resident 查找
- 这是一个与 workload 无关的固定成本优化，适用于所有“当前没有 delta 前缀”的 leaf

### 16.11 当前效果

在本轮优化后，`cargo test --release --test bench -- --nocapture` 的典型结果已经收敛到与 pre-ds 基本同一水平：

- `put` 已不再回退，多轮结果中通常持平或略优
- `hot get` 已回到 pre-ds 同量级
- `cold get` 保持持平

这些优化都是在保持 delayed-serialization 既有 crash-safety 与生命周期语义不变的前提下完成的。

### 16.12 补充：当前代码中的 cache / dirty pool / evict 控制线

本节不再描述未来可能的机制，而是只记录当前代码已经落地的真实行为边界。

#### 16.12.1 当前三条内存控制线

当前实现已经把内存控制拆成三条独立控制线：

- upper cache
  - `NodeCache`
  - 由 `cache_capacity` 控制
  - 只负责 `PageMap` 中处于 `Pointer Swip` 状态的 active pid 对应 `Node` 生命周期
- lower cache
  - file-backed `ShardPriorityLru<BoxRef>`
  - 由显式的
    - `low_priority_cache_capacity`
    - `high_priority_cache_capacity`
    两个字节配额控制
- dirty-side
  - `Pool.active_bytes`
  - 由 `dirty_pool_capacity` 作为前台流控上限
  - 同时仍受 `data_file_size` 这个 checkpoint 触发线约束

这三条线的职责已经明确分离：

- resident / pointer-swip node 生命周期压力先交给 evictor
- file-backed clean page / blob cache 交给 lower LRU
- dirty page backlog 交给 checkpoint + foreground backpressure

#### 16.12.2 `NodeCache` 的真实语义

当前代码中，`NodeCache` 的职责是：

- 统计 `PageMap` 中 active `Pointer Swip` pid 对应 `Page<Node>` 的账面大小
- 通过 `cool/warm/evict` 管理这些 active pid 的冷热状态
- 为 evictor 提供候选和预算压力信号

它不是：

- `Loader::pinned` 的镜像
- 每次 in-place delta 插入都同步增长的精确 live-bytes 账本

当前记账边界只发生在 `PageMap` 生命周期变化上：

- 新 pid 发布
  - `Publish::map / map_to / map_reserved`
  - commit 后经 `cache_after_commit -> bucket.cache(page)` 入账
- 已有 pid 成功 replace
  - `Publish::replace`
  - 成功后经 `bucket.warm(pid, new.cache_weight())` 更新账面并升温
- `Tagged Swip -> Pointer Swip`
  - `BucketContext::load`
  - 只有 tagged 头恢复成 pointer 头后，才重新入账
- `Pointer Swip -> Tagged Swip / NULL`
  - `evict_simple / evict_runtime_only / unmap`
  - 经 `evict_cache(pid)` 扣账

对应代码可见：

- [src/map/buffer.rs](/home/workspace/gits/github/mace/src/map/buffer.rs)
- [src/map/publish.rs](/home/workspace/gits/github/mace/src/map/publish.rs)

#### 16.12.3 `Tree::link` 不触碰 cache

当前代码里，same-pid 的 foreground 写入路径：

- `Tree::link -> NodeGuard::insert -> insert_inplace`

不会触碰 `NodeCache`。

这不是遗漏，而是当前实现的明确取舍：

- 不在 foreground hot path 上对 `NodeCache` 的 `DashMap` 做额外操作
- `warm()` 只保留在 `Publish::replace` 等换代路径

也就是说，当前代码对 same-pid in-place delta growth 的定义是：

- 可以增长 live node 的实际内存
- 但不会在 `Tree::link` 里更新 `NodeCache`
- `NodeCache` 继续被解释为“PageMap publication / replacement 边界上的 upper-cache 账本”，而不是每次 in-place 插入都实时精确增长的字节账本

对应代码可见：

- `Tree::link` 不再触碰 cache：[src/index/tree.rs](/home/workspace/gits/github/mace/src/index/tree.rs)
- `warm()` 只在 replace 路径使用：[src/map/publish.rs](/home/workspace/gits/github/mace/src/map/publish.rs)

#### 16.12.4 `cache_weight` 的当前口径

当前 `Page::cache_weight()` 已切换到：

- `Node::total_size()`

因此它不再使用纯 sidecar `retained_bytes` 作为权重。

这条修正解决的是：

- addressable node 的 base/head 本体必须进入 upper-cache 账面
- `NodeCache` 不再系统性低估 `Pointer Swip` 侧的已发布 node generation

对应代码可见：

- [src/types/page.rs](/home/workspace/gits/github/mace/src/types/page.rs)
- [src/types/node.rs](/home/workspace/gits/github/mace/src/types/node.rs)

#### 16.12.5 lower LRU 的当前语义

lower LRU 现在已经不再由 `cache_count` 或 ratio 派生控制。

当前 lower LRU 只由两个显式字节配额控制：

- `low_priority_cache_capacity`
- `high_priority_cache_capacity`

这两个值相加，就是 lower LRU 的总容量。

当前实现里：

- `CachePriority::Low`
  只消耗 low-priority quota
- `CachePriority::High`
  只消耗 high-priority quota
- eviction 纯按字节超限触发
- dirty `Pool.pages` 命中不会再写入 lower LRU，避免双计费

对应代码可见：

- [src/utils/options.rs](/home/workspace/gits/github/mace/src/utils/options.rs)
- [src/utils/lru.rs](/home/workspace/gits/github/mace/src/utils/lru.rs)
- [src/map/mod.rs](/home/workspace/gits/github/mace/src/map/mod.rs)

#### 16.12.6 dirty pool 的当前语义

当前 dirty-side 有两条独立阈值线：

- checkpoint 触发线
  - `active_bytes > data_file_size`
- foreground flow control 触发线
  - `active_bytes >= dirty_pool_capacity`

这意味着：

- 只要 dirty backlog 超过 `data_file_size`，就要开始 checkpoint
- 但只有当 dirty backlog 到达 `dirty_pool_capacity`，才会把压力映射进 foreground backpressure

`dirty_pool_capacity` 本身仍是显式配置项；若用户不设，则在 `validate()` 阶段默认推导为：

- `data_file_size * 2`

所以当前代码的真实解释是：

- `data_file_size`
  - 是 checkpoint 的 dirty-side 触发线
- `dirty_pool_capacity`
  - 是 foreground flow-control 的上限

对应代码可见：

- [src/utils/options.rs](/home/workspace/gits/github/mace/src/utils/options.rs)
- [src/map/buffer.rs](/home/workspace/gits/github/mace/src/map/buffer.rs)

#### 16.12.7 foreground backpressure 的当前语义

当前 foreground backpressure 继续通过 `FlowController` 统一实现。

dirty-side 压力接入方式是：

- 如果 `active_bytes > data_file_size`
  - 先触发 checkpoint
- 如果 `active_bytes >= dirty_pool_capacity`
  - 再把超限部分转成 transient extra debt，交给 `FlowController` 当前这一次的 delay 计算

这条路径不做：

- direct sleep/stall in `Pool`
- persistent synthetic debt 写入 `FlowController.debt_bytes`

backpressure 仍然只负责：

- 控制 foreground 写入继续放大 flush debt 的速度

它不负责：

- 直接表达 resident cache 的上限
- 替代 evictor

#### 16.12.8 evict 可观测性

当前代码已经补上了最小可用的 evict 可观测性：

- `CounterMetric::CacheEvict`

它只在真实成功 eviction 路径上递增，不统计：

- candidate cooling
- skipped page
- stale candidate

这样测试可以直接验证“evict 确实发生过”，而不必通过间接行为推断。

对应代码可见：

- [src/utils/observe.rs](/home/workspace/gits/github/mace/src/utils/observe.rs)
- [src/map/evictor.rs](/home/workspace/gits/github/mace/src/map/evictor.rs)

#### 16.12.9 当前测试覆盖

围绕这一节当前已有的直接测试包括：

- upper cache / NodeCache
  - `cache_uses_addressable_total_size_not_sidecar_only`
  - `cache_uses_resident_total_size_not_retained_bytes`
  - `cache_warm_replaces_accounted_size_on_same_pid`
  - `put_does_not_touch_cache_accounting_on_same_pid_growth`
- dirty pool / checkpoint / backpressure
  - `dirty_pool_capacity_defaults_to_legacy_formula`
  - `checkpoint_starts_when_active_bytes_exceeds_data_file_size`
  - `dirty_pool_pressure_skips_delay_below_upper_limit`
  - `dirty_pool_pressure_triggers_checkpoint_and_foreground_delay`
  - `dirty_pool_pressure_skips_delay_when_backpressure_disabled`
- lower LRU
  - `priority_lru_evicts_by_bytes`
  - `priority_lru_respects_explicit_high_priority_quota`
  - `priority_lru_respects_explicit_low_priority_quota`
  - `loader_find_on_dirty_page_does_not_populate_lower_lru`
  - `loader_load_remote_on_dirty_page_does_not_populate_lower_lru`
- evict observability
  - `evict_increments_cache_evict_counter`
- integration
  - `dirty_pressure_delays_writes_and_reopen_reads`

#### 16.12.10 当前结论

截至当前代码，这一节应当被理解为：

- `cache_capacity`
  只约束 upper cache
- `low_priority_cache_capacity + high_priority_cache_capacity`
  共同约束 lower LRU
- `data_file_size`
  决定 dirty-side checkpoint 触发线
- `dirty_pool_capacity`
  决定 foreground flow-control 上限

也就是说，当前实现已经形成：

- upper cache
- lower cache
- dirty pool

三条分离的控制线。

这并不等价于“总内存存在单一上限”，但已经避免了：

- `Tree::link` 在 hot path 上碰 `NodeCache`
- dirty page 命中被 lower LRU 双计费
- lower LRU 继续靠 `cache_count + ratio` 派生容量
- dirty-side checkpoint 与 foreground flow-control 共用同一阈值
