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,即使批量并行(
4f063f733s→9.4s),Swift 小对象 PUT 吞吐下限依然封顶。 - 非相邻区间天然不合并,所以“合并 dirty 区间“这条路走不到头。
R3 目标: 把多个小 dirty 写打包进 1 个 Swift 对象(index + payload),使
Swift PUT 次数 ≈ dirty_bytes / pack_target
后台 compaction 再把 pack 合并回普通 block。完全符合宗旨:
object 永远 immutable;不读旧数据;mutation 只在 metadata。
设计要点(六节):
以及必须的: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):
- 对象自描述——孤儿 pack(PUT 成功但 metadata 未提交)可被独立枚举/清理;
- 首次读取时可选校验:对象内 index 与 metadata 内 entries 一致(防元数据/数据漂移);
- 不增加读路径成本:读路径从不解析 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 RTT | benchmark: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.rs | ChunkWriter::new | 委托新构造器,PackConfig::default()(关闭)→ 行为逐字节不变 |
| chunk-plane/src/writer.rs | ChunkWriter::flush | per-chunk 循环内:小区间分批打包 + 组装 pack slice(关闭时走原代码路径) |
| chunk-plane/src/map.rs | map_read | pack slice 展开为带 obj_offset 的 spans |
| chunk-plane/src/reader.rs | ChunkReader::read_range | 组装起点 obj_offset + block_offset |
| chunk-plane/src/compact.rs | Compactor::compact_chunk | 旧块删除改走 GcQueue(先提交后 push);提交前 slice 集一致性检查(建议项) |
| chunk-plane/src/compact.rs | Compactor::scan_and_compact | 每轮末尾 GcQueue::drain |
| swift-meta/src/model.rs | Chunk::covered_bytes / end_offset | pack slice 按 entries 计算(普通 slice 结果不变) |
| swift-fuse/src/chunk_fs.rs | ChunkFs::new | 存 pack/gc 配置,spawn gc 循环 |
| swift-fuse/src/chunk_fs.rs | ensure_writer | pack_target>0 时用 new_with_pack |
| swift-fuse/src/chunk_fs.rs | read_amplified / client_range | obj_offset + block_offset |
| swift-fuse/src/chunk_fs.rs | gc_blocks | 改走 GcQueue |
| swiftfuse-cli/src/main.rs | run_chunk_mode | 新配置字段接线 |
7.2 新增 API(additive,全为新类型/新函数/新字段)
| 位置 | 新增 |
|---|---|
| swift-meta/src/model.rs | PackEntry;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.rs | ChunkWriter::new_with_pack |
| chunk-plane/src/compact.rs | Compactor::with_gc(...)(或 set_gc_grace)、MAX_PACK_ENTRIES_PER_CHUNK |
| chunk-plane/src/lib.rs | 模块与新类型 re-export |
| swift-fuse/src/chunk_fs.rs | ChunkFsConfig.pack_target / pack_min_ranges / gc_grace_secs |
| swiftfuse-cli/src/main.rs | FsCfg.pack_target / pack_min_ranges / gc_grace_secs |
| chunk-plane/src/store.rs | MemBlockStore 增加 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 单测):
pack_roundtrip:serialize_pack → parse_pack相等;magic/version;40 + 24N + P == len;不变量校验。map_read_pack_slice:构造含 pack slice 的 chunk,读 plan → Block 段指向 pack obj、obj_offset == payload_offset、entry 间为 Hole。- 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。 packed_cross_chunk_writes:两 chunk 各 50 个 4K → 对象数 == 2(PUT ≈ 2)。packed_disabled_identical_to_today:同一批写走ChunkWriter::new→ 对象数 == 100, block 命名.../<slice-id>/0不变。pack_compaction_restores_plain_blocks:pack →compact_chunk→ 单一普通 slice (pack_entries空、obj 为.../<slice-id>/0)、读回一致、旧 pack 进 GcQueue(grace=0 drain 后删除)。old_format_deserializes:无pack_entries键的旧 JSON →pack_entries == [](前后向兼容)。- 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_entriesserde 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,余量充足);集群实测后定稿。