[RFC] 关于 OpenViking 支持存储后端多写的设计方案/OpenViking supports a multi-write design for its storage backend. #2430
baojun-zhang
started this conversation in
RFC
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
本文档为双语版本(中文 + English)。中文版在前,英文版在后。
This document is bilingual (Chinese + English). The Chinese version comes first, and the English version follows.
中文版 / Chinese Version
概述
本文主要讨论 OpenViking 多存储后端的具体实现, 功能包括多 backend 配置支持, primary/backup 角色设定支持, 文件重定向支持,文件排除能力支持,加密配置调整,数据同/异步策略支持。
背景
场景
Primary/Backup 同构
Primary/Backup 同构指 ov 配置的 primary/backup 数据源为相同类型,比如都为 localfs,Primary/Backup 同构主要可用于如下场景
数据高可用
双写/多写作为备份策略的一种, 相当于连续数据保护(CDP),副存储实时拥有主存储的完整副本,Openviking 通过自身就可以保证数据的高可用性,RPO 趋近于零。
多AZ区域路由优先
当实例部署在不同的 AZ ,不同 AZ 的网络延迟会极大的影响到整体请求响应,通过多 AZ backup 节点,可以提高请求响应能力。
Primary/Backup 异构
数据迁移
当需要把数据从旧系统迁移到新系统,仅仅使用 ovpack 会丢失部分实时数据,而通过双写+ovpack 方式,可以达到零丢失。
异构数据服务
同一份数据存储到不同形态的存储中,可提供更丰富的能力,比如:
术语定义
backend:由 backend plugin registry 根据backend+params初始化得到的底层句柄(如localfs、s3fs、memfs、kvfs)。它不感知加密,仅负责纯字节读写;只允许作为 wrapper 的构造输入,不对外暴露,也禁止用它直接写入业务数据/元数据。primary_backend:ragfs 内部对 primary 的写入句柄(上层不可见)。全局加密开启时为EncryptionWrappedFS(primary),否则为primary。MultiWriteWrappedFS 在写入业务文件与.redirect.json/.sync_log.json系统元数据时都通过它,从而保证 primary 命名空间下的加密语义一致。上层(viking_fs / Python)只感知统一的FileSystemtrait,不感知 primary/backup。backup_backend:ragfs 内部对 backup 的写入句柄(上层不可见)。全局加密开启且该 backup 自身encryption.enabled=true时为EncryptionWrappedFS(backup),否则为backup。元数据旁路直写(负面示例):指保留一个未加密的
primary句柄(即跳过EncryptionWrappedFS),直接明文写.redirect.json/.sync_log.json的做法。这是错误路径——会造成同一 primary 目录下「业务文件加密 + 系统文件明文」的安全语义退化,必须禁止。配置结构
现有配置模式:
双写/多写模式配置模式:
关键方案
多 Backend 配置
兼容模式
backups与原有backend-map结构共存:backups为空或不存在 → 保持现有单 backend 逻辑不变backups不为空 → 顶层backend为 primary,backups.items为 backup字段定义
primary(顶层
backend)在现有AGFSConfig结构基础上新增如下字段。nameprimaryencryptionoperationsredirectsbackup 节点(
backups.items)每个 item 直接复用AGFSConfig,并额外增加多写相关字段, 新增字段如下:name.sync_log.json/.redirect.json中引用encryptionoperationsexcludesbackups本身的容器字段包含:sync_typeasync/syncwrite_ack_countwrite_ack_timeout_mswrite_concurrencyitemsAGFSConfig并增加多写字段配置示例
{ "agfs": { "backend": "local", "timeout": 10, "encryption": { "enabled": true }, "redirects": [ { "type": "FileExtensionPolicy", "extensions": ["(pdf|ppt)"], "target": ["s3-backup"] } ], "backups": { "sync_type": "async", "write_ack_count": 1, "write_ack_timeout_ms": 5000, "write_concurrency": 8, "items": [ { "name": "s3-backup", "backend": "s3", "timeout": 10, "queuefs": { "backend": "sqlite" }, "s3": { "bucket": "test-zbj-backup", "region": "cn-beijing", "access_key": "xxx", "secret_key": "xxx", "endpoint": "http://tos-s3-cn-beijing.volces.com", "prefix": "backup", "use_ssl": false, "use_path_style": false, "directory_marker_mode": "nonempty" }, "encryption": { "enabled": false }, "excludes": [ { "type": "FileOverSizePolicy", "max_size_mb": 1024 } ], "operations": [{"operation": "read", "priority": 100}] } ] } } }Primary/Backup 区分
通过配置位置区分 primary/backup,不使用
role字段:backend/s3/local等backups.items[]文件重定向 (redirects)
文件重定向指某些符合规则的文件可以写入到指定的 backend,而不是写到 primary,比如 PDF/PPT 等特殊格式的文件,或者超过一定 size 的文件。
适用场景
primary 可配置
redirects,将特定文件不写入 primary,而写入指定的 backup backend。支持策略
FileOverSizePolicy{"type": "FileOverSizePolicy", "max_size_mb": 100, "target": ["s3-backup"]}FileExtensionPolicy配置示例
{ "agfs": { "backend": "local", "redirects": [ { "type": "FileOverSizePolicy", "max_size_mb": 100, "target": ["s3-backup"] }, { "type": "FileExtensionPolicy", "extensions": ["(pdf|ppt)"], "target": ["s3-backup"]} ], "backups": { "sync_type": "async", "items": [...] } } }.redirect.json 元数据
redirect 写入后,在 primary 对应资源所在目录下生成
.redirect.json,记录文件与目标 backend name 的映射。某个目录下如果存在 redirect 文件则生成一份。.redirect.json内容:{ "version": 1, "entries": { "large_pdf.pdf": { "targets": ["s3-backup", "xxx-backup"] } } }.redirect.json仅存在于 primary,不双写到 backup;primary 启用加密时,该文件也必须经primary_backend加密落盘.redirect.json.redirect.json加入_INTERNAL_NAMES,对用户不可见.redirect.json与.sync_log.json必须串行更新:由MetaStateStore的目录级互斥锁保护,在锁内同时读取/合并两份元数据、再原子写回。禁止在未持锁的情况下先读后写(裸read_meta → write_meta),否则高频并发写会覆盖掉对方的 entry。large_pdf.pdf→ 要在.redirect.json里新增一条large_pdf.pdf → [s3-backup];线程 B 同时写report.doc→ 要在.sync_log.json里latest_seq++。如果不串行化,B 读到的latest_seq可能是旧值,写回后 A 刚写入的 redirect entry 就会被覆盖丢。.redirect.json/.sync_log.json的读写都必须通过MetaStateStore::update_dir_meta(dir, ctx, |redirect_meta, sync_meta| { ... })完成。内部由dir_locks[dir]保证同一目录下的两份元数据不会被并发修改。redirect 写入流程
redirect 命中后,写入目标从
primary + write_backups切换为targets,但同步状态机不变:.sync_log.json仍然记录这次写入的latest_seq,并为targets中的每个 backup 维护acked_seqsync模式下仍按write_ack_count/write_ack_timeout_ms计算确认;async模式下仍由后台任务推进acked_seq.redirect.json而不落.sync_log.json;必须保留未追平状态,由retry_loop继续补偿文件排除 (excludes)
适用场景
仅 backup 节点可配置
excludes,指定哪些文件不写入当前 backup 节点。比如将 memfs/kvfs 作为 backup 节点,以提供系统响应速度。策略类型
与 redirects 一致,支持
FileOverSizePolicy、FileExtensionPolicy等。配置示例
{ "name": "memfs-cache", "backend": "memfs", "excludes": [ { "type": "FileOverSizePolicy", "max_size_mb": 500 }, { "type": "FileExtensionPolicy", "extensions": [".mp4"] } ] }与 redirects 冲突
如果 primary 配置 redirect 到 backend A,但 backend A 又 exclude 了该文件,属于用户自身配置冲突,本方案不兜底(该文件将不会被写入任何 backend)。
加密配置 (encryption)
决策逻辑
双写场景下的加密
允许 primary 和 backup 使用不同的加密策略:
各 backend 使用统一密钥管理,具体加密/解密只由 Rust
EncryptionWrappedFS完成。多写实现需把EncryptionWrappedFS作为可复用的 per-backend 数据 wrapper 使用:primary_backend:当全局encryption.enabled=true时为EncryptionWrappedFS(primary),否则为primary;primary 不允许单独关闭加密。backup_backend:当全局encryption.enabled=true且该 backup 自身encryption.enabled=true时为EncryptionWrappedFS(backup),否则为backup。.redirect.json/.sync_log.json存在于 primary backend,属于 primary 命名空间中的系统文件,必须通过primary_backend读写,从而继承 primary 的加密配置。primary去写.redirect.json/.sync_log.json)。否则 primary 开启加密时,同一 backend 内会同时存在加密业务文件和明文系统状态文件,造成安全语义退化。encrypt迁移.md/ 代码里的Stats(Encryption(Mountable))全局栈需要演进为Stats(MultiWriteWrappedFS),由MultiWriteWrappedFS内部为 primary/backup 分别持有primary_backend/backup_backend。EncryptionWrappedFS的实现不变,只调整组装位置。内部文件过滤
_INTERNAL_NAMES 扩展
原有
_INTERNAL_NAMES列表需新增以下文件,使其对用户不可见:过滤位置
在 VikingFS 的
ls、tree、glob等目录遍历方法中统一过滤。这些文件对用户透明,但MultiWriteWrappedFS内部可直接读写。若 primary 开启加密,文件名仍按上述规则隐藏,文件内容由primary_backend加密。操作路由 (operations)
操作类型
双写按 RAGFS 的真实方法分类。
write,mkdir,remove,remove_all,rename,create,chmod,truncate,ensure_parent_dirsread,stat,exists,read_dir,grep优先级规则
每个 backup 节点可通过
operations字段声明支持的操作类型及其优先级:{ "name": "kvfs-cache", "backend": "kvfs", "operations": [ {"operation": "read", "priority": 50} ] }operationsoperationsreadwrite/缺省read不声明write则只读不双写(如 kvfs 纯读缓存)位置约束
backend对应的 primary 总是写入第一站,也是读路由的最终兜底,不配置operationsbackups.items中的 backup 节点可配置operations的 read/write 优先级;缺省时仅参与 write(不进入读路由),需要读加速须显式声明read读操作路由(回退链路)
读操作按以下顺序尝试,找到即返回:
sequenceDiagram participant Client participant MultiWrite participant Backup as Backup (read-enabled) participant Primary participant Redirect as .redirect.json Client->>MultiWrite: read(path) MultiWrite->>Backup: read(path) Backup-->>MultiWrite: NotFound MultiWrite->>Primary: read(path) Primary-->>MultiWrite: NotFound MultiWrite->>Primary: read(.redirect.json) Primary-->>MultiWrite: targets=["s3-backup"] MultiWrite->>Redirect: read(path) (s3-backup) Redirect-->>MultiWrite: data MultiWrite-->>Client: data写操作路由
所有写操作一律先写 primary,再根据
sync_type同步/异步写入所有可写 backup 节点:sequenceDiagram participant Client participant MultiWrite participant Primary participant Backup as Backup (write-enabled) Client->>MultiWrite: write(data) MultiWrite->>Primary: write(data) Primary-->>MultiWrite: OK Note over MultiWrite: sync 模式: 等所有 backup 节点 MultiWrite->>Backup: write(data) Backup-->>MultiWrite: OK MultiWrite-->>Client: OK目录类操作路由
read_dir.redirect.json中本目录的 redirect 文件名ls看不到自己写入的文件(语义退化)grepstat/existsPython 层高阶 API (
ls/tree/glob) 内部组合read_dir等 trait 方法,自动落入 MultiWriteWrappedFS 的 primary 路由。文件同步日志 (.sync_log.json)
定位
.sync_log.json是记录 backup 节点同步进度的元数据文件,sync / async 模式都使用它表达“latest_seq 与各 backup acked_seq 是否追平”。它记录的不是“某个文件是否曾经同步过”,而是“当前路径的最新版本号,以及各 backup 已追到哪个版本号”。
存储规则
.sync_log.json;当前不做分片_INTERNAL_NAMES,对用户不可见primary_backend加密落盘内容结构
{ "entries": { "file.txt": { "latest_seq": 12, "last_op": "write", "backends": { "memfs-cache": { "acked_seq": 12 }, "local-az2": { "acked_seq": 11 } } }, "report.pdf": { "latest_seq": 3, "last_op": "remove", "backends": { "memfs-cache": { "acked_seq": 3 } } } } }字段语义:
entries的 key 使用当前目录下的文件名。.sync_log.json本身就是目录级元数据,目录路径由该文件所在位置隐含提供,不再重复存完整urilatest_seq:当前路径最新一次成功写入 primary 的单调递增版本号;判断同步完成以它为准last_op:该latest_seq对应的操作类型(write/mkdir/remove/remove_all/rename/chmod/truncate)。retry_loop 据此决定如何在落后 backup 上重放:write/truncate回源 primary(或 redirect target)读最新内容重写;remove/remove_all重放删除;mkdir/chmod重放对应操作;rename额外在rename_to记录目标 uri。无此字段则 retry 无法重放删除/重命名等非内容类操作(语义退化)。rename_to(仅last_op==rename时存在):重命名目标的规范化uri。之所以保留完整路径,是因为 rename 可能跨目录backends.{name}.acked_seq:该 backup 已确认成功应用到的最新版本号backends中的 key 使用当前配置中的 backendname作为身份标识同步完成条件:
生命周期
手动同步接口
提供命令行工具供运维手动触发同步状态检查和修复:
并发安全
.sync_log.json/.redirect.json都是目录级 JSON 元数据,读-改-写操作存在并发竞争风险(前台写、多个tokio::spawnack 更新、retry_loop、手动 sync-retry 可能同时修改同一目录)。统一 MetaStateStore
这里开始进入具体实现约束:
MetaStateStore:MultiWriteWrappedFS内部的目录级元数据管理器,负责.redirect.json与.sync_log.json的统一读-改-写,并持有目录锁 / 路径串行化队列。FsContextResolver:后台任务使用的上下文恢复 helper;当retry_loop、backfill、system_sync_retry没有前台请求上下文时,由它从规范化路径恢复account_id,再进入FS_CTX.scope(ctx, ...)执行加密读写。每次更新
.redirect.json或.sync_log.json时,必须先获取对应目录的锁,并在同一临界区内完成两个文件的 read-modify-write。redirect 写入场景下,.redirect.json的 entries 更新与.sync_log.json的latest_seq更新必须同锁提交,避免"数据已写到 redirect target,但 redirect 映射或 sync 版本丢失"。另外,为避免同一路径的多个写操作在 backup 侧乱序生效,必须配合 per-path 串行化队列。
.sync_log.json负责记录“每个 backup 已确认到哪个版本号”,per-path FIFO 负责保证版本不会倒退。对于
rename,若源目录与目标目录不同,单目录锁不够,必须升级为双目录锁:.redirect.json与.sync_log.jsonsource_dir == target_dir,退化为单目录锁rename_to仅用于记录目标uri;真正的元数据一致性由双目录锁保障,而不是依赖后台重试兜底同步模式 (sync_type)
OpenViking 通过
sync_type支持设置不同一致性策略:两种模式
asyncsync同步写 (sync)
数据流:
时序图:
sequenceDiagram participant Client participant MultiWrite participant Primary participant Backup1 participant Backup2 Client->>MultiWrite: write(data) MultiWrite->>Primary: write(data) Primary-->>MultiWrite: OK MultiWrite->>Backup1: write(data) Backup1-->>MultiWrite: OK MultiWrite->>Backup2: write(data) Backup2-->>MultiWrite: OK MultiWrite-->>Client: OK同步模式 quorum 支持
当前设计:sync 模式必须等所有 backup 节点写完才返回。一个慢 backup 节点会拖垮整体延迟。增加最小确认数和超时:
{ "backups": { "sync_type": "sync", "write_ack_count": 2, "write_ack_timeout_ms": 5000 } }write_ack_countwrite_ack_timeout_msNote
风险与失败语义:
write_ack_count+write_ack_timeout_ms缓解:达到 quorum 即返回).sync_log.json已记录latest_seq,未追平的 backup 由 retry_loop 持续补偿至最终一致。即"客户端看到失败,但系统保证最终把数据同步到位",需在 API 文档明确该语义,避免调用方误判数据丢失。异步写 (async)
数据流:
时序图:
Note
风险:
异步写入并发控制
当前不限制异步写入的并发数。如果未来出现 backup 节点写入速度慢于调用方写入速度的场景(如 primary=localfs,backup=S3),可通过
write_concurrency配置启用 tokio Semaphore 限流:异步写入顺序保证(per-path 串行化)
异步双写下,同一文件路径的多次操作(如 write → remove → write)在 backup 节点上可能乱序执行,导致最终数据不一致。解决方案:按路径维护 FIFO 队列,同一路径的操作在 backup 节点上顺序执行。
读路径一致性子句
异步模式下,删除操作在 backup 节点上可能延迟执行。读请求可能命中尚未删除的旧数据:
失败重试机制
同步双写场景下,backup 节点写入可能因网络抖动等临时故障而超时失败(quorum 满足即返回客户端成功,但未达 ack 的 backup 仍需补偿)。
异步双写下,
tokio::spawn的 backup 节点写入可能因网络抖动等临时故障而失败。两种模式都通过
.sync_log.json落地"未追平"状态:latest_seq,spawn 成功才更新acked_seq。write_ack_count即返回;写 primary 时同步更新latest_seq,对未确认/超时/失败的 backup 不更新其acked_seq(保持落后),交由 retry_loop 补偿。因此 retry_loop 在两种模式下都需运行。只要存在 write-enabled backup,
new()即启动 retry_loop(不再限定 async)。参见
MultiWriteWrappedFS 结构一节:new()中if !inner.write_backends().is_empty() { tokio::spawn(Inner::retry_loop(...)) }。参数定义:
.sync_log.json独立任务队列
.sync_log.json本身就是持久化的记录。不需要引入 RabbitMQ/Kafka 等外部任务队列。数据流:具体实现
Encrypt 能力下沉到 ragfs 层,双写能力在 ragfs 层实现,python 层只负责配置传递。
每层职责:
_ensure_access)、向量索引同步、锁管理。配置传递给 Rust 侧。backups.items列表,管理读写路由、.sync_log.json、.redirect.json;内部元数据也通过primary_backend读写。配置透传(FFI)
核对现状代码:
PluginConfig仅{name, mount_path, params: HashMap<String, ConfigValue>};ConfigValue只有String / Int / Bool / StringList,无嵌套 dict / list。v.str()的 repr 字符串。configdict。/local,无backups组装。因此
backups嵌套结构无法经现有通道传入。最小改造(推荐,复用现有 JSON 能力):ConfigValue::Json(serde_json::Value)变体,并让py_dict_to_config对 dict/list 递归转为serde_json::Value(lib.rs已有serde_json_to_py反向转换可复用,只需补正向py_any_to_json)。这样无需为每个嵌套字段定义 PyO3 提取逻辑,backups整体以一个 JSON 值透传。PluginConfig新增字段:BackendsConfig由serde_json::from_value反序列化(mount()识别params["backups"]或单独参数),避免逐字段 PyO3 提取。agfs_utils.py把agfs.backups(含每个 item 的 plugin 子参数)原样塞进 mount 的 config dict;backend/s3/local顶层参数继续作为 primary 的params。MountableFS::mount() 集成
mount()中检测config.backups:为空走现状单 backend 逻辑(零改动);非空则调用新增私有方法build_multi_write_fs()构建MultiWriteWrappedFS,外层仍包StatsWrappedFS(保持 metrics 一致)。多写挂载内的 primary/backup 不再挂回同一个MountableFS,而是由 registry 初始化 backend 后按配置包装为BackendEntry::backend,避免全局 Encryption wrapper 与多写调度顺序相互绕过。build_multi_write_fs()步骤:这里开始进入 backend 组装细节:
BackendEntry是MultiWriteWrappedFS内部保存的 backend 描述结构,字段包括name、role(Primary/Backup)、backend、operations(仅 backup 允许)、excludes。backend/params经 registry 初始化 primary(即未加密的底层句柄);跟随全局server.encryption.enabled决定是否包装为EncryptionWrappedFS(primary),结果记为primary_backend。primary 不允许关闭加密。bc.items:各自经 registry 初始化 backup(即未加密的底层句柄);按server.encryption.enabled && item.encryption.enabled决定是否包装为EncryptionWrappedFS(backup),结果记为backup_backend。item.name全局唯一;primary 不接受operations(否则报错);redirects.target/excludes引用的 name 必须存在。BackendEntry { name, backend, role, operations, excludes },组装为MultiWriteWrappedFS。内部元数据文件必须使用primary_backend,不得保存未加密的 primary 句柄作为元数据旁路直写入口。MultiWriteWrappedFS 结构(Arc 固化)
代码复用抽象(写/读/策略三处去重)
1. 写 fanout 骨架
fanout_write—— 所有写操作复用:2. 读 backend 解析
resolve_read_backend—— read/stat/exists 复用回退链路:3. 文件策略
FilePolicytrait —— redirects 与 excludes 同源:4. 元数据 json 编解码复用 ——
.redirect.json/.sync_log.json均为 JSON(符合"系统内部状态用 JSON"约束),共用MetaStateStore::read_meta::<T>() / write_meta::<T>() / update_dir_meta()泛型 helper(基于primary_backend+ serde_json),不重复手写解析。primary 开启加密时 helper 读写的是加密信封,反序列化前由EncryptionWrappedFS解密;primary 未开启加密时 helper 直接读写明文 JSON。5. FsContext 快照复用 —— 所有后台任务必须显式携带或恢复 ctx,避免重复散落
FS_CTX.scope:写路径完整流程(含 redirect / exclude)
文件清单
crates/ragfs/src/core/multiwrite_wrapper.rsMultiWriteWrappedFS+Inner+BackendEntry::backend+fanout_write/resolve_read_backend/FilePolicycrates/ragfs/src/core/multiwrite_meta.rsMetaStateStore+MetaLockProvider+ encrypted JSON 元数据 helper + ctx 恢复crates/ragfs/src/core/types.rsConfigValue::Json;PluginConfig新增 backups/encryption 字段;BackendsConfig等crates/ragfs/src/core/mountable.rsmount()识别 backups;新增build_multi_write_fs();multiwrite 挂载需直接初始化 backend entry,不通过全局 Encryption(Mountable) 绕行crates/ragfs/src/core/mod.rspub mod multiwrite_wrapper; pub mod multiwrite_meta;crates/ragfs/src/core/filesystem.rscrates/ragfs-python/src/lib.rspy_dict_to_config支持嵌套;mount()透传 backups;新增system_sync_status(path, ctx)/system_sync_retry(path, ctx)openviking/utils/agfs_utils.pyagfs.backups下传 mountopenviking/storage/viking_fs.py_INTERNAL_NAMES增.sync_log.json/.redirect.json后续可扩展项
多进程 / 多实例边界
本期不实现跨进程 / 多实例的并发安全协调。当前方案中的
tokio::sync::Mutex只能保证单进程内安全;如果未来部署形态允许多个 RAGFS 进程同时写同一 primary mount,需要补齐独立的MetaLockProvider,例如 backend-native conditional write / Redis / etcd。约束要求:
写放大风险
本期不实现目录级元数据写放大优化。当前设计每写一个文件就会重写所在目录的
.redirect.json/.sync_log.json,在热点目录下可能成为瓶颈。后续可选优化方向:
MetaStateStore的管控下)。Wrapper 分组调度
当前方案直接在
MultiWriteWrappedFS内部持有primary_backend/backup_backend。如果后续需要进一步优化 “per-backend wrapper 组装” 的重复开销,可以考虑引入GroupedWrappedFS,但本次不实现。核心思路:
GroupedWrappedFS负责按稳定的“分组特征”对请求分类,然后为不同分组挂接不同的后续 wrapper 链。StatsWrappedFS -> GroupedWrappedFS -> EncryptionWrappedFS -> MultiWriteWrappedFSStatsWrappedFS -> GroupedWrappedFS -> MultiWriteWrappedFSEncryptionWrappedFS -> MultiWriteWrappedFS,明文组直接走MultiWriteWrappedFS,从而避免当前按 backend 逐个决定后续 wrapper 的心智负担。这个抽象不只适用于多写,也可扩展到单写场景:例如按配置 / 路径 / 能力标签为不同请求加载不同的 wrapper 组合,而不是把是否挂某个 wrapper 固化在全局挂载链上。
实现前提与边界:
GroupedWrappedFS的分组条件必须是稳定且可重放的;写路径如何分组,读路径也必须能用同样规则命中同一条 wrapper 链,否则会出现“写时走加密链、读时走明文链”的语义错乱。StatsWrappedFS统计总耗时,GroupedWrappedFS可额外暴露各分组命中次数,EncryptionWrappedFS继续承担加密链路的性能统计。build_multi_write_fs()的职责边界:它不再直接组装最终 wrapper,而是产出分组规则与每组对应的下游链。结论:
GroupedWrappedFS是一个可实现的后续优化方向,而且比“per-backend 分别决定 wrapper”更通用;但它会显著抬高挂载编排复杂度,只有在确认当前多写 + 加密的组合路径已经成为主要复杂度来源时,才值得引入。可观测性
MultiWriteWrappedFS应通过StatsWrappedFS暴露 metrics:.sync_log.json体积健康检查
在
openviking/server/routers/system.py::checks["agfs"]中加入 multiwrite 健康度(primary / 各 backup 节点可用性)。测试矩阵
实现后至少覆盖:
.redirect.json/.sync_log.json落盘为OVE1信封;primary 明文时为 JSON;加密 primary + 不加密 backup 下读写往返;retry_loop 通过路径恢复 ctx 后可读取加密 sync logls/grep可见且内容正确;exclude 文件不写入对应 backup;同名多 backup(多 AZ)下.sync_log.json按name区分;.redirect.json与.sync_log.json同目录并发更新不丢 entryEnglish Version / 英文版
Overview
This document mainly discusses the concrete implementation of multiple storage backends in OpenViking. The features include support for multiple backend configurations, support for primary/backup role assignment, support for file redirection, support for file exclusion, encryption configuration adjustments, and support for synchronous/asynchronous data strategies.
Background
localfs,s3fs,kvfs,memfs, etc.viking_fslayer of OpenViking supports multiple operations on resources.Scenarios
Primary/Backup Homogeneous
Primary/Backup homogeneous means that the primary/backup data sources configured in OV are of the same type, for example both are
localfs. Primary/Backup homogeneous is mainly applicable to the following scenarios.High Data Availability
Dual-write/multi-write, as one type of backup strategy, is equivalent to continuous data protection (CDP). The secondary storage holds a complete real-time replica of the primary storage. OpenViking itself can guarantee high data availability, with RPO approaching zero.
Multi-AZ Routing Priority
When instances are deployed in different AZs, network latency across different AZs can greatly affect the overall request response. With multi-AZ backup nodes, request responsiveness can be improved.
Primary/Backup Heterogeneous
Data Migration
When data needs to be migrated from an old system to a new one, using only
ovpackwill lose some real-time data. With dual-write +ovpack, zero loss can be achieved.Heterogeneous Data Services
The same data can be stored in different forms of storage to provide richer capabilities, for example:
kvfsto provide high-speed read capabilityTerminology Definitions
backend: The underlying handle initialized by the backend plugin registry based onbackend+params(such aslocalfs,s3fs,memfs,kvfs). It is not aware of encryption and is only responsible for raw byte read/write. It may only be used as constructor input for wrappers, must not be exposed externally, and it is also forbidden to use it to directly write business data/metadata.primary_backend: The write handle for primary insideragfs(not visible to upper layers). When global encryption is enabled, it isEncryptionWrappedFS(primary); otherwise it isprimary.MultiWriteWrappedFSuses it when writing both business files and system metadata such as.redirect.json/.sync_log.json, thereby ensuring consistent encryption semantics under the primary namespace. Upper layers (viking_fs/ Python) only perceive the unifiedFileSystemtrait and do not perceive primary/backup.backup_backend: The write handle for backup insideragfs(not visible to upper layers). When global encryption is enabled and the backup itself hasencryption.enabled=true, it isEncryptionWrappedFS(backup); otherwise it isbackup.Metadata bypass direct write (negative example): Refers to retaining an unencrypted
primaryhandle, that is, bypassingEncryptionWrappedFS, and directly writing.redirect.json/.sync_log.jsonin plaintext. This is an incorrect path because it causes a degradation of security semantics under the same primary directory, where business files are encrypted while system files are plaintext, and it must be forbidden.Configuration Structure
Existing configuration pattern:
Dual-write/multi-write configuration pattern:
Key Design
Multiple Backend Configuration
Compatibility Mode
backupscoexists with the originalbackend-mapstructure:backupsis empty or does not exist -> keep the existing single-backend logic unchangedbackupsis not empty -> the top-levelbackendis primary, andbackups.itemsare backupsField Definitions
Primary, the top-level
backend, adds the following fields based on the existingAGFSConfigstructure.nameprimaryencryptionoperationsredirectsEach backup node in
backups.itemsdirectly reusesAGFSConfigand additionally adds multi-write-related fields. The new fields are as follows:name.sync_log.json/.redirect.jsonencryptionoperationsexcludesThe container fields of
backupsitself include:sync_typeasync/syncwrite_ack_countwrite_ack_timeout_mswrite_concurrencyitemsAGFSConfigand adds multi-write fieldsConfiguration Example
{ "agfs": { "backend": "local", "timeout": 10, "encryption": { "enabled": true }, "redirects": [ { "type": "FileExtensionPolicy", "extensions": ["(pdf|ppt)"], "target": ["s3-backup"] } ], "backups": { "sync_type": "async", "write_ack_count": 1, "write_ack_timeout_ms": 5000, "write_concurrency": 8, "items": [ { "name": "s3-backup", "backend": "s3", "timeout": 10, "queuefs": { "backend": "sqlite" }, "s3": { "bucket": "test-zbj-backup", "region": "cn-beijing", "access_key": "xxx", "secret_key": "xxx", "endpoint": "http://tos-s3-cn-beijing.volces.com", "prefix": "backup", "use_ssl": false, "use_path_style": false, "directory_marker_mode": "nonempty" }, "encryption": { "enabled": false }, "excludes": [ { "type": "FileOverSizePolicy", "max_size_mb": 1024 } ], "operations": [{"operation": "read", "priority": 100}] } ] } } }Primary/Backup Distinction
Primary/backup is distinguished by configuration location, without using a
rolefield:backend/s3/localetc.backups.items[]File Redirection (
redirects)File redirection means that certain files matching specific rules can be written to designated backends instead of being written to primary, such as files in special formats like PDF/PPT, or files exceeding a certain size.
Applicable Scenario
Primary can be configured with
redirectsso that specific files are not written to primary, but instead written to designated backup backends.Supported Policies
FileOverSizePolicy{"type": "FileOverSizePolicy", "max_size_mb": 100, "target": ["s3-backup"]}FileExtensionPolicyConfiguration Example
{ "agfs": { "backend": "local", "redirects": [ { "type": "FileOverSizePolicy", "max_size_mb": 100, "target": ["s3-backup"] }, { "type": "FileExtensionPolicy", "extensions": ["(pdf|ppt)"], "target": ["s3-backup"]} ], "backups": { "sync_type": "async", "items": [...] } } }.redirect.jsonMetadataAfter a redirect write, a
.redirect.jsonfile is generated under the corresponding resource directory in primary to record the mapping between files and target backend names. If redirect files exist under a directory, one such file is generated for that directory.Contents of
.redirect.json:{ "version": 1, "entries": { "large_pdf.pdf": { "targets": ["s3-backup", "xxx-backup"] } } }.redirect.jsonexists only in primary and is not dual-written to backup; when primary encryption is enabled, this file must also be encrypted and persisted throughprimary_backend.redirect.jsonmust be updated synchronously.redirect.jsonis added to_INTERNAL_NAMESand is invisible to users.redirect.jsonand.sync_log.jsonunder the same directory must be updated serially: this is protected by the directory-level mutex ofMetaStateStore; both metadata files must be read and merged, then atomically written back within the lock. Reading first and writing later without holding the lock, namely bareread_meta -> write_meta, is forbidden, otherwise high-frequency concurrent writes may overwrite the other party's entry.large_pdf.pdfand needs to add an entrylarge_pdf.pdf -> [s3-backup]to.redirect.json; thread B simultaneously writesreport.docand needs to dolatest_seq++in.sync_log.json. Without serialization, thelatest_seqread by B may be stale, and when it writes back, the redirect entry just written by A may be overwritten and lost..redirect.json/.sync_log.jsonmust go throughMetaStateStore::update_dir_meta(dir, ctx, |redirect_meta, sync_meta| { ... }). Internally,dir_locks[dir]guarantees that the two metadata files in the same directory cannot be modified concurrently.Redirect Write Flow
sequenceDiagram participant Client participant MultiWrite participant Primary participant Backup as Backup (s3-backup,xxx-backup) Client->>MultiWrite: write(large_pdf.pdf) Note over MultiWrite: Check the redirects policy<br/>Hit `FileOverSizePolicy` MultiWrite->>Backup: write(large_pdf.pdf) Backup-->>MultiWrite: OK MultiWrite->>Primary: write(.redirect.json)<br/>(record `large_pdf.pdf` -> `s3-backup`, `xxx-backup`) MultiWrite-->>Client: OKAfter a redirect hit, the write targets switch from
primary + write_backupstotargets, but the synchronization state machine remains unchanged:.sync_log.jsonstill records thelatest_seqof this write, and maintainsacked_seqfor each backup intargetssyncmode, confirmation is still calculated bywrite_ack_count/write_ack_timeout_ms; inasyncmode, background tasks still advanceacked_seq.redirect.jsonmust not be written without also persisting.sync_log.json; the not-yet-caught-up state must be retained forretry_loopcompensationFile Exclusion (
excludes)Applicable Scenarios
Only backup nodes can configure
excludes, specifying which files are not written to the current backup node. For example,memfs/kvfscan be used as backup nodes to improve system responsiveness.Policy Types
Consistent with
redirects, it supportsFileOverSizePolicy,FileExtensionPolicy, and so on.Configuration Example
{ "name": "memfs-cache", "backend": "memfs", "excludes": [ { "type": "FileOverSizePolicy", "max_size_mb": 500 }, { "type": "FileExtensionPolicy", "extensions": [".mp4"] } ] }Conflict with
redirectsIf
primaryis configured to redirect to backend A, but backend A excludes that file, this is considered a user configuration conflict. This design does not provide a fallback for it, and the file will not be written to any backend.Encryption Configuration (
encryption)Decision Logic
Encryption in Dual-Write Scenarios
primaryandbackupare allowed to use different encryption strategies:All backends use unified key management, and the actual encryption/decryption is handled only by Rust
EncryptionWrappedFS. The multi-write implementation must reuseEncryptionWrappedFSas a per-backend data wrapper:primary_backend: when globalencryption.enabled=true, it isEncryptionWrappedFS(primary); otherwise it isprimary. Encryption cannot be disabled separately forprimary.backup_backend: when globalencryption.enabled=trueand the backup's ownencryption.enabled=true, it isEncryptionWrappedFS(backup); otherwise it isbackup..redirect.json/.sync_log.jsonlive in the primary backend and are system files in the primary namespace. They must be read and written throughprimary_backend, so they inherit the primary encryption configuration..redirect.json/.sync_log.jsondirectly with the unencryptedprimary. Otherwise, when primary encryption is enabled, encrypted business files and plaintext system state files would coexist in the same backend, weakening the security semantics.Stats(Encryption(Mountable))inencrypt迁移.md/ the code needs to evolve intoStats(MultiWriteWrappedFS), whereMultiWriteWrappedFSinternally holdsprimary_backend/backup_backendseparately for primary/backup. The implementation ofEncryptionWrappedFSremains unchanged; only the assembly location changes.Internal File Filtering
_INTERNAL_NAMESExtensionThe existing
_INTERNAL_NAMESlist needs to add the following files so that they are invisible to users:Filtering Location
Apply unified filtering in VikingFS directory traversal methods such as
ls,tree, andglob. These files are transparent to users, butMultiWriteWrappedFScan read and write them directly internally. If primary encryption is enabled, file names are still hidden according to the above rules, while file contents are encrypted byprimary_backend.Operation Routing (
operations)Operation Types
Dual-write is classified according to the actual methods in RAGFS.
write,mkdir,remove,remove_all,rename,create,chmod,truncate,ensure_parent_dirsread,stat,exists,read_dir,grepPriority Rules
Each backup node can declare the supported operation types and their priorities through the
operationsfield:{ "name": "kvfs-cache", "backend": "kvfs", "operations": [ {"operation": "read", "priority": 50} ] }primarydoes not configureoperationsprimaryalways participates in write and serves as the final read fallback. Anyoperationsconfig on primary is ignored and rejected by build-time validationbackupdoes not configureoperationsbackupexplicitly declaresreadprioritybackupexplicitly declareswrite/ omittedreadand notwrite, then it is read-only and not dual-written, for example a pure-readkvfscachePlacement Constraints
primarycorresponding to the top-levelbackendis always the first stop for writes and the final fallback for read routing, and it does not configureoperationsbackups.itemsmay configure read/write priorities inoperations; if omitted, they participate only in write and do not enter read routing. Read acceleration requires explicitly declaringreadRead Operation Routing (Fallback Chain)
Read operations are attempted in the following order, returning immediately once found:
sequenceDiagram participant Client participant MultiWrite participant Backup as Backup (read-enabled) participant Primary participant Redirect as .redirect.json Client->>MultiWrite: read(path) MultiWrite->>Backup: read(path) Backup-->>MultiWrite: NotFound MultiWrite->>Primary: read(path) Primary-->>MultiWrite: NotFound MultiWrite->>Primary: read(.redirect.json) Primary-->>MultiWrite: targets=["s3-backup"] MultiWrite->>Redirect: read(path) (s3-backup) Redirect-->>MultiWrite: data MultiWrite-->>Client: dataWrite Operation Routing
All write operations are always written to primary first, and then synchronously or asynchronously written to all writable backup nodes according to
sync_type:sequenceDiagram participant Client participant MultiWrite participant Primary participant Backup as Backup (write-enabled) Client->>MultiWrite: write(data) MultiWrite->>Primary: write(data) Primary-->>MultiWrite: OK Note over MultiWrite: sync mode: wait for all backup nodes MultiWrite->>Backup: write(data) Backup-->>MultiWrite: OK MultiWrite-->>Client: OKDirectory-Like Operation Routing
read_dirprimary, and merge redirect file names from.redirect.jsonin the current directoryprimary; it must be added back, otherwise users will not see files they wrote inls, which degrades semanticsgrepprimaryfirst; when a redirect file name is hit, read the content from the target backend on demand for matchingstat/existspriority->primary->redirectHigher-level Python APIs such as
ls,tree, andglobinternally compose trait methods such asread_dir, and naturally fall into the primary routing ofMultiWriteWrappedFS.File Sync Log (
.sync_log.json)Role
.sync_log.jsonis a metadata file that records the synchronization progress of backup nodes. Bothsyncandasyncmodes use it to express whetherlatest_seqhas caught up with each backup'sacked_seq.What it records is not whether a file has ever been synchronized, but the latest version number of the current path and which version each backup has caught up to.
Storage Rules
primary.sync_log.jsonper directory; currently no sharding_INTERNAL_NAMESand invisible to usersprimary_backendwith encryptionContent Structure
{ "entries": { "file.txt": { "latest_seq": 12, "last_op": "write", "backends": { "memfs-cache": { "acked_seq": 12 }, "local-az2": { "acked_seq": 11 } } }, "report.pdf": { "latest_seq": 3, "last_op": "remove", "backends": { "memfs-cache": { "acked_seq": 3 } } } } }Field semantics:
entriesuses the file name under the current directory. Since.sync_log.jsonitself is directory-level metadata, the directory path is already implied by the file location, so the fulluridoes not need to be stored againlatest_seq: the monotonically increasing version number of the latest successful write to primary for the current path; synchronization completion is judged against itlast_op: the operation type corresponding to thislatest_seq, namelywrite/mkdir/remove/remove_all/rename/chmod/truncate.retry_loopuses this to decide how to replay on lagging backups: forwrite/truncate, read the latest content back from primary, or the redirect target, and rewrite it; forremove/remove_all, replay deletion; formkdir/chmod, replay the corresponding operation; forrename, additionally record the target URI inrename_to. Without this field, retry cannot replay non-content operations such as delete or rename, causing semantic degradationrename_to, present only whenlast_op == rename: the normalized targeturiof the rename operation. The full path is intentionally retained here because rename may cross directoriesbackends.{name}.acked_seq: the latest version number that this backup has successfully acknowledged and appliedbackendsuse the configured backendnameas the identitySynchronization completion condition:
Lifecycle
Manual Sync Interface
Provide a CLI tool for operations to manually trigger synchronization status checks and repairs:
Concurrency Safety
.sync_log.jsonand.redirect.jsonare both directory-level JSON metadata files, and read-modify-write operations have concurrency race risks. Foreground writes, multipletokio::spawnack updates,retry_loop, and manualsync-retrymay all modify the same directory at the same time.Unified
MetaStateStoreThe following begins the concrete implementation constraints:
MetaStateStore: the directory-level metadata manager insideMultiWriteWrappedFS, responsible for unified read-modify-write of.redirect.jsonand.sync_log.json, and holding directory locks and path-serialization queuesFsContextResolver: a helper for restoring context in background tasks. Whenretry_loop,backfill, orsystem_sync_retryhas no foreground request context, it restoresaccount_idfrom the normalized path, then executes encrypted reads and writes insideFS_CTX.scope(ctx, ...)Whenever
.redirect.jsonor.sync_log.jsonis updated, the lock for the corresponding directory must be acquired first, and the read-modify-write of both files must be completed within the same critical section. In redirect write scenarios, theentriesupdate in.redirect.jsonand thelatest_sequpdate in.sync_log.jsonmust be committed under the same lock, to avoid the situation where data has already been written to the redirect target, but the redirect mapping or sync version is lost.In addition, to avoid out-of-order effects of multiple writes on the same path on the backup side, this must be paired with a per-path serialization queue.
.sync_log.jsonis responsible for recording which version each backup has acknowledged, while per-path FIFO is responsible for ensuring versions never move backward.For
rename, if the source directory and target directory differ, a single directory lock is insufficient and must be upgraded to dual-directory locking:.redirect.jsonand.sync_log.jsonof both the source directory and target directory within the same critical sectionsource_dir == target_dir, degrade to a single directory lockrename_tois used only to record the targeturi; actual metadata consistency is guaranteed by the dual-directory lock rather than being delegated to background retrySynchronization Mode (
sync_type)OpenViking supports different consistency strategies through
sync_type:Two Modes
asyncsyncSynchronous Write (
sync)Data Flow:
Sequence Diagram:
sequenceDiagram participant Client participant MultiWrite participant Primary participant Backup1 participant Backup2 Client->>MultiWrite: write(data) MultiWrite->>Primary: write(data) Primary-->>MultiWrite: OK MultiWrite->>Backup1: write(data) Backup1-->>MultiWrite: OK MultiWrite->>Backup2: write(data) Backup2-->>MultiWrite: OK MultiWrite-->>Client: OKQuorum Support in Synchronous Mode
Current design: in
syncmode, the call returns only after all backup nodes have completed writing. One slow backup node can drag down overall latency. Add a minimum acknowledgment count and timeout:{ "backups": { "sync_type": "sync", "write_ack_count": 2, "write_ack_timeout_ms": 5000 } }write_ack_countwrite_ack_timeout_msNote
Risks and Failure Semantics:
write_ack_count+write_ack_timeout_ms: return as soon as quorum is reached.sync_log.jsonhas already recordedlatest_seq, and backups that are not yet caught up are continuously compensated byretry_loopuntil eventual consistency is reached. In other words, the client sees a failure, but the system guarantees that the data will eventually be synchronized. This semantic must be made explicit in the API documentation to prevent callers from misjudging it as data lossAsynchronous Write (
async)Data Flow:
Sequence Diagram:
sequenceDiagram participant Client participant MultiWriteWrappedFS participant Primary participant Backup1 Client->>MultiWriteWrappedFS: write(data) MultiWriteWrappedFS->>Primary: write(data) Primary-->>MultiWriteWrappedFS: OK MultiWriteWrappedFS-->>Client: OK Note over MultiWriteWrappedFS: tokio::spawn MultiWriteWrappedFS->>Backup1: write(data) Note right of Client: Client has already received the response Backup1-->>MultiWriteWrappedFS: OK/ERR Note over MultiWriteWrappedFS: Update `.sync_log.json`Note
Risks:
Asynchronous Write Concurrency Control
Currently, there is no limit on the concurrency of asynchronous writes. If a future scenario appears where backup-node write speed is slower than caller write speed, for example
primary=localfsandbackup=S3,tokio::Semaphorerate limiting can be enabled through thewrite_concurrencyconfiguration:Asynchronous Write Ordering Guarantee, Per-Path Serialization
Under asynchronous dual write, multiple operations on the same file path, such as
write -> remove -> write, may execute out of order on backup nodes, resulting in inconsistent final data. The solution is to maintain a FIFO queue per path so that operations on the same path execute in order on backup nodes.Read-Path Consistency Clause
In asynchronous mode, delete operations may be delayed on backup nodes. Read requests may hit stale data that has not yet been deleted:
Failure Retry Mechanism
In synchronous dual-write scenarios, backup-node writes may time out and fail due to temporary faults such as network jitter. Even if quorum is satisfied and success is returned to the client, backups that have not reached ack still need compensation.
Under asynchronous dual write, backup-node writes in
tokio::spawnmay also fail due to temporary faults such as network jitter.Both modes persist the not-yet-caught-up state through
.sync_log.json:async: updatelatest_seqimmediately after writing the primary; updateacked_seqonly after the spawned write succeedssync: return as soon aswrite_ack_countis reached; updatelatest_seqsynchronously when writing the primary, and do not updateacked_seqfor unacknowledged, timed-out, or failed backups so they remain behind and can be compensated byretry_loopTherefore,
retry_loopmust run in both modes. As long as there is any write-enabled backup,new()startsretry_loopimmediately, no longer limited toasync.See the
MultiWriteWrappedFSstructure section: innew(),if !inner.write_backends().is_empty() { tokio::spawn(Inner::retry_loop(...)) }.Parameter definitions:
.sync_log.jsonIndependent task queue
.sync_log.jsonitself is already a persistent record. There is no need to introduce an external task queue such as RabbitMQ or Kafka. Data flow:Concrete Implementation
Encryption capability is pushed down to the
ragfslayer, and the dual-write capability is implemented in theragfslayer. The Python layer is responsible only for passing configuration.Responsibilities of each layer:
VikingFS: only performs URI to path conversion, access control via_ensure_access, vector index synchronization, and lock management. Configuration is passed to the Rust sideMultiWriteWrappedFS: receives the primary configuration and thebackups.itemslist, and manages read/write routing,.sync_log.json, and.redirect.json; its internal metadata is also read and written throughprimary_backendEncryptionWrappedFS: a transparent wrapper that is invisible to upper layers. Each backend independently decides whether it should be wrapped. The primary must follow the global switch and must not bypass encryption through an unencrypted backend handleConfiguration Passthrough (FFI)
PluginConfigis only{name, mount_path, params: HashMap<String, ConfigValue>};ConfigValuesupports onlyString / Int / Bool / StringList, and does not support nested dict / listv.str()configdict/local, with no assembly for abackupsgroupTherefore, the nested
backupsstructure cannot be passed through the existing channel. The minimal change set is as follows, recommended because it reuses the existing JSON capability:ConfigValue::Json(serde_json::Value)variant in Rust, and makepy_dict_to_configrecursively convert dict/list intoserde_json::Value.lib.rsalready hasserde_json_to_pyfor reverse conversion, so only a forwardpy_any_to_jsonneeds to be added. This avoids defining PyO3 extraction logic for every nested field and allows the entirebackupsstructure to pass through as a single JSON value.PluginConfig:BackendsConfigviaserde_json::from_value, wheremount()recognizesparams["backups"]or a standalone parameter, avoiding field-by-field PyO3 extraction.agfs_utils.pyplacesagfs.backupsdirectly into the config dict passed to mount, including the plugin sub-parameters of each item; the top-levelbackend/s3/localparameters continue to serve as the primaryparams.MountableFS::mount()IntegrationIn
mount(), detectconfig.backups: if empty, keep the current single-backend logic unchanged; if not empty, call the newly added private methodbuild_multi_write_fs()to constructMultiWriteWrappedFS, and still wrap it withStatsWrappedFSon the outside to keep metrics consistent. The primary and backup inside a multi-write mount should no longer be mounted back into the sameMountableFS; instead, after registry initialization, each backend should be wrapped intoBackendEntry::backendaccording to configuration, preventing the global Encryption wrapper and the multi-write scheduling order from bypassing each other.build_multi_write_fs()steps:The backend assembly details begin here:
BackendEntryis the backend descriptor structure stored internally byMultiWriteWrappedFS. Its fields includename,role, namelyPrimary/Backup,backend,operations, allowed only for backup, andexcludes.backend/paramsthrough the registry, yielding the unencrypted underlying handle; follow the globalserver.encryption.enabledto decide whether to wrap it asEncryptionWrappedFS(primary), and record the result asprimary_backend. The primary is not allowed to disable encryption.bc.items: initialize each backup through the registry, yielding an unencrypted underlying handle; decide whether to wrap it asEncryptionWrappedFS(backup)based onserver.encryption.enabled && item.encryption.enabled, and record the result asbackup_backend.item.namemust be globally unique; the primary must not acceptoperations, otherwise report an error; everyredirects.target/excludesreference must point to an existing name.BackendEntry { name, backend, role, operations, excludes }and assemble them intoMultiWriteWrappedFS. Internal metadata files must useprimary_backend; it is forbidden to keep an unencrypted primary handle as a metadata side-channel direct-write entry point.MultiWriteWrappedFSStructure,Arc<Inner>SolidifiedReusable Abstractions in Code, Deduplicate Write / Read / Policy in Three Places
1. Write fanout skeleton
fanout_writefor all write operations:2. Read backend resolution
resolve_read_backendto reuse the fallback chain forread,stat, andexists:3. File policy
FilePolicytrait for shared logic betweenredirectsandexcludes:4. Reuse metadata JSON encoding and decoding.
.redirect.jsonand.sync_log.jsonare both JSON and meet the constraint that internal system state should use JSON. They share generic helpersMetaStateStore::read_meta::<T>(),write_meta::<T>(), andupdate_dir_meta(), all based onprimary_backend+serde_json, without handwritten parsing duplication. When encryption is enabled on the primary, the helpers read and write encrypted envelopes andEncryptionWrappedFSdecrypts before deserialization; when encryption is not enabled, the helpers read and write plain JSON directly.5. Reuse
FsContextsnapshots. All background tasks must explicitly carry or recoverctx, avoiding repeated scatteredFS_CTX.scopecalls:Complete Write-Path Flow, Including
redirect/excludeFile List
crates/ragfs/src/core/multiwrite_wrapper.rsMultiWriteWrappedFS+Inner+BackendEntry::backend+fanout_write/resolve_read_backend/FilePolicycrates/ragfs/src/core/multiwrite_meta.rsMetaStateStore+MetaLockProvider+ encrypted JSON metadata helper + ctx recoverycrates/ragfs/src/core/types.rsConfigValue::Json; add backups/encryption fields toPluginConfig;BackendsConfig, etc.crates/ragfs/src/core/mountable.rsmount()recognizes backups; addbuild_multi_write_fs(); multiwrite mount directly initializes backend entries instead of detouring through global Encryption(Mountable)crates/ragfs/src/core/mod.rspub mod multiwrite_wrapper; pub mod multiwrite_meta;crates/ragfs/src/core/filesystem.rscrates/ragfs-python/src/lib.rspy_dict_to_configsupports nesting;mount()passes backups through; addsystem_sync_status(path, ctx)/system_sync_retry(path, ctx)openviking/utils/agfs_utils.pyagfs.backupsand pass it into mountopenviking/storage/viking_fs.py.sync_log.json/.redirect.jsonto_INTERNAL_NAMESFollow-up Extensibility
Multi-process / Multi-instance Boundary
This phase does not implement cross-process or multi-instance concurrency coordination. The
tokio::sync::Mutexin the current design can only guarantee safety within a single process. If future deployment allows multiple RAGFS processes to write to the same primary mount at the same time, an independentMetaLockProvidermust be added, such as backend-native conditional write, Redis, or etcd.Constraint requirements:
Write Amplification Risk
This phase does not implement optimization for directory-level metadata write amplification. In the current design, every file write rewrites the
.redirect.jsonand.sync_log.jsonof its directory, which may become a bottleneck in hot directories.Optional follow-up optimization directions:
MetaStateStoreWrapper Group Scheduling
The current design directly holds
primary_backendandbackup_backendinsideMultiWriteWrappedFS. If future work needs to further optimize the repeated cost of per-backend wrapper assembly,GroupedWrappedFScan be considered, but it is not implemented in this iteration.Core idea:
GroupedWrappedFSclassifies requests by stable group characteristics and attaches different downstream wrapper chains to different groupsStatsWrappedFS -> GroupedWrappedFS -> EncryptionWrappedFS -> MultiWriteWrappedFSStatsWrappedFS -> GroupedWrappedFS -> MultiWriteWrappedFSEncryptionWrappedFS -> MultiWriteWrappedFS, while the plaintext group goes directly throughMultiWriteWrappedFS, thus avoiding the mental burden of deciding downstream wrappers one by one for each backend in the current designThis abstraction is not only applicable to multi-write, but can also be extended to single-write scenarios. For example, different wrapper combinations can be loaded for different requests based on configuration, path, or capability tags, instead of hardcoding whether a wrapper is mounted into the global mount chain.
Prerequisites and boundaries for implementation:
GroupedWrappedFSmust be stable and replayable. If the write path is grouped in a certain way, the read path must also be able to hit the same wrapper chain with the same rule, otherwise semantic confusion like encrypted on write but plaintext on read will occurretry_loopStatsWrappedFSmeasures total latency,GroupedWrappedFScan additionally expose hit counts by group, andEncryptionWrappedFScontinues to measure encryption-path performancebuild_multi_write_fs()must be revisited. It will no longer directly assemble the final wrapper, but instead produce grouping rules and the downstream chain corresponding to each groupConclusion:
GroupedWrappedFSis a feasible future optimization direction, and it is more general than deciding wrappers per backend. However, it significantly raises mount orchestration complexity, so it is only worth introducing after confirming that the current combination path of multi-write plus encryption has become the main source of complexity.Observability
MultiWriteWrappedFSshould expose metrics throughStatsWrappedFS:.sync_log.jsonsizeHealth Check
Add multi-write health status, including primary availability and the availability of each backup node, into
openviking/server/routers/system.py::checks["agfs"].Test Matrix
At minimum, the implementation should cover:
.redirect.json/.sync_log.jsonmust persist asOVE1envelopes; when primary is plaintext, they remain JSON; round-trip read/write with encrypted primary + unencrypted backup;retry_loopcan read encrypted sync logs after recovering ctx from pathls/grep; excluded files are not written to the corresponding backup; under multiple backups with the same file name (multi-AZ),.sync_log.jsondistinguishes byname; concurrent updates to.redirect.jsonand.sync_log.jsonin the same directory do not lose entriesAll reactions