R6 Design — Failpoint crash testing + G7 fencing exam harness
日期:2026-08-18 · 状态:设计 + crate 脚手架(未接入写路径,未跑 10000 seeds) 对应 ROADMAP-2026-08-10 §R6。Porcupine / fixture 已关(
crates/correctness+scripts/porcupine_check.sh)。不要重做。 本文件管剩余:六处 crash 点、seed 1..N 重启环、以及 G7 fencing 考试的 harness 设计(活门在 R2,本轮只设计不跑)。
0. 本轮交付 vs 以后
| 本轮 | 以后(别的会话 / 别的 owner) |
|---|---|
| 本文 | 把 failpoint! 编进 writer.rs / chunk_fs.rs / compact.rs / gc |
crates/swift-failpoints/ 库 + 单测:env 对上才 trip | 10000-seed 真跑(集群、会杀 daemon) |
| harness 环的文字合同 | R2 落地后再跑 G7 fencing 考试 |
不改 crates/correctness、不改 porcupine 脚本 |
writer.rs / etcd.rs 本轮不插宏(避免和 R2 / 写路径 owner 打架)。
1. 不变量(崩溃只许留 orphan)
宗旨第三条:block PUT 先于 metadata CAS。崩溃之后可见状态只能是:
- CAS 没成功:inode JSON / 名 key 与崩溃前相同。Swift 上多出来的对象是 orphan(无指针)。读、stat、readdir 不得看见半截文件。
- CAS 成功、回复没发出:元数据已是新 version。重启后 getattr/read 必须看到完整新图。内核可能重发 FUSE 写/fsync:必须幂等(同一 slice 对象名再 PUT,或 CAS 发现 version 已新)。
- 禁止:名已指向新 inode 但 inode JSON 是旧的;inode JSON 指向还没 PUT 的对象;dir listing 有名但 LOOKUP ENOENT(除非 POSIX 允许的并发窗口,failpoint 环里是单线程写)。
Compaction / GC 同构:
- compaction:新对象 PUT 成功、CAS 没换指针 → 新对象 orphan,旧切片仍被引用。读必须仍是旧字节。
- GC:DELETE 前崩溃 → 对象仍在,只是泄漏。DELETE 后崩溃 → 对象已无引用才允许消失。
2. 六个点(名字即合同)
宏名与 ROADMAP 一致。每个名字是 SWIFTFUSE_FAILPOINT 的精确匹配值。
| 常量 | 以后插在哪(本轮不插) | 崩溃时已发生 | 重启后必须 |
|---|---|---|---|
FAILPOINT_AFTER_BLOCK_PUT | ChunkWriter::flush:upload_blocks 成功之后、返回给 flush_handle 之前 | Swift 有新对象;etcd 仍是旧 chunk map | 读旧数据;新对象无引用(orphan) |
FAILPOINT_BEFORE_METADATA_CAS | flush_handle:组好 meta 之后、commit_inode 之前 | 同上(PUT 已完成) | 同上 |
FAILPOINT_AFTER_METADATA_CAS | commit_inode 返回 Committed 之后、改 Handle / invalidate / FUSE 回复之前 | etcd 已是新 version | 重启后读新数据;重放 fsync 不得损坏 |
FAILPOINT_BEFORE_REPLY | FUSE 写路径:本地 invalidate 之后、Ok(Reply*) 回到 fuse3 之前(flush/fsync/release) | 元数据已提交 | 同 AFTER_CAS;内核会当没收到回复 |
FAILPOINT_COMPACTION_AFTER_PUT | Compactor::compact_chunk:upload_blocks 之后、commit_inode 之前 | 新 compacted 对象在 Swift;旧切片仍被引用 | 读仍等于 snapshot;新对象 orphan |
FAILPOINT_GC_BEFORE_DELETE | gc_blocks / store.delete_blocks 之前 | inode 名已删(或 deferred-GC 已摘名);对象还在 | 名不可见;对象可留(泄漏,不是损坏) |
BEFORE_METADATA_CAS 和 AFTER_BLOCK_PUT 在 flush 上几乎同一窗口。两个都要:以后 PUT 和 CAS 之间若插入 lease/fencing 检查(R2),两点会分开。脚手架先都留着。
etcd.rs 的 commit_inode 不要作为第一插点:R2 拥有该文件;CAS 前后的点放在 调用方(flush_handle / setattr 循环 / compact)。
3. Crate API(已脚手架)
路径:crates/swift-failpoints/。包名 swift-failpoints。
未写入根 Cargo.toml members(该文件和其他 agent 抢)。以后一行:
"crates/swift-failpoints",
接入写路径时再:
# crates/chunk-plane/Cargo.toml 和/或 crates/swift-fuse/Cargo.toml
swift-failpoints = { path = "../swift-failpoints" }
3.1 行为
failpoint!(NAME)
- 默认 空操作(零分配,读两三个 env)。
- 同时满足才 trip:
SWIFTFUSE_FAILPOINT等于NAME(不是逗号列表,不是前缀);SWIFTFUSE_FAILPOINT_SEED非空(harness 写入的 seed 十进制串)。
- 默认 trip:
std::process::abort()(不跑 drop,模拟 SIGABRT/kill -9)。 - 测试:
set_hook(fn(&str))替代 abort。单测用 hook,不 abort 测试进程。
SWIFTFUSE_FAILPOINT_SEED 不参与 “是否 trip” 的哈希。它是 这次崩溃的身份:harness 重启后还能说 “seed 4291 死在 AFTER_BLOCK_PUT”。进程内不必读 seed 来做概率。10000 个 seed = 10000 次 独立 的 “设 env → 打一枪 → 杀 → 起 → 查”。
不要静默的 hash(seed) % 10000 == 0。那种测不完、也复现不了。
3.2 表面(lib.rs)
#![allow(unused)]
fn main() {
pub const FAILPOINT_AFTER_BLOCK_PUT: &str = "FAILPOINT_AFTER_BLOCK_PUT";
pub const FAILPOINT_BEFORE_METADATA_CAS: &str = "FAILPOINT_BEFORE_METADATA_CAS";
pub const FAILPOINT_AFTER_METADATA_CAS: &str = "FAILPOINT_AFTER_METADATA_CAS";
pub const FAILPOINT_BEFORE_REPLY: &str = "FAILPOINT_BEFORE_REPLY";
pub const FAILPOINT_COMPACTION_AFTER_PUT: &str = "FAILPOINT_COMPACTION_AFTER_PUT";
pub const FAILPOINT_GC_BEFORE_DELETE: &str = "FAILPOINT_GC_BEFORE_DELETE";
pub fn armed(name: &str) -> bool; // 两 env 都对
pub fn trip(name: &str); // armed 则 hook 或 abort
pub fn set_hook(hook: fn(&str)); // 测 / 可控崩溃
pub fn clear_hook();
pub fn seed() -> Option<u64>; // 解析 SEED;harness 日志用
failpoint!(NAME); // → trip(NAME)
}
以后调用点长这样(现在不要提交这些行):
#![allow(unused)]
fn main() {
// chunk-plane/src/writer.rs after upload_blocks
swift_failpoints::failpoint!(swift_failpoints::FAILPOINT_AFTER_BLOCK_PUT);
// swift-fuse/src/chunk_fs.rs flush_handle, before commit_inode
swift_failpoints::failpoint!(swift_failpoints::FAILPOINT_BEFORE_METADATA_CAS);
}
生产:两 env 都不设 → 编译进二进制也是 no-op。不要 #[cfg(test)] 把宏拿掉:集群要测的是 同一份 release 二进制。
4. Harness 环(设计,本会话不跑)
合同:seed 1..=10000,每次 crash/restart 后 只有 orphan,从无 half-visible。
10000 是 ROADMAP 数字。本会话不跑;下面是以后在 swift1 上跑的环(Mac 不编、不 ssh 杀进程)。
4.1 一次 seed
seed S in 1..=N
site = SITES[(S - 1) % 6] # 六种点均匀铺开
# 可选:SITES[(S-1)/6 % 6] 若只要某种点的连续段
1. 停干净:数字 PID 或 pkill -9 -x swiftfuse;umount -l;禁止 pkill -f
2. 起 daemon,环境:
SWIFTFUSE_FAILPOINT=<site>
SWIFTFUSE_FAILPOINT_SEED=<S>
二进制仍是 /usr/local/bin/swiftfuse(先拷 /tmp/swiftfuse.new 再 install)
3. 探针进程(不是第二个 100k python):
写唯一文件 /mnt/swift-etcd/r6-fp/s-<S>
内容 = 已知 64KiB 图案(或 4K × 16)
fsync
预期:daemon 在选定点 abort。探针得到 EIO / 连接断。timeout --kill-after=2 15
4. 确认 swiftfuse 已死(pid 文件 / pgrep -x)。必要时 SIGKILL 数字 PID
5. **清掉两 env**,再起 daemon(否则一上来又 abort)
6. 判定(见 4.2)
7. 删探针文件(若可见)+ 记一行 JSONL
N=10000。先用 N=6 把六点各打一次,再 N=60,最后 N=10000。全量要占挂载:单独排期,不要和 G1/G6/100k 并行。
4.2 判定(half-visible 的定义)
对 /mnt/swift-etcd/r6-fp/s-<S>:
| 观察 | PASS | FAIL |
|---|---|---|
| ENOENT | 允许(CAS 前死)。Swift 容器里可以有无 inode 引用的对象 | 名在 readdir 里但 LOOKUP ENOENT |
| 存在,size=0 且从未 fsync 成功 | 允许 | size>0 但读到短于 size 的有效图案 / 洞里是旧垃圾当新数据 |
| 存在,size=期望,字节全对 | 允许(CAS 后死) | size=期望但 crc 错 |
| 存在,size 不是 0 也不是期望 | FAIL(半可见) | — |
etcd inodes/{id} 的 chunk 指针指向 404 的 Swift 对象 | FAIL | — |
| compaction 点:读字节 ≠ 崩溃前 snapshot | FAIL | — |
辅助(审计,不是 POSIX):枚举 {fs_name}-data 容器,减去 etcd 里所有 BlockRef.obj。差集 = orphan。orphan 计数只增不减 可以,不得 被任何活着的 inode 引用。
不要用 “exit code 0” 当 PASS。每行 JSONL 必须有 seed site visible size verdict。没有 verdict= 的汇总 = INCONCLUSIVE。
4.3 以后的脚本形状(现在不要写进 scripts/detect)
建议 scripts/r6-failpoint-loop.sh(未写):
N=${1:-10000}
for S in $(seq 1 $N); do
site=...
start_with_failpoint "$site" "$S"
timeout --kill-after=2 15 python3 scripts/r6-failpoint-probe.py "$S" || true
kill_daemon_numeric
start_clean
python3 scripts/r6-failpoint-judge.py "$S" >> "$OUT/results.jsonl"
done
# 汇总:grep FAIL;零 FAIL 且 rows==N → PASS
约束(HANDOFF §7):--kill-after;数字 PID;测完四节点 kernel_ttl=0;不 cargo clean;不 wf.sh find-touch;不并行第二只写挂载的 python。
4.4 和 G4b / 现有 G7c 的区别
| G4b / G7c | R6 failpoint | |
|---|---|---|
| 怎么死 | 随机 SIGKILL / fio 中途杀 | 指定 在 PUT/CAS/回复/compact/GC 切开 |
| 证明 | 已 commit 的 crc 还在 | 未 commit 的不可见;已 commit 的完整;指针不指向空洞 |
| 次数 | 一次耐久 | 10000 seeds,六点循环 |
G4b 过了 ≠ failpoint 合同关了。
5. G7 fencing 考试 — 只设计 harness(活门是 R2)
HANDOFF-2026-08-15:G6 lease 在 101550 能工作,不是 R2 门禁。
R2 门禁:partition → 旧 writer 租约过期 → 新 writer → 旧 writer 复活 commit 必须 100% 失败。
现有 G7a/b/c(etcd move-leader / netem / SIGKILL、已提交数据还在)是另一张成绩单。fencing 考试是 扩展,不要和 08-09 的 G7 PASS 混为一谈。
5.1 场景(实现 R2 的 token/epoch 之后再跑)
节点 W_old = swift2 持有 inode L 的写 lease,已 PUT 一批 block,尚未 CAS
或:已 CAS 一版,又有第二批 dirty
节点 W_new = swift3
etcd = 仍在 swift1(或活着的多数派)
1. W_old 打开 /r6-g7/fence.dat,写 32KiB 图案 A,fsync(commit v1)
2. W_old 再写 32KiB 图案 B,upload 完成,停在 CAS 前
(R2 落地后可用 FAILPOINT_BEFORE_METADATA_CAS;或 iptables 丢掉 W_old→etcd)
3. 分区:W_old 到 etcd 的 2379 丢掉(iptables OUTPUT / 输入)。不要杀 etcd
4. 等 lease TTL(现役 30s)+ 2s
5. W_new 打开同一文件,写图案 C,fsync。必须成功(新 fencing_epoch)
6. 愈合 W_old
7. W_old 的未完成 flush 继续 commit_inode / 旧 lease id
8. 断言:该 commit **100%** Conflict 或 fencing 拒绝。etcd 上的 version/epoch 仍是 W_new 的
9. 第三客户(swift4)读:看见 A+C(或 R2 规定的可见前缀),**不许** 看见 B 盖掉 C
5.2 判定
| 检查 | 必须 |
|---|---|
| W_old 复活 CAS | 失败。一次成功 = FAIL(fencing 没挡住) |
| W_new fsync | 成功 |
| 第三客户字节 | 等于 W_new 的 commit,不是 W_old 的迟到切片 |
| lease key | holder / epoch 是 W_new,不是愈合后的 W_old |
跑 100 次分区环(不是 10000;10000 是 failpoint 数据面)。100/100 旧 commit 失败才写 G7-FENCE-VERDICT: PASS。
5.3 本轮不做
- 不写 iptables 脚本,不分区,不改 lease 格式。
- 不把 fencing 考试当 R6 crate 的单测。crate 只证明 env trip。
- R2 agent 拥有
etcd.rs的 epoch / client_id。harness 挂在 R2 的 token 上,不抢。
6. 非目标
- 不重做 Porcupine fixture /
porcupine_check.sh。 - 不把 failpoint 做成编译 feature flag(集群要同一条 release 线)。
- 不在 Mac 上
cargo test当集群证据。脚手架单测是逻辑证明;10000 是集群合同。 - 不
pkill -f、不cargo clean、不wf.sh、不本轮 remount。
7. 以后的接入顺序
- 根
Cargo.toml加 member;chunk-plane+swift-fuse加 path dep。 - 六个
failpoint!按 §2 表插入。一次一个文件的 PR 面,避免和 R2 同改etcd.rs。 - N=6 烟雾(每点一次)在 swift1,独占挂载。
- N=10000 排期。
- G7 fencing 等 R2 token 合并后再写 orchestrator。