swiftfuse architecture
Pure-Rust FUSE client for OpenStack Swift. Native Swift v1 API — no S3 translation layer.
┌───────────────────────────────┐
│ swiftfuse (Rust, tokio) │
POSIX │ │
syscalls │ PathFilesystem (fuse3) │
│ │ │ │
▼ │ ├─ attr cache (TTL) │
/dev/fuse ─────►│ ├─ dir cache (TTL) │
│ ├─ open-file handles │
│ │ └─ temp-file write-back │
│ └─ prefetch read window │
│ │ │
│ ▼ │
│ SwiftClient (retry, auth) │
└──────────┬────────────────────┘
│ native Swift v1 HTTP
▼
Swift proxy (LB) → object nodes
Phase 1: thin native adapter (this codebase)
Layout: / = Swift account, /container = container, /container/name =
object. Sub-directories are synthesized from name prefixes (/ separator);
mkdir materializes a zero-byte marker object <dir>/ so empty directories
persist across remounts. Raw objects remain fully visible to native Swift
clients.
Consistency model (close-to-open, GeeseFS/JuiceFS-style)
| Operation | Implementation |
|---|---|
| read | range GETs straight from Swift; per-handle prefetch |
| write | buffered into a local temp file per open handle |
| flush/close | ONE atomic object PUT (≤ 5 GiB) or segment manifest |
| metadata | TTL caches; invalidated on local mutation |
| rename | server-side COPY + DELETE (file); recursive dir copy |
Readers never observe a partially written object: a replace is either the old object or the new one. This is the “objects are immutable, mutation happens in metadata” principle applied at the object granularity.
Large objects
Files above single_put_max are written as immutable segments
(<cont>_segments/<name>.segments/<hash>/<seq>) and published atomically as a
manifest:
- DLO (default): empty object +
X-Object-Manifestheader. Works on every Swift deployment. - SLO: JSON manifest PUT. Requires the cluster’s
slomiddleware; the lab cluster’s SLO middleware is not functional (missing[filter:slo]config was added, still inert), hence DLO default.
Segment names are deterministic (content hash of the object name), so rewrites coalesce and orphaned segments can be garbage-collected by prefix.
Authentication
TempAuthV1:GET /auth/v1.0, X-Auth-User / X-Auth-Key. Storage URL overridable (node-local endpoint instead of VIP).KeystoneV3: password → scoped token; object-store endpoint resolved from the catalog, overridable.- Token cached, proactively refreshed, force-refreshed on 401.
- Idempotent requests retry with exponential backoff; streaming PUTs recreate their body per attempt.
Known limitations (phase 1)
- Partial overwrite of a large existing file materializes the whole object to the temp file first (write amplification, like every object adapter).
- No multi-client locking; last-flush-wins (close-to-open consistency).
- No hard links; symlinks unsupported (ENOSYS).
- xattrs not supported (ENOSYS).
- Orphaned segments from overwritten large objects need GC.
Phase 2 roadmap: chunk/slice/block data plane
The 宗旨 architecture (metadata/data separation, immutable objects, mutation only in metadata) maps onto this codebase as follows:
inode (metadata: size, times, mode) ← SQLite/etcd/TiKV (control plane)
└── chunk 0..N (logical 32–256 MiB) ← fixed-size, like JuiceFS
└── slice (one write op) ← immutable
└── block ≤ 4–16 MiB ← the physical Swift object
- Random 4 KiB write = one new immutable block + atomic metadata commit (never rewrite the object).
- Background compaction merges slices per chunk.
- FUSE layer already routes through
Handle+SloWriter; the chunk plane replaces the temp-file full-buffer path behind the same flush/close hook. - Metadata engine choice for a 4-node lab: SQLite (single mount) or etcd (multi-client leases, ProxyFS-style).
Testing
crates/swift-client/tests/client_test.rs: auth flows, pagination, range GETs, retry/401-refresh (wiremock).- Integration on the cluster:
scripts/smoke_test.sh(POSIX ops + md5 verification + SLO/DLO large files).
swiftfuse(1)
NAME
swiftfuse - mount an OpenStack Swift account as a FUSE filesystem
SYNOPSIS
swiftfuse [OPTIONS]
swiftfuse mount [MOUNTPOINT] [OPTIONS]
swiftfuse umount [MOUNTPOINT] [--lazy]
swiftfuse status [MOUNTPOINT]
swiftfuse version
swiftfuse doctor [MOUNTPOINT]
swiftfuse help [COMMAND]
--version / -V prints the crate version. swiftfuse version also lists
features.
DESCRIPTION
Native Swift v1 API (TempAuth or Keystone). Chunk mode stores metadata in etcd or SQLite and data as immutable blocks in Swift. This is not s3fs.
Without a subcommand, the binary mounts using the top-level flags. That is the systemd path:
swiftfuse -c /etc/swiftfuse-etcd.toml --foreground
scripts/mount.swiftfuse (fstab helper, shipped separately) should call
swiftfuse mount and swiftfuse umount.
COMMANDS
mount
Load a TOML config, optionally override with -o, then mount.
-c, --config PATH TOML file (default /etc/swiftfuse.toml)
-f, --foreground Stay in foreground
--log FILTER tracing filter (default info)
-o KEY[=VALUE] Repeatable. Comma lists allowed.
Mountpoint comes from the positional argument, else [fs].mountpoint.
umount
Unmount. Never kills a process.
fusermount3 -u MOUNTPOINT- if still mounted,
umount -l MOUNTPOINT
--lazy / -l skips step 1 and runs umount -l only.
If MOUNTPOINT is omitted and exactly one swiftfuse mount exists, that one is used.
status
Print the /proc/mounts line, config path (from daemon argv or -c),
kernel_ttl and write_back if the config is readable, and pid from
/proc (argv0 basename swiftfuse).
version
swiftfuse 0.1.0
features: chunk etcd
doctor
Checks, then exits 0 or 1. Output never includes auth keys or passwords.
- config file exists and parses
- etcd TCP ping if
[fs].meta_backend = "etcd" - Swift
HEADaccount if[auth]has a secret - mount exists (positional, else config, else
/mnt/swift-etcd)
MOUNT OPTIONS
-o keys map onto [fs] (and --log). Unknown keys are errors.
| key | type | config field |
|---|---|---|
| allow_other | bool | allow_other |
| write_back | bool | write_back |
| kernel_ttl | u64 seconds | kernel_ttl |
| attr_ttl | u64 seconds | attr_ttl |
| dir_ttl | u64 seconds | dir_ttl |
| retries | u32 | retries |
| tmpdir, tmp_dir | path | tmp_dir |
| block_size | u64 bytes | block_size |
| cache_blocks | usize | cache_blocks |
| compact_interval | u64 seconds | compact_interval |
| etcd_endpoints | host:port,… | etcd_endpoints |
| meta_backend | sqlite or etcd | meta_backend |
| fs_name | string | fs_name |
| lease | bool | lease |
| stats_listen | host:port | stats_listen |
| stats_file | path | stats_file |
| mode | chunk or raw | mode |
| insecure | bool | insecure |
| uid | u32 | uid |
| gid | u32 | gid |
| default_mode | octal | default_mode |
| log | filter | --log |
Bare bool (-o allow_other) means true. true/false, 1/0, yes/no,
on/off are accepted.
Generic fstab flags (_netdev, rw) are not accepted here. The fstab
helper must strip them before calling swiftfuse mount.
FSTAB
/etc/swiftfuse-etcd.toml /mnt/swift-etcd fuse.swiftfuse _netdev,allow_other 0 0
Requires scripts/mount.swiftfuse on the PATH that mount(8) uses
(/sbin or /usr/sbin). That helper is not this binary.
FILES
/etc/swiftfuse.toml
/etc/swiftfuse-etcd.toml
/var/lib/swiftfuse/client_id
/var/tmp/swiftfuse
/run/swiftfuse/<fs_name>.stats.json
EXIT STATUS
0 on success. 1 on error. doctor is 1 if any check fails. status is 1
if the requested path is not mounted.
EXAMPLES
swiftfuse -c /etc/swiftfuse-etcd.toml --foreground
swiftfuse mount /mnt/swift-etcd -c /etc/swiftfuse-etcd.toml -f -o kernel_ttl=0
swiftfuse umount /mnt/swift-etcd
swiftfuse umount -l /mnt/swift-etcd
swiftfuse status /mnt/swift-etcd
swiftfuse doctor -c /etc/swiftfuse-etcd.toml
swiftfuse version
SEE ALSO
docs/ARCHITECTURE.md, config/swiftfuse.example.toml, systemd/swiftfuse.service
swiftfuse benchmark — chunk mode, 4-node etcd cluster
Environment: Contabo lab, 4 nodes (swift1-4, 8GB RAM), OpenStack Swift
(cluster-wide throughput shared), etcd 3-node cluster (swift1-3), blocks 4 MiB,
leases on, FUSE write-back on, prefetch 8 MiB, read/write workers 8/8,
kernel_ttl = 0(正确性优先:内核每次 LOOKUP 重验;进程内 path/attr/dir 缓存仍在内存服务)。
数值口径(G11):kernel_ttl=0 下 stat 不再是“3ms 路径缓存“ 意义上的纯缓存命中—— 每次 stat 先走内核 LOOKUP(带 TTL=0 的 entry 回复),再由进程内
path_inodes缓存命中后 0 次 etcd 往返。2026-08-09 G8 重跑后本节数值以 raw JSON 为准 (/opt/detect/results/20260809-full/g8/perf.json)。
Benchmark 2.0 framework(R7,2026-08-11)
scripts/benchmark.sh 已从 v1(纯 dd 序列)重写为 Benchmark 2.0 框架(R7 交付物)。
框架只负责运行与记录;R7 先交付框架本身,不跑 live benchmark。
工作负载分类(workload taxonomy,显式 phases)
| workload | 内容 | 默认 |
|---|---|---|
seq-write | 顺序写 BENCH_SIZE_MIB MiB(bs=BENCH_FIO_BS) | 256 MiB / 1M |
seq-read | 顺序读同一文件 | 256 MiB / 1M |
rand-write | BENCH_RAND_WRITES × 4KiB 随机写(M1 object 放大探针) | 100 |
small-file | create + stat + readdir,BENCH_SMALL_FILES 个 4K 文件 | 1000 |
rename | BENCH_RENAME_FILES 次 rename | 500 |
multi-node | 跨 BENCH_NODES 并发读/写(共享 FS,每节点 BENCH_MULTI_MIB) | 128 MiB |
冷 / 温 / 混合分离(per-workload 分别记录)
- cold = 新文件 +
drop_caches(本地 + 各节点 ssh)+ dataset > cache。local_seq_read若必须 seedseq.bin,seed 之后立刻sync+drop_caches。 GeeseFS / JuiceFS 的用户态缓存不受drop_caches影响:公平列用FAIR_REMOUNT=1(写完 persist、umount、清 JuiceFS--cache-dir、再drop_caches、再 remount)。GeeseFS 在本 lab 的 s3api 上要用--single-part 80,默认 multipart 不会落地。 - warm = 数据已入 cache、100% 缓存命中路径(不 drop_caches,重复 pass);
- mixed = 交错负载:IO 用 fio
rwmixwrite/rwmixread(70/30), metadata 用 create+stat+readdir 融合单 pass;multi-node = 一半读一半写。
确定性输出
每次运行写入 $RESULTS_ROOT/bench2.0-<UTC ts>-<commit8>/(结果在本地盘;
被测数据在挂载点 $BENCH_ROOT/$RUN_ID 下,避免结果写入污染被测路径):
meta.json(运行元数据+config env)、summary.json(统一结果表,jq 友好)、
results.tsv(平表)、run.log、workloads/<backend>/<workload>/<phase>.json。
当日可测 instrumentation(框架内实现)
- fio(优先)/ dd(回退)吞吐 + 延迟(clat p50/p99;dd 总时长);
- Swift proxy 请求延迟+count:以 run 窗口为界,
tail -n窗口行数$SWIFT_PROXY_LOG(默认/var/log/swift/proxy-server.log)统计 request count / method / status / elapsed_time(默认 access log 的 float 字段); - etcd txn 延迟:窗口前后 diff
$ETCD_ENDPOINT/metrics的grpc_server_handling_seconds{grpc_method="Txn"}sum/count → 窗口均值。
对比矩阵(env-gated backend sections)
| gate | 默认 | 后端 |
|---|---|---|
COMPARE_SWIFTFUSE=1 | 1 | 本地 mount $BENCH_MOUNT |
COMPARE_RAW_SWIFT=1 | 0 | swift CLI(RAW_SWIFT_*) |
COMPARE_RCLONE=1 | 0 | rclone(RCLONE_REMOTE) |
COMPARE_GEESEFS=1 | 0 | GeeseFS(S3API) mount $GEESEFS_MNT |
COMPARE_JUICEFS=1 | 0 | JuiceFS mount $JUICEFS_MNT |
所有启用 backend 写入同一 results.tsv / summary.json,结尾打印统一对齐表格。
Metrics Contract(R7)
daemon 暴露 GET http://127.0.0.1:<stats_listen>/metrics 以及
/run/swiftfuse/<fs_name>.stats.json(每 2s)。benchmark.sh 在每个
phase 窗口前后各拍一张,写入 phase JSON。字段:
| 字段 | 含义 | 单位 |
|---|---|---|
fuse_op_latency_us | FUSE op 延迟 p50/p99 | µs |
etcd_txn_latency_us | daemon 侧 etcd txn 延迟 p50/p99 | µs |
swift_req_latency_us | daemon 侧 Swift 请求延迟 | µs |
swift_req_count | Swift 请求数(PUT/GET/DELETE/COPY 分桶) | count |
payload_bytes | 每 workload 读写字节 | bytes |
cache_hit_ratio | 内存 block cache 命中率 | ratio |
network_bytes | client↔proxy / proxy↔object 线上字节 | bytes |
Results(2026-08-07 历史值;2026-08-09 重跑值见 G8 报告;2026-08-11 R3 修正见下)
单节点网络拓扑(M3,2026-08-11):
10.0.0.1= loopback(27.9 GB/s)。 单节点 seq read/write 不跨网卡;“千兆内网” 不适用,352-406 MiB/s 物理自洽。
| Workload | Result | Limiting factor |
|---|---|---|
| seq-write 4MiB(2026-08-09 集群安静期) | 202.5 MiB/s(历史 12.4-13.2 MiB/s) | Swift cluster PUT 延迟(并行 block PUT;忙时单块 0.3-3s) |
| seq-read 4MiB / 1MiB QD32 | 352.3 / 406.3 MiB/s | Swift proxy 并发 GET 上限 |
| rand-read 64K QD8 | 129.9 MiB/s(2079 iops) | block 级随机读 |
| 4K 随机写(32MiB 全随机,close-flush) | 超时(8192 块串行 PUT) | 结构性写放大:每 4K 写 1 新块;非相邻写不合并 → close 时数千块 PUT;开 compaction 后可后台合并 |
| 4K 随机写(单次独立 flush) | ~0.9 s/次 | 1 block PUT (~0.4s) + etcd commit |
| 256 MiB warm read (block cache) | 915 MB/s | memory |
| small-file create | ~0.4–1.3 s/file | Swift PUT latency (1 block per file) |
| stat(hot path,路径缓存命中) | 进程内命中 → 0 次 etcd 往返;含内核 LOOKUP 全程为一次 fuse round-trip | 见上文口径 |
| readdir(726 文件,BUG-1 修复后) | 冷 0.75 s / 热 0.079 s;根目录(4 项)0.19 s | etcd 前缀扫描(并行化后) |
| mdtest file create / stat | 9.5 / 103.5 ops/s | Swift PUT vs etcd metadata(两条路径清晰分离) |
| rename / truncate | ~0.2 s / 0.22 s | etcd quorum commit(strong consistency cost) |
| 2-node concurrent read | — | (节点间 ssh 不可达;多节点由 Mac 驱动 multi_node.sh) |
注意:上表 small-file create 为带 4K 内容的文件(1 对象 PUT);空文件 create 是纯 metadata(0 个 Swift 对象,114ms/create,见下文 M2 口径)——两条路径不要混读。
pjdfstest(G1,2026-08-09 全量)
| 分类 | 文件数 |
|---|---|
| PASS(适用用例) | 见 REPORT-2026-08-08.md §1 |
| UNSUPPORTED(symlink/link/mkfifo/mknod → ENOSYS 声明) | 同上 |
| KNOWN DEVIATION(unlink/14 open-deleted 链式 fd) | 1 |
| FAIL | 0 |
Analysis
- Data plane: parallel block PUT/GET confirmed correct (16 blocks in one round-trip); throughput is bounded by the Swift cluster itself (single-block PUT 0.3–3 s during benchmark runs — object servers were busy with auditor passes and replication). Raw Swift API has the same ceiling; swiftfuse adds no serialization.
- Metadata plane: etcd quorum commits are the cost of strong consistency; path-resolution caching keeps hot stat at one fuse round-trip with zero etcd lookups (BUG-1 parallel list + BUG-3 umask 修复后).
- The 宗旨 properties measured:
- 4K random write into a 256 MiB file = 1 new block + CAS commit; no old-data read/rewrite (verified via container block audit).
- rename/truncate/chmod = metadata transactions only.
- Crash of a lease holder: other writers blocked (EBUSY) until etcd TTL expiry (30 s), then recovery (verified).
- Concurrent writers on different nodes: CAS conflict → reload → retry (verified with 2 mounts writing the same file).
Amplification metrics(宗旨专项,2026-08-11 容器审计定死)
更正(2026-08-11 M1 审计):此前 REPORT/BENCHMARK 的 “4K 写 → 1 × 4MiB 对象 ≈ 1024× byte amplification” 是错误声明。 实测:4K 写 + fsync → 1 × 4096B Swift 对象(字节放大 = 1×)。 真实问题是 object/request amplification(每次 4K 写 = 1 个对象 + 1 次 PUT)。
| 指标 | 值 | 说明 |
|---|---|---|
| 4K 写 + fsync → Swift | 1 × 4096B 对象,1 PUT | 字节放大 1×;491ms/写(PUT + etcd commit) |
| 100×4K 随机写 + 单 fsync | 96 个对象(94×4K + 2×8K 合并) | 非相邻 4K 写不合并 → N 对象;flush 曾串行 33s → 批量并行后 9.4s(4f063f7) |
| empty-file create → Swift | 0 个对象(纯 metadata) | 114ms/create = 串行 etcd 往返(非 Swift);stat 9ms |
| stat → etcd | 0(路径缓存命中)/ 1(miss) | BUG-1 前缀修复 + 并行化后 |
| readdir(726) → etcd | 1 次前缀扫描 + 并行 inode 拉取 | 修复前 1008 key 泄漏 + 串行 = 25s |
网络拓扑(M3 定死,与 ROADMAP 附录 M1-M3 对齐):
10.0.0.1是 loopback (dev lo,27.9 GB/s)—— 单节点 benchmark 的 client→Swift 路径不跨网卡, 352-406 MiB/s 物理自洽;“千兆内网” 表述不适用单节点。eth1(10.0.0.x 内部网)为 Contabo virtio 网卡(ethtool 无法读速,物理 ≥10Gb 量级)。真实跨节点上限需 跨节点路径测量(G10 的 5-12 MiB/s 为部分证据)。
G8 判定(R7,2026-08-11)
G8 性能 gate 判定从 PASS/FAIL 改为 MEASURED / REGRESSION / TIMEOUT / SLO-VIOLATION
(scripts/detect/run_gates.sh)。results.tsv 保留历史 PASS/FAIL 列(向后兼容),
新增 class= 列承载 R7 判定:
MEASURED:干净跑完,高于 SLO(且已知 baseline 时高于 baseline);REGRESSION:高于 SLO,但低于已记录 baseline(默认 seq-write 202.5 MiB/s,G8_BASELINE_MIB,容差G8_REGRESSION_PCT=0.8);SLO-VIOLATION:低于 SLO(G8_SLO_SEQ_WRITE_MIB=10、G8_SLO_SEQ_READ_MIB=50、G8_SLO_RANDREAD_IOPS=500,env 可覆盖);TIMEOUT:任一 fio/mdtest job 触到 time bound(exit 124)。
mdtest 同理(create ops/s SLO G8_SLO_MDTEST_CREATE_OPS=1,baseline 9.5 ops/s)。
Tuning knobs (config)
block_size(4 MiB default): trade HTTP requests vs write amplificationprefetch(8 MiB default): read window per requestread_workers/write_workers: parallel GET/PUT concurrencycache_blocks: in-memory read cache capacitywrite_back: kernel write-back cache (on by default)lease: ProxyFS-style write leases (etcd only)kernel_ttl: 0 = 正确性优先(内核每次重验);bridge patch 稳定后可回正
SwiftFuse 深度检测流程(12-Gate)
版本:2026-08-08 · 与
docs/REPORT-2026-08-08.md(检测报告)配套 原则:结果如实记录 —— 每个 gate 输出 PASS / FAIL / INCONCLUSIVE / NOT SUPPORTED, 绝不假装通过。KNOWN DEVIATION 与声明限制分开记录。
0. 检测体系总览
SwiftFuse Test System (12 gates)
Correctness Distributed Performance
G1 pjdfstest (POSIX) G6 多客户端一致性 G8 fio 矩阵 + mdtest
G2 fstests generic G7 故障注入 (etcd/tc) G9 Filebench
G3 LTP syscall G10 1→2→4 节点 scale G12 IO500/SPECstorage*
G4a fsx G11 可复现性 (env/tag/raw)
G4b fio verify+SIGKILL
G5 fsstress
- *G12:IO500 需多节点 MPI 集群(lab 4×8GB 不满足 → NOT SUPPORTED); SPECstorage 2020 需商业 license($2,000)→ NOT SUPPORTED。
- 单节点跑 G1-G5、G7、G8、G9、G11;G6/G10 需要 swift2-4 可达。
1. 运行方法
# 在 swift1 上(检测工具已装好)
/opt/detect/run_gates.sh /mnt/swift-etcd /opt/detect/results/$(date +%s)
# 等价于仓库镜像(同步到节点后):
/opt/swiftfuse/scripts/detect/run_gates.sh /mnt/swift-etcd /opt/detect/results/$(date +%s)
前置条件
deploy_cluster.sh已全节点部署(代码 = 当前 master HEAD,config 含kernel_ttl = 0)。- 工具链在 swift1:
/opt/detect/bin/{fsx,fsstress}、/opt/detect/src/pjdfstest、 (可选)/opt/detect/src/{fstests,ltp}、mdtest、filebench。 - 挂载存活:
mount | grep /mnt/swift-etcd。
产物结构
$R/ (如 /opt/detect/results/20260808-203000)
├── environment.txt # G11: kernel/commit/tag/mount/config/df
├── results.tsv # 逐 gate: gate<TAB>case<TAB>verdict<TAB>detail
├── summary.txt # 汇总
├── perf-summary.txt # G8 性能值
├── errors.log # FAIL 时附加的证据
├── gate.log # 时间戳流水
└── g1..g10/ # 每 gate 原始产物(prove/fio/fsx/…)
2. Gate 明细
| Gate | 工具 | 方法 | 判定规则 |
|---|---|---|---|
| G1 | pjdfstest | 在挂载点内 prove -r 全量 | 失败文件分类:symlink/link/mkfifo/mknod* → UNSUPPORTED(ARCHITECTURE.md 声明 ENOSYS);utimensat/05 → KNOWN DEVIATION;其余 → FAIL |
| G2 | fstests (xfstests) | check -f fuse curated generic/001-100 | FUSE 非块设备 FS,大量 generic 用例 NOT SUPPORTED(mkfs/scratch/reflink);PASS/FAIL/NOT RUN 计数 |
| G3 | LTP | runltp -f runtest/syscalls -s '^(stat|rename|unlink|…)' | 只跑文件系统相关 syscall 子集 |
| G4a | fsx | 3 轮 × 2000 ops(16MiB 文件) | 退出码 0 = PASS |
| G4b | fio verify | 64MiB 4K crc32c 写+verify;SIGKILL daemon → remount → verify_only | verify 通过 = committed data 完好 |
| G5 | fsstress | 4 proc × 200 ops | 无 panic/segfault 且有实际 ops = PASS;无 ops = INCONCLUSIVE |
| G6 | 自研 harness | 本地 4 线程并发 append(size 单调不减)+ 50 rename + readdir 去重;swift2/3 远程并发写 | 全部断言过 = PASS |
| G7 | 故障注入 | a) etcd move-leader + verify;b) tc netem 5% 丢包+100ms×45s 内 fio write+verify;c) SIGKILL(=G4b 交叉引用) | verify 数据完好 |
| G8 | fio 矩阵 + mdtest | 4K/64K/1M × QD1/8/32 × seq/rand;mdtest 500 文件×3 轮 create/stat/remove | fio 产出 JSON;mdtest 有统计输出 |
| G9 | filebench | fileserver 模型 30s | 有 IO Summary 输出 |
| G10 | 多节点 | 1→2→4 节点并发 dd 32MiB/节点 | 记录 nodes/files/dur/bw 曲线 |
| G11 | 可复现性 | environment.txt 记录 commit/tag/config/raw JSON | 每次运行自动产出 |
| G12 | IO500/SPECstorage | — | NOT SUPPORTED(lab 资源/商业 license),附条件与路线 |
3. 手工复现命令(单测 / 回归)
# 快速 rename-over-existing 回归(BUG-2)
cd /mnt/swift-etcd && echo BBB > t && echo AAA > s && mv s t && cat t # 期望 AAA
# open-unlinked 回归(BUG-5)
python3 - <<'EOF'
import os
p = "/mnt/swift-etcd/od.bin"
fd = os.open(p, os.O_CREAT|os.O_RDWR, 0o644)
os.write(fd, b"x"*4096); os.fsync(fd)
os.unlink(p)
assert os.path.exists(p) is False # 名字已消失
st = os.fstat(fd); assert st.st_nlink == 0 # nlink=0
os.write(fd, b"y"*100); os.fsync(fd) # 写仍可用
assert os.read(fd, 10) == b"" # 读到 EOF
os.close(fd) # 最后 fd 关闭 → 真正回收
print("open-unlinked OK")
EOF
# pjdfstest 单目录
mkdir -p /mnt/swift-etcd/pjd && cd /mnt/swift-etcd/pjd && prove -r /opt/detect/src/pjdfstest/tests
# fsx / fsstress(G4a/G5 单跑)
/opt/detect/bin/fsx -q -N 2000 -P /tmp -c 65536 -l 16777216 /mnt/swift-etcd/fsxfile
/opt/detect/bin/fsstress -d /mnt/swift-etcd/stress -n 200 -p 4
# 单元测试(节点上)
cd /opt/swiftfuse && cargo test --workspace
4. 教训与注意事项(历史)
- pjdfstest 必须在挂载点内跑 —— 曾误在本地 XFS 跑出假绿(HANDOFF G1)。
- fio 不要用
sync=1+ 4K —— 每次写变成 block PUT,一轮数小时。 - G6 并发写断言不能用精确 size —— 写序未定义,用单调不减。
- G4b 的 SIGKILL 必须用
-9—— 优雅退出会正常 flush,测不到崩溃恢复。 - fstests 对 FUSE 有大量 N/A —— 用 curated 子集 + NOT RUN 矩阵,不追求全绿。
- fstests 的 mount/unmount 工作流无法驱动单 daemon FUSE 挂载(2026-08-09)——
_test_mount会对 TEST_DEV 二次mount -t fuse;判定必须要求“实际执行了测试“ (0 测试执行 = NOT SUPPORTED,不是 PASS)。 - G6 脚本必须自清理(2026-08-09)—— 残留 cc-/rn-/rdir-* 会在下一轮被打开, 叠加 lease 语义制造假 FAIL。
- lease 断言区分同 holder 重入与跨 holder 拒绝(2026-08-09)—— 同进程重开同文件 是重入(成功),不是 EBUSY;跨 holder 拒绝需多 daemon 或注入测试。
- smoke 的 rename+truncate 是 BUG-7 哨兵(2026-08-09)—— rename 后任意新目录条目 创建会暴露 bridge nodeid 复用;smoke 9/9 是回归底线。
5. 路线图(未落地项)
- G2/G3 全量:xfstests 全 generic + LTP 全 syscall(需更稳的构建链与专用测试盘)。
- G7 增强:etcd 成员 kill(非 leader)、Swift proxy 重启注入、双节点网络分区。
- G12 IO500:需要 ≥4 节点 × ≥64GB + InfiniBand/万兆网络 + MPI 集群。
- JSepsen 式线性一致性检查:Rust harness → JSON history → Porcupine checker (现 G6 为轻量版,G6 论文级版本见路线)。
- doc site:本期只同步 Google Drive;doc site(如 nginx @ swift1)列入后续。
Fleet status
Measured on the four-node lab (swift1–swift4, one etcd, one Swift).
Live prefix /swiftfuse/etcdfs/ at /mnt/swift-etcd. G2 is the
scratch mount. Live never gets NVMe.
Closed gates
| Gate | Verdict | Notes |
|---|---|---|
| G1 / 006 / 035 | PASS | Official fstests |
| G3 LTP | PASS | |
| G4a / G4b | PASS | |
| G5 | PASS | |
| G6 | PASS | Official |
| R1 inode | PASS | |
| R2 fencing | 100/100 | |
| R3 live pack | PASS | delta=1, then pack_target=0 |
| R4 | PASS | |
| R5 watch 10k | PASS | Not 几万 |
| R6 failpoint | PASS | |
| R7 swiftfuse | MEASURED | G2 small-file 1000/1000 |
| R7 cache-hit | PASS | /opt/detect/results/r7-cache-hit-20260902-021100/ window hits=+1 misses=+1 |
| R7 s3api compare | MEASURED | https://10.0.0.10:8085 RegionOne path-style |
| R8 NVMe 10G | PASS | 50G / 100G / 1TB deferred |
| Porcupine 2-mount | LINEARIZABLE | Same-mount writeback is illegal |
| G7 full fault | PASS | Quorum remount is not seamless failover |
R7 comparators, client-cache column (2026-09-02)
Swift on this lab exposes S3 (s3api + s3token on :8085, not :8080).
/info does not advertise s3api; that was a false negative.
/opt/detect/results/r7-s3-fs-20260902-025444/ MEASURED. Dedicated buckets
s3://r7-geese-cmp and s3://r7-juice-cmp only. No keys in logs.
GeeseFS mem 256, JuiceFS cache 256. benchmark.sh cold seq-read used to
seed seq.bin and read it without a remount, so userspace caches stayed hot.
| Backend | seq-write cold | seq-read cold | seq-read warm | small-file cold |
|---|---|---|---|---|
| swiftfuse (G2, 2026-09-01) | 34.6 MiB/s | 21.3 MiB/s | 135.6 MiB/s | 100/100 in 92s |
| swift CLI | 21.9 MiB/s | 45.2 MiB/s | 70.9 MiB/s | 71s |
rclone :swift: | 25.1 MiB/s | 33.9 MiB/s | 33.4 MiB/s | 7.6s |
| GeeseFS s3api | 69.4 MiB/s | 587 MiB/s | 318 MiB/s | 0.48s |
| JuiceFS s3api | 55.2 MiB/s | 31.5 MiB/s | 1306 MiB/s | 7.9s |
GeeseFS 587 and JuiceFS 1306 are client cache, not Swift line rate.
R7 fair remount + drop_caches (2026-09-02)
/opt/detect/results/r7-s3-fs-fair-20260902-031641/ MEASURED.
FAIR_REMOUNT=1: write → persist → umount → wipe JuiceFS --cache-dir →
drop_caches → remount → read-cold → read-warm.
GeeseFS persist on this proxy needs --single-part 80. Default multipart
never completed, so a 64 MiB file stayed dirty in RAM and vanished on umount.
| Backend | seq-write | seq-read cold (S3 miss) | seq-read warm (client cache after refill) |
|---|---|---|---|
| GeeseFS s3api | 16.5 MiB/s | 50.2 MiB/s | 675 MiB/s |
| JuiceFS s3api | 34.4 MiB/s | 8.87 MiB/s | 5029 MiB/s |
Fair cold is the column to put next to swiftfuse G2 seq-read cold 21.3. Warm after remount is still client cache; it is not a Swift number.
Daemon fields
File /run/swiftfuse/<fs_name>.stats.json (HTTP /metrics if stats_listen is free; 9102 is statsd_exporter here). R7 scrape: EXPOSED.
fuse_op_latency_usp50/p99etcd_txn_latency_usp50/p99cache_hit_rationetwork_bytes
Live bin md5 756c4c16f2cacbdd9fbaafa650e2e51c. kernel_ttl=0 write_back=true pack_target=0.
Live policy
kernel_ttl=0 write_back=true pack_target=0 lease=true. No NVMe.
Numeric PIDs only. Never pkill -f.
R1 Design — Inode-Centric FUSE Boundary (InodeFs)
日期:2026-08-11 · 状态:设计定稿(待实现) · 对应 ROADMAP-2026-08-10 §R1 一句话:让
nodeid == inode_id == MetadataEngine 的 inode id,FUSE 层不再维护任何 path→nodeid 映射,InodePathBridge从 chunk 模式移除 → BUG-2 / BUG-5 / BUG-7 在边界上不可能。
摘要(执行摘要)
三个 bug 同根:PathFilesystem(经 InodePathBridge)把 path 当 identity。
R1 把 chunk 模式从 fuse3::path::PathFilesystem 迁移到 fuse3::raw::Filesystem:
- nodeid = inode_id = 引擎分配的 id(root=1;etcd
counter/inode单调递增,永不回收)。 → nodeid 复用/混叠在结构上不可能(BUG-7 消灭)。 - 路径解析只存在一种形式:
(parent_nodeid, name) → engine.lookup → inode_id。 → rename 后没有任何「第二份 nodeid→path 映射」可以损坏(BUG-2 消灭)。 - open-unlinked 文件的 inode 记录存活到「最后
RELEASE+FORGET确认」才 GC;GETATTR(nodeid, fh=None)直接命中存活的引擎记录 →fstat不再 ENOENT(BUG-5 消灭)。 - 代价:
ChunkFs的resolve()/paths/path_inodes全部删除;新增NodeTable(内核引用计数) 与inode_fs.rs(raw impl,约 400 行);swift-meta增加原子rename_replace。 kernel_ttl恢复非 0 的前提正是 inode 层不再依赖内核重查 —— 见 §8。
1. 根因与目标架构
1.1 为什么 bridge 必然坏
InodePathBridge(third_party/fuse3/src/path/inode_path_bridge.rs)维护两份独立于内核的状态:
name_to_inode: HashMap<(parent_nodeid, name), nodeid>(桥自己的 slab 分配,InodeGenerator)inode_to_names: HashMap<nodeid, HashSet<(parent, name)>>(nodeid→绝对路径 的推导来源)
内核只认 nodeid;桥为了把 raw 请求翻译成 path 请求,必须让这两份表和内核的
dentry/d_move 完全一致。BUG-2(rename-over-existing 后旧 nodeid 映射到已删路径)、
BUG-7(rename 释放源 nodeid → slab 复用 → 映射被覆盖)都是这份第二份真相与内核脱节的必然结果。
kernel_ttl=0 只是让内核每次重 LOOKUP、强迫桥重建映射 —— 治标。
1.2 目标:identity 只存在一份
R1 之后 chunk 模式的 identity 链:
FUSE nodeid (u64, 内核 dentry/inode 的句柄)
│ == (同一 u64,无翻译表)
▼
inode_id (u64, 引擎 inode 主键)
│ lookup/get_inode/commit/…
▼
InodeMeta (crates/swift-meta/src/model.rs, JSON in etcd / row in sqlite)
- nodeid 永远等于引擎 inode id:引擎 id 由
counter/inode(etcd)或单调分配(sqlite,见 §9.2) 产生,永不回收。所以 nodeid 永不回收、永不混叠 —— 不需要 slab,不需要 generation 校验。 - 没有 path→nodeid 缓存。
paths/path_inodes/resolve()删除。名字只出现在engine.lookup(parent_inode, name)的入参里。 - 内核 d_move 只影响内核自己的 dentry;我们对 rename 的唯一反应是改引擎记录 + 失效本地 attr/dir 缓存。不再维护任何会与内核脱节的 nodeid 语义副本。
2. 模块布局(目标态)
crates/swift-fuse/src/
lib.rs # pub mod inode_fs; 导出不变
chunk_fs.rs # ChunkFs 核心:engine 逻辑、Handle、attrs/dirs 缓存、读写/压缩/GC。
# ✂ 删除 PathFilesystem impl 与 path 机制
# ✚ 新增 nodes: NodeTable 字段 + maybe_gc()
inode_fs.rs # ✚ 新文件:NodeTable/NodeEntry + impl fuse3::raw::Filesystem for ChunkFs
# (同一 crate 内可分文件 impl,ChunkFs 结构体本身不动)
fs.rs / handle.rs / attr.rs / key.rs / prefetch.rs # raw 模式 (SwiftFs) 不动
crates/swift-meta/src/
engine.rs # ✚ trait 默认实现 rename_replace()(两段式,兼容 sqlite)
etcd.rs # ✚ override rename_replace():单个 etcd txn 原子替换目标
sqlite.rs # ✚ id 单调性修复(§9.2)+ rename_replace 走默认实现
model.rs # 不变
crates/swiftfuse-cli/src/main.rs
# chunk 分支:fuse3::path::Session → fuse3::raw::Session
third_party/fuse3/src/path/inode_path_bridge.rs
# 保留不动(raw 模式的 SwiftFs 仍需 path bridge + vendored patch)
关键点:ChunkFs 结构体与全部数据面/句柄逻辑复用;raw impl 是薄壳,只做
nodeid 到引擎调用的翻译 + 引用计数 + 失效。fuse3::raw::Filesystem 的所有未实现方法
走 trait 默认 ENOSYS —— 与当前声明一致(无 symlink / hardlink / mknod / xattr)。
3. 数据结构(具体类型)
3.1 NodeTable / NodeEntry(新,inode_fs.rs)
#![allow(unused)]
fn main() {
/// 内核侧引用记账:一个 nodeid(== inode_id)一条。
/// 存在的唯一目的是:(a) 正确响应 FORGET;(b) 决定未链接 inode 的 GC 时机。
struct NodeEntry {
/// 恒等于 FUSE nodeid(无翻译)。存一份便于断言与调试。
inode: u64,
/// 已返回给内核的 lookup 次数(lookup/create/mkdir/readdirplus 各 +1)。
/// FORGET 减去 nlookup;到 0 表示内核不再持有该 nodeid 的 dentry 引用。
lookup_count: AtomicU64,
}
pub(crate) struct NodeTable {
nodes: DashMap<u64, NodeEntry>,
/// 每个 inode 在本挂载点的 open handle 数(代替现状的 O(n) 扫描 has_open_handles)。
open_per_inode: DashMap<u64, AtomicU64>,
/// 已 unlink 但未 GC 的 inode(名字已从引擎消失,记录 + blocks 存活)。
deleted: tokio::sync::Mutex<std::collections::HashSet<u64>>,
}
impl NodeTable {
/// lookup/create/mkdir/readdirplus 返回 nodeid 前调用(内核 +1 引用)。
fn bump(&self, inode: u64);
/// FORGET:扣 nlookup;返回 true 表示内核引用归零。
fn forget(&self, inode: u64, n: u64) -> bool;
/// open/create 成功后 +1;release 后 -1。
fn note_open(&self, inode: u64);
fn note_close(&self, inode: u64);
/// GC 三条件:deleted ∧ open==0 ∧ lookup_count==0。
fn gc_ready(&self, inode: u64) -> bool;
fn mark_deleted(&self, inode: u64);
}
}
3.2 ChunkFs 字段变化(chunk_fs.rs)
#![allow(unused)]
fn main() {
pub struct ChunkFs {
engine: Arc<dyn MetadataEngine>,
store: Arc<dyn chunk_plane::BlockStore>,
pub reader: ChunkReader,
cfg: Arc<ChunkFsConfig>,
attrs: DashMap<u64, AttrEntry>, // 不变(键 = inode id)
dirs: DashMap<u64, (Instant, Vec<(raw::DirectoryEntry, InodeMeta)>)>, // 键不变,条目类型换 raw
pub handles: DashMap<u64, Arc<Handle>>, // fh 注册表,不变
nodes: NodeTable, // ✚ 新
next_fh: AtomicU64, // 不变
compact_interval: Option<Duration>, // 不变
block_cache: Option<Arc<chunk_plane::BlockCache>>, // 不变
inflight: DashMap<String, Arc<tokio::sync::Notify>>, // 不变
notify: Mutex<Option<fuse3::raw::Notify>>, // 类型改 raw
stale_names: Mutex<HashSet<(u64, OsString)>>, // 保留,降级为防御层(§6.4)
// ✂ 删除:paths, path_inodes, deleted_inodes(并入 NodeTable.deleted)
}
// ✂ 删除方法:resolve(), cache_path(), invalidate_path(), path_for()
// (children_meta()/attr()/open_handle()/flush_handle()/remove_file_entry() 等保留)
}
3.3 打开句柄注册表(不变,仅记账)
Handle(chunk_fs.rs:71)保持不变:{inode, writable, writer, chunks, size, meta}。
handles: DashMap<fh, Arc<Handle>> 是 fh 注册表;NodeTable.open_per_inode 提供
inode→open 数。open_handle() 增加一行 nodes.note_open(inode);release 增加
nodes.note_close(inode) + maybe_gc(inode)。
3.4 raw 属性构建
fuse3::raw::reply::FileAttr 带 ino 字段(nodeid 由 attr.ino 上送,
见 raw/reply.rs:110 的 fuse_entry_out { nodeid: attr.ino, .. })。
现有 attr_of()(chunk_fs.rs:135)构建的是 fuse3::path::reply::FileAttr(无 ino,
时间是 SystemTime)。迁移必须:
#![allow(unused)]
fn main() {
/// 新:raw FileAttr。ino 必填 = meta.id。
fn attr_of(meta: &InodeMeta, _cfg: &ChunkFsConfig) -> fuse3::raw::reply::FileAttr {
let mut a = /* 与原 attr_of 相同字段,时间转 Timestamp */;
a.ino = meta.id;
a
}
}
任何遗漏 ino 的回复都会让内核拿到 nodeid=0 → ENOENT/错乱,这是迁移的头号陷阱。
4. Nodeid 分配与复用规则
4.1 分配
- FUSE 层从不分配 nodeid。nodeid 是
insert_inode()的返回值(引擎分配), 由lookup / create / mkdir / readdirplus原样上送内核。 - root:
engine.root_id() == 1,与内核根 nodeid 1 天然一致;init()时nodes.bump(1)预置一条引用(内核挂载即持根引用,unmount 时收到forget(1))。
4.2 复用规则 —— 为什么「永不复用」而非「复用但防混叠」
混叠 bug 的唯一来源是同一个 u64 在两个不同时刻指向两个不同 inode,而内核 (或旧进程 fd)还持有旧身份。我们的规则:
- 引擎 id 永不回收(硬性不变量):
- etcd:
counter/inode只增不减(etcd.rs的bump_counter)✓ 已满足。 - sqlite:
id INTEGER PRIMARY KEY在删除最大行后会复用 rowid → 必须改AUTOINCREMENT或改走counters表(§9.2),否则 R1 不变量在 dev 引擎上不成立。
- etcd:
- nodeid := inode_id:两条规则合起来 ⇒ 一个 nodeid 在文件系统整个生命周期内 只指向一个 inode。没有可复用的 nodeid,混叠在结构上不可能。BUG-7 类 (rename 释放 nodeid → 后续 create 复用 → 覆盖映射)失去发生条件。
- 内核引用归零(FORGET)不释放 id,只触发未链接 inode 的记录删除(§5)。 id 本身永远属于它创建的那个 inode。
- 反方论证(为什么不用 slab 复用 + generation):slab 复用需要精确复刻内核
d_move 语义(正是 BUG-2/BUG-7 的失败点),generation 需要内核
FUSE_LOOKUP带generation校验(Linux 内核 FUSE 目前不校验 generation)。直接不复用是 更简单且可证明 的选择;代价只是 u64 单调增长(64 位,工程上无上限问题)。
4.3 防御性校验
inode_fs.rs 的每个 raw handler 收到 nodeid 后不需要查 nodes 表即可服务
(get_meta(inode) 直接走引擎,nodeid 自校验)。nodes 表只回答
「内核还引不引用它」。若 GETATTR 收到未知 nodeid(理论不应发生),
仍按引擎记录应答;记录不存在 → ENOENT(正常,无需 ESTALE 特判)。
5. FORGET 生命周期与延迟 inode GC
5.1 引用记账点(每个「把 nodeid 介绍给内核」的回复)
| 事件 | 记账 | 说明 |
|---|---|---|
lookup 成功 | bump(child_id) | ReplyEntry |
create 成功 | bump(id) | ReplyCreated |
mkdir 成功 | bump(id) | ReplyEntry |
readdirplus 每项 | bump(child_id) | 协议:readdirplus 条目各携带一次 lookup 引用 |
readdir(非 plus) | 不记账 | 内核只认名字,使用时自行 LOOKUP |
forget(nodeid, n) | lookup_count -= n | 归零 → maybe_gc |
batch_forget | 同上,逐条 | 必须 override:fuse3 默认实现是空操作,漏掉会泄漏计数 |
注意:
forget必须在nodes.forget()归零后仍保留 nodeid→inode 的有效性 直到 open handle 清空 —— 但因为我们不删 NodeEntry 本体、只删引擎记录,而引擎记录 由 open handle 条件保护,两者天然衔接(见 5.3)。
5.2 延迟 GC(BUG-5 的机制)
现状(chunk_fs.rs::remove_file_entry)已有雏形,R1 把它变成三条件精确化:
maybe_gc(inode):
if nodes.gc_ready(inode): # deleted ∧ open==0 ∧ lookup_count==0
nodes.deleted.remove(inode)
finish_deferred_gc(inode) # engine.delete_inode + gc_blocks(现有逻辑)
unlink(parent, name):meta = engine.lookup(parent, name),is_dir → EISDIR。- 若
open_per_inode[meta.id] > 0 && engine.supports_deferred_gc():engine.unlink_name(parent, name, meta.id)(只删名字,等值 CAS 防名字被复用后误删),nodes.mark_deleted(meta.id)。 - 否则
engine.delete_inode(meta.id)+gc_blocks(与现状一致;sqlite 走这里)。
release(fh):flush → 释放 lease →note_close(inode)→maybe_gc(inode)。forget/batch_forget归零:maybe_gc(inode)。
为什么必须等 lookup_count==0:内核可能在 unlink 前已通过 readdirplus 缓存了
该 nodeid 的 positive dentry(TTL>0)。若记录在 FORGET 前删除,内核随后对缓存的
GETATTR(nodeid) 会命中不存在记录。三条件保证:只要内核还可能对某个 nodeid 发起
无 fh 请求,记录就活着(nlink 报 0,数据可读)——这正是 open-unlinked fstat
需要的窗口,也是 readdirplus 缓存安全性的来源。
5.3 open-unlinked 请求矩阵(BUG-5 逐例)
| 内核请求 | 我们的应答路径 | 结果 |
|---|---|---|
GETATTR(nodeid, fh=Some(k)) | handles[k].meta 快照 + nlink=0(现有 attr() 逻辑) | ✅ |
GETATTR(nodeid, fh=None)(内核 fstat 特性,chunk_fs.rs:794 注释确认存在) | get_meta(nodeid) 命中存活记录 + deleted 集合 → nlink=0 | ✅ 不再被 bridge 的 ENOENT 截断 |
READ/WRITE/FSYNC/RELEASE(fh) | 只看 fh → Handle → inode,不看 nodeid 表 | ✅ |
| 未 open 即 unlink | delete_inode + 记录删除;内核无 fd 无 dentry 后不再请求 | ✅ |
attr() 中现有的 engine.get_inode(...).is_none() 兜底(跨节点 unlink)保留。
5.4 内存卫生
maybe_gc 之外,forget 归零但未删除的 inode(正常 unlink 后已删、或未 unlink 只是
dentry 被逐出)→ 顺手 attrs.remove(inode)、dirs.remove(inode)(重新访问时
重查引擎,代价极小)。NodeEntry 本体保留(防内核「迟到」请求),只清缓存。
5.5 异常路径
nlookup>lookup_count:saturating_sub+warn!(内核引用数以我们上送为准, 理论上只会小于等于)。- unmount 时内核不保证发全 FORGET(协议明示):destroy 阶段无需回收,进程退出即释放。
- 崩溃:unlinked-未 GC 的记录 = 现状已容忍的 orphan 块(finish_deferred_gc 的 best-effort 语义,不变)。
6. Rename 语义(BUG-2 / BUG-7 不可成立)
6.1 raw rename 处理流程
#![allow(unused)]
fn main() {
async fn rename(&self, req, parent: u64 /*nodeid*/, name: &OsStr,
new_parent: u64, new_name: &OsStr) -> Result<()> {
// 0. 同名同父 → no-op 成功(POSIX)
if parent == new_parent && name == new_name { return Ok(()); }
// 1. 源存在性
let src = self.engine.lookup(parent, name).await? .ok_or(ENOENT)?;
// 1b. 硬化:目录不能移入自身子树(否则产生环,readdir/路径游走会死循环)
if src.is_dir() && self.is_ancestor(src.id, new_parent).await? { return Err(EINVAL); }
// 2. 目标存在性与类型
let dst = self.engine.lookup(new_parent, new_name).await?;
if let Some(d) = &dst {
if src.is_dir() != d.is_dir() { return Err(if src.is_dir() { ENOTDIR } else { EISDIR }.into()); }
if src.is_dir() {
let kids = self.engine.list_children(d.id).await?;
if !kids.is_empty() { return Err(ENOTEMPTY.into()); }
}
}
// 3. 原子改名(替换目标同一 txn,§9.1)
let replaced = match &dst {
Some(_) => self.engine.rename_replace(src.id, parent, name, new_parent, new_name).await?,
None => { self.engine.rename_inode(src.id, new_parent, new_name).await?; None }
};
// 4. 被替换目标的回收(延迟 or 立即)
if let Some(d) = replaced {
if self.nodes.open_per_inode.get(&d.id).map(|c| c.load()>0).unwrap_or(false)
&& self.engine.supports_deferred_gc() {
self.nodes.mark_deleted(d.id); // 名字已由 txn 删除,等 last release
} else {
if let Some(removed) = self.engine.delete_inode(d.id).await? {
self.gc_blocks(removed.chunks);
}
}
}
// 5. 本地失效(无任何 nodeid 记账 —— 这是与 bridge 的本质区别)
self.invalidate(src.id);
self.dirs.remove(&parent);
self.dirs.remove(&new_parent);
self.touch_parent(parent).await;
self.touch_parent(new_parent).await;
self.mark_stale(new_parent, new_name).await; // 防御层,见 6.4
self.invalidate_kernel_entry(new_parent, new_name).await; // best-effort notify
Ok(())
}
// rename2(renameat2 带 flags):flags==0 → 上面的 rename;
// RENAME_NOREPLACE → dst 存在则 EEXIST(否则走 rename);RENAME_EXCHANGE → ENOSYS。
}
6.2 为什么 BUG-2 不可能
BUG-2 的本质:桥把「新名字」指向了「旧目标 nodeid」,内核 d_move 把「新名字」指向了
「源 nodeid」,随后内核 GETATTR(源nodeid) 被桥解析成已删除的路径 → ENOENT。
R1 后:
- 内核 d_move 后对
t的 dentry 持有源 nodeid;GETATTR(源nodeid)直达get_meta(源id)→ 源 inode 记录完好(改名不改记录存在性)→ 正常应答。 - 不存在任何「nodeid→path」映射,不存在「路径已删」这一层故障。
- 被替换的旧目标由内核 FORGET + 我们的三条件 GC 处理;即使它 open,延迟 GC 保证 记录存活,任何迟到请求可答。
6.3 为什么 BUG-7 不可能
BUG-7 的本质:rename 释放了源 nodeid → 后续 create 在 slab 里复用该 nodeid →
inode_to_names 覆盖 → 旧文件路径解析损坏。
R1 后:rename 不做任何 nodeid 释放(没有 slab);create 从引擎计数器拿全新 id; 引擎 id 永不回收(§4.2)。「释放-复用-覆盖」三步全部失去发生条件。
6.4 stale_names 与 notify 的定位(降级为防御层)
原 stale_names(rename/unlink 后 lookup 返回 ttl=0)是为桥的损坏映射止血而加。
R1 后内核 d_move 已经 unhash 掉旧目标 dentry,正常情况下不再需要。保留但不承担
正确性:代价是一行集合查询;防御对象是「内核在 d_move 前已缓存旧目标 dentry、
且 entry_valid 未过期」这类实现细节。invalidate_kernel_entry(FUSE_NOTIFY_INVAL_ENTRY)
同理,best-effort。真正的跨挂载点 stale 问题归 R5(etcd watch 失效),见 §7.3。
7. kernel_ttl 恢复非 0
7.1 为什么可以恢复
kernel_ttl=0 的语义是「每次访问都让内核重新 LOOKUP」——它强迫桥重建 nodeid→path
映射,是对桥损坏的规避,不是正确性本身。它的代价是每次 stat/open 都多一次
FUSE LOOKUP 往返(HANDOFF 记录 stat 9ms/114ms-create 中部分就来自这里)。
R1 后 inode 层对内核重查的依赖彻底消失:
| 依赖点 | 现状(bridge) | R1(raw inode) |
|---|---|---|
| 内核缓存 dentry → 下次 GETATTR | 桥可能解析到死路径 → ENOENT | nodeid 直达引擎记录,永远可答 |
| rename 后新名字 | 桥映射可能错 | 内核 d_move + 引擎记录,天然一致 |
| unlink 后名字 | 桥若还缓存旧路径 → 静默错 | 名字只在引擎里,LOOKUP 即得真相 |
恢复的充分条件就是「nodeid 即身份」:内核可以信任 cached dentry,因为 nodeid
永远不会骗人;改名/删除的时效性由 entry_valid 内的本地失效(notify + stale_names)
补上。建议 kernel_ttl = 1s(ROADMAP 目标值),attr_ttl/dir_ttl 保持配置分离
(它们只控制进程内缓存)。
7.2 需要同步改动的配置
FsCfg.kernel_ttl默认0 → 1(swiftfuse-cli/src/main.rs:166)。- 节点部署模板
/etc/swiftfuse-etcd.toml里的kernel_ttl = 0→1(scripts/deploy_cluster.sh模板 +scripts/detect/run_gates.sh环境配置)。 - lookup/create/mkdir 的 ReplyEntry
ttl返回cfg.kernel_ttl(fuse3 单 ttl 同时 驱动 entry_valid 与 attr_valid,见raw/reply.rs:113);进程内attrs缓存仍用attr_ttl,不冲突。
7.3 边界(诚实声明)
kernel_ttl>0 的正确性在单挂载点内成立。跨挂载点(另一节点 rename/unlink 后,
本节点内核还持有 positive dentry,entry_valid 未过期 → 可能对已删 nodeid 发 GETATTR
→ ENOENT,或对已改名 nodeid 读到旧 attrs)是 R5(cache coherence) 的范畴:
需要 etcd watch 失效或 version 校验。R1 的 G6 重跑在 §10.4 处理这一边界。
8. 迁移策略(从 PathFilesystem 到新边界)
8.1 决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| vendored fuse3 bridge | 保留(chunk 模式不再使用 path::Session,但 raw 模式 SwiftFs 仍走 bridge) | patch 已稳定且对 raw 模式无副作用;回退上游是独立清理项,R1 不动 |
| chunk 模式会话 | fuse3::path::Session → fuse3::raw::Session(main.rs 一处) | 完全绕开 InodePathBridge |
| ChunkFs 改动量 | 中等:删 path 机制(~120 行),加 nodes 字段与 maybe_gc,attr_of 换 raw FileAttr 类型,children_meta 条目换 raw DirectoryEntry | 数据面/句柄/压缩逻辑零改动 |
| 新代码 | inode_fs.rs 一个文件(~400 行) | 所有 raw 翻译集中一处,便于 review |
| 引擎 | trait 加 rename_replace(默认两段式)+ etcd 单 txn override + sqlite id 单调 | §9 |
8.2 落地顺序(每步可编译可回滚)
swift-meta:sqlite id 单调(§9.2)→ trait 加rename_replace默认实现 → etcd override。engine_test.rs/etcd_test.rs补 rename_replace 用例。inode_fs.rs:NodeTable + raw impl(此时 ChunkFs 仍实现 PathFilesystem,双 impl Rust 允许,仅PathFilesystem::lookup等需要路径签名的旧方法死代码暂留)。chunk_fs.rs:删 path 机制与 PathFilesystem impl;attr_of/children_meta换 raw 类型。main.rschunk 分支切raw::Session;配置默认 kernel_ttl=1。- 全量测试(§10)+ 节点部署。
步骤 2/3 之间「死代码暂留」是为了让迁移中间态可编译;合并时一次删净。
8.3 测试代码适配
tests/chunk_fs_test.rs 目前以 PathFilesystem trait 方法驱动。新增
pub(crate) mod test_driver(或测试私有助手):把 (path→inode) 的 walk 用一个
内存 HashMap<path, inode> 模拟(测试专用,不进生产路径),把 raw 方法包装成与
现在相同的调用形态 —— 减少既有用例改动。测试驱动永远经过 raw impl,
这样才能测到 FORGET/GC/rename 的真实语义。
9. 引擎层改动
9.1 原子 rename_replace(新 trait 方法)
动机:现状的「remove_file_entry(dst) + rename_inode(src)」是两个独立 txn,
中间窗口内另一客户端可在 dst 名字上 create → rename_inode 的
Compare::version(new_name, Equal, 0) 失败 → rename 报错(多客户端不原子)。
R1 单 txn 化,让 rename 语义在并发下也正确(R2/R6 之前就能自圆)。
#![allow(unused)]
fn main() {
// engine.rs trait(默认实现 = 现有两段式,sqlite 零成本获得正确行为)
async fn rename_replace(&self, src_id: u64, src_parent: u64, src_name: &str,
dst_parent: u64, dst_name: &str)
-> Result<Option<InodeMeta>, MetaError> {
let dst = self.lookup(dst_parent, dst_name).await?;
if let Some(d) = &dst {
self.unlink_name(dst_parent, dst_name, d.id).await?; // 删名字;sqlite 默认 = 连记录删
}
self.rename_inode(src_id, dst_parent, dst_name).await?;
Ok(dst)
}
}
etcd override(etcd.rs,单个 txn):
when:
names/{src_parent}/{src_name} == value(src_id) // 源名仍指向源
names/{dst_parent}/{dst_name} == value(dst_id) // 目标名仍指向我们读到的目标
inodes/{src_id} == mod_revision(rev) // 源记录未被并发修改
then:
delete names/{src_parent}/{src_name}
delete names/{dst_parent}/{dst_name}
put names/{dst_parent}/{dst_name} = src_id
put inodes/{src_id}(parent=dst_parent, name=dst_name, version+1)
返回(读好的)dst 记录 → 调用方决定立即 GC 或延迟 GC
语义契约:只保证名字侧原子性;目标 inode 记录的删除仍是调用方步骤
(延迟 GC 需要记录存活,因此不能在 txn 里删记录)。任何 when 失败 → 整 txn 回滚 →
rename 返回错误(调用方不需要补偿)。
9.2 sqlite id 单调性修复(R1 不变量前提,§4.2)
sqlite.rs:59 的 id INTEGER PRIMARY KEY(rowid 别名)在删除最大行后可复用 rowid。
改法(二选一,推荐前者):
id INTEGER PRIMARY KEY AUTOINCREMENT+open()时迁移已有表 (AUTOINCREMENT 保证新 id > 历史 max(id),天然单调不复用)。- 沿用现有
counters表做next_inode_id分配(与 etcd 同构)。
结论不依赖选型:只要新 id 严格大于历史最大 id,nodeid 不变量即成立。
9.3 supports_deferred_gc 语义不变
etcd = true(unlink_name 只删名字);sqlite = false(默认 unlink_name 连记录删,
调用方立即 GC)。R1 不改此契约;sqlite 的 open-unlinked fstat 仍受限(dev 引擎,
如实记录 KNOWN DEVIATION,生产路径是 etcd)。
10. 测试计划(映射 R1 门禁)
10.1 G1 — pjdfstest(含 unlink/14)
- 在挂载点内
prove -r /opt/detect/src/pjdfstest/tests,kernel_ttl=1。 - 判定:
unlink/14(open→unlink→fstat 存活)必须 PASS(现状唯一真实偏差)。 新增断言:unlink 后fstat返回合法 attr 且nlink==0;read/write继续可用;close后 GC(etcd 侧 inode 记录消失)。 - 预期保留的失败:symlink/link/mkfifo/mknod → ENOSYS 级联(声明限制,非回归)。
utimensat/05(symlink)同前。
10.2 rename torture 100k+(新脚本 scripts/detect/rename_torture.sh)
分三阶段,全部 kernel_ttl=1:
- 确定性阶段:
echo B>t; echo A>s; mv s t; cat t循环 100k 次, 每次断言内容 == A;交错chmod 700 t、stat t、readdir。快速回归 BUG-2 形态。 - 随机阶段:N 个并发线程随机 op
{create, write, unlink, rename, mkdir, rmdir, readdir, chmod, stat, read}; 内存 shadow model 记录「路径 → 内容/存在性」;每轮后断言:- model 中存在的路径,FS 解析必成功(无 ENOENT);
- readdir 条目 == model(无幽灵/重复条目);
- 覆盖语义:
mv s t(t 存在)后t内容 ==s; - rename 后对源名的访问必须 ENOENT;对目标名必须成功。
- open-fd 交错阶段:open s(持 fd)→ mv s t → 通过 fd 读/写/fsync → close; 断言 fd 数据完整、无 ENOENT;close 后目标名 t 正常。验证三条件 GC 时机。
判定:0 个「model 说成功而 FS 报 ENOENT」;100k+ ops 全程无 panic/无悬挂请求。
10.3 G4/G5 — fsx / fsstress / fio verify
- 沿用
run_gates.sh的现有命令(fsx -N 2000、fsstress -n 200 -p 4、fio verify),唯一改动:kernel_ttl=1。 - fsx 增加
-F(fallocate 相关非必需,保持现状即可);fio 保持 crc32c verify- SIGKILL/remount 持久性验证(G4b 原样复跑,证明 GC 三条件不影响 crash 语义)。
10.4 kernel_ttl=1 下 G1/G6 重跑
- G1:10.1 本身就是 kernel_ttl=1 跑法。
- G6(多客户端并发 harness,HANDOFF 已修断言为单调不减):以 kernel_ttl=1 复跑。 单挂载点内所有断言必须全绿(本地失效覆盖 rename/unlink/create 的可见性)。 跨挂载点 stale 窗口(另一节点改名后本节点 cached dentry 未过期)属 R5; 若 G6 harness 触发此类失败,判定策略见 §11 Q1 —— 不把 R5 工作提前塞进 R1, 但如实记录。
- 新增nodeid 复用审计:脚本在 torture 前后
etcdctl get --prefix {prefix}/inodes导出全部 id,断言max(id)单调、且同一 id 不同时存在两个记录(混叠即失败)。
10.5 单元/集成测试(swift-fuse/tests/chunk_fs_test.rs 适配 + 新增)
lookup→getattr往返:返回的 ino == 引擎 id。- FORGET 记账:
lookup×2 + forget(1)→ 引用仍 >0;再forget(1)→ 归零。 - open-unlinked:open → unlink →
getattr(inode, None)与getattr(inode, Some(fh))均返回 nlink=0;release 后get_inode为 None(etcd 引擎);maybe_gc三条件逐条验证。 - rename-over-existing:rename 后
lookup(dst)返回源 id;旧目标 id 在 forget+release 后被 GC;rename_replace冲突路径(目标名被并发占用 → 错误且 src/dst 均不变)。 - nodeid 不变量:unlink+create 循环后新 create 的 id 严格大于历史 max(跨引擎断言)。
- batch_forget:一次多 inode 归零全部触发 GC。
- 集成(可选):
raw::Session挂载 loopback 起 mount,跑 10.2 阶段 1 的 1000 次子集。
11. Open Questions(实现期需要拍板/观察的点)
- Q1(G6 × kernel_ttl=1 边界):跨挂载点的 cached-dentry stale 窗口在 R1 的 G6 判定中如何界定?建议:G6 的 rename/unlink/create 断言限定在「发起节点自己的挂载内 可见性」,跨节点立刻可见性归 R5(etcd watch);若 harness 在 kernel_ttl=1 下出现 跨节点 stale 失败,判定为 KNOWN DEVIATION(R5 前)而非 R1 回归。是否把 watch 失效 前置到 R1,等实现期 G6 复跑结果再定。
- Q2(sqlite id 单调选型):
AUTOINCREMENT迁移 vscounters表分配。 前者改动最小但需要 schema 迁移(现有库在节点上是 etcd,sqlite 只在测试); 建议 AUTOINCREMENT +open()内一次性max(id)续起校验。 - Q3(stale_names/notify 去留):设计定「保留降级为防御层」。若 10.2 随机阶段 在去掉 stale_names 后仍 0 stale,可再删 —— 依赖测试证据,先不删。
- Q4(generation):
ReplyEntry.generation/DirectoryEntryPlus.generation一律 0。若未来做 nodeid 压缩或 id 复用才需要 generation 语义;当前不启用。 - Q5(rename2 RENAME_EXCHANGE):返回 ENOSYS 是否可被 fstests/fsstress 接受? 现路径 impl 未实现 renameat2,行为不变;观察 G2/G3 是否触及,不预实现。
- Q6(dir rename 祖先环检查):§6.1 的 EINVAL 检查是硬化项;确认 pjdfstest rename 系用例(move dir into own subtree)在 R1 后全过,若现有测试未覆盖则补一条。
附:与 ROADMAP/HANDOFF 的对应
| 本设计章节 | 对应的 ROADMAP/HANDOFF 目标 |
|---|---|
| §4 nodeid 不复用 | ROADMAP R1 目标 3(rename/nodeid/FORGET 生命周期正确) |
| §5 FORGET + 延迟 GC | HANDOFF P1(BUG-5 未完成部分)、ROADMAP R1 目标 2 |
| §6 rename 语义 | HANDOFF BUG-2(bridge 损坏)与 BUG-7(nodeid 释放) |
| §7 kernel_ttl | ROADMAP R1 目标 4、HANDOFF §8 kernel_ttl=0 是缓解手段 |
| §10 测试 | ROADMAP R1 Gate(pjdfstest unlink/14 / rename 100k+ / fsx / kernel_ttl=1 G1/G6) |
R2 Design — Distributed Ownership (fencing + handle registry)
Date: 2026-08-18 · Status: implemented in tree, not deployed · ROADMAP-2026-08-10 §R2 One line: a write lease is a JSON grant (
client_id+session_id+fencing_epoch), nothostname-PID; every file-data commit CASes that grant so a partitioned old writer cannot publish after a new writer holds the inode.
0. Why this exists
The G6 exclusive-writer lease is a TTL lock, not fencing.
- Holder is
hostname-PID(swiftfuse-climount). Daemon restart → new PID → 30sEBUSYeven though it is the same machine. acquire_write_leasePUTs that string under{prefix}/leases/{inode}attached to an etcd lease. Re-entrancy is byte-equality on the string.commit_inodeCASesinode.version+ etcdmod_revisiononly. A partitioned old writer that still holds a staleHandlecan commit after a new writer has the lease.- There is no distributed open-handle table. Cross-node open-unlinked GC is local
NodeTableonly.
R2 does not add INVAL_ENTRY / INVAL_INODE / NOTIFY_STORE, does not flip write_back, and does not use FOPEN_DIRECT_IO. JSON-bytes CAS stays on mod_revision.
1. Identities
| Token | Meaning | Lifetime |
|---|---|---|
client_id | Stable mount identity: {hostname}-{uuid} | Survives daemon restart. UUID file: /var/lib/swiftfuse/client_id, else {meta_db.parent}/client_id. |
session_id | This process / this mount | New UUID (or hostname-PID) at each start. |
fencing_epoch | Monotonic token for this inode’s write generation | Increments on each successful acquire that is not same-client + same-session re-entry. Fresh key starts at 1. |
etcd_lease_id | The etcd lease attached to the lease key | Dies with the writer process (TTL 30s, no keepalive — same as today). |
session_id changing with the same client_id is the restart case: re-acquire is allowed and must bump fencing_epoch so in-flight commits from the dead session are fenced.
2. Lease record (etcd key {prefix}/leases/{inode})
JSON, schema_version = 1:
{
"schema_version": 1,
"client_id": "swift1-a1b2c3d4-...",
"session_id": "sess-7f8e...",
"fencing_epoch": 3,
"etcd_lease_id": 75849201023
}
Backward compatible: a pre-R2 value that is a bare UTF-8 string is parsed as
client_id = that string, session_id = "", fencing_epoch = 0, etcd_lease_id = 0
The next acquire from that holder upgrades the key to JSON.
Acquire (acquire_write_lease_v2)
- Grant a new etcd lease. Try
Compare::version(lease_key) == 0PUT of a JSON record withfencing_epoch = 1. - If the key exists:
- other
client_id→None(FUSE maps toEBUSY). Surplus grant revoked. - same
client_id+ samesession_id→ return the existing grant (no epoch bump). Surplus grant revoked. - same
client_id+ newsession_id→ PUT replacement JSON withfencing_epoch + 1attached to the new etcd lease (CAS on the bytes we just read). This is restart.
- other
acquire_write_lease(inode, holder)stays. It calls v2 withclient_id = session_id = holderand returnsbool. Existing client-A / client-B tests stay valid.
Sqlite: acquire always succeeds (single-node). An in-memory map still tracks the latest grant so commit_inode_fenced can reject a stale epoch. Other-client EBUSY is etcd-only.
Grant stored on the FUSE Handle
LeaseGrant { client_id, session_id, fencing_epoch }
Set at open / create after a successful acquire. flush_handle and the O_TRUNC commit pass this grant into commit_inode_fenced.
3. Fencing on file-data commit
commit_inode is unchanged: version + mod_revision only. Directory mtime touches, chmod, and the grow-only SETATTR helper (no write lease) stay on this path.
commit_inode_fenced(meta, grant) is the write path. It must succeed only if all of:
- stored
inode.version == meta.version - lease key exists
lease.client_id == grant.client_idlease.fencing_epoch == grant.fencing_epoch
Otherwise CommitResult::Conflict (never Committed).
On etcd this is one txn:
WHEN inode.mod_revision == expected
AND lease.value == bytes we just read (after the epoch/client check)
THEN PUT inode (version+1)
Lease missing, other holder, or stale epoch fails the compare → Conflict. A partitioned old writer with epoch N cannot commit after epoch N+1 is on the key.
Wired at:
| Call site | Function | Grant source |
|---|---|---|
| Flush / fsync | ChunkFs::flush_handle → commit_file_data | Handle.lease_grant |
open(O_TRUNC) chunk wipe | inode_fs.rs open | grant from this open |
| SETATTR shrink (size/chunks) | inode_fs.rs setattr | any open writable handle of that inode; grow-only / no handle stays unfenced |
Pure check (no etcd) lives in swift_meta::lease::lease_allows_commit.
4. Handle registry (minimum viable)
Keys: {prefix}/handles/{inode}/{client_id}/{fh}
- Value:
"1"(presence only). - Attached to a daemon session etcd lease (TTL 60s, keepalive ~20s). Daemon death expires every handle this mount registered.
register_handleon everyopen/create.unregister_handleonrelease.handle_count(inode)= prefix GET count.
Sqlite: in-memory HashMap<(inode, client_id, fh), ()>.
GC hook — wired
remove_file_entry must consult the registry before unlink_inode. A mount that never opened the file used to delete the record under a peer fd. If handle_count>0 or lease_held, only the name is dropped.
ChunkFs::maybe_gc does not AND handle_count onto the old local gc_ready. That would leak: the unlinking mount never sees another FORGET, and the holding mount never marked deleted.
Full condition:
this mount: open==0 ∧ lookup_count==0
engine: handle_count==0 ∧ !lease_held(inode)
name: sqlite tombstone `deleted:/{id}`, or lookup(parent, name)
missing / points at another inode (etcd)
Engine errors skip GC. After the last peer close+forget, that peer’s maybe_gc reclaims (nlink is not stored; name-gone is the distributed deleted bit). Tests: existing open-unlinked suite + remote_handle_blocks_deferred_gc + writer_lease_blocks_deferred_gc.
5. What this change does not do
- Live partition gate (tc netem + old writer resume). That is the ROADMAP R2 gate, not this unit of work.
- Live kernel FORGET timing. Remount runs
sweep_unlinkedfor nameless leftovers. - Deploy, remount,
kernel_ttlflip,pkill,cargo clean. - Notify / direct-IO / write-through workarounds.
6. Tests (must exist)
| Test | Engine | Asserts |
|---|---|---|
stale_epoch_cannot_commit | sqlite (always) + etcd (ETCD_ENDPOINTS) | A epoch N, then N+1 (new session or other writer); A commit with N → Conflict 100% |
same_client_id_reacquire_after_new_session | both | same client_id, new session_id → acquire succeeds, epoch increments |
other_client_cannot_acquire | etcd | other client_id → None / false |
existing etcd_leases | etcd | client-A / client-B string API still green |
lease_allows_commit_* | unit, no backend | missing / mismatch / stale epoch |
| handle registry register/count/unregister | sqlite always; etcd if endpoints | count 1 after register, 0 after unregister |
7. Files
crates/swift-meta/src/lease.rs— record, grant, parse,lease_allows_commitcrates/swift-meta/src/{engine,etcd,sqlite,lib,model}.rscrates/swift-fuse/src/{chunk_fs,inode_fs}.rscrates/swiftfuse-cli/src/main.rs— stableclient_idcrates/swift-meta/tests/{etcd_test,engine_test}.rs
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,余量充足);集群实测后定稿。
R5 Design — Cache Coherence (etcd watch → process-cache drop)
日期:2026-08-18 · 状态:设计定稿(未实现) · 对应 ROADMAP-2026-08-10 §R5 权威现态:
docs/HANDOFF-2026-08-15.md。R1 已关;本文件不改代码。 一句话:kernel_ttl=0只逼内核再问 userspace;userspace 的 inode attr / dir listing 仍按attr_ttl/dir_ttl回答。跨节点 mutation 今天不失效对端进程缓存。 解法:etcd watch{prefix}→ 按 key 丢掉对端attrs/dirs。断线 fail-safe:清空全部。
0. 本轮范围
| 做 | 不做 |
|---|---|
| 写清现有缓存、stale 路径、watch 机制、gate、非目标 | 不改 chunk_fs.rs / inode_fs.rs / etcd.rs |
| 标明以后谁改哪些文件 | 不恢复 INVAL_ENTRY / INVAL_INODE / NOTIFY_STORE |
不跑 100k 跨节点 stale 环,不跑 kernel_ttl=1 G6 |
实现落点见 §7。R2 拥有 swift-meta;watch 可以用独立 etcd_client::Client,不必等 EtcdEngine 长 watch API。
1. 现有缓存(2026-08-18 代码事实)
R1 之后 没有 path→nodeid 缓存。paths / path_inodes / resolve() 已删。
名字只出现在 engine.lookup(parent, name)。进程内要失效的是 inode-keyed attr
和 inode-keyed dir listing。Roadmap 里的 “path cache” 在 chunk 模式已不存在;
对端 stale 的是这两个 DashMap,加上内核自己的 dentry(由 kernel_ttl 管)。
1.1 三层 TTL,不要混
| 层 | 字段 / 回复 | 默认(CLI / 舰队) | 谁看 |
|---|---|---|---|
| 进程 attr | ChunkFsConfig.attr_ttl → attrs: DashMap<u64, AttrEntry> | 10s(main.rs / swiftfuse.example.toml) | attr() / getattr |
| 进程 dir | ChunkFsConfig.dir_ttl → dirs: DashMap<u64, (Instant, Vec<(DirectoryEntry, InodeMeta)>)> | 10s | children_meta() / readdir / readdirplus |
| 内核 | ChunkFsConfig.kernel_ttl → ReplyEntry / ReplyAttr / readdirplus entry_ttl/attr_ttl | 配置默认 1s;舰队基线 0(HANDOFF-2026-08-15) | 内核 dentry / i_attr |
kernel_ttl 不驱动 attr_cached / children_meta。那两处只比 fetched.elapsed() 和 attr_ttl / dir_ttl。
fs.rs(raw / path 模式)另有一份 path-keyed attr/dir cache。R5 门禁是 chunk + inode 边界。raw 模式不在本设计实现范围。
1.2 进程 attr 缓存(chunk_fs.rs)
attrs: DashMap<u64, AttrEntry> // key = inode id == FUSE nodeid
AttrEntry { attr: FileAttr, fetched: Instant }
- 命中:
attr_cached(inode),fetched.elapsed() < attr_ttl。 - 填充:
lookup写入;attr()在 cache miss 后get_inode再 insert。 - 带 fh 的 getattr:走
Handle(dirty size / 本地 unlink 的 nlink=0),不靠attrs。 - 无 fh:先扫本挂载 open handle 的 dirty size 上抬,再
attr_cached;命中则 不再读 etcd。
这是跨节点 chmod / setattr / 对端 flush 变 size 的 stale 面:B 的 GETATTR 在 TTL 内直接吐 A 改之前的 FileAttr。
1.3 进程 dir 缓存(chunk_fs.rs)
dirs: DashMap<u64, (Instant, Vec<(DirectoryEntry, InodeMeta)>)>
key 是 父 inode。children_meta 在 t.elapsed() < dir_ttl 时整表返回,含每个 child 的 InodeMeta(readdirplus 用它组 attr,避免 N+1 etcd)。
这是跨节点 create / unlink / rmdir / rename 的 stale 面:B 的 READDIR 在 TTL 内看不见 A 的新名、仍看见 A 已删的名。
1.4 本挂载才丢的东西(今天)
invalidate(inode) = attrs.remove + dirs.remove(同一个 inode)。不发 FUSE notify,不碰对端。
| 本地 mutation | 丢掉什么 |
|---|---|
setattr 成功 CAS | invalidate(inode) |
mkdir / create | dirs.remove(parent);create 不 invalidate 新 inode(本挂载刚 insert) |
unlink / rmdir | invalidate(victim) + dirs.remove(parent) + mark_stale(parent, name) |
rename | invalidate(src) + 两父的 dirs + invalidate 两父 + mark_stale(new_parent, new_name) |
flush_handle 成功 | invalidate(h.inode) |
forget / batch_forget | 该 nodeid 的 attrs/dirs(内存卫生) |
mark_stale 只服务 本内核 下一次 LOOKUP(一次性 entry_ttl=0)。对端没有这份集合。
touch_parent 会 commit_inode 父 mtime,从而 bump 父的 InodeMeta.version,但 对端 的 dirs[parent] / attrs[parent] 仍热。
1.5 明确不是 R5 缓存的东西
| 结构 | 为什么不是跨节点元数据缓存 |
|---|---|
stale_names | 本挂载 LOOKUP 防御,不复制到对端 |
Handle.{chunks,size,meta,dirty} | 本 open 的写缓冲;权威是 dirty,不是 peer 视图 |
block_cache / CachedBlockStore | 不可变 block 内容。CAS 只换指针;旧对象仍合法直到 GC |
NodeTable | 本挂载 lookup_count / open / deleted。对端有自己的表 |
pending_isize_grow | 本挂载 writeback i_size 补丁,不是元数据 cache |
数据面靠 “新对象 + CAS 换指针”。R5 管的是 名字和 attr 的进程缓存,不是 Swift 字节。
2. Stale-path 风险(kernel_ttl=0 救不了 B)
2.1 时间线
t0 B: LOOKUP /dir/x → etcd → attrs[x]=mode 0644, fetched=t0
READDIR /dir → etcd → dirs[parent]=[..., x, ...], fetched=t0
t1 A: chmod 0600 /dir/x → etcd inodes/{x} CAS
本挂载 invalidate(x)
对端 attrs[x] 仍热
t2 B: GETATTR(x) → attr_cached 命中 → mode 0644 STALE
t3 A: unlink /dir/x → 删 names/{parent}/x
本挂载 dirs.remove(parent)
t4 B: READDIR(parent) → dirs 命中 → 仍列出 x STALE
t5 A: create /dir/y ; rename …
t6 B: 同上,dir/attr TTL 内全错
kernel_ttl=0 的效果:B 的内核 每次 发 GETATTR / READDIR / LOOKUP。
LOOKUP 走 engine.lookup,不读 attrs/dirs,所以纯 LOOKUP 在 ttl=0 下是新鲜的。
GETATTR 无 fh、READDIR、readdirplus 先读进程缓存。内核重问救不了 warm userspace cache。
舰队现在 kernel_ttl=0 且 attr_ttl=dir_ttl=10。跨节点 POSIX 名/模式在最多 10s 内可以错。
这就是 roadmap 标的高风险未证明项。R1 用 ttl=0 过 G1/G6,是因为那些门禁不测 “A chmod 后 B 立刻 getattr”。
2.2 门禁会打到的四类
| A 做 | B 立刻做 | 今天为什么会 stale |
|---|---|---|
| rename | stat 旧名 / 新名;readdir 两父 | B 的 dirs 两父 + 可能仍缓存的 attr |
| unlink / rmdir | stat 该名;readdir 父 | B 的 dirs[parent];victim attr 仍在 |
| create / mkdir | readdir 父;stat 新名 | B 的 dirs[parent] 没有新 child |
| chmod / chown / truncate setattr | getattr | B 的 attrs[inode] |
LOOKUP 在 ttl=0 下相对安全。门禁必须包含 getattr + readdir,不能只 LOOKUP。
2.3 和 G6 / writeback 分开
G6 的 bug 是对端内核 i_size、O_APPEND 偏移、lease,不是 attrs DashMap。
R5 先关 userspace 元数据 stale,再恢复 kernel_ttl=1 重跑 G6(§5)。
不要把 R5 watch 当成 G6 isize 的补丁。也不要用 watch 当理由把 INVAL_* 加回来。
3. 提议机制
主路径:etcd watch {prefix} → 按 key 丢进程缓存。
备选:cache hit 时校验 inode version。 能做对,但每个 GETATTR/READDIR 多一趟 etcd,和 R4 瘦身对着干。只在 watch 长期不可用时当降级,不当门禁主路径。
3.1 主路径:watch
每个 FUSE 进程(每个挂载)一条 watch:
- 独立
etcd_client::Client(不要占用EtcdEngine的请求 pool;R2 也不该被强迫改 engine API)。 WatchOptions::with_prefix(),key =EtcdEngine的 fs prefix:/swiftfuse/{sanitized_fs_name}(见etcd.rsconnect_with_options)。- 从 当前 revision 起看(
with_start_revision= 刚才一次 prefix get 的 header revision + 1)。启动前先invalidate_all(),避免 watch 前的空洞。 - 每个 event → §4 映射 →
invalidate/dirs.remove。 - 本地 mutation 仍立刻
invalidate(今天的表)。watch 是给 对端 的。自己的 event 再 drop 一次是幂等的。
不需要新的 etcd key。现有 mutation 已经写 inodes/{id} 和 names/{parent}/{name}。
3.2 备选:hit 时 version 校验
AttrEntry 增 version: u64(来自 InodeMeta.version)。attr() 命中后:
get_inode(id)(或只要 version 的瘦 get,今天没有这条 API);stored.version != cached.version→ 丢缓存,用新 meta;- dir 缓存:父 inode version 变 或 任一名 key 的 etcd mod_revision 变。可靠做法是
list_children,等于废掉 dir cache。
代价:热 getattr 从 0 RTT 变 1 RTT。watch 只在别人改的时候付费。门禁主路径用 watch。
Version 校验可以当 watch 断线后的短窗(见 §4.4):断线后先 invalidate_all(),重连前可以用 “命中必校验” 以免空窗。不是默认稳态。
3.3 不采用
| 方案 | 原因 |
|---|---|
FUSE_NOTIFY_INVAL_ENTRY / INVAL_INODE | 本挂载死锁(chmod/03 fuse_rmdir × fuse_reverse_inval_entry;G4a folio_wait_bit_common)。notify 到不了对端内核。 |
NOTIFY_STORE | 062111 SHARED 缺 seq。flush 路径禁止。 |
把 attr_ttl/dir_ttl 设 0 | 每个 getattr/readdir 打 etcd,R4 白做。ttl=0 是调试旋钮,不是相干。 |
| 对端 RPC / gossip | 第二控制面。etcd 已经是 CAS 真相。 |
4. Watch:key、映射、debounce、断线
4.1 看哪些 key
etcd 布局(etcd.rs 文件头):
{prefix}/inodes/{id} JSON(InodeMeta) — attr、size、mode、parent、name、chunks、version
{prefix}/names/{parent}/{name} inode id 字符串 — 目录成员
{prefix}/counter/{inode,slice} 单调计数
{prefix}/leases/{inode} 写租约(TTL)
Watch 整个 {prefix}(一条 stream),在 userspace 过滤。不要对每个 inode 开一条 watch。
| 前缀 | 动作 |
|---|---|
{prefix}/inodes/{id} | PUT / DELETE → 丢该 inode 的 attr 和 它作为目录的 listing(invalidate(id)) |
{prefix}/names/{parent}/{name} | PUT / DELETE → dirs.remove(parent)。DELETE 再 invalidate(parsed_inode)(value 在 event 里;没有 value 就只丢父 dir) |
{prefix}/leases/{inode} | 忽略。lease 不是 attr/dir 真相。看 lease 会在 G6 开关租约时刷爆缓存 |
{prefix}/counter/* | 忽略。id 分配不改变已可见的名/attr |
chunk map 活在 inodes/{id} JSON 里,没有单独的 chunks/ key。flush/setattr 的 CAS 已经是 inode PUT,对端 invalidate(id) 即可。B 下一次 getattr/read 走 engine。
4.2 Event → cache drop
解析 key 时必须用 slash 分段,不要当数字前缀(names/1 会误伤 names/1249/...,和 list_children 的 trailing-slash 纪律同一类)。
fn apply_watch_event(fs: &ChunkFs, ev: WatchEvent) {
match parse(prefix, ev.key) {
Inode(id) => fs.invalidate(id),
Name { parent, .. } => {
fs.dirs.remove(&parent);
if let Some(id) = ev.value_as_inode() { // PUT 的 value;DELETE 用 prev_kv
fs.invalidate(id);
}
}
Lease | Counter | Other => {}
}
}
invalidate 已同时摘 attrs 和 dirs。rename 的单 txn 会打:
names/{old_parent}/{old}DELETEnames/{new_parent}/{new}PUTinodes/{src}PUT(parent/name/version)- 常常还有两父
inodes/{parent}PUT(mtime / version,rename_inode_touched)
映射后:两父 dir 掉、src attr 掉、两父 attr 掉。和今天本挂载 rename 后的 drop 对齐。
chmod:只有 inodes/{id} PUT → invalidate(id)。够。父 dir 缓存里的 child InodeMeta.mode 会过期;readdirplus 用的是 dirs[parent] 里那份 meta。严格说 chmod 还应丢父 dir,否则 B 的 readdirplus 在 dir_ttl 内仍报旧 mode。
收紧规则:
inodes/{id}变更 →invalidate(id)并且 若能读到 JSON 的parent,dirs.remove(parent)。- DELETE 且 body 不可用 →
invalidate(id);父 dir 靠伴随的names/event。
create:names/{parent}/{name} PUT + inodes/{new} PUT + 父 inode PUT → 父 dir 掉。B 下次 readdir miss → list_children。
4.3 Debounce
rename/create 一次 txn 产生 3–5 个 event,会连打同一 parent。
- 按 inode 合并 50ms(可配,默认 50ms,上限 100ms)。
- 窗内同一 inode 的多次
invalidate合成一次。 - 到期在 FUSE 线程 之外 跑(watch task)。
DashMap::remove不需要 engine 锁。 - 门禁要的是 “B 立刻 stat 不 stale”。50ms debounce 对人工 stat 可接受;100k 环应按 “A 返回后 B 再发 syscall”,不要和 debounce 赛跑。实现时可对门禁目录关 debounce,或 watch apply 同步 drop、debounce 只合并重复 remove。
推荐:drop 同步、立刻;debounce 只防止日志和重复 remove。 语义是 “event 入队后缓存已脏”。DashMap::remove 便宜。
4.4 断线(fail-safe)
Watch 不是尽力而为。空洞必须当 全缓存不可信。
| 事件 | 动作 |
|---|---|
| stream 结束 / gRPC 错 / 超时 | attrs.clear() + dirs.clear()。立刻。然后重连 |
etcd compacted(start revision 没了) | 同样 clear 全部。从 当前 revision 再 watch。不要重放 |
| 进程启动 / 重挂 | 缓存空。watch 从 now 开始 |
| 本进程自己的 mutation | 保持今天的同步 invalidate(不要等 watch 绕一圈) |
断线期间:缓存空 → 每个 getattr/readdir 打 etcd。正确,慢。禁止 “断线还继续用旧 DashMap”。
重连风暴:指数退避 50ms → 1s,封顶 2s。退避期间缓存保持空。
leases/ keepalive 不要绑在这条 watch 上(那是 engine 的 lease client)。
4.5 和本地 dirty handle 的边界
对端 watch 丢掉 attrs[inode] 之后,B 的 attr(inode, Some(fh)) 仍以 B 自己的 Handle 为准。这是对的:B 的未 flush 写是 B 的。无 fh 的 getattr 在 clear 后走 get_inode,看见 A 已 commit 的 size/mode。
不要因为 watch 去清对端 Handle.dirty。那是另一台机器的写缓冲。
5. Gate(本会话不跑)
ROADMAP §R5,收成可引用的两段。
5.1 跨节点立刻可见(主门禁)
A(swift1)rename / unlink / create / chmod
B(swift2)立刻 stat / readdir / getattr
循环 100_000 次
0 stale
建议独立脚本(以后写,不叫 run_gates.sh,不翻全舰队 ttl):
- 四节点保持现役
kernel_ttl=0(先证 userspace 相干,不把 G6 isize 叠进来)。 attr_ttl/dir_ttl保持 10s(必须在 TTL 内 打中 bug;若有人把它们改成 0 再测,门禁作废)。- 工作目录:
/mnt/swift-etcd/r5-coh/,每轮唯一名字。 - 每轮:A 做一种 op → A syscall 返回 → B 立即 对应检查,无 sleep。
- 四种 op 打散(或 25000×4)。任一次 stale → FAIL,记下 inode、两边 getattr、etcd
get inodes/{id}。 - 判定:
stale=0且loops=100000。没有loops=行 = INCONCLUSIVE(同一 G1Files=纪律)。
attr_ttl=0 的对照跑可以有,但不能替代主门禁。
5.2 kernel_ttl=1 下重跑 G6(第二段,R5 实现之后)
Watch 落地并过 5.1 之后:四节点一起 kernel_ttl=1,只跑 scripts/r1-ttl1-g6-only-run.sh,trap 拉回 ttl=0。
本会话禁止跑 G6。 现役绿线仍是 101550。R5 没有改 writer/lease;重跑 G6 只为证明恢复内核缓存后 userspace 仍相干。
6. 非目标(明示)
- 不恢复
FUSE_NOTIFY_INVAL_ENTRY、FUSE_NOTIFY_INVAL_INODE、FUSE_NOTIFY_STORE。本挂载会死锁;对端内核收不到。HANDOFF-2026-08-15 §3.2 / §3.4 / §7.3–7.4。 - 不用 notify “加速对端”。对端只信 etcd + 自己的进程缓存。
- 不在本设计改
etcd.rs/ 租约 / fencing(R2)。 - 不把 block cache 纳入元数据相干。
- 不把
attr_ttl=0当 R5 完成。 - 不做 symlink/mknod 名缓存(仍 ENOSYS)。
7. 以后的文件归属(本会话不写这些代码)
| 文件 | 以后做什么 |
|---|---|
crates/swift-fuse/src/chunk_fs.rs | invalidate_all();watch task 可调用的 invalidate / dirs.remove(已是 pub) |
crates/swift-fuse/src/inode_fs.rs | 挂载后启动 watch(init 之后);destroy 时停。不改 mutation 的本地 drop 表 |
新文件 crates/swift-fuse/src/meta_watch.rs(名可改) | prefix watch、key 解析、过滤 lease/counter、重连、clear-on-gap |
crates/swiftfuse-cli/src/main.rs | 可选:把 fs_name / etcd endpoints 传给 watch(多半 ChunkFs 已有 engine,能自连) |
crates/swift-meta/src/etcd.rs | 默认不动。若要 prefix() getter,R2 合入时加一行只读。不要为了 R5 抢 etcd.rs |
scripts/detect/ | 以后才写 100k 环。本会话不写、不跑 |
本会话:只有这份设计和 docs/workflows/r5-r6-scaffold-REPORT.md。无 Rust 实现。
8. 实现顺序(给下一刀,不是现在)
invalidate_all+meta_watch.rs+init里启动。单测:假 event → 对应 DashMap 空。- 集群:A chmod / B getattr,目测一次。
- 100k 环(§5.1)。
- 过了再考虑 ttl=1 G6。
失败时:先确认 B 的 watch 连着、A 的 key 在 {prefix}/inodes 下、B 没有把 lease event 当 cache drop。
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。
R8 — NVMe sparse cache
Status: last. Do not implement until R5 watch is live, R3 pack is measured, and R7 has at least one cold/warm MEASURED row. Gate: working-set 10/50/100GB/1TB hit curve. RAM index + NVMe blocks + Swift backing.
This file is the contract, not the implementation. Sequence is in the 2026-08-28 plan: correctness → amplification → coherence → benchmark credibility → NVMe.
PLAN — 完成宗旨
宗旨: 自研 Rust Swift-native FUSE client。 ProxyFS distributed-client/lease + JuiceFS chunk/slice/block+compaction
- rclone native Swift + GeeseFS async/parallel IO + Mountpoint 高吞吐 data path ⟹ metadata 与 data 分离 / control plane 强一致 / data plane 完全 scale-out / object 永远 immutable / mutation 只发生在 metadata
Phase 1(thin adapter,已交付)达成度 ~45%:native Swift 数据路径 ✓、 scale-out 客户端 ✓、对象级不可变 ✓。缺 chunk 数据模型、强一致控制面、 并行读吞吐。本计划以完成宗旨为唯一目标,不再新增 thin-adapter 功能。
目标架构
control plane (强一致)
┌───────────────────────────────────────┐
│ MetadataEngine (trait) │
│ ├─ P1: SQLite (单机验证数据模型) │
│ └─ P2: etcd + lease (多客户端强一致) │
│ inode: size/mode/times/version │
│ chunk map: chunks → slices → blocks │
│ 原子 commit: CAS on version │
└───────────────┬───────────────────────┘
│
┌───────────────▼───────────────────────┐
│ FUSE client (ChunkFs, 每节点一个) │
│ lookup/getattr/readdir ← engine │
│ read ← blocks 并行 GET + hole 补零 │
│ write → slice buffer → blocks PUT │
│ → metadata commit (先数据后元数据) │
│ compaction: 后台合并 slices │
│ GC: 孤儿 blocks 异步回收 │
└───────────────┬───────────────────────┘
│ data plane (完全 scale-out, 直连 Swift)
▼
Swift: 容器 <fsid>-data, 对象 = immutable blocks
文件 ≠ 对象; 对象永远只写一次
数据模型(JuiceFS 对齐,参数可配)
inode
├─ chunk 0 [0, 64MiB)
│ ├─ slice A start=0 blocks=[b0,b1] ← 一次 flush 的产物
│ └─ slice B start=1MiB blocks=[b2] ← 随机写 = 新 slice, 不读旧数据
├─ chunk 1 [64, 128MiB)
│ └─ slice C ...
CHUNK_SIZE = 64 MiB(逻辑),BLOCK_SIZE = 4 MiB(物理,可配)- block 对象名:
<inode-hex>/<chunk>/<slice-id>/<block-idx>,全局唯一、永不覆盖 - slice-id 由 metadata 引擎分配(单调),保证跨客户端不冲突
- chunk 未覆盖区间 = holes → 读补零
- 覆盖语义:先 PUT blocks(幂等,孤儿无害),再 metadata CAS commit (崩溃 = 孤儿 blocks,GC 回收;读者永远看不到半成品)
阶段与验收
| 阶段 | 内容 | 宗旨映射 | 验收 |
|---|---|---|---|
| P1 ✅ | SQLite 元数据引擎 + chunk/slice/block 读写 + 原子 commit + compaction + GC | JuiceFS 数据模型、metadata/data 分离、object immutable | 单机挂载:随机 4K 写 100MB 不重读旧数据;md5 全对;45/45 测试;双挂载并发 CAS 一致 |
| P2 ✅ | etcd 引擎(同 trait)+ open/close lease + txn CAS | ProxyFS lease、control plane 强一致 | 双节点并发写一致;EBUSY + TTL 崩溃回收实测通过 |
| P3 ✅ | 窗口放大读 + 块缓存 + inflight 去重 + path 缓存 + write-back | GeeseFS/Mountpoint 吞吐 | 热读 915MB/s、冷读 80MB/s(Swift 集群上限)、stat 3ms |
| P4 ✅ | 4 节点部署 + benchmark matrix + 一致性验证 | 整体 | docs/BENCHMARK.md 全矩阵;多节点并发写/读实测 |
里程碑(本仓库分支策略)
main= 宗旨实现主线;thin adapter 保留为--mode=raw- 每阶段合并前提:单元测试 + 集群集成验证全绿
P1 交付证据(2026-08-07, Contabo lab)
- 数据面块布局实测:100MB 文件 = chunk0(64MB,17块) + chunk1(36MB,9块);
50MB 处 8KB 随机写 = 1 个新 8KB 块,旧块零接触(
chunkfs-data容器审计) - 双挂载(同 meta.db)并发写同一文件:CAS Conflict 重试,两端读到一致结果
- compaction:30 次重叠写 → 合并为 1 块,读数据前后一致;与并发写竞争时 Conflict 恢复正确(新 slices 优先,JuiceFS 同款语义)
- hole 补零、跨 chunk 读、truncate(纯 metadata)、dir rename(纯 metadata)全过
已定决策(不再回问)
- 元数据引擎先 SQLite(数据模型验证),P2 换 etcd —— trait 隔离,写路径不变
- 文件 ≠ Swift 对象(宗旨必然推论,ProxyFS 2.x 同样放弃 bi-modal);
原始对象可见性由
--mode=raw保留 - 一致性 = lease-based close/open(ProxyFS 路线),非 close-to-open
- chunk 参数默认 64MiB/4MiB,Swift 实测后按 U 型曲线调
SwiftFuse Phase-2 Roadmap(R1–R8)
日期:2026-08-10 · 上一阶段:v0.1.1(12-gate 检测 + BUG-6/BUG-7 修复,REPORT-2026-08-08) 定位:从“架构正确“走向“语义严格、故障可证明、性能稳定“。 总原则:R1 完成前不加复杂 feature;每个 R 有明确 gate 与可回滚 commit。
0. 阶段判断(2026-08-10)
- 成立:chunk/slice/block + etcd + Swift 大架构;五条核心原则落地。
- 不成立:production-ready 声称(inode identity、fencing、跨节点 handle 生命周期、 write request amplification、cache coherence、测试可信度 六个系统性问题未收敛)。
- 三个 bug 同根:BUG-2/BUG-5/BUG-7 都是
PathFilesystem把 path 当 identity。 R1 用 inode-centric FUSE boundary 根治,不再给InodePathBridge打补丁。
R1 — Identity & Correctness Foundation(最高优先级)
目标
- inode-centric FUSE boundary:
FUSE nodeid → SwiftFuse inode_id → InodeMeta, path lookup 仅(parent_inode, name) → inode;rename/unlink-open/FORGET/lookup/GC 全自然。 - open-unlinked
fstat()正确(现在 5/5 ENOENT,bridge 层)。 - rename/nodeid/FORGET 生命周期正确(BUG-7 类消除)。
kernel_ttl从 0 恢复非 0(先证明 inode 层不再依赖内核重查)。- schema_version + CAS 改 revision(已部分完成
4f063f7:mod_revision CAS + schema_version 落地)。
Gate
pjdfstest unlink/14 PASS
rename torture 100k+(跨 rename/readdir/chmod 交错)
fsx / fsstress / fio verify 全绿
kernel_ttl = 1 后 G1/G6 重跑
状态(2026-08-18 据 HANDOFF-2026-08-15)
- CAS mod_revision + schema_version:✅
4f063f7 - inode-centric 重写:✅ 已落地(
InodeFs,nodeid == engine inode_id) - ttl=1 G1 + G4a×3 + G6:✅ 2026-08-15 关上(测完已回 ttl=0)
- F2b ≥35 轮 × ttl × write_back:⏳ 合同未关。t0w1(ttl=0 wb=true)35/35 PASS(2026-08-18)。t0w0 / t1w1 / t1w0 未跑。
R2 — Distributed Ownership(fencing + 全局 handle 生命周期)
目标
- stable client_id + session_id + lease_id + fencing_epoch:
holder 从
hostname-PID改为稳定身份;每次写 acquire 携带 session/epoch。 - 所有 metadata commit 同时 CAS:
旧 writer 复活(epoch 过期)→ 100% 无法 commit。inode_version == expected AND lease_epoch == N AND holder == client - distributed open-handle registry:
/handles/<inode>/<client>/<handle>绑定 etcd lease; daemon 死亡自动消失;inode GC 条件 =nlink==0 AND handle_count==0 AND no writer lease。 - 解决:daemon 重启 30s EBUSY、stale writer、cross-node open-deleted、安全 block/inode GC。
- 安全 block GC 的前提:old reader 持有旧 metadata version 时不能删其引用的 blocks → open-handle generation / grace period / reader lease。
Gate
partition → lease expiry → new writer 获得 lease → old writer 恢复 → 尝试 commit
→ 必须 100% 失败
跨节点 open-deleted:A unlink 后 B 的 fd 读写正常,B close 后才 GC
R3 — Write Path 2.0
已定死的事实(M1 测量,2026-08-11)
- 4K 写 + fsync → 1 × 4096B Swift object(字节放大 = 1×,不是 1024×)。 REPORT/BENCHMARK 的 “1024×” 声明已修正(下文 §M1)。
- 100 × 4K 随机写 → ~96 个对象(非相邻不合并);真实问题是 object/request amplification。
- flush 曾逐 slice 串行 PUT(96 块 = 33s)→ 已修复
4f063f7:批量并行上传,33s → 9.4s。 - 剩余瓶颈:Swift 小对象 PUT 吞吐下限 + 对象数量爆炸。
路线
- dirty interval aggregation(已有合并,随机 4K 天然不合并)✅ 部分
- cross-slice bounded parallel PUT ✅
4f063f7 - debounce / write aggregation(fsync 语义内的窗口合并)
- Immutable Delta Pack:多个小写打包进 1 个对象(index + payload),
Swift PUT count ≈ dirty_bytes / pack_target;后台 compaction 合并回大 block。 完全符合宗旨:object immutable、不读旧数据、mutation 只在 metadata。 - background compaction(
compact_interval=0→ 默认开启)
Gate
100 × 4K random write:
PUT 次数 ≈ 1~2(delta pack)
对象 size 分布记录
fsync 完成时间可预测
容器对象数增长 = dirty_bytes / pack_target
R4 — Small-file Path
已定死的事实(M2 测量)
- empty-file create 0 个 Swift 对象(纯 metadata,设计已正确)。
- 114ms/create 的瓶颈 = 串行 etcd 往返(daemon 单
Mutex<Client>+ create 路径 6-8 次等时操作),不是 Swift。 - stat 9ms(路径缓存命中)。
路线
- create 路径 etcd 操作瘦身(合并 counter bump / parent touch;批量 inode 分配)。
- 多 etcd client / 连接池消除
Mutex<Client>串行化。 - 目标:create ceiling 从 ~114ms 降到 etcd txn 延迟量级(目标 <20ms); mdtest create 从 9.5/s 到 >50/s。
Gate
touch 1000 空文件:0 Swift 对象(审计容器)
mdtest create/s 提升 ≥2×
R5 — Cache Coherence
现状
- 本地 mutation 会 invalidate 进程内 cache;无跨节点通知(文档已标高风险未证明项)。
kernel_ttl=0只保证内核重新问 userspace;userspace 的 path/attr/dir cache 若无跨节点失效,仍可回答 stale。
路线
- etcd watch revision stream:监听
{prefix}变更 → invalidate inode/path/dir cache。 - 或 inode version validation(每次 cache 命中验证
version)。 - 恢复
kernel_ttl = 1~5s。
Gate
A rename/unlink/create/chmod → B 立即 stat/readdir/getattr,循环几十万次,0 stale
kernel_ttl=1 下 G6 重跑
R6 — Professional Correctness Suite
目标
- G2 fstests:加
mount.swiftfuse/swiftfuse mount/unmount(可被 fstests 驱动 mount/remount)。 - G3 LTP:16GB builder 编译 → artifact 拷到节点(不再 NOT SUPPORTED)。
- Porcupine linearizability:Rust operation history recorder → JSON → Porcupine checker (多 client + 同 inode + 随机 history + partition/restart)。
- failpoint crash testing:
seed 1..10000 反复 crash/restart → 只产生 orphan、绝不半可见。FAILPOINT_AFTER_BLOCK_PUT / BEFORE_METADATA_CAS / AFTER_METADATA_CAS FAILPOINT_BEFORE_REPLY / COMPACTION_AFTER_PUT / GC_BEFORE_DELETE - G7 扩展:kill 真实 etcd leader / follower / lose quorum / asymmetric partition / Swift proxy & object-server kill / writer partition → lease expiry → new writer → old writer 复活。
Gate
Porcupine: 所有 history linearizable
failpoint: 10000 seeds 无 committed-data corruption
G7 fencing 考试通过
R7 — Benchmark 2.0
修正(M3 测量)
- 单节点 benchmark 走 loopback(10.0.0.1 = lo,27.9 GB/s),352-406 MiB/s 物理自洽; “千兆内网 + Swift proxy ceiling” 表述已修正。
- 真实跨节点上限需跨节点路径测量(G10 的 5-12 MiB/s 是部分证据)。
要求
- 每个 workload 同时记录:FUSE op latency / etcd txn latency / Swift request latency+count / payload bytes / cache hit ratio / network bytes / CPU / memory / Swift auditor-replicator 活动。
Raw Swift → SwiftFuse → Raw Swift交替跑,隔离环境噪声。- cold / warm / mixed 分开:cold = 新文件 + 清 cache + dataset > cache; warm = 100% 本地 cache。
- 对比矩阵:Raw Swift / rclone / GeeseFS(S3API) / JuiceFS / SwiftFuse。
- G8 判定改为 MEASURED / REGRESSION / TIMEOUT / SLO-VIOLATION(不再叫 PASS)。
R8 — NVMe sparse cache(最后)
- 顺序:correctness → write amplification → consistency/fencing → benchmark credibility → NVMe。
- 实现:RAM metadata/index + NVMe block cache + Swift backing(学 Mountpoint)。
- 测:working set 10/50/100GB/1TB cache hit curve。
执行顺序与当前状态
| R | 状态 | 已落地 / 下一步 |
|---|---|---|
| R1 | 门禁已关 | ✅ inode-centric + ttl=1 G1/G4a/G6(HANDOFF-2026-08-15);✅ F2b t0w1 35/35;⏳ 其余三格 |
| R2 | 门禁已关 | ✅ fencing + persist-grant + GC hook(maybe_gc/peer_holds/sweep_unlinked);✅ live 100/100 081421;⏳ 四节点线上 bin 未换 |
| R3 | 部分 | ✅ 并行 PUT + pack 单测绿(swift2,packed_100x4k PUT=1);⏳ pack 默认关,舰队 100×4K PUT≈1~2 未关 |
| R4 | 部分 | ✅ create 纯 metadata + etcd 瘦身单测绿(live 4→2 RTT);⏳ mdtest ≥2× 未测 |
| R5 | 设计中 | etcd watch 失效(本会话设计;不碰 etcd.rs) |
| R6 | 部分 | ✅ Porcupine fixture;⏳ failpoint 10000 seeds + G7 fencing 考试 |
| R7 | 框架在 | Benchmark 2.0 脚本在;⏳ 实跑 |
| R8 | 未开始 | NVMe cache(最后) |
测量附录(M1-M3,2026-08-11)
M1 — 4K 写放大真相(容器审计 + etcd)
| 场景 | 对象增量 | 对象大小 | 结论 |
|---|---|---|---|
| 100×4K 写 + 每次 fsync | +100 | 全部 4096B | 字节放大 1×;491ms/写 = 1 PUT + commit |
| 100×4K 写 + 单 fsync | +96(94×4K + 2×8K) | 合并 2 对相邻 | flush 33s(串行)→ 9.4s(批量并行后) |
- REPORT/BENCHMARK 的 “4K→4MiB ≈1024× byte amplification” 为错误声明,已修正。
M2 — empty-file create
- 100 个空文件 create:0 个 Swift 对象(纯 metadata);114ms/create 为 etcd 串行瓶颈。
M3 — 网络拓扑
10.0.0.1是 loopback(dev lo,27.9 GB/s);单节点 benchmark 未跨网卡。- eth1(10.0.0.x 内部网)为 Contabo virtio 网卡(ethtool 无法读速,物理 ≥10Gb 量级)。
- “千兆内网” 描述不适用于单节点;跨节点真实上限需跨节点路径测量。