R1 Design — Inode-Centric FUSE Boundary (InodeFs)
日期:2026-08-11 · 状态:设计定稿(待实现) · 对应 ROADMAP-2026-08-10 §R1 一句话:让
nodeid == inode_id == MetadataEngine 的 inode id,FUSE 层不再维护任何 path→nodeid 映射,InodePathBridge从 chunk 模式移除 → BUG-2 / BUG-5 / BUG-7 在边界上不可能。
摘要(执行摘要)
三个 bug 同根:PathFilesystem(经 InodePathBridge)把 path 当 identity。
R1 把 chunk 模式从 fuse3::path::PathFilesystem 迁移到 fuse3::raw::Filesystem:
- nodeid = inode_id = 引擎分配的 id(root=1;etcd
counter/inode单调递增,永不回收)。 → nodeid 复用/混叠在结构上不可能(BUG-7 消灭)。 - 路径解析只存在一种形式:
(parent_nodeid, name) → engine.lookup → inode_id。 → rename 后没有任何「第二份 nodeid→path 映射」可以损坏(BUG-2 消灭)。 - open-unlinked 文件的 inode 记录存活到「最后
RELEASE+FORGET确认」才 GC;GETATTR(nodeid, fh=None)直接命中存活的引擎记录 →fstat不再 ENOENT(BUG-5 消灭)。 - 代价:
ChunkFs的resolve()/paths/path_inodes全部删除;新增NodeTable(内核引用计数) 与inode_fs.rs(raw impl,约 400 行);swift-meta增加原子rename_replace。 kernel_ttl恢复非 0 的前提正是 inode 层不再依赖内核重查 —— 见 §8。
1. 根因与目标架构
1.1 为什么 bridge 必然坏
InodePathBridge(third_party/fuse3/src/path/inode_path_bridge.rs)维护两份独立于内核的状态:
name_to_inode: HashMap<(parent_nodeid, name), nodeid>(桥自己的 slab 分配,InodeGenerator)inode_to_names: HashMap<nodeid, HashSet<(parent, name)>>(nodeid→绝对路径 的推导来源)
内核只认 nodeid;桥为了把 raw 请求翻译成 path 请求,必须让这两份表和内核的
dentry/d_move 完全一致。BUG-2(rename-over-existing 后旧 nodeid 映射到已删路径)、
BUG-7(rename 释放源 nodeid → slab 复用 → 映射被覆盖)都是这份第二份真相与内核脱节的必然结果。
kernel_ttl=0 只是让内核每次重 LOOKUP、强迫桥重建映射 —— 治标。
1.2 目标:identity 只存在一份
R1 之后 chunk 模式的 identity 链:
FUSE nodeid (u64, 内核 dentry/inode 的句柄)
│ == (同一 u64,无翻译表)
▼
inode_id (u64, 引擎 inode 主键)
│ lookup/get_inode/commit/…
▼
InodeMeta (crates/swift-meta/src/model.rs, JSON in etcd / row in sqlite)
- nodeid 永远等于引擎 inode id:引擎 id 由
counter/inode(etcd)或单调分配(sqlite,见 §9.2) 产生,永不回收。所以 nodeid 永不回收、永不混叠 —— 不需要 slab,不需要 generation 校验。 - 没有 path→nodeid 缓存。
paths/path_inodes/resolve()删除。名字只出现在engine.lookup(parent_inode, name)的入参里。 - 内核 d_move 只影响内核自己的 dentry;我们对 rename 的唯一反应是改引擎记录 + 失效本地 attr/dir 缓存。不再维护任何会与内核脱节的 nodeid 语义副本。
2. 模块布局(目标态)
crates/swift-fuse/src/
lib.rs # pub mod inode_fs; 导出不变
chunk_fs.rs # ChunkFs 核心:engine 逻辑、Handle、attrs/dirs 缓存、读写/压缩/GC。
# ✂ 删除 PathFilesystem impl 与 path 机制
# ✚ 新增 nodes: NodeTable 字段 + maybe_gc()
inode_fs.rs # ✚ 新文件:NodeTable/NodeEntry + impl fuse3::raw::Filesystem for ChunkFs
# (同一 crate 内可分文件 impl,ChunkFs 结构体本身不动)
fs.rs / handle.rs / attr.rs / key.rs / prefetch.rs # raw 模式 (SwiftFs) 不动
crates/swift-meta/src/
engine.rs # ✚ trait 默认实现 rename_replace()(两段式,兼容 sqlite)
etcd.rs # ✚ override rename_replace():单个 etcd txn 原子替换目标
sqlite.rs # ✚ id 单调性修复(§9.2)+ rename_replace 走默认实现
model.rs # 不变
crates/swiftfuse-cli/src/main.rs
# chunk 分支:fuse3::path::Session → fuse3::raw::Session
third_party/fuse3/src/path/inode_path_bridge.rs
# 保留不动(raw 模式的 SwiftFs 仍需 path bridge + vendored patch)
关键点:ChunkFs 结构体与全部数据面/句柄逻辑复用;raw impl 是薄壳,只做
nodeid 到引擎调用的翻译 + 引用计数 + 失效。fuse3::raw::Filesystem 的所有未实现方法
走 trait 默认 ENOSYS —— 与当前声明一致(无 symlink / hardlink / mknod / xattr)。
3. 数据结构(具体类型)
3.1 NodeTable / NodeEntry(新,inode_fs.rs)
#![allow(unused)]
fn main() {
/// 内核侧引用记账:一个 nodeid(== inode_id)一条。
/// 存在的唯一目的是:(a) 正确响应 FORGET;(b) 决定未链接 inode 的 GC 时机。
struct NodeEntry {
/// 恒等于 FUSE nodeid(无翻译)。存一份便于断言与调试。
inode: u64,
/// 已返回给内核的 lookup 次数(lookup/create/mkdir/readdirplus 各 +1)。
/// FORGET 减去 nlookup;到 0 表示内核不再持有该 nodeid 的 dentry 引用。
lookup_count: AtomicU64,
}
pub(crate) struct NodeTable {
nodes: DashMap<u64, NodeEntry>,
/// 每个 inode 在本挂载点的 open handle 数(代替现状的 O(n) 扫描 has_open_handles)。
open_per_inode: DashMap<u64, AtomicU64>,
/// 已 unlink 但未 GC 的 inode(名字已从引擎消失,记录 + blocks 存活)。
deleted: tokio::sync::Mutex<std::collections::HashSet<u64>>,
}
impl NodeTable {
/// lookup/create/mkdir/readdirplus 返回 nodeid 前调用(内核 +1 引用)。
fn bump(&self, inode: u64);
/// FORGET:扣 nlookup;返回 true 表示内核引用归零。
fn forget(&self, inode: u64, n: u64) -> bool;
/// open/create 成功后 +1;release 后 -1。
fn note_open(&self, inode: u64);
fn note_close(&self, inode: u64);
/// GC 三条件:deleted ∧ open==0 ∧ lookup_count==0。
fn gc_ready(&self, inode: u64) -> bool;
fn mark_deleted(&self, inode: u64);
}
}
3.2 ChunkFs 字段变化(chunk_fs.rs)
#![allow(unused)]
fn main() {
pub struct ChunkFs {
engine: Arc<dyn MetadataEngine>,
store: Arc<dyn chunk_plane::BlockStore>,
pub reader: ChunkReader,
cfg: Arc<ChunkFsConfig>,
attrs: DashMap<u64, AttrEntry>, // 不变(键 = inode id)
dirs: DashMap<u64, (Instant, Vec<(raw::DirectoryEntry, InodeMeta)>)>, // 键不变,条目类型换 raw
pub handles: DashMap<u64, Arc<Handle>>, // fh 注册表,不变
nodes: NodeTable, // ✚ 新
next_fh: AtomicU64, // 不变
compact_interval: Option<Duration>, // 不变
block_cache: Option<Arc<chunk_plane::BlockCache>>, // 不变
inflight: DashMap<String, Arc<tokio::sync::Notify>>, // 不变
notify: Mutex<Option<fuse3::raw::Notify>>, // 类型改 raw
stale_names: Mutex<HashSet<(u64, OsString)>>, // 保留,降级为防御层(§6.4)
// ✂ 删除:paths, path_inodes, deleted_inodes(并入 NodeTable.deleted)
}
// ✂ 删除方法:resolve(), cache_path(), invalidate_path(), path_for()
// (children_meta()/attr()/open_handle()/flush_handle()/remove_file_entry() 等保留)
}
3.3 打开句柄注册表(不变,仅记账)
Handle(chunk_fs.rs:71)保持不变:{inode, writable, writer, chunks, size, meta}。
handles: DashMap<fh, Arc<Handle>> 是 fh 注册表;NodeTable.open_per_inode 提供
inode→open 数。open_handle() 增加一行 nodes.note_open(inode);release 增加
nodes.note_close(inode) + maybe_gc(inode)。
3.4 raw 属性构建
fuse3::raw::reply::FileAttr 带 ino 字段(nodeid 由 attr.ino 上送,
见 raw/reply.rs:110 的 fuse_entry_out { nodeid: attr.ino, .. })。
现有 attr_of()(chunk_fs.rs:135)构建的是 fuse3::path::reply::FileAttr(无 ino,
时间是 SystemTime)。迁移必须:
#![allow(unused)]
fn main() {
/// 新:raw FileAttr。ino 必填 = meta.id。
fn attr_of(meta: &InodeMeta, _cfg: &ChunkFsConfig) -> fuse3::raw::reply::FileAttr {
let mut a = /* 与原 attr_of 相同字段,时间转 Timestamp */;
a.ino = meta.id;
a
}
}
任何遗漏 ino 的回复都会让内核拿到 nodeid=0 → ENOENT/错乱,这是迁移的头号陷阱。
4. Nodeid 分配与复用规则
4.1 分配
- FUSE 层从不分配 nodeid。nodeid 是
insert_inode()的返回值(引擎分配), 由lookup / create / mkdir / readdirplus原样上送内核。 - root:
engine.root_id() == 1,与内核根 nodeid 1 天然一致;init()时nodes.bump(1)预置一条引用(内核挂载即持根引用,unmount 时收到forget(1))。
4.2 复用规则 —— 为什么「永不复用」而非「复用但防混叠」
混叠 bug 的唯一来源是同一个 u64 在两个不同时刻指向两个不同 inode,而内核 (或旧进程 fd)还持有旧身份。我们的规则:
- 引擎 id 永不回收(硬性不变量):
- etcd:
counter/inode只增不减(etcd.rs的bump_counter)✓ 已满足。 - sqlite:
id INTEGER PRIMARY KEY在删除最大行后会复用 rowid → 必须改AUTOINCREMENT或改走counters表(§9.2),否则 R1 不变量在 dev 引擎上不成立。
- etcd:
- nodeid := inode_id:两条规则合起来 ⇒ 一个 nodeid 在文件系统整个生命周期内 只指向一个 inode。没有可复用的 nodeid,混叠在结构上不可能。BUG-7 类 (rename 释放 nodeid → 后续 create 复用 → 覆盖映射)失去发生条件。
- 内核引用归零(FORGET)不释放 id,只触发未链接 inode 的记录删除(§5)。 id 本身永远属于它创建的那个 inode。
- 反方论证(为什么不用 slab 复用 + generation):slab 复用需要精确复刻内核
d_move 语义(正是 BUG-2/BUG-7 的失败点),generation 需要内核
FUSE_LOOKUP带generation校验(Linux 内核 FUSE 目前不校验 generation)。直接不复用是 更简单且可证明 的选择;代价只是 u64 单调增长(64 位,工程上无上限问题)。
4.3 防御性校验
inode_fs.rs 的每个 raw handler 收到 nodeid 后不需要查 nodes 表即可服务
(get_meta(inode) 直接走引擎,nodeid 自校验)。nodes 表只回答
「内核还引不引用它」。若 GETATTR 收到未知 nodeid(理论不应发生),
仍按引擎记录应答;记录不存在 → ENOENT(正常,无需 ESTALE 特判)。
5. FORGET 生命周期与延迟 inode GC
5.1 引用记账点(每个「把 nodeid 介绍给内核」的回复)
| 事件 | 记账 | 说明 |
|---|---|---|
lookup 成功 | bump(child_id) | ReplyEntry |
create 成功 | bump(id) | ReplyCreated |
mkdir 成功 | bump(id) | ReplyEntry |
readdirplus 每项 | bump(child_id) | 协议:readdirplus 条目各携带一次 lookup 引用 |
readdir(非 plus) | 不记账 | 内核只认名字,使用时自行 LOOKUP |
forget(nodeid, n) | lookup_count -= n | 归零 → maybe_gc |
batch_forget | 同上,逐条 | 必须 override:fuse3 默认实现是空操作,漏掉会泄漏计数 |
注意:
forget必须在nodes.forget()归零后仍保留 nodeid→inode 的有效性 直到 open handle 清空 —— 但因为我们不删 NodeEntry 本体、只删引擎记录,而引擎记录 由 open handle 条件保护,两者天然衔接(见 5.3)。
5.2 延迟 GC(BUG-5 的机制)
现状(chunk_fs.rs::remove_file_entry)已有雏形,R1 把它变成三条件精确化:
maybe_gc(inode):
if nodes.gc_ready(inode): # deleted ∧ open==0 ∧ lookup_count==0
nodes.deleted.remove(inode)
finish_deferred_gc(inode) # engine.delete_inode + gc_blocks(现有逻辑)
unlink(parent, name):meta = engine.lookup(parent, name),is_dir → EISDIR。- 若
open_per_inode[meta.id] > 0 && engine.supports_deferred_gc():engine.unlink_name(parent, name, meta.id)(只删名字,等值 CAS 防名字被复用后误删),nodes.mark_deleted(meta.id)。 - 否则
engine.delete_inode(meta.id)+gc_blocks(与现状一致;sqlite 走这里)。
release(fh):flush → 释放 lease →note_close(inode)→maybe_gc(inode)。forget/batch_forget归零:maybe_gc(inode)。
为什么必须等 lookup_count==0:内核可能在 unlink 前已通过 readdirplus 缓存了
该 nodeid 的 positive dentry(TTL>0)。若记录在 FORGET 前删除,内核随后对缓存的
GETATTR(nodeid) 会命中不存在记录。三条件保证:只要内核还可能对某个 nodeid 发起
无 fh 请求,记录就活着(nlink 报 0,数据可读)——这正是 open-unlinked fstat
需要的窗口,也是 readdirplus 缓存安全性的来源。
5.3 open-unlinked 请求矩阵(BUG-5 逐例)
| 内核请求 | 我们的应答路径 | 结果 |
|---|---|---|
GETATTR(nodeid, fh=Some(k)) | handles[k].meta 快照 + nlink=0(现有 attr() 逻辑) | ✅ |
GETATTR(nodeid, fh=None)(内核 fstat 特性,chunk_fs.rs:794 注释确认存在) | get_meta(nodeid) 命中存活记录 + deleted 集合 → nlink=0 | ✅ 不再被 bridge 的 ENOENT 截断 |
READ/WRITE/FSYNC/RELEASE(fh) | 只看 fh → Handle → inode,不看 nodeid 表 | ✅ |
| 未 open 即 unlink | delete_inode + 记录删除;内核无 fd 无 dentry 后不再请求 | ✅ |
attr() 中现有的 engine.get_inode(...).is_none() 兜底(跨节点 unlink)保留。
5.4 内存卫生
maybe_gc 之外,forget 归零但未删除的 inode(正常 unlink 后已删、或未 unlink 只是
dentry 被逐出)→ 顺手 attrs.remove(inode)、dirs.remove(inode)(重新访问时
重查引擎,代价极小)。NodeEntry 本体保留(防内核「迟到」请求),只清缓存。
5.5 异常路径
nlookup>lookup_count:saturating_sub+warn!(内核引用数以我们上送为准, 理论上只会小于等于)。- unmount 时内核不保证发全 FORGET(协议明示):destroy 阶段无需回收,进程退出即释放。
- 崩溃:unlinked-未 GC 的记录 = 现状已容忍的 orphan 块(finish_deferred_gc 的 best-effort 语义,不变)。
6. Rename 语义(BUG-2 / BUG-7 不可成立)
6.1 raw rename 处理流程
#![allow(unused)]
fn main() {
async fn rename(&self, req, parent: u64 /*nodeid*/, name: &OsStr,
new_parent: u64, new_name: &OsStr) -> Result<()> {
// 0. 同名同父 → no-op 成功(POSIX)
if parent == new_parent && name == new_name { return Ok(()); }
// 1. 源存在性
let src = self.engine.lookup(parent, name).await? .ok_or(ENOENT)?;
// 1b. 硬化:目录不能移入自身子树(否则产生环,readdir/路径游走会死循环)
if src.is_dir() && self.is_ancestor(src.id, new_parent).await? { return Err(EINVAL); }
// 2. 目标存在性与类型
let dst = self.engine.lookup(new_parent, new_name).await?;
if let Some(d) = &dst {
if src.is_dir() != d.is_dir() { return Err(if src.is_dir() { ENOTDIR } else { EISDIR }.into()); }
if src.is_dir() {
let kids = self.engine.list_children(d.id).await?;
if !kids.is_empty() { return Err(ENOTEMPTY.into()); }
}
}
// 3. 原子改名(替换目标同一 txn,§9.1)
let replaced = match &dst {
Some(_) => self.engine.rename_replace(src.id, parent, name, new_parent, new_name).await?,
None => { self.engine.rename_inode(src.id, new_parent, new_name).await?; None }
};
// 4. 被替换目标的回收(延迟 or 立即)
if let Some(d) = replaced {
if self.nodes.open_per_inode.get(&d.id).map(|c| c.load()>0).unwrap_or(false)
&& self.engine.supports_deferred_gc() {
self.nodes.mark_deleted(d.id); // 名字已由 txn 删除,等 last release
} else {
if let Some(removed) = self.engine.delete_inode(d.id).await? {
self.gc_blocks(removed.chunks);
}
}
}
// 5. 本地失效(无任何 nodeid 记账 —— 这是与 bridge 的本质区别)
self.invalidate(src.id);
self.dirs.remove(&parent);
self.dirs.remove(&new_parent);
self.touch_parent(parent).await;
self.touch_parent(new_parent).await;
self.mark_stale(new_parent, new_name).await; // 防御层,见 6.4
self.invalidate_kernel_entry(new_parent, new_name).await; // best-effort notify
Ok(())
}
// rename2(renameat2 带 flags):flags==0 → 上面的 rename;
// RENAME_NOREPLACE → dst 存在则 EEXIST(否则走 rename);RENAME_EXCHANGE → ENOSYS。
}
6.2 为什么 BUG-2 不可能
BUG-2 的本质:桥把「新名字」指向了「旧目标 nodeid」,内核 d_move 把「新名字」指向了
「源 nodeid」,随后内核 GETATTR(源nodeid) 被桥解析成已删除的路径 → ENOENT。
R1 后:
- 内核 d_move 后对
t的 dentry 持有源 nodeid;GETATTR(源nodeid)直达get_meta(源id)→ 源 inode 记录完好(改名不改记录存在性)→ 正常应答。 - 不存在任何「nodeid→path」映射,不存在「路径已删」这一层故障。
- 被替换的旧目标由内核 FORGET + 我们的三条件 GC 处理;即使它 open,延迟 GC 保证 记录存活,任何迟到请求可答。
6.3 为什么 BUG-7 不可能
BUG-7 的本质:rename 释放了源 nodeid → 后续 create 在 slab 里复用该 nodeid →
inode_to_names 覆盖 → 旧文件路径解析损坏。
R1 后:rename 不做任何 nodeid 释放(没有 slab);create 从引擎计数器拿全新 id; 引擎 id 永不回收(§4.2)。「释放-复用-覆盖」三步全部失去发生条件。
6.4 stale_names 与 notify 的定位(降级为防御层)
原 stale_names(rename/unlink 后 lookup 返回 ttl=0)是为桥的损坏映射止血而加。
R1 后内核 d_move 已经 unhash 掉旧目标 dentry,正常情况下不再需要。保留但不承担
正确性:代价是一行集合查询;防御对象是「内核在 d_move 前已缓存旧目标 dentry、
且 entry_valid 未过期」这类实现细节。invalidate_kernel_entry(FUSE_NOTIFY_INVAL_ENTRY)
同理,best-effort。真正的跨挂载点 stale 问题归 R5(etcd watch 失效),见 §7.3。
7. kernel_ttl 恢复非 0
7.1 为什么可以恢复
kernel_ttl=0 的语义是「每次访问都让内核重新 LOOKUP」——它强迫桥重建 nodeid→path
映射,是对桥损坏的规避,不是正确性本身。它的代价是每次 stat/open 都多一次
FUSE LOOKUP 往返(HANDOFF 记录 stat 9ms/114ms-create 中部分就来自这里)。
R1 后 inode 层对内核重查的依赖彻底消失:
| 依赖点 | 现状(bridge) | R1(raw inode) |
|---|---|---|
| 内核缓存 dentry → 下次 GETATTR | 桥可能解析到死路径 → ENOENT | nodeid 直达引擎记录,永远可答 |
| rename 后新名字 | 桥映射可能错 | 内核 d_move + 引擎记录,天然一致 |
| unlink 后名字 | 桥若还缓存旧路径 → 静默错 | 名字只在引擎里,LOOKUP 即得真相 |
恢复的充分条件就是「nodeid 即身份」:内核可以信任 cached dentry,因为 nodeid
永远不会骗人;改名/删除的时效性由 entry_valid 内的本地失效(notify + stale_names)
补上。建议 kernel_ttl = 1s(ROADMAP 目标值),attr_ttl/dir_ttl 保持配置分离
(它们只控制进程内缓存)。
7.2 需要同步改动的配置
FsCfg.kernel_ttl默认0 → 1(swiftfuse-cli/src/main.rs:166)。- 节点部署模板
/etc/swiftfuse-etcd.toml里的kernel_ttl = 0→1(scripts/deploy_cluster.sh模板 +scripts/detect/run_gates.sh环境配置)。 - lookup/create/mkdir 的 ReplyEntry
ttl返回cfg.kernel_ttl(fuse3 单 ttl 同时 驱动 entry_valid 与 attr_valid,见raw/reply.rs:113);进程内attrs缓存仍用attr_ttl,不冲突。
7.3 边界(诚实声明)
kernel_ttl>0 的正确性在单挂载点内成立。跨挂载点(另一节点 rename/unlink 后,
本节点内核还持有 positive dentry,entry_valid 未过期 → 可能对已删 nodeid 发 GETATTR
→ ENOENT,或对已改名 nodeid 读到旧 attrs)是 R5(cache coherence) 的范畴:
需要 etcd watch 失效或 version 校验。R1 的 G6 重跑在 §10.4 处理这一边界。
8. 迁移策略(从 PathFilesystem 到新边界)
8.1 决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| vendored fuse3 bridge | 保留(chunk 模式不再使用 path::Session,但 raw 模式 SwiftFs 仍走 bridge) | patch 已稳定且对 raw 模式无副作用;回退上游是独立清理项,R1 不动 |
| chunk 模式会话 | fuse3::path::Session → fuse3::raw::Session(main.rs 一处) | 完全绕开 InodePathBridge |
| ChunkFs 改动量 | 中等:删 path 机制(~120 行),加 nodes 字段与 maybe_gc,attr_of 换 raw FileAttr 类型,children_meta 条目换 raw DirectoryEntry | 数据面/句柄/压缩逻辑零改动 |
| 新代码 | inode_fs.rs 一个文件(~400 行) | 所有 raw 翻译集中一处,便于 review |
| 引擎 | trait 加 rename_replace(默认两段式)+ etcd 单 txn override + sqlite id 单调 | §9 |
8.2 落地顺序(每步可编译可回滚)
swift-meta:sqlite id 单调(§9.2)→ trait 加rename_replace默认实现 → etcd override。engine_test.rs/etcd_test.rs补 rename_replace 用例。inode_fs.rs:NodeTable + raw impl(此时 ChunkFs 仍实现 PathFilesystem,双 impl Rust 允许,仅PathFilesystem::lookup等需要路径签名的旧方法死代码暂留)。chunk_fs.rs:删 path 机制与 PathFilesystem impl;attr_of/children_meta换 raw 类型。main.rschunk 分支切raw::Session;配置默认 kernel_ttl=1。- 全量测试(§10)+ 节点部署。
步骤 2/3 之间「死代码暂留」是为了让迁移中间态可编译;合并时一次删净。
8.3 测试代码适配
tests/chunk_fs_test.rs 目前以 PathFilesystem trait 方法驱动。新增
pub(crate) mod test_driver(或测试私有助手):把 (path→inode) 的 walk 用一个
内存 HashMap<path, inode> 模拟(测试专用,不进生产路径),把 raw 方法包装成与
现在相同的调用形态 —— 减少既有用例改动。测试驱动永远经过 raw impl,
这样才能测到 FORGET/GC/rename 的真实语义。
9. 引擎层改动
9.1 原子 rename_replace(新 trait 方法)
动机:现状的「remove_file_entry(dst) + rename_inode(src)」是两个独立 txn,
中间窗口内另一客户端可在 dst 名字上 create → rename_inode 的
Compare::version(new_name, Equal, 0) 失败 → rename 报错(多客户端不原子)。
R1 单 txn 化,让 rename 语义在并发下也正确(R2/R6 之前就能自圆)。
#![allow(unused)]
fn main() {
// engine.rs trait(默认实现 = 现有两段式,sqlite 零成本获得正确行为)
async fn rename_replace(&self, src_id: u64, src_parent: u64, src_name: &str,
dst_parent: u64, dst_name: &str)
-> Result<Option<InodeMeta>, MetaError> {
let dst = self.lookup(dst_parent, dst_name).await?;
if let Some(d) = &dst {
self.unlink_name(dst_parent, dst_name, d.id).await?; // 删名字;sqlite 默认 = 连记录删
}
self.rename_inode(src_id, dst_parent, dst_name).await?;
Ok(dst)
}
}
etcd override(etcd.rs,单个 txn):
when:
names/{src_parent}/{src_name} == value(src_id) // 源名仍指向源
names/{dst_parent}/{dst_name} == value(dst_id) // 目标名仍指向我们读到的目标
inodes/{src_id} == mod_revision(rev) // 源记录未被并发修改
then:
delete names/{src_parent}/{src_name}
delete names/{dst_parent}/{dst_name}
put names/{dst_parent}/{dst_name} = src_id
put inodes/{src_id}(parent=dst_parent, name=dst_name, version+1)
返回(读好的)dst 记录 → 调用方决定立即 GC 或延迟 GC
语义契约:只保证名字侧原子性;目标 inode 记录的删除仍是调用方步骤
(延迟 GC 需要记录存活,因此不能在 txn 里删记录)。任何 when 失败 → 整 txn 回滚 →
rename 返回错误(调用方不需要补偿)。
9.2 sqlite id 单调性修复(R1 不变量前提,§4.2)
sqlite.rs:59 的 id INTEGER PRIMARY KEY(rowid 别名)在删除最大行后可复用 rowid。
改法(二选一,推荐前者):
id INTEGER PRIMARY KEY AUTOINCREMENT+open()时迁移已有表 (AUTOINCREMENT 保证新 id > 历史 max(id),天然单调不复用)。- 沿用现有
counters表做next_inode_id分配(与 etcd 同构)。
结论不依赖选型:只要新 id 严格大于历史最大 id,nodeid 不变量即成立。
9.3 supports_deferred_gc 语义不变
etcd = true(unlink_name 只删名字);sqlite = false(默认 unlink_name 连记录删,
调用方立即 GC)。R1 不改此契约;sqlite 的 open-unlinked fstat 仍受限(dev 引擎,
如实记录 KNOWN DEVIATION,生产路径是 etcd)。
10. 测试计划(映射 R1 门禁)
10.1 G1 — pjdfstest(含 unlink/14)
- 在挂载点内
prove -r /opt/detect/src/pjdfstest/tests,kernel_ttl=1。 - 判定:
unlink/14(open→unlink→fstat 存活)必须 PASS(现状唯一真实偏差)。 新增断言:unlink 后fstat返回合法 attr 且nlink==0;read/write继续可用;close后 GC(etcd 侧 inode 记录消失)。 - 预期保留的失败:symlink/link/mkfifo/mknod → ENOSYS 级联(声明限制,非回归)。
utimensat/05(symlink)同前。
10.2 rename torture 100k+(新脚本 scripts/detect/rename_torture.sh)
分三阶段,全部 kernel_ttl=1:
- 确定性阶段:
echo B>t; echo A>s; mv s t; cat t循环 100k 次, 每次断言内容 == A;交错chmod 700 t、stat t、readdir。快速回归 BUG-2 形态。 - 随机阶段:N 个并发线程随机 op
{create, write, unlink, rename, mkdir, rmdir, readdir, chmod, stat, read}; 内存 shadow model 记录「路径 → 内容/存在性」;每轮后断言:- model 中存在的路径,FS 解析必成功(无 ENOENT);
- readdir 条目 == model(无幽灵/重复条目);
- 覆盖语义:
mv s t(t 存在)后t内容 ==s; - rename 后对源名的访问必须 ENOENT;对目标名必须成功。
- open-fd 交错阶段:open s(持 fd)→ mv s t → 通过 fd 读/写/fsync → close; 断言 fd 数据完整、无 ENOENT;close 后目标名 t 正常。验证三条件 GC 时机。
判定:0 个「model 说成功而 FS 报 ENOENT」;100k+ ops 全程无 panic/无悬挂请求。
10.3 G4/G5 — fsx / fsstress / fio verify
- 沿用
run_gates.sh的现有命令(fsx -N 2000、fsstress -n 200 -p 4、fio verify),唯一改动:kernel_ttl=1。 - fsx 增加
-F(fallocate 相关非必需,保持现状即可);fio 保持 crc32c verify- SIGKILL/remount 持久性验证(G4b 原样复跑,证明 GC 三条件不影响 crash 语义)。
10.4 kernel_ttl=1 下 G1/G6 重跑
- G1:10.1 本身就是 kernel_ttl=1 跑法。
- G6(多客户端并发 harness,HANDOFF 已修断言为单调不减):以 kernel_ttl=1 复跑。 单挂载点内所有断言必须全绿(本地失效覆盖 rename/unlink/create 的可见性)。 跨挂载点 stale 窗口(另一节点改名后本节点 cached dentry 未过期)属 R5; 若 G6 harness 触发此类失败,判定策略见 §11 Q1 —— 不把 R5 工作提前塞进 R1, 但如实记录。
- 新增nodeid 复用审计:脚本在 torture 前后
etcdctl get --prefix {prefix}/inodes导出全部 id,断言max(id)单调、且同一 id 不同时存在两个记录(混叠即失败)。
10.5 单元/集成测试(swift-fuse/tests/chunk_fs_test.rs 适配 + 新增)
lookup→getattr往返:返回的 ino == 引擎 id。- FORGET 记账:
lookup×2 + forget(1)→ 引用仍 >0;再forget(1)→ 归零。 - open-unlinked:open → unlink →
getattr(inode, None)与getattr(inode, Some(fh))均返回 nlink=0;release 后get_inode为 None(etcd 引擎);maybe_gc三条件逐条验证。 - rename-over-existing:rename 后
lookup(dst)返回源 id;旧目标 id 在 forget+release 后被 GC;rename_replace冲突路径(目标名被并发占用 → 错误且 src/dst 均不变)。 - nodeid 不变量:unlink+create 循环后新 create 的 id 严格大于历史 max(跨引擎断言)。
- batch_forget:一次多 inode 归零全部触发 GC。
- 集成(可选):
raw::Session挂载 loopback 起 mount,跑 10.2 阶段 1 的 1000 次子集。
11. Open Questions(实现期需要拍板/观察的点)
- Q1(G6 × kernel_ttl=1 边界):跨挂载点的 cached-dentry stale 窗口在 R1 的 G6 判定中如何界定?建议:G6 的 rename/unlink/create 断言限定在「发起节点自己的挂载内 可见性」,跨节点立刻可见性归 R5(etcd watch);若 harness 在 kernel_ttl=1 下出现 跨节点 stale 失败,判定为 KNOWN DEVIATION(R5 前)而非 R1 回归。是否把 watch 失效 前置到 R1,等实现期 G6 复跑结果再定。
- Q2(sqlite id 单调选型):
AUTOINCREMENT迁移 vscounters表分配。 前者改动最小但需要 schema 迁移(现有库在节点上是 etcd,sqlite 只在测试); 建议 AUTOINCREMENT +open()内一次性max(id)续起校验。 - Q3(stale_names/notify 去留):设计定「保留降级为防御层」。若 10.2 随机阶段 在去掉 stale_names 后仍 0 stale,可再删 —— 依赖测试证据,先不删。
- Q4(generation):
ReplyEntry.generation/DirectoryEntryPlus.generation一律 0。若未来做 nodeid 压缩或 id 复用才需要 generation 语义;当前不启用。 - Q5(rename2 RENAME_EXCHANGE):返回 ENOSYS 是否可被 fstests/fsstress 接受? 现路径 impl 未实现 renameat2,行为不变;观察 G2/G3 是否触及,不预实现。
- Q6(dir rename 祖先环检查):§6.1 的 EINVAL 检查是硬化项;确认 pjdfstest rename 系用例(move dir into own subtree)在 R1 后全过,若现有测试未覆盖则补一条。
附:与 ROADMAP/HANDOFF 的对应
| 本设计章节 | 对应的 ROADMAP/HANDOFF 目标 |
|---|---|
| §4 nodeid 不复用 | ROADMAP R1 目标 3(rename/nodeid/FORGET 生命周期正确) |
| §5 FORGET + 延迟 GC | HANDOFF P1(BUG-5 未完成部分)、ROADMAP R1 目标 2 |
| §6 rename 语义 | HANDOFF BUG-2(bridge 损坏)与 BUG-7(nodeid 释放) |
| §7 kernel_ttl | ROADMAP R1 目标 4、HANDOFF §8 kernel_ttl=0 是缓解手段 |
| §10 测试 | ROADMAP R1 Gate(pjdfstest unlink/14 / rename 100k+ / fsx / kernel_ttl=1 G1/G6) |