Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

R3 — Immutable Delta Pack(不可变增量包)

日期:2026-08-11 · 状态:Design · 范围:chunk-plane + swift-meta model + swift-fuse + swiftfuse-cli 上游:docs/ROADMAP-2026-08-10.md R3 节 + 测量附录 M1;crates/chunk-plane/src/{lib,writer,map,reader,compact,cache,store}.rs; crates/swift-meta/src/{engine,model}.rs 约束:chunk-plane 与 MetadataEngine trait 的公共 API 只增不改;只写本文档,不碰代码、不碰 git。


0. 问题与目标(M1 复述)

已定死的事实(M1,2026-08-11):

  • 100 × 4K 随机写 + 单次 fsync → +96 个 Swift 对象(94×4K + 2×8K,仅 2 对相邻区间被合并)。
  • 字节放大 = 1×(REPORT/BENCHMARK 的 “1024×” 已修正),真实瓶颈是 object/request 数量放大: 96 个对象 = 96 次 PUT,即使批量并行(4f063f7 33s→9.4s),Swift 小对象 PUT 吞吐下限依然封顶。
  • 非相邻区间天然不合并,所以“合并 dirty 区间“这条路走不到头。

R3 目标: 把多个小 dirty 写打包进 1 个 Swift 对象(index + payload),使

Swift PUT 次数 ≈ dirty_bytes / pack_target

后台 compaction 再把 pack 合并回普通 block。完全符合宗旨:

object 永远 immutable;不读旧数据;mutation 只在 metadata。

设计要点(六节):

  1. Pack 对象布局(header/index/payload、序列化、版本化)
  2. 命名与不可变性
  3. 读路径
  4. 写路径集成
  5. Compaction 与删除安全
  6. Gate 映射

以及必须的:API 变更清单(body vs signature)、测试计划、兼容性/部署。


1. Pack 对象布局(index + payload)

1.1 总体结构

一个 delta pack 是一个 Swift 对象,内部为三段连续字节:

┌──────────┬────────────────────┬─────────────┐
│ header   │ index (N×24 B)     │ payload     │
│ 40 B     │ N 个 PackEntry     │ N 段 dirty  │
└──────────┴────────────────────┴─────────────┘

payload 按 index 顺序首尾相接;index 里的 payload_offset 指向每段 payload 在对象内的起始字节。

1.2 二进制格式(v1,全部小端)

Offset  Size  Field
0       4     magic        = b"SFDP"
4       1     version      = 1
5       1     flags        = 0(v1 保留)
6       2     reserved     = 0
8       4     entry_count  (u32)  = N
12      8     payload_len  (u64)  = P,payload 区总字节数
20      20    reserved     = 0(未来扩展预留)
40      24N   index        :N × PackEntryBin(24 B)
40+24N  P     payload      :各 entry 的 payload 按序拼接
PackEntryBin(24 B):
0   8  file_offset   (u64) —— 文件绝对偏移(非 chunk 内偏移)
8   4  len           (u32) —— 本段 payload 长度,> 0
12  4  reserved      (0)
16  8  payload_offset(u64) —— 本段 payload 在 payload 区内的起始字节

不变量(parse_pack 校验,任一条失败即视为对象损坏):

  • payload_offset[0] == 0,payload_offset[i] == payload_offset[i-1] + len[i-1];
  • payload_offset[N-1] + len[N-1] == payload_len;
  • file_offset 严格递增(与 metadata 中 pack slice 的 entry 顺序一致);
  • 对象总大小 == 40 + 24·N + P。

1.3 序列化 / 反序列化(新模块 chunk-plane/src/pack.rs)

#![allow(unused)]
fn main() {
// 与二进制 PackEntryBin 一一对应(去掉 reserved)。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackEntry {
    pub file_offset: u64,
    pub len: u32,              // > 0
    pub payload_offset: u64,
}

pub const PACK_MAGIC: [u8; 4] = *b"SFDP";
pub const PACK_VERSION: u8 = 1;
pub const PACK_HEADER_SIZE: usize = 40;
pub const PACK_ENTRY_SIZE: usize = 24;

/// 把一批 (file_offset, data) 序列化为一个 pack 对象。
/// 返回 metadata 要保存的 entries(含 payload_offset)与对象 body。
/// 输入必须已按 file_offset 排序。
pub fn serialize_pack(ranges: &[(u64, Bytes)]) -> (Vec<PackEntry>, Bytes);

/// 校验 magic/version/不变量并还原 index(调试/灾难恢复/只读校验用)。
pub fn parse_pack(data: &[u8]) -> Result<Vec<PackEntry>, PackError>;
}

为何把 index 也写进对象(而不是只放 metadata):

  1. 对象自描述——孤儿 pack(PUT 成功但 metadata 未提交)可被独立枚举/清理;
  2. 首次读取时可选校验:对象内 index 与 metadata 内 entries 一致(防元数据/数据漂移);
  3. 不增加读路径成本:读路径从不解析 index,file→payload 的解析只查 metadata(见 §3)。

版本化策略:

  • header version=1。未知 magic/version → parse_pack 报错,绝不猜测。
  • v2 演进:bump PACK_VERSION;扩展字段进 20B reserved 区,格式不兼容才 bump。
  • metadata 侧:Slice.pack_entries 带 #[serde(default, skip_serializing_if = "Vec::is_empty")]—— 旧记录(无该字段)读到空;空值时序列化字节与旧版一致(避免 etcd BUG-6 式 round-trip 归一化抖动)。 注意:旧二进制读取含 pack 的文件会得到错误数据(把 index 字节当数据),部署约束见 §9。

1.4 metadata 模型(swift-meta/src/model.rs)

#![allow(unused)]
fn main() {
pub struct Slice {
    pub start: u64,
    pub blocks: Vec<BlockRef>,
    /// 非空 ⇒ 本 slice 是 PACK:`blocks` 恰好 1 个 BlockRef
    /// (obj = pack 对象名,size = 总 payload 长度,etag = pack 对象 etag),
    /// 每个 entry 映射一个文件区间到该对象的一段 payload。
    /// `start` = 第一个 entry 的 file_offset。
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub pack_entries: Vec<PackEntry>,
}
}
  • 一个 pack = 一个 Slice(而非每个 entry 一个 slice)——否则 100 个 entry 会让 MAX_SLICES_PER_CHUNK=16 被一次 flush 击穿。pack slice 计入 slice 数,正合 compaction 触发语义。
  • BlockRef 字段不动(obj 语义保持“该对象就是这段数据“,只是 pack 对象内有多段,用 pack_entries 索引)。
  • 辅助方法 body 微调:Chunk::end_offset() 对 pack slice 取 max(entry.file_offset + entry.len); covered_bytes() 语义不变(blocks 的 size 和 = 各 entry 长度和)。

2. 命名与不可变性

复用 slice-id 机制(MetadataEngine::alloc_slice_id(),etcd CAS 递增 / SQLite 计数器,全局唯一、单调):

pack 对象名:format!("{:x}/{}/{:x}.pack", inode, chunk, slice_id)
             例:5/2/1.pack

对比现有 block 名 format!("{:x}/{}/{:x}/{:x}", inode, chunk, slice_id, i)(例 5/2/1/0):

  • 同一 slice_id 命名空间下,.../{idx} 是 block、....pack 是 pack,天然无碰撞;
  • 每个 pack 用独立新分配的 slice_id → 名字永不复用 → PUT 永不覆盖既有对象;
  • 崩溃在 metadata CAS 之前 → 只留孤儿 pack(与孤儿 block 同类,无害,见 §5.4);
  • 后续再写同一文件区间:新 flush 分配新 slice_id、新 pack,旧 pack 保持不动(append-only), 读路径 newest-wins 自然覆盖(§3)。

immutable 在模型层同样成立:flush 从不修改既有 slice,只追加。


3. 读路径

3.1 map_read(chunk-plane/src/map.rs,body-only)

现有 flatten 循环把每个 slice 展开成 (file_offset, BlockRef) spans,然后 newest-wins 减除。 对 pack slice,每个 PackEntry 展开成一个 span:

for slice in &chunk.slices {
    if slice.pack_entries.is_empty() {
        let mut s_off = slice.start;
        for b in &slice.blocks { spans.push((s_off, b.clone(), 0)); s_off += b.size; }
    } else {
        let b = &slice.blocks[0];
        for e in &slice.pack_entries {
            // 数据在 b.obj 内,字节区间 [e.payload_offset, e.payload_offset + e.len)
            spans.push((e.file_offset,
                        BlockRef { obj: b.obj.clone(), size: e.len as u64, etag: b.etag.clone() },
                        e.payload_offset));
        }
    }
}

span 类型从 (u64, BlockRef) 扩为 (u64, BlockRef, u64 /*obj_offset*/);newest-wins 减除逻辑、hole 填补逻辑完全不变(每个 span 的 origin 就是其 file_offset,block_offset = a - file_offset 对普通 block 和 pack entry 同样成立)。

DataSegment::Block 变体新增一个字段(消费方都用 .. 解构,兼容):

#![allow(unused)]
fn main() {
pub enum DataSegment {
    Block {
        block: BlockRef,
        block_offset: u64,   // 数据区内的偏移
        obj_offset: u64,     // 新增:block.obj 内数据区的起始字节(0 = 普通 block;>0 = pack payload_offset)
        len: u64,
        file_offset: u64,
    },
    Hole { file_offset: u64, len: u64 },
}
}

关键性质(gate 的 “no old-data reads”):

  • file→payload 的解析只查 metadata 的 pack_entries,不碰对象里的 index;
  • 旧切片被 newest-wins 减除逻辑自然屏蔽,不会读旧 block;
  • entry 之间的空隙 = Hole(全零),不需要读任何对象。

3.2 ChunkReader::read_range(body-only)

  • fetch 去重仍按 block.obj(pack 只 fetch 一次整个对象);
  • 组装时字节区间改为 start = obj_offset + block_offset,end = start + len;
  • 失败即报错(缺块不是静默零),与今天一致。

取舍(写进设计): 命中 pack 的一次小读会 fetch 整个 pack(≤ pack_target = 1 MiB 默认)。 可接受的理由:pack 是瞬态的(compaction 会把它换成普通 block);整对象缓存(CachedBlockStore 按 obj 缓存)让重复读命中缓存;compaction 后稳态读路径 obj_offset 全为 0,零额外成本。 (可选优化,不属 R3 必须:给 BlockStore 加一个带默认实现的 read_block_range(obj, start, len), SwiftBlockStore 覆写为 HTTP Range GET;reader 按 (obj, range) 去重。见 §7 备注。)

3.3 cache / prefetch(body-only)

  • CachedBlockStore::read_block:不变(整对象缓存,pack 对象整体进 cache);
  • blocks_for_range / prefetch_blocks:不变(按 block.obj 取对象名,pack 对象整体预取);
  • ChunkFs::read_amplified 与 client_range 组装处:start 改为 obj_offset + block_offset(body-only)。

4. 写路径集成

4.1 位置:ChunkWriter::flush 内部、upload 之前

packing 发生在 flush 的 per-chunk 循环里、upload_blocks 之前——合并 dirty 区间之后、切片分配之后。 fsync 语义内的窗口合并:聚合窗口 = 一次 flush() 调用 × 一个 chunk;按字节预算 pack_target 分批;每批一个 pack。

4.2 新配置类型(chunk-plane/src/pack.rs)

#![allow(unused)]
fn main() {
#[derive(Clone, Debug)]
pub struct PackConfig {
    /// pack 目标字节数。0 = 关闭(行为与今天完全一致)。
    pub target: u64,
    /// 每个 chunk 每次 flush 至少要有多少个可打包小区间才打包。
    pub min_ranges: usize,
}
impl Default for PackConfig {
    fn default() -> Self { Self { target: 0, min_ranges: 2 } }
}
}
  • ChunkWriter::new 签名不变,内部以 PackConfig::default()(关闭)委托给新构造器; → 现有测试/调用方零改动、行为逐字节一致(满足“disabled 时保留今天行为“的默认)。
  • 新增 ChunkWriter::new_with_pack(engine, inode, size, block_size, PackConfig)(新签名,additive)。
  • 配置校验:pack_target ≤ 1 GiB(u32 len 上限)且建议 pack_target ≤ block_size(防读放大)。

4.3 flush 算法(伪代码,packing 开启时)

按 chunk 分组 dirty ranges(不变)→ 排序 + 相邻/重叠合并 → merged(不变)
对每个 chunk:
  for (start, data) in merged:
      len ≥ pack_target      → 大区间:现有路径(alloc_slice_id + split_blocks + 记 plan)
      else                   → 小区间:进 small[]
  if small.len() ≥ min_ranges:
      贪心分批:每批累计 ≤ pack_target 字节 → pack_batches[]
      for batch in pack_batches:
          slice_id = alloc_slice_id()
          (entries, body) = serialize_pack(batch)          # file_offset 排序
          name = format!("{:x}/{}/{:x}.pack", inode, chunk, slice_id)
          all_blocks.push((name, body))
          记 pack_plan(name, entries)
  else:
      小区间走现有路径(单区间 flush 与今天逐字节一致,含 block 命名 `.../<slice-id>/0`)
  # 一次批量上传(现有 parallel upload 语义不变,pack 对象只是 batch 里一个 (name, bytes))
  refs = store.upload_blocks(all_blocks).await
  组装:大区间 → 普通 Slice;pack → Slice { start: 首 entry.file_offset,
        blocks: [BlockRef{ obj, size: payload_len, etag }], pack_entries }
  推入 chunk.slices 并按 start 排序(不变)

要点:

  • 一个 pack = 一个 slice_id = 一个对象;serialize_pack 里 payload_offset 从 0 累积。
  • 大区间(≥ pack_target)永不进 pack:它要么是单对象,要么进现有 split_blocks—— 避免把 3 MiB 区间塞进 pack 造成读放大。
  • 与现有相邻合并的交互:合并先发生、packing 只处理合并后剩余的非相邻小区间—— 正是 M1 里“非相邻不合并“的那部分;相邻小区间合并后是 1 个区间(1 个 entry,甚至 1 个普通 block)。
  • min_ranges = 2 的意义:单次小写 flush(顺序写/常见测试)行为与今天完全相同 (1 次 PUT、普通 block 命名),packing 只在一次 flush 里真有“多个小区间要坍缩“时才起作用。 生产默认 pack_target = 1 MiB、min_ranges = 2(可调 1)。
  • flush 的签名 (store, chunks) -> (Vec<Chunk>, u64) 与返回语义不变;调用方 flush_handle 的 CAS 重试、孤儿容忍全部不变。
  • 崩溃安全:pack 先 PUT 后 CAS,与 block 完全同构(§2)。

4.4 配置接线(swift-fuse / swiftfuse-cli,body-only)

# config/swiftfuse.example.toml([fs] 段新增)
pack_target = 1048576      # bytes;0 关闭 delta pack(默认开启 1 MiB)
pack_min_ranges = 2        # 每次 flush 每 chunk 至少几个小区间才打包
gc_grace_secs = 600        # 失引用 block 延迟删除秒数(§5)
  • ChunkFsConfig 新增字段 pack_target、pack_min_ranges、gc_grace_secs (struct 字面量构造点:main.rs、chunk_fs_test.rs —— 机械补字段;测试用 0/关闭保持行为)。
  • ensure_writer:pack_target > 0 时用 ChunkWriter::new_with_pack,否则 ChunkWriter::new。

5. Compaction 与删除安全

5.1 触发(compact.rs)

  • 现有阈值不变:MAX_SLICES_PER_CHUNK=16 / MAX_BLOCKS_PER_CHUNK=128(pack slice 计为 1 个 slice);
  • 新增 additive 常量 MAX_PACK_ENTRIES_PER_CHUNK = 256:chunk 内所有 pack slice 的 entry 总数 超限即触发(回收 pack 带来的 metadata 膨胀)。
  • 调度不变:ChunkFs::start_compaction 按 compact_interval 跑 scan_and_compact; compact_interval = 0 = 关闭(roadmap 路线 5 的默认语义保持不变,但见 §5.4 的 GC 联动说明)。

5.2 过程(Compactor::compact_chunk,body-only)

1. read_range 读整个 chunk(对 pack 透明,见 §3)——数据正确
2. 新 slice_id + split_blocks → 普通 block(pack 被拍平成大 block,读放大归零)
3. CAS:chunk.slices = [新 slice](先提交,后删除 —— 顺序不变)
4. old_blocks(含 pack 对象名)→ GcQueue(不再立即异步 delete)

scan_and_compact 每轮末尾顺带 GcQueue::drain(store) 一次。

5.3 删除安全(gate:compaction 绝不能删掉旧 metadata 版本 reader 仍在引用的 block)

现状风险:compact_chunk 提交后立即 tokio::spawn(delete_blocks)——持有旧 metadata 版本 (handle 的 chunks 快照、attr/dir 缓存、在途读)的 reader 可能 404。pack 让旧块更多, 风险面更大,R3 必须收敛。

R3 机制:延迟删除(grace period)。 新模块 chunk-plane/src/gc.rs:

#![allow(unused)]
fn main() {
pub struct GcQueue {
    pending: Mutex<VecDeque<(Instant, Vec<String>)>>,
    grace: Duration,                       // 默认 600s(gc_grace_secs)
}
impl GcQueue {
    pub fn new(grace: Duration) -> Self;
    pub fn push(&self, objs: Vec<String>);                       // 记 now
    pub async fn drain(&self, store: &dyn BlockStore) -> usize;  // 只删 now - t ≥ grace 的
}
}
  • Compactor::compact_chunk、ChunkFs::gc_blocks(inode 删除)都改为 push; 由后台循环统一 drain(ChunkFs 复用现有 spawn 模式)。
  • 提交先于删除(现有顺序保留)+ grace 后删除:在 [commit, commit+grace] 窗口内, 任何持旧 metadata 版本的 reader 都能取到块。
  • grace 取值依据:grace > max(kernel_ttl, attr_ttl, dir_ttl, 在途读时长) + 余量,默认 600s; 由 gc_grace_secs 配置。
  • 结构保证:pack 对象名内嵌 chunk + 唯一 slice_id,只被它所属 chunk 的 slices 引用; compaction 原子替换该 chunk 的全部 slices → 提交后 pack 对当前版本全局失引用, 只可能被旧版本引用 → grace 覆盖。
  • 长期方案(R2 依赖,本文不实现):open-handle generation / reader lease 才能做到 “绝不删除”(无上界滞留的 reader)。R3 的 grace 是安全的有界启发;R2 落地后删除可收紧。

预置缺陷(写入设计、非本 R 引入):compact_chunk 在数据读取与 CAS 之间若同 chunk 被并发写者追加了 slice,重试循环只重读 meta、不重读数据,可能覆盖并发写。 建议随 R3 一并做一个小 body 修复:提交前比对当前 slice 集与数据快照时的 slice 集, 不一致则整体重来(重读数据);R2 写租约是最终互斥。

5.4 孤儿与 GC 联动

  • 崩溃于 PUT 与 CAS 之间 → 孤儿 pack(metadata 无引用)→ GcQueue 永远看不到 → 与孤儿 block 同类,无害;由 R6 failpoint GC(按容器前缀枚举)统一回收,不在 R3 范围。
  • compact_interval = 0 时 compaction 与 GC drain 都不跑:pack 会累积、旧块不回收—— 运营者显式选择;gc_grace_secs 独立于 compact_interval。

6. Gate 映射

ROADMAP R3 gate → 设计落点:

Roadmap gate(M1 语境)设计映射验证
100 × 4K 随机写:PUT 次数 ≈ 1~2(delta pack)同 chunk 400KB → 1 批 ≤1MiB → 1 个 pack;跨 2 chunk → 2 个 pack。PUT 次数 = pack 数 + 大区间数新单测:MemBlockStore 对象数 == 1..2;集群:swift list <fs>-data 前后计数(M1 审计法)
对象 size 分布记录pack 对象 size = 40 + 24N + payload ≤ pack_target + 40 + 24N;普通 block 不变benchmark 记录对象 size 直方图
fsync 完成时间可预测每次 flush 每 chunk 只发 1 次批量 upload_blocks,请求数不随区间数增长 → 时延 ~1-2 RTTbenchmark:100×4K 单 fsync 时延(对比今天 96 PUT 的量级)
容器对象数增长 = dirty_bytes / pack_target每 flush 每 chunk 增长 ⌈small_bytes_c / pack_target⌉ 个 pack(+ 大区间个数);上限 ⌈D/pack_target⌉ + #chunk容器审计
现有测试保持绿默认关闭(§4.2);serde 默认值(§1.3);模型字面量仅机械补字段(§7)cargo test 全 crate

一个 pack 的利用率:每 chunk 每 flush 最后一包平均半满 → 实际增长 ≈ dirty/pack_target + #chunk/2。 此界写入文档与 benchmark 判定(不承诺精确相等,承诺 ⌈·⌉ 界)。


7. API 变更清单

7.1 Signature 不变的函数(只改 body)

文件函数body 变更
chunk-plane/src/writer.rsChunkWriter::new委托新构造器,PackConfig::default()(关闭)→ 行为逐字节不变
chunk-plane/src/writer.rsChunkWriter::flushper-chunk 循环内:小区间分批打包 + 组装 pack slice(关闭时走原代码路径)
chunk-plane/src/map.rsmap_readpack slice 展开为带 obj_offset 的 spans
chunk-plane/src/reader.rsChunkReader::read_range组装起点 obj_offset + block_offset
chunk-plane/src/compact.rsCompactor::compact_chunk旧块删除改走 GcQueue(先提交后 push);提交前 slice 集一致性检查(建议项)
chunk-plane/src/compact.rsCompactor::scan_and_compact每轮末尾 GcQueue::drain
swift-meta/src/model.rsChunk::covered_bytes / end_offsetpack slice 按 entries 计算(普通 slice 结果不变)
swift-fuse/src/chunk_fs.rsChunkFs::new存 pack/gc 配置,spawn gc 循环
swift-fuse/src/chunk_fs.rsensure_writerpack_target>0 时用 new_with_pack
swift-fuse/src/chunk_fs.rsread_amplified / client_rangeobj_offset + block_offset
swift-fuse/src/chunk_fs.rsgc_blocks改走 GcQueue
swiftfuse-cli/src/main.rsrun_chunk_mode新配置字段接线

7.2 新增 API(additive,全为新类型/新函数/新字段)

位置新增
swift-meta/src/model.rsPackEntry;Slice.pack_entries 字段(serde default + skip_if_empty)
chunk-plane/src/pack.rs(新模块)PackConfig、serialize_pack、parse_pack、pack_object_name、PACK_MAGIC/PACK_VERSION/PACK_HEADER_SIZE/PACK_ENTRY_SIZE、PackError
chunk-plane/src/gc.rs(新模块)GcQueue
chunk-plane/src/writer.rsChunkWriter::new_with_pack
chunk-plane/src/compact.rsCompactor::with_gc(...)(或 set_gc_grace)、MAX_PACK_ENTRIES_PER_CHUNK
chunk-plane/src/lib.rs模块与新类型 re-export
swift-fuse/src/chunk_fs.rsChunkFsConfig.pack_target / pack_min_ranges / gc_grace_secs
swiftfuse-cli/src/main.rsFsCfg.pack_target / pack_min_ranges / gc_grace_secs
chunk-plane/src/store.rsMemBlockStore 增加 PUT 计数(测试用,additive 字段)

7.3 测试字面量机械更新(编译需要,行为不变)

  • map.rs 测试 mk_chunk 的 Slice{..}、engine_test.rs 的 chunk_with_slices 的 Slice{..} / Chunk{..}:补 pack_entries: vec![];
  • compact.rs:100、writer.rs:132 的 Slice{..}:补 pack_entries: vec![];
  • chunk_fs_test.rs:21、main.rs:375 的 ChunkFsConfig{..}:补 pack_target: 0(测试保持关闭)。

7.4 完全不动

split_blocks、BlockStore trait(3 个方法)、MetadataEngine trait、ChunkWriter::write/is_dirty、 ChunkReader::new、Compactor::new、ChunkFs::flush_handle、start_compaction 签名、 slice_len/chunk_has_data/blocks_for_range/prefetch_blocks。

可选优化(不属 R3 必须,保留为独立改动):BlockStore 增加带默认实现的 read_block_range(obj, start, len)(默认 = 全量 GET + 本地切片),SwiftBlockStore 覆写为 Range GET。 默认实现保证既有 impl 零改动。


8. 测试计划

新单测(chunk-plane/tests/pack_test.rs + map.rs 单测):

  1. pack_roundtrip:serialize_pack → parse_pack 相等;magic/version;40 + 24N + P == len;不变量校验。
  2. map_read_pack_slice:构造含 pack slice 的 chunk,读 plan → Block 段指向 pack obj、 obj_offset == payload_offset、entry 间为 Hole。
  3. gate 主测 packed_100x4k_single_flush_put_count:MemBlockStore(带 PUT 计数), ChunkWriter::new_with_pack(target=1MiB),100 × 4K 非相邻写(offset=i*8192,天然不合并), 单次 flush → 断言 store 对象数 == 1、读回逐字节正确、chunk 内 pack slice 数 == 1。
  4. packed_cross_chunk_writes:两 chunk 各 50 个 4K → 对象数 == 2(PUT ≈ 2)。
  5. packed_disabled_identical_to_today:同一批写走 ChunkWriter::new → 对象数 == 100, block 命名 .../<slice-id>/0 不变。
  6. pack_compaction_restores_plain_blocks:pack → compact_chunk → 单一普通 slice (pack_entries 空、obj 为 .../<slice-id>/0)、读回一致、旧 pack 进 GcQueue(grace=0 drain 后删除)。
  7. old_format_deserializes:无 pack_entries 键的旧 JSON → pack_entries == [](前后向兼容)。
  8. chunk_fs_test 集成:pack_target 开启的 write_read_through_chunkfs 读回正确。

集群 gate(scripts/detect/run_gates.sh 扩展,M1 审计法): 100×4K 随机写 + 单 fsync → 容器对象数增量 ≈ 1..2、对象 size 分布记录、fsync 时延记录;关闭 packing 跑同款作回归对照。


9. 兼容性 / 部署

  • 旧二进制 vs pack:含 pack slice 的文件,旧二进制会把 pack 对象的 header/index 当数据读出 (serde 忽略未知字段 → 当普通 slice 处理)→ 错误数据。因此:
    • 升级顺序:先升级所有读节点,再开启 pack_target > 0 写;
    • 降级顺序:先关 pack_target 并等一次 compaction(把所有 pack 拍平回普通 block),再回滚二进制;
    • 同 fs_name 数据容器上不允许 pack 写者与旧读者混布(单机单版本部署不受影响)。
    • (可选加固,留给 R6:含 pack 的记录写 schema_version = 2,未来版本据此显式拒绝/迁移。)
  • metadata 兼容:Slice.pack_entries serde default + skip_if_empty;etcd CAS 用 mod_revision、 SQLite CAS 用 version 列,均不因序列化字节变化而失效;BUG-6 归一化不受影响(空值时字节不变)。
  • 对象计数增长按 §6 的 ⌈·⌉ 界承诺,不承诺精确相等。

10. 非目标 / 开放问题

  • 跨 flush 时间窗聚合(debounce)——roadmap 路线 3,独立项;R3 只做 flush 内聚合。
  • 孤儿 pack 枚举回收(R6 failpoint GC);持久化 tombstone(崩溃丢 GcQueue 只泄漏不损坏)。
  • pack 的 Range GET 读优化(§7 备注);reader lease 级“绝不删除“(R2)。
  • 开放:pack_min_ranges 默认 2 还是 1(M1 单次 fsync 场景两者同效,顺序写场景 2 更优);保持 2。
  • 开放:grace 默认 600s 是否够覆盖 attr/dir TTL 峰值(当前默认 10s,余量充足);集群实测后定稿。