Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)

OperationImplementation
readrange GETs straight from Swift; per-handle prefetch
writebuffered into a local temp file per open handle
flush/closeONE atomic object PUT (≤ 5 GiB) or segment manifest
metadataTTL caches; invalidated on local mutation
renameserver-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-Manifest header. Works on every Swift deployment.
  • SLO: JSON manifest PUT. Requires the cluster’s slo middleware; 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.

  1. fusermount3 -u MOUNTPOINT
  2. 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.

  1. config file exists and parses
  2. etcd TCP ping if [fs].meta_backend = "etcd"
  3. Swift HEAD account if [auth] has a secret
  4. mount exists (positional, else config, else /mnt/swift-etcd)

MOUNT OPTIONS

-o keys map onto [fs] (and --log). Unknown keys are errors.

keytypeconfig field
allow_otherboolallow_other
write_backboolwrite_back
kernel_ttlu64 secondskernel_ttl
attr_ttlu64 secondsattr_ttl
dir_ttlu64 secondsdir_ttl
retriesu32retries
tmpdir, tmp_dirpathtmp_dir
block_sizeu64 bytesblock_size
cache_blocksusizecache_blocks
compact_intervalu64 secondscompact_interval
etcd_endpointshost:port,…etcd_endpoints
meta_backendsqlite or etcdmeta_backend
fs_namestringfs_name
leaseboollease
stats_listenhost:portstats_listen
stats_filepathstats_file
modechunk or rawmode
insecureboolinsecure
uidu32uid
gidu32gid
default_modeoctaldefault_mode
logfilter--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-writeBENCH_RAND_WRITES × 4KiB 随机写(M1 object 放大探针)100
small-filecreate + stat + readdir,BENCH_SMALL_FILES 个 4K 文件1000
renameBENCH_RENAME_FILES 次 rename500
multi-node跨 BENCH_NODES 并发读/写(共享 FS,每节点 BENCH_MULTI_MIB)128 MiB

冷 / 温 / 混合分离(per-workload 分别记录)

  • cold = 新文件 + drop_caches(本地 + 各节点 ssh)+ dataset > cache。 local_seq_read 若必须 seed seq.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=11本地 mount $BENCH_MOUNT
COMPARE_RAW_SWIFT=10swift CLI(RAW_SWIFT_*)
COMPARE_RCLONE=10rclone(RCLONE_REMOTE)
COMPARE_GEESEFS=10GeeseFS(S3API) mount $GEESEFS_MNT
COMPARE_JUICEFS=10JuiceFS 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_usFUSE op 延迟 p50/p99µs
etcd_txn_latency_usdaemon 侧 etcd txn 延迟 p50/p99µs
swift_req_latency_usdaemon 侧 Swift 请求延迟µs
swift_req_countSwift 请求数(PUT/GET/DELETE/COPY 分桶)count
payload_bytes每 workload 读写字节bytes
cache_hit_ratio内存 block cache 命中率ratio
network_bytesclient↔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 物理自洽。

WorkloadResultLimiting 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 QD32352.3 / 406.3 MiB/sSwift proxy 并发 GET 上限
rand-read 64K QD8129.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/smemory
small-file create~0.4–1.3 s/fileSwift 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 setcd 前缀扫描(并行化后)
mdtest file create / stat9.5 / 103.5 ops/sSwift PUT vs etcd metadata(两条路径清晰分离)
rename / truncate~0.2 s / 0.22 setcd 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
FAIL0

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 → Swift1 × 4096B 对象,1 PUT字节放大 1×;491ms/写(PUT + etcd commit)
100×4K 随机写 + 单 fsync96 个对象(94×4K + 2×8K 合并)非相邻 4K 写不合并 → N 对象;flush 曾串行 33s → 批量并行后 9.4s(4f063f7)
empty-file create → Swift0 个对象(纯 metadata)114ms/create = 串行 etcd 往返(非 Swift);stat 9ms
stat → etcd0(路径缓存命中)/ 1(miss)BUG-1 前缀修复 + 并行化后
readdir(726) → etcd1 次前缀扫描 + 并行 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 amplification
  • prefetch (8 MiB default): read window per request
  • read_workers / write_workers: parallel GET/PUT concurrency
  • cache_blocks: in-memory read cache capacity
  • write_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)

前置条件

  1. deploy_cluster.sh 已全节点部署(代码 = 当前 master HEAD,config 含 kernel_ttl = 0)。
  2. 工具链在 swift1:/opt/detect/bin/{fsx,fsstress}、/opt/detect/src/pjdfstest、 (可选)/opt/detect/src/{fstests,ltp}、mdtest、filebench。
  3. 挂载存活: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工具方法判定规则
G1pjdfstest在挂载点内 prove -r 全量失败文件分类:symlink/link/mkfifo/mknod* → UNSUPPORTED(ARCHITECTURE.md 声明 ENOSYS);utimensat/05 → KNOWN DEVIATION;其余 → FAIL
G2fstests (xfstests)check -f fuse curated generic/001-100FUSE 非块设备 FS,大量 generic 用例 NOT SUPPORTED(mkfs/scratch/reflink);PASS/FAIL/NOT RUN 计数
G3LTPrunltp -f runtest/syscalls -s '^(stat|rename|unlink|…)'只跑文件系统相关 syscall 子集
G4afsx3 轮 × 2000 ops(16MiB 文件)退出码 0 = PASS
G4bfio verify64MiB 4K crc32c 写+verify;SIGKILL daemon → remount → verify_onlyverify 通过 = committed data 完好
G5fsstress4 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 数据完好
G8fio 矩阵 + mdtest4K/64K/1M × QD1/8/32 × seq/rand;mdtest 500 文件×3 轮 create/stat/removefio 产出 JSON;mdtest 有统计输出
G9filebenchfileserver 模型 30s有 IO Summary 输出
G10多节点1→2→4 节点并发 dd 32MiB/节点记录 nodes/files/dur/bw 曲线
G11可复现性environment.txt 记录 commit/tag/config/raw JSON每次运行自动产出
G12IO500/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. 教训与注意事项(历史)

  1. pjdfstest 必须在挂载点内跑 —— 曾误在本地 XFS 跑出假绿(HANDOFF G1)。
  2. fio 不要用 sync=1 + 4K —— 每次写变成 block PUT,一轮数小时。
  3. G6 并发写断言不能用精确 size —— 写序未定义,用单调不减。
  4. G4b 的 SIGKILL 必须用 -9 —— 优雅退出会正常 flush,测不到崩溃恢复。
  5. fstests 对 FUSE 有大量 N/A —— 用 curated 子集 + NOT RUN 矩阵,不追求全绿。
  6. fstests 的 mount/unmount 工作流无法驱动单 daemon FUSE 挂载(2026-08-09)—— _test_mount 会对 TEST_DEV 二次 mount -t fuse;判定必须要求“实际执行了测试“ (0 测试执行 = NOT SUPPORTED,不是 PASS)。
  7. G6 脚本必须自清理(2026-08-09)—— 残留 cc-/rn-/rdir-* 会在下一轮被打开, 叠加 lease 语义制造假 FAIL。
  8. lease 断言区分同 holder 重入与跨 holder 拒绝(2026-08-09)—— 同进程重开同文件 是重入(成功),不是 EBUSY;跨 holder 拒绝需多 daemon 或注入测试。
  9. 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

GateVerdictNotes
G1 / 006 / 035PASSOfficial fstests
G3 LTPPASS
G4a / G4bPASS
G5PASS
G6PASSOfficial
R1 inodePASS
R2 fencing100/100
R3 live packPASSdelta=1, then pack_target=0
R4PASS
R5 watch 10kPASSNot 几万
R6 failpointPASS
R7 swiftfuseMEASUREDG2 small-file 1000/1000
R7 cache-hitPASS/opt/detect/results/r7-cache-hit-20260902-021100/ window hits=+1 misses=+1
R7 s3api compareMEASUREDhttps://10.0.0.10:8085 RegionOne path-style
R8 NVMe 10GPASS50G / 100G / 1TB deferred
Porcupine 2-mountLINEARIZABLESame-mount writeback is illegal
G7 full faultPASSQuorum 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.

Backendseq-write coldseq-read coldseq-read warmsmall-file cold
swiftfuse (G2, 2026-09-01)34.6 MiB/s21.3 MiB/s135.6 MiB/s100/100 in 92s
swift CLI21.9 MiB/s45.2 MiB/s70.9 MiB/s71s
rclone :swift:25.1 MiB/s33.9 MiB/s33.4 MiB/s7.6s
GeeseFS s3api69.4 MiB/s587 MiB/s318 MiB/s0.48s
JuiceFS s3api55.2 MiB/s31.5 MiB/s1306 MiB/s7.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.

Backendseq-writeseq-read cold (S3 miss)seq-read warm (client cache after refill)
GeeseFS s3api16.5 MiB/s50.2 MiB/s675 MiB/s
JuiceFS s3api34.4 MiB/s8.87 MiB/s5029 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_us p50/p99
  • etcd_txn_latency_us p50/p99
  • cache_hit_ratio
  • network_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:

  1. nodeid = inode_id = 引擎分配的 id(root=1;etcd counter/inode 单调递增,永不回收)。 → nodeid 复用/混叠在结构上不可能(BUG-7 消灭)。
  2. 路径解析只存在一种形式:(parent_nodeid, name) → engine.lookup → inode_id。 → rename 后没有任何「第二份 nodeid→path 映射」可以损坏(BUG-2 消灭)。
  3. open-unlinked 文件的 inode 记录存活到「最后 RELEASE + FORGET 确认」才 GC; GETATTR(nodeid, fh=None) 直接命中存活的引擎记录 → fstat 不再 ENOENT(BUG-5 消灭)。
  4. 代价:ChunkFs 的 resolve()/paths/path_inodes 全部删除;新增 NodeTable(内核引用计数) 与 inode_fs.rs(raw impl,约 400 行);swift-meta 增加原子 rename_replace。
  5. 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)还持有旧身份。我们的规则:

  1. 引擎 id 永不回收(硬性不变量):
    • etcd:counter/inode 只增不减(etcd.rs 的 bump_counter)✓ 已满足。
    • sqlite:id INTEGER PRIMARY KEY 在删除最大行后会复用 rowid → 必须改 AUTOINCREMENT 或改走 counters 表(§9.2),否则 R1 不变量在 dev 引擎上不成立。
  2. nodeid := inode_id:两条规则合起来 ⇒ 一个 nodeid 在文件系统整个生命周期内 只指向一个 inode。没有可复用的 nodeid,混叠在结构上不可能。BUG-7 类 (rename 释放 nodeid → 后续 create 复用 → 覆盖映射)失去发生条件。
  3. 内核引用归零(FORGET)不释放 id,只触发未链接 inode 的记录删除(§5)。 id 本身永远属于它创建的那个 inode。
  4. 反方论证(为什么不用 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 即 unlinkdelete_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桥可能解析到死路径 → ENOENTnodeid 直达引擎记录,永远可答
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 落地顺序(每步可编译可回滚)

  1. swift-meta:sqlite id 单调(§9.2)→ trait 加 rename_replace 默认实现 → etcd override。engine_test.rs/etcd_test.rs 补 rename_replace 用例。
  2. inode_fs.rs:NodeTable + raw impl(此时 ChunkFs 仍实现 PathFilesystem,双 impl Rust 允许,仅 PathFilesystem::lookup 等需要路径签名的旧方法死代码暂留)。
  3. chunk_fs.rs:删 path 机制与 PathFilesystem impl;attr_of/children_meta 换 raw 类型。
  4. main.rs chunk 分支切 raw::Session;配置默认 kernel_ttl=1。
  5. 全量测试(§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。 改法(二选一,推荐前者):

  1. id INTEGER PRIMARY KEY AUTOINCREMENT + open() 时迁移已有表 (AUTOINCREMENT 保证新 id > 历史 max(id),天然单调不复用)。
  2. 沿用现有 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:

  1. 确定性阶段:echo B>t; echo A>s; mv s t; cat t 循环 100k 次, 每次断言内容 == A;交错 chmod 700 t、stat t、readdir。快速回归 BUG-2 形态。
  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;对目标名必须成功。
  3. 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 迁移 vs counters 表分配。 前者改动最小但需要 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 + 延迟 GCHANDOFF P1(BUG-5 未完成部分)、ROADMAP R1 目标 2
§6 rename 语义HANDOFF BUG-2(bridge 损坏)与 BUG-7(nodeid 释放)
§7 kernel_ttlROADMAP 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), not hostname-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.

  1. Holder is hostname-PID (swiftfuse-cli mount). Daemon restart → new PID → 30s EBUSY even though it is the same machine.
  2. acquire_write_lease PUTs that string under {prefix}/leases/{inode} attached to an etcd lease. Re-entrancy is byte-equality on the string.
  3. commit_inode CASes inode.version + etcd mod_revision only. A partitioned old writer that still holds a stale Handle can commit after a new writer has the lease.
  4. There is no distributed open-handle table. Cross-node open-unlinked GC is local NodeTable only.

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

TokenMeaningLifetime
client_idStable mount identity: {hostname}-{uuid}Survives daemon restart. UUID file: /var/lib/swiftfuse/client_id, else {meta_db.parent}/client_id.
session_idThis process / this mountNew UUID (or hostname-PID) at each start.
fencing_epochMonotonic token for this inode’s write generationIncrements on each successful acquire that is not same-client + same-session re-entry. Fresh key starts at 1.
etcd_lease_idThe etcd lease attached to the lease keyDies 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)

  1. Grant a new etcd lease. Try Compare::version(lease_key) == 0 PUT of a JSON record with fencing_epoch = 1.
  2. If the key exists:
    • other client_id → None (FUSE maps to EBUSY). Surplus grant revoked.
    • same client_id + same session_id → return the existing grant (no epoch bump). Surplus grant revoked.
    • same client_id + new session_id → PUT replacement JSON with fencing_epoch + 1 attached to the new etcd lease (CAS on the bytes we just read). This is restart.
  3. acquire_write_lease(inode, holder) stays. It calls v2 with client_id = session_id = holder and returns bool. 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_id
  • lease.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 siteFunctionGrant source
Flush / fsyncChunkFs::flush_handle → commit_file_dataHandle.lease_grant
open(O_TRUNC) chunk wipeinode_fs.rs opengrant from this open
SETATTR shrink (size/chunks)inode_fs.rs setattrany 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_handle on every open / create.
  • unregister_handle on release.
  • 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_unlinked for nameless leftovers.
  • Deploy, remount, kernel_ttl flip, pkill, cargo clean.
  • Notify / direct-IO / write-through workarounds.

6. Tests (must exist)

TestEngineAsserts
stale_epoch_cannot_commitsqlite (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_sessionbothsame client_id, new session_id → acquire succeeds, epoch increments
other_client_cannot_acquireetcdother client_id → None / false
existing etcd_leasesetcdclient-A / client-B string API still green
lease_allows_commit_*unit, no backendmissing / mismatch / stale epoch
handle registry register/count/unregistersqlite always; etcd if endpointscount 1 after register, 0 after unregister

7. Files

  • crates/swift-meta/src/lease.rs — record, grant, parse, lease_allows_commit
  • crates/swift-meta/src/{engine,etcd,sqlite,lib,model}.rs
  • crates/swift-fuse/src/{chunk_fs,inode_fs}.rs
  • crates/swiftfuse-cli/src/main.rs — stable client_id
  • crates/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,即使批量并行(4f063f7 33s→9.4s),Swift 小对象 PUT 吞吐下限依然封顶。
  • 非相邻区间天然不合并,所以“合并 dirty 区间“这条路走不到头。

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

Swift PUT 次数 ≈ dirty_bytes / pack_target

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

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

设计要点(六节):

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

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


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

1.1 总体结构

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

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

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

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

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

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

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

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

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

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

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

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

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

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

版本化策略:

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

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

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

2. 命名与不可变性

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

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

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

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

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


3. 读路径

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

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

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

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

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

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

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

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

3.2 ChunkReader::read_range(body-only)

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

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

3.3 cache / prefetch(body-only)

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

4. 写路径集成

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

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

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

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

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

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

要点:

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

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

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

5. Compaction 与删除安全

5.1 触发(compact.rs)

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

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

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

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

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

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

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

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

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

5.4 孤儿与 GC 联动

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

6. Gate 映射

ROADMAP R3 gate → 设计落点:

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

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


7. API 变更清单

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

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

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

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

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

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

7.4 完全不动

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

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


8. 测试计划

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

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

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


9. 兼容性 / 部署

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

10. 非目标 / 开放问题

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

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 / 舰队)谁看
进程 attrChunkFsConfig.attr_ttl → attrs: DashMap<u64, AttrEntry>10s(main.rs / swiftfuse.example.toml)attr() / getattr
进程 dirChunkFsConfig.dir_ttl → dirs: DashMap<u64, (Instant, Vec<(DirectoryEntry, InodeMeta)>)>10schildren_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 成功 CASinvalidate(inode)
mkdir / createdirs.remove(parent);create 不 invalidate 新 inode(本挂载刚 insert)
unlink / rmdirinvalidate(victim) + dirs.remove(parent) + mark_stale(parent, name)
renameinvalidate(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
renamestat 旧名 / 新名;readdir 两父B 的 dirs 两父 + 可能仍缓存的 attr
unlink / rmdirstat 该名;readdir 父B 的 dirs[parent];victim attr 仍在
create / mkdirreaddir 父;stat 新名B 的 dirs[parent] 没有新 child
chmod / chown / truncate setattrgetattrB 的 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:

  1. 独立 etcd_client::Client(不要占用 EtcdEngine 的请求 pool;R2 也不该被强迫改 engine API)。
  2. WatchOptions::with_prefix(),key = EtcdEngine 的 fs prefix:/swiftfuse/{sanitized_fs_name}(见 etcd.rs connect_with_options)。
  3. 从 当前 revision 起看(with_start_revision = 刚才一次 prefix get 的 header revision + 1)。启动前先 invalidate_all(),避免 watch 前的空洞。
  4. 每个 event → §4 映射 → invalidate / dirs.remove。
  5. 本地 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_STORE062111 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} DELETE
  • names/{new_parent}/{new} PUT
  • inodes/{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):

  1. 四节点保持现役 kernel_ttl=0(先证 userspace 相干,不把 G6 isize 叠进来)。
  2. attr_ttl / dir_ttl 保持 10s(必须在 TTL 内 打中 bug;若有人把它们改成 0 再测,门禁作废)。
  3. 工作目录:/mnt/swift-etcd/r5-coh/,每轮唯一名字。
  4. 每轮:A 做一种 op → A syscall 返回 → B 立即 对应检查,无 sleep。
  5. 四种 op 打散(或 25000×4)。任一次 stale → FAIL,记下 inode、两边 getattr、etcd get inodes/{id}。
  6. 判定:stale=0 且 loops=100000。没有 loops= 行 = INCONCLUSIVE(同一 G1 Files= 纪律)。

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. 非目标(明示)

  1. 不恢复 FUSE_NOTIFY_INVAL_ENTRY、FUSE_NOTIFY_INVAL_INODE、FUSE_NOTIFY_STORE。本挂载会死锁;对端内核收不到。HANDOFF-2026-08-15 §3.2 / §3.4 / §7.3–7.4。
  2. 不用 notify “加速对端”。对端只信 etcd + 自己的进程缓存。
  3. 不在本设计改 etcd.rs / 租约 / fencing(R2)。
  4. 不把 block cache 纳入元数据相干。
  5. 不把 attr_ttl=0 当 R5 完成。
  6. 不做 symlink/mknod 名缓存(仍 ENOSYS)。

7. 以后的文件归属(本会话不写这些代码)

文件以后做什么
crates/swift-fuse/src/chunk_fs.rsinvalidate_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. 实现顺序(给下一刀,不是现在)

  1. invalidate_all + meta_watch.rs + init 里启动。单测:假 event → 对应 DashMap 空。
  2. 集群:A chmod / B getattr,目测一次。
  3. 100k 环(§5.1)。
  4. 过了再考虑 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 对上才 trip10000-seed 真跑(集群、会杀 daemon)
harness 环的文字合同R2 落地后再跑 G7 fencing 考试
不改 crates/correctness、不改 porcupine 脚本

writer.rs / etcd.rs 本轮不插宏(避免和 R2 / 写路径 owner 打架)。


1. 不变量(崩溃只许留 orphan)

宗旨第三条:block PUT 先于 metadata CAS。崩溃之后可见状态只能是:

  1. CAS 没成功:inode JSON / 名 key 与崩溃前相同。Swift 上多出来的对象是 orphan(无指针)。读、stat、readdir 不得看见半截文件。
  2. CAS 成功、回复没发出:元数据已是新 version。重启后 getattr/read 必须看到完整新图。内核可能重发 FUSE 写/fsync:必须幂等(同一 slice 对象名再 PUT,或 CAS 发现 version 已新)。
  3. 禁止:名已指向新 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_PUTChunkWriter::flush:upload_blocks 成功之后、返回给 flush_handle 之前Swift 有新对象;etcd 仍是旧 chunk map读旧数据;新对象无引用(orphan)
FAILPOINT_BEFORE_METADATA_CASflush_handle:组好 meta 之后、commit_inode 之前同上(PUT 已完成)同上
FAILPOINT_AFTER_METADATA_CAScommit_inode 返回 Committed 之后、改 Handle / invalidate / FUSE 回复之前etcd 已是新 version重启后读新数据;重放 fsync 不得损坏
FAILPOINT_BEFORE_REPLYFUSE 写路径:本地 invalidate 之后、Ok(Reply*) 回到 fuse3 之前(flush/fsync/release)元数据已提交同 AFTER_CAS;内核会当没收到回复
FAILPOINT_COMPACTION_AFTER_PUTCompactor::compact_chunk:upload_blocks 之后、commit_inode 之前新 compacted 对象在 Swift;旧切片仍被引用读仍等于 snapshot;新对象 orphan
FAILPOINT_GC_BEFORE_DELETEgc_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>:

观察PASSFAIL
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 点:读字节 ≠ 崩溃前 snapshotFAIL—

辅助(审计,不是 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 / G7cR6 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 keyholder / 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. 以后的接入顺序

  1. 根 Cargo.toml 加 member;chunk-plane + swift-fuse 加 path dep。
  2. 六个 failpoint! 按 §2 表插入。一次一个文件的 PR 面,避免和 R2 同改 etcd.rs。
  3. N=6 烟雾(每点一次)在 swift1,独占挂载。
  4. N=10000 排期。
  5. 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 + GCJuiceFS 数据模型、metadata/data 分离、object immutable单机挂载:随机 4K 写 100MB 不重读旧数据;md5 全对;45/45 测试;双挂载并发 CAS 一致
P2 ✅etcd 引擎(同 trait)+ open/close lease + txn CASProxyFS lease、control plane 强一致双节点并发写一致;EBUSY + TTL 崩溃回收实测通过
P3 ✅窗口放大读 + 块缓存 + inflight 去重 + path 缓存 + write-backGeeseFS/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)全过

已定决策(不再回问)

  1. 元数据引擎先 SQLite(数据模型验证),P2 换 etcd —— trait 隔离,写路径不变
  2. 文件 ≠ Swift 对象(宗旨必然推论,ProxyFS 2.x 同样放弃 bi-modal); 原始对象可见性由 --mode=raw 保留
  3. 一致性 = lease-based close/open(ProxyFS 路线),非 close-to-open
  4. 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(最高优先级)

目标

  1. inode-centric FUSE boundary:FUSE nodeid → SwiftFuse inode_id → InodeMeta, path lookup 仅 (parent_inode, name) → inode;rename/unlink-open/FORGET/lookup/GC 全自然。
  2. open-unlinked fstat() 正确(现在 5/5 ENOENT,bridge 层)。
  3. rename/nodeid/FORGET 生命周期正确(BUG-7 类消除)。
  4. kernel_ttl 从 0 恢复非 0(先证明 inode 层不再依赖内核重查)。
  5. 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 生命周期)

目标

  1. stable client_id + session_id + lease_id + fencing_epoch: holder 从 hostname-PID 改为稳定身份;每次写 acquire 携带 session/epoch。
  2. 所有 metadata commit 同时 CAS:
    inode_version == expected AND lease_epoch == N AND holder == client
    
    旧 writer 复活(epoch 过期)→ 100% 无法 commit。
  3. distributed open-handle registry:/handles/<inode>/<client>/<handle> 绑定 etcd lease; daemon 死亡自动消失;inode GC 条件 = nlink==0 AND handle_count==0 AND no writer lease。
  4. 解决:daemon 重启 30s EBUSY、stale writer、cross-node open-deleted、安全 block/inode GC。
  5. 安全 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 吞吐下限 + 对象数量爆炸。

路线

  1. dirty interval aggregation(已有合并,随机 4K 天然不合并)✅ 部分
  2. cross-slice bounded parallel PUT ✅ 4f063f7
  3. debounce / write aggregation(fsync 语义内的窗口合并)
  4. Immutable Delta Pack:多个小写打包进 1 个对象(index + payload), Swift PUT count ≈ dirty_bytes / pack_target;后台 compaction 合并回大 block。 完全符合宗旨:object immutable、不读旧数据、mutation 只在 metadata。
  5. 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(路径缓存命中)。

路线

  1. create 路径 etcd 操作瘦身(合并 counter bump / parent touch;批量 inode 分配)。
  2. 多 etcd client / 连接池消除 Mutex<Client> 串行化。
  3. 目标: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。

路线

  1. etcd watch revision stream:监听 {prefix} 变更 → invalidate inode/path/dir cache。
  2. 或 inode version validation(每次 cache 命中验证 version)。
  3. 恢复 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:
    FAILPOINT_AFTER_BLOCK_PUT / BEFORE_METADATA_CAS / AFTER_METADATA_CAS
    FAILPOINT_BEFORE_REPLY / COMPACTION_AFTER_PUT / GC_BEFORE_DELETE
    
    seed 1..10000 反复 crash/restart → 只产生 orphan、绝不半可见。
  • 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 量级)。
  • “千兆内网” 描述不适用于单节点;跨节点真实上限需跨节点路径测量。