R5 Design — Cache Coherence (etcd watch → process-cache drop)
日期:2026-08-18 · 状态:设计定稿(未实现) · 对应 ROADMAP-2026-08-10 §R5 权威现态:
docs/HANDOFF-2026-08-15.md。R1 已关;本文件不改代码。 一句话:kernel_ttl=0只逼内核再问 userspace;userspace 的 inode attr / dir listing 仍按attr_ttl/dir_ttl回答。跨节点 mutation 今天不失效对端进程缓存。 解法:etcd watch{prefix}→ 按 key 丢掉对端attrs/dirs。断线 fail-safe:清空全部。
0. 本轮范围
| 做 | 不做 |
|---|---|
| 写清现有缓存、stale 路径、watch 机制、gate、非目标 | 不改 chunk_fs.rs / inode_fs.rs / etcd.rs |
| 标明以后谁改哪些文件 | 不恢复 INVAL_ENTRY / INVAL_INODE / NOTIFY_STORE |
不跑 100k 跨节点 stale 环,不跑 kernel_ttl=1 G6 |
实现落点见 §7。R2 拥有 swift-meta;watch 可以用独立 etcd_client::Client,不必等 EtcdEngine 长 watch API。
1. 现有缓存(2026-08-18 代码事实)
R1 之后 没有 path→nodeid 缓存。paths / path_inodes / resolve() 已删。
名字只出现在 engine.lookup(parent, name)。进程内要失效的是 inode-keyed attr
和 inode-keyed dir listing。Roadmap 里的 “path cache” 在 chunk 模式已不存在;
对端 stale 的是这两个 DashMap,加上内核自己的 dentry(由 kernel_ttl 管)。
1.1 三层 TTL,不要混
| 层 | 字段 / 回复 | 默认(CLI / 舰队) | 谁看 |
|---|---|---|---|
| 进程 attr | ChunkFsConfig.attr_ttl → attrs: DashMap<u64, AttrEntry> | 10s(main.rs / swiftfuse.example.toml) | attr() / getattr |
| 进程 dir | ChunkFsConfig.dir_ttl → dirs: DashMap<u64, (Instant, Vec<(DirectoryEntry, InodeMeta)>)> | 10s | children_meta() / readdir / readdirplus |
| 内核 | ChunkFsConfig.kernel_ttl → ReplyEntry / ReplyAttr / readdirplus entry_ttl/attr_ttl | 配置默认 1s;舰队基线 0(HANDOFF-2026-08-15) | 内核 dentry / i_attr |
kernel_ttl 不驱动 attr_cached / children_meta。那两处只比 fetched.elapsed() 和 attr_ttl / dir_ttl。
fs.rs(raw / path 模式)另有一份 path-keyed attr/dir cache。R5 门禁是 chunk + inode 边界。raw 模式不在本设计实现范围。
1.2 进程 attr 缓存(chunk_fs.rs)
attrs: DashMap<u64, AttrEntry> // key = inode id == FUSE nodeid
AttrEntry { attr: FileAttr, fetched: Instant }
- 命中:
attr_cached(inode),fetched.elapsed() < attr_ttl。 - 填充:
lookup写入;attr()在 cache miss 后get_inode再 insert。 - 带 fh 的 getattr:走
Handle(dirty size / 本地 unlink 的 nlink=0),不靠attrs。 - 无 fh:先扫本挂载 open handle 的 dirty size 上抬,再
attr_cached;命中则 不再读 etcd。
这是跨节点 chmod / setattr / 对端 flush 变 size 的 stale 面:B 的 GETATTR 在 TTL 内直接吐 A 改之前的 FileAttr。
1.3 进程 dir 缓存(chunk_fs.rs)
dirs: DashMap<u64, (Instant, Vec<(DirectoryEntry, InodeMeta)>)>
key 是 父 inode。children_meta 在 t.elapsed() < dir_ttl 时整表返回,含每个 child 的 InodeMeta(readdirplus 用它组 attr,避免 N+1 etcd)。
这是跨节点 create / unlink / rmdir / rename 的 stale 面:B 的 READDIR 在 TTL 内看不见 A 的新名、仍看见 A 已删的名。
1.4 本挂载才丢的东西(今天)
invalidate(inode) = attrs.remove + dirs.remove(同一个 inode)。不发 FUSE notify,不碰对端。
| 本地 mutation | 丢掉什么 |
|---|---|
setattr 成功 CAS | invalidate(inode) |
mkdir / create | dirs.remove(parent);create 不 invalidate 新 inode(本挂载刚 insert) |
unlink / rmdir | invalidate(victim) + dirs.remove(parent) + mark_stale(parent, name) |
rename | invalidate(src) + 两父的 dirs + invalidate 两父 + mark_stale(new_parent, new_name) |
flush_handle 成功 | invalidate(h.inode) |
forget / batch_forget | 该 nodeid 的 attrs/dirs(内存卫生) |
mark_stale 只服务 本内核 下一次 LOOKUP(一次性 entry_ttl=0)。对端没有这份集合。
touch_parent 会 commit_inode 父 mtime,从而 bump 父的 InodeMeta.version,但 对端 的 dirs[parent] / attrs[parent] 仍热。
1.5 明确不是 R5 缓存的东西
| 结构 | 为什么不是跨节点元数据缓存 |
|---|---|
stale_names | 本挂载 LOOKUP 防御,不复制到对端 |
Handle.{chunks,size,meta,dirty} | 本 open 的写缓冲;权威是 dirty,不是 peer 视图 |
block_cache / CachedBlockStore | 不可变 block 内容。CAS 只换指针;旧对象仍合法直到 GC |
NodeTable | 本挂载 lookup_count / open / deleted。对端有自己的表 |
pending_isize_grow | 本挂载 writeback i_size 补丁,不是元数据 cache |
数据面靠 “新对象 + CAS 换指针”。R5 管的是 名字和 attr 的进程缓存,不是 Swift 字节。
2. Stale-path 风险(kernel_ttl=0 救不了 B)
2.1 时间线
t0 B: LOOKUP /dir/x → etcd → attrs[x]=mode 0644, fetched=t0
READDIR /dir → etcd → dirs[parent]=[..., x, ...], fetched=t0
t1 A: chmod 0600 /dir/x → etcd inodes/{x} CAS
本挂载 invalidate(x)
对端 attrs[x] 仍热
t2 B: GETATTR(x) → attr_cached 命中 → mode 0644 STALE
t3 A: unlink /dir/x → 删 names/{parent}/x
本挂载 dirs.remove(parent)
t4 B: READDIR(parent) → dirs 命中 → 仍列出 x STALE
t5 A: create /dir/y ; rename …
t6 B: 同上,dir/attr TTL 内全错
kernel_ttl=0 的效果:B 的内核 每次 发 GETATTR / READDIR / LOOKUP。
LOOKUP 走 engine.lookup,不读 attrs/dirs,所以纯 LOOKUP 在 ttl=0 下是新鲜的。
GETATTR 无 fh、READDIR、readdirplus 先读进程缓存。内核重问救不了 warm userspace cache。
舰队现在 kernel_ttl=0 且 attr_ttl=dir_ttl=10。跨节点 POSIX 名/模式在最多 10s 内可以错。
这就是 roadmap 标的高风险未证明项。R1 用 ttl=0 过 G1/G6,是因为那些门禁不测 “A chmod 后 B 立刻 getattr”。
2.2 门禁会打到的四类
| A 做 | B 立刻做 | 今天为什么会 stale |
|---|---|---|
| rename | stat 旧名 / 新名;readdir 两父 | B 的 dirs 两父 + 可能仍缓存的 attr |
| unlink / rmdir | stat 该名;readdir 父 | B 的 dirs[parent];victim attr 仍在 |
| create / mkdir | readdir 父;stat 新名 | B 的 dirs[parent] 没有新 child |
| chmod / chown / truncate setattr | getattr | B 的 attrs[inode] |
LOOKUP 在 ttl=0 下相对安全。门禁必须包含 getattr + readdir,不能只 LOOKUP。
2.3 和 G6 / writeback 分开
G6 的 bug 是对端内核 i_size、O_APPEND 偏移、lease,不是 attrs DashMap。
R5 先关 userspace 元数据 stale,再恢复 kernel_ttl=1 重跑 G6(§5)。
不要把 R5 watch 当成 G6 isize 的补丁。也不要用 watch 当理由把 INVAL_* 加回来。
3. 提议机制
主路径:etcd watch {prefix} → 按 key 丢进程缓存。
备选:cache hit 时校验 inode version。 能做对,但每个 GETATTR/READDIR 多一趟 etcd,和 R4 瘦身对着干。只在 watch 长期不可用时当降级,不当门禁主路径。
3.1 主路径:watch
每个 FUSE 进程(每个挂载)一条 watch:
- 独立
etcd_client::Client(不要占用EtcdEngine的请求 pool;R2 也不该被强迫改 engine API)。 WatchOptions::with_prefix(),key =EtcdEngine的 fs prefix:/swiftfuse/{sanitized_fs_name}(见etcd.rsconnect_with_options)。- 从 当前 revision 起看(
with_start_revision= 刚才一次 prefix get 的 header revision + 1)。启动前先invalidate_all(),避免 watch 前的空洞。 - 每个 event → §4 映射 →
invalidate/dirs.remove。 - 本地 mutation 仍立刻
invalidate(今天的表)。watch 是给 对端 的。自己的 event 再 drop 一次是幂等的。
不需要新的 etcd key。现有 mutation 已经写 inodes/{id} 和 names/{parent}/{name}。
3.2 备选:hit 时 version 校验
AttrEntry 增 version: u64(来自 InodeMeta.version)。attr() 命中后:
get_inode(id)(或只要 version 的瘦 get,今天没有这条 API);stored.version != cached.version→ 丢缓存,用新 meta;- dir 缓存:父 inode version 变 或 任一名 key 的 etcd mod_revision 变。可靠做法是
list_children,等于废掉 dir cache。
代价:热 getattr 从 0 RTT 变 1 RTT。watch 只在别人改的时候付费。门禁主路径用 watch。
Version 校验可以当 watch 断线后的短窗(见 §4.4):断线后先 invalidate_all(),重连前可以用 “命中必校验” 以免空窗。不是默认稳态。
3.3 不采用
| 方案 | 原因 |
|---|---|
FUSE_NOTIFY_INVAL_ENTRY / INVAL_INODE | 本挂载死锁(chmod/03 fuse_rmdir × fuse_reverse_inval_entry;G4a folio_wait_bit_common)。notify 到不了对端内核。 |
NOTIFY_STORE | 062111 SHARED 缺 seq。flush 路径禁止。 |
把 attr_ttl/dir_ttl 设 0 | 每个 getattr/readdir 打 etcd,R4 白做。ttl=0 是调试旋钮,不是相干。 |
| 对端 RPC / gossip | 第二控制面。etcd 已经是 CAS 真相。 |
4. Watch:key、映射、debounce、断线
4.1 看哪些 key
etcd 布局(etcd.rs 文件头):
{prefix}/inodes/{id} JSON(InodeMeta) — attr、size、mode、parent、name、chunks、version
{prefix}/names/{parent}/{name} inode id 字符串 — 目录成员
{prefix}/counter/{inode,slice} 单调计数
{prefix}/leases/{inode} 写租约(TTL)
Watch 整个 {prefix}(一条 stream),在 userspace 过滤。不要对每个 inode 开一条 watch。
| 前缀 | 动作 |
|---|---|
{prefix}/inodes/{id} | PUT / DELETE → 丢该 inode 的 attr 和 它作为目录的 listing(invalidate(id)) |
{prefix}/names/{parent}/{name} | PUT / DELETE → dirs.remove(parent)。DELETE 再 invalidate(parsed_inode)(value 在 event 里;没有 value 就只丢父 dir) |
{prefix}/leases/{inode} | 忽略。lease 不是 attr/dir 真相。看 lease 会在 G6 开关租约时刷爆缓存 |
{prefix}/counter/* | 忽略。id 分配不改变已可见的名/attr |
chunk map 活在 inodes/{id} JSON 里,没有单独的 chunks/ key。flush/setattr 的 CAS 已经是 inode PUT,对端 invalidate(id) 即可。B 下一次 getattr/read 走 engine。
4.2 Event → cache drop
解析 key 时必须用 slash 分段,不要当数字前缀(names/1 会误伤 names/1249/...,和 list_children 的 trailing-slash 纪律同一类)。
fn apply_watch_event(fs: &ChunkFs, ev: WatchEvent) {
match parse(prefix, ev.key) {
Inode(id) => fs.invalidate(id),
Name { parent, .. } => {
fs.dirs.remove(&parent);
if let Some(id) = ev.value_as_inode() { // PUT 的 value;DELETE 用 prev_kv
fs.invalidate(id);
}
}
Lease | Counter | Other => {}
}
}
invalidate 已同时摘 attrs 和 dirs。rename 的单 txn 会打:
names/{old_parent}/{old}DELETEnames/{new_parent}/{new}PUTinodes/{src}PUT(parent/name/version)- 常常还有两父
inodes/{parent}PUT(mtime / version,rename_inode_touched)
映射后:两父 dir 掉、src attr 掉、两父 attr 掉。和今天本挂载 rename 后的 drop 对齐。
chmod:只有 inodes/{id} PUT → invalidate(id)。够。父 dir 缓存里的 child InodeMeta.mode 会过期;readdirplus 用的是 dirs[parent] 里那份 meta。严格说 chmod 还应丢父 dir,否则 B 的 readdirplus 在 dir_ttl 内仍报旧 mode。
收紧规则:
inodes/{id}变更 →invalidate(id)并且 若能读到 JSON 的parent,dirs.remove(parent)。- DELETE 且 body 不可用 →
invalidate(id);父 dir 靠伴随的names/event。
create:names/{parent}/{name} PUT + inodes/{new} PUT + 父 inode PUT → 父 dir 掉。B 下次 readdir miss → list_children。
4.3 Debounce
rename/create 一次 txn 产生 3–5 个 event,会连打同一 parent。
- 按 inode 合并 50ms(可配,默认 50ms,上限 100ms)。
- 窗内同一 inode 的多次
invalidate合成一次。 - 到期在 FUSE 线程 之外 跑(watch task)。
DashMap::remove不需要 engine 锁。 - 门禁要的是 “B 立刻 stat 不 stale”。50ms debounce 对人工 stat 可接受;100k 环应按 “A 返回后 B 再发 syscall”,不要和 debounce 赛跑。实现时可对门禁目录关 debounce,或 watch apply 同步 drop、debounce 只合并重复 remove。
推荐:drop 同步、立刻;debounce 只防止日志和重复 remove。 语义是 “event 入队后缓存已脏”。DashMap::remove 便宜。
4.4 断线(fail-safe)
Watch 不是尽力而为。空洞必须当 全缓存不可信。
| 事件 | 动作 |
|---|---|
| stream 结束 / gRPC 错 / 超时 | attrs.clear() + dirs.clear()。立刻。然后重连 |
etcd compacted(start revision 没了) | 同样 clear 全部。从 当前 revision 再 watch。不要重放 |
| 进程启动 / 重挂 | 缓存空。watch 从 now 开始 |
| 本进程自己的 mutation | 保持今天的同步 invalidate(不要等 watch 绕一圈) |
断线期间:缓存空 → 每个 getattr/readdir 打 etcd。正确,慢。禁止 “断线还继续用旧 DashMap”。
重连风暴:指数退避 50ms → 1s,封顶 2s。退避期间缓存保持空。
leases/ keepalive 不要绑在这条 watch 上(那是 engine 的 lease client)。
4.5 和本地 dirty handle 的边界
对端 watch 丢掉 attrs[inode] 之后,B 的 attr(inode, Some(fh)) 仍以 B 自己的 Handle 为准。这是对的:B 的未 flush 写是 B 的。无 fh 的 getattr 在 clear 后走 get_inode,看见 A 已 commit 的 size/mode。
不要因为 watch 去清对端 Handle.dirty。那是另一台机器的写缓冲。
5. Gate(本会话不跑)
ROADMAP §R5,收成可引用的两段。
5.1 跨节点立刻可见(主门禁)
A(swift1)rename / unlink / create / chmod
B(swift2)立刻 stat / readdir / getattr
循环 100_000 次
0 stale
建议独立脚本(以后写,不叫 run_gates.sh,不翻全舰队 ttl):
- 四节点保持现役
kernel_ttl=0(先证 userspace 相干,不把 G6 isize 叠进来)。 attr_ttl/dir_ttl保持 10s(必须在 TTL 内 打中 bug;若有人把它们改成 0 再测,门禁作废)。- 工作目录:
/mnt/swift-etcd/r5-coh/,每轮唯一名字。 - 每轮:A 做一种 op → A syscall 返回 → B 立即 对应检查,无 sleep。
- 四种 op 打散(或 25000×4)。任一次 stale → FAIL,记下 inode、两边 getattr、etcd
get inodes/{id}。 - 判定:
stale=0且loops=100000。没有loops=行 = INCONCLUSIVE(同一 G1Files=纪律)。
attr_ttl=0 的对照跑可以有,但不能替代主门禁。
5.2 kernel_ttl=1 下重跑 G6(第二段,R5 实现之后)
Watch 落地并过 5.1 之后:四节点一起 kernel_ttl=1,只跑 scripts/r1-ttl1-g6-only-run.sh,trap 拉回 ttl=0。
本会话禁止跑 G6。 现役绿线仍是 101550。R5 没有改 writer/lease;重跑 G6 只为证明恢复内核缓存后 userspace 仍相干。
6. 非目标(明示)
- 不恢复
FUSE_NOTIFY_INVAL_ENTRY、FUSE_NOTIFY_INVAL_INODE、FUSE_NOTIFY_STORE。本挂载会死锁;对端内核收不到。HANDOFF-2026-08-15 §3.2 / §3.4 / §7.3–7.4。 - 不用 notify “加速对端”。对端只信 etcd + 自己的进程缓存。
- 不在本设计改
etcd.rs/ 租约 / fencing(R2)。 - 不把 block cache 纳入元数据相干。
- 不把
attr_ttl=0当 R5 完成。 - 不做 symlink/mknod 名缓存(仍 ENOSYS)。
7. 以后的文件归属(本会话不写这些代码)
| 文件 | 以后做什么 |
|---|---|
crates/swift-fuse/src/chunk_fs.rs | invalidate_all();watch task 可调用的 invalidate / dirs.remove(已是 pub) |
crates/swift-fuse/src/inode_fs.rs | 挂载后启动 watch(init 之后);destroy 时停。不改 mutation 的本地 drop 表 |
新文件 crates/swift-fuse/src/meta_watch.rs(名可改) | prefix watch、key 解析、过滤 lease/counter、重连、clear-on-gap |
crates/swiftfuse-cli/src/main.rs | 可选:把 fs_name / etcd endpoints 传给 watch(多半 ChunkFs 已有 engine,能自连) |
crates/swift-meta/src/etcd.rs | 默认不动。若要 prefix() getter,R2 合入时加一行只读。不要为了 R5 抢 etcd.rs |
scripts/detect/ | 以后才写 100k 环。本会话不写、不跑 |
本会话:只有这份设计和 docs/workflows/r5-r6-scaffold-REPORT.md。无 Rust 实现。
8. 实现顺序(给下一刀,不是现在)
invalidate_all+meta_watch.rs+init里启动。单测:假 event → 对应 DashMap 空。- 集群:A chmod / B getattr,目测一次。
- 100k 环(§5.1)。
- 过了再考虑 ttl=1 G6。
失败时:先确认 B 的 watch 连着、A 的 key 在 {prefix}/inodes 下、B 没有把 lease event 当 cache drop。