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

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。