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

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)