Spacedrive 同步后重建闭包表entry_closure/tag_closure 一致性的设计与修复实践【免费下载链接】spacedriveSpacedrive is an open source cross-platform file explorer, powered by a virtual distributed filesystem written in Rust.项目地址: https://gitcode.com/gh_mirrors/sp/spacedrive导读Spacedrive 是一个用 Rust 编写的开源跨平台文件管理器其核心是一个虚拟分布式文件系统。为了让目录树与标签层级在多设备间保持一致的层级查询能力Spacedrive 使用闭包表closure tableentry_closure与tag_closure存储祖先-后代关系。本文以仓库任务文档 LSYNC-023-rebuild-closure-tables-on-sync.md 为骨架结合当前仓库中 entry.rs、tag_relationship.rs、backfill.rs 等源码系统讲解闭包表为何会在同步后损坏、三种重建方案的取舍以及当前仓库中实时重建 批量重建双保险的落地实现帮助你理解闭包表在多设备同步架构中的一致性保证方式。背景为什么 Spacedrive 需要闭包表Spacedrive 把文件系统目录树建模为entries表其中每个条目通过parent_id指向其父条目。如果只依赖parent_id查询某个目录下有多少层后代就必须递归遍历在深度层级较多时性能很差。为此database_storage.rs 的模块注释明确说明Closure Table Hierarchy:Parent-child relationships use a closure table (entry_closure) instead of recursive Common Table Expressions (CTEs). This makes hierarchy queries efficient, but requires rebuilding closures for the entire moved subtree when an entry is moved.也就是说闭包表是 Spacedrive 索引层实现高效层级查询的核心数据结构entry_closure为文件系统树服务tag_closure为标签层级服务。闭包表的数据模型从实体定义可以清楚看到两张闭包表的结构entry_closure.rs 定义entry_closure表复合主键为(ancestor_id, descendant_id)另有depth字段表示祖先到后代相隔的层数#[sea_orm(table_name entry_closure)] pub struct Model { #[sea_orm(primary_key, auto_increment false)] pub ancestor_id: i32, #[sea_orm(primary_key, auto_increment false)] pub descendant_id: i32, pub depth: i32, }而 tag_closure.rs 定义的tag_closure表在此基础上多了一个path_strength: f32字段用来表达标签路径的语义强度ActiveModelBehavior::new()会把path_strength默认初始化为1.0#[sea_orm(table_name tag_closure)] pub struct Model { #[sea_orm(primary_key, auto_increment false)] pub ancestor_id: i32, #[sea_orm(primary_key, auto_increment false)] pub descendant_id: i32, pub depth: i32, pub path_strength: f32, }两张表都提供两个辅助判断方法is_self_reference()判断是否为depth 0的自引用is_direct_relationship()判断是否为depth 1的直接父子关系tag_closure还额外提供normalized_path_strength()与calculated_strength()用于基于深度计算关系强度深度越小强度越大。以文档中的示例数据说明闭包表的含义当Desktopid1下有子目录Deskid2、.localizedid3而Desk下又有文件file.txtid4时完整闭包表应包含(1, 1, 0) -- Desktop → Desktop自引用 (1, 2, 1) -- Desktop → Desk直接子级 (1, 3, 1) -- Desktop → .localized直接子级 (1, 4, 2) -- Desktop → file.txt孙级 ...这种以空间换时间的建模方式让WHERE ancestor_id X一次查询即可拿到整棵子树也是 Spacedrive 索引层删除子树、位置作用域location scoping查询、变更检测的基础。问题陈述同步后闭包表只剩自引用任务文档 LSYNC-023 记录了一个CRITICAL 级别的 bug当条目entries或标签tags从其他设备同步过来时闭包表没有被重建导致同步后的设备上闭包表只剩下自引用记录。损坏后的表现假设设备 A 是源设备其上entry_closure有完整的 79 条关系设备 B 通过同步拿到同样的条目后闭包表却是这样-- Device B (after sync) has BROKEN closure table: SELECT * FROM entry_closure; (1, 1, 0) -- Desktop → Desktop (self only!) (2, 2, 0) -- Desk → Desk (self only!) (3, 3, 0) -- .localized → .localized (self only!) ... NO parent-child relationships!即每个条目都只有一条(id, id, 0)的自引用父子关系全部缺失。文档给出的真实运行证据是某个已同步的 Jam 实例有 1,987 个条目但entry_closure只有 27 条记录全部为自引用缺失约 1,960 条父子闭包关系。影响面为什么这是致命缺陷闭包表损坏会连锁破坏依赖它的所有核心功能文档明确列出了后果无法查询后代WHERE ancestor_id X查不到任何子节点无法删除子树删除操作只会删掉单个条目无法级联删除整棵子树INDEX-003 修复失效依赖entry_closureJOIN 的位置作用域逻辑无法工作位置作用域location scoping损坏无法找到位置树中的条目变更检测损坏无法在子树中找到已有条目路径解析歧义无法修复没有闭包表就无法消除路径解析的歧义。结合仓库代码可以看到闭包表确实是这些能力的底层依赖。例如 database_storage.rs 中的delete_subtree、unique_to_location.rs 中的位置唯一性查询、aggregation.rs 与 persistent.rs 中的索引聚合与变更检测都围绕entry_closure展开。因此任务被标记为priority: High、status: Done并关联了 CORE-004 闭包表架构 与 INDEX-003 设备所有权违规 两个任务。根因分析两条写入路径的不对称为什么本地索引正常、同步却会损坏闭包表根因在于两条写入路径对闭包表的处理不一致。本地索引路径正确填充闭包本地索引时DatabaseStorage::create_entry_in_conn()会在创建条目时同步填充闭包表。核心代码位于 database_storage.rslet self_closure entry_closure::ActiveModel { ancestor_id: Set(result.id), descendant_id: Set(result.id), depth: Set(0), }; out_self_closures.push(self_closure); // Copy all parents ancestor relationships to build the transitive closure for this entry. if let Some(parent_id) parent_id { conn.execute_unprepared(format!( INSERT INTO entry_closure (ancestor_id, descendant_id, depth) \ SELECT ancestor_id, {}, depth 1 \ FROM entry_closure \ WHERE descendant_id {}, result.id, parent_id )) ... }这段逻辑分两步先插入(id, id, 0)自引用如果存在父条目就把父条目的所有祖先复制一份、深度加一从而一次性构造出该条目的全部传递闭包关系。同步路径只 upsert 条目本身而在 entry.rs 的entry::Model::apply_state_change()中文档明确指出它在修复前只负责插入/更新条目记录没有重建entry_closure。也就是说从对端设备同步过来的条目只写了entries表本身闭包表完全无人维护于是设备 B 上只剩自引用。这是典型的同一份业务数据存在多条写入路径而只有其中一条路径维护衍生数据导致的一致性漏洞。解决方案三种方案的设计与取舍任务文档给出了三种修复方案并给出了推荐意见。Option 1同步期间按条目实时重建在 entry.rs 的apply_state_change()中完成 upsert 后立即调用rebuild_entry_closure(entry_id, parent_id, db)pub async fn apply_state_change(data: serde_json::Value, db: DatabaseConnection) - Result(), sea_orm::DbErr { // ... existing upsert logic ... let entry_id if let Some(existing_entry) existing { // Update ... existing_entry.id } else { // Insert ... inserted.id }; // Rebuild entry_closure for this entry rebuild_entry_closure(entry_id, parent_id, db).await?; // If directory, update directory_paths ... Ok(()) } async fn rebuild_entry_closure( entry_id: i32, parent_id: Optioni32, db: DatabaseConnection, ) - Result(), sea_orm::DbErr { use sea_orm::{ConnectionTrait, Set}; // Delete existing closure records for this entry entry_closure::Entity::delete_many() .filter(entry_closure::Column::DescendantId.eq(entry_id)) .exec(db) .await?; // Insert self-reference let self_closure entry_closure::ActiveModel { ancestor_id: Set(entry_id), descendant_id: Set(entry_id), depth: Set(0), }; self_closure.insert(db).await?; // If theres a parent, copy all parents ancestors if let Some(parent_id) parent_id { db.execute(Statement::from_sql_and_values( DbBackend::Sqlite, r# INSERT INTO entry_closure (ancestor_id, descendant_id, depth) SELECT ancestor_id, ?, depth 1 FROM entry_closure WHERE descendant_id ? #, vec![entry_id.into(), parent_id.into()], )) .await?; } Ok(()) }优点闭包表实时保持正确无需批量重建任务同时适用于回填backfill与增量实时同步。缺点每个条目的同步都增加额外开销且要求父条目必须先于子条目到达存在依赖顺序问题——如果子条目先同步而父条目尚未就绪复制父条目祖先的INSERT ... SELECT就查不到任何记录。Option 2回填完成后批量重建在 backfill.rs 的 Phase 3backfill_device_owned_state之后追加rebuild_all_entry_closures()与rebuild_all_tag_closures()的调用。批量重建的实现思路是迭代扩散async fn rebuild_all_entry_closures(db: DatabaseConnection) - Result() { // Clear existing closure table entry_closure::Entity::delete_many().exec(db).await?; // 1. Insert all self-references db.execute(Statement::from_sql_and_values( DbBackend::Sqlite, r# INSERT INTO entry_closure (ancestor_id, descendant_id, depth) SELECT id, id, 0 FROM entries #, vec![], )) .await?; // 2. Recursively build parent-child relationships // Keep inserting until no new relationships found let mut iteration 0; loop { let result db.execute(Statement::from_sql_and_values( DbBackend::Sqlite, r# INSERT OR IGNORE INTO entry_closure (ancestor_id, descendant_id, depth) SELECT ec.ancestor_id, e.id, ec.depth 1 FROM entries e INNER JOIN entry_closure ec ON ec.descendant_id e.parent_id WHERE e.parent_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM entry_closure WHERE ancestor_id ec.ancestor_id AND descendant_id e.id ) #, vec![], )) .await?; iteration 1; let rows_affected result.rows_affected(); if rows_affected 0 { break; // No more relationships to add } if iteration 100 { return Err(anyhow::anyhow!(entry_closure rebuild exceeded max iterations - possible cycle)); } } info!(Rebuilt entry_closure table in {} iterations, iteration); Ok(()) }这个算法的关键在于每一轮迭代只沿着entries.parent_id向下一层扩散depth 1配合INSERT OR IGNORE与NOT EXISTS去重当一轮没有任何新记录插入时即收敛。iteration 100的上限用于防御数据环cycle导致的死循环。优点一次批量操作即可完成同步期间无逐条开销天然容忍乱序同步父条目晚于子条目到达也不影响因为重建完全基于最终的parent_id关系。缺点回填期间闭包表不完整在重建完成前依赖它的查询会失败。Option 3混合方案推荐文档推荐双保险策略小规模同步 100 个条目走 Option 1 的实时重建保证闭包表始终正确大规模回填操作结束后再补一次 Option 2 的批量重建兜住实时路径可能遗漏的条目例如乱序到达导致父祖先未就绪的情况。推荐理由是实时重建保证闭包表始终正确、同时覆盖初始回填与增量同步、能让 INDEX-003 的 Phase 2 在同步条目上生效并且与本地索引期间的行为模式一致批量重建则作为安全网兜底。当前仓库中的最终落地实现值得注意的是当前仓库源码已经落地了上述混合方案任务状态为Done并且实现比任务文档中的草图更进一步用同步注册机制with_rebuild把批量重建挂接到回填流程中而不是硬编码在 backfill 代码里。实时重建apply_state_change()内的按条维护entry.rs 在 upsert 完成后调用rebuild_entry_closure()// Rebuild entry_closure for this synced entry, unless were inside a // backfill apply loop — in that case the post_backfill_rebuild hook // does a single bulk rebuild at the end, so per-entry work is wasted. if !crate::infra::sync::is_in_backfill() { Self::rebuild_entry_closure(entry_id, parent_id, db).await?; }这里有两个值得注意的设计细节回填期跳过逐条重建通过 backfill_context.rs 提供的is_in_backfill()全局标记判断当前是否处于回填应用循环中。如果是就跳过逐条重建因为回填结束后会有一次批量重建统一处理避免重复劳动——这正是任务文档 Option 1 Option 2 组合的落地形态且两者通过一个原子标记优雅衔接。先删后插保证幂等rebuild_entry_closure() 会先删除该条目作为后代的全部闭包记录防止父条目变更后残留过期关系再插入depth 0自引用最后用INSERT ... SELECT ... WHERE descendant_id parent_id复制父条目的祖先链并整体depth 1。这个三步流程与本地索引路径 database_storage.rs文档中引用为core/src/ops/indexing/database_storage.rs中的create_entry_in_conn()逻辑完全对齐。批量重建通过post_backfill_rebuild注册进回填流程文档 Option 2 建议在 backfill.rs 的 Phase 3 之后硬编码调用当前仓库的实现更优雅entry 实体在文件末尾用with_rebuild变体注册到同步系统crate::register_syncable_device_owned!(Model, entry, entries, with_deletion, with_rebuild);对应的宏见 registry.rs会把post_backfill_rebuild回调挂到注册项上entry.rs 中该回调即批量重建入口// Post-backfill hook to rebuild entry_closure table async fn post_backfill_rebuild(db: DatabaseConnection) - Result(), sea_orm::DbErr { Self::rebuild_all_entry_closures(db).await }批量重建的完整实现位于 entry.rs与任务文档中的算法一致清空表 → 批量插入全部自引用 → 循环迭代扩散父子关系INSERT OR IGNORE ... SELECT ec.ancestor_id, e.id, ec.depth 1 FROM entries e INNER JOIN entry_closure ec ON ec.descendant_id e.parent_id WHERE e.parent_id IS NOT NULL AND NOT EXISTS (...)直到rows_affected 0收敛迭代超过 100 次则视为可能存在数据环而报错。完成后会统计并记录总关系数与迭代次数。而回填流程侧backfill.rs 在 Phase 3.5backfill_device_owned_state之后、Phase 4 切换到 ready 之前统一执行注册过的所有后置重建// Phase 3.5: Run post-backfill rebuilds via registry (polymorphic) // Models that registered post_backfill_rebuild will have their derived tables rebuilt debug!(Running post-backfill rebuilds via registry...); crate::infra::sync::registry::run_post_backfill_rebuilds(self.peer_sync.db().clone()).await... // Phase 4: Transition to ready (processes buffer) self.peer_sync.transition_to_ready().await?;也就是说回填完成后会自动触发所有注册了post_backfill_rebuild的模型如 entry、tag_relationship重建其衍生表然后才切换设备状态为 ready 并开始处理缓冲的增量消息。文件头注释backfill.rs把流程描述为1. 握手 → 2. 获取远端状态 → 3. 回填设备自有状态 →3.5 后置重建→ 4. 切换到 ready → 5. 增量同步。标签闭包表同样的模式任务文档 Phase 3 要求检查标签是否也有同样问题必要时实现重建。当前仓库中tag_relationship.rs 用register_syncable_shared!(Model, tag_relationship, tag_relationship, with_rebuild)注册并在post_backfill_rebuild()tag_relationship.rs中实现tag_closure的批量重建清空 → 从tag_relationships插入depth 1的直接关系带path_strength→ 迭代计算传递闭包INNER JOIN tag_closure tc1 ... tc2 ... ON tc1.descendant_id tc2.ancestor_id→ 同样有 100 次迭代上限防御环。由于tag_closure是共享shared模型走的是日志同步但其闭包表同样通过with_rebuild注册被纳入回填后的统一重建。由此tag 闭包表与entry 闭包表共用同一套机制实现了任务文档 Phase 3 的目标。验证与测试任务文档 Phase 4 规划了一个新的集成测试core/tests/sync_closure_rebuild_test.rs验证同步后闭包表必须完整#[tokio::test] async fn test_entry_closure_rebuilt_during_sync() { let (device_a, device_b) setup_paired_devices().await; // Device A creates location with nested entries create_location(device_a, /Test).await; create_file(device_a, /Test/folder/subfolder/file.txt).await; // Verify Device A has full closure table let closure_a count_closure_records(device_a).await; assert!(closure_a 10); // Self-refs parent-child rels // Sync to Device B wait_for_sync().await; // Verify Device B has FULL closure table (not just self-refs) let closure_b count_closure_records(device_b).await; assert_eq!(closure_a, closure_b); // Should match! // Verify can query descendants on Device B let descendants query_descendants(device_b, root_entry_id).await; assert!(descendants.len() 1); // Should find children! }核心断言是设备 B 的闭包关系总数必须与设备 A 完全一致assert_eq!(closure_a, closure_b)且能成功查询到后代。这与仓库测试体系中 sync_backfill_test.rs、sync_harness.rs、indexing_harness.rs 中已有的配对设备 等待同步 断言两端状态一致的测试模式一脉相承。结合测试与实现可以总结出验收标准对应的验证方式entry_closure在apply_state_change()中重建 → 源码rebuild_entry_closure()调用点entry.rs回填完成后批量重建 →post_backfill_rebuild注册与 backfill.rs 的 Phase 3.5标签闭包表同样重建 → tag_relationship.rs同步条目具有完整闭包记录、可查询后代 → 集成测试断言删除子树、位置作用域在同步条目上可用 → 依赖 database_storage.rs 的delete_subtree与位置查询逻辑。总结与经验从 LSYNC-023 这个任务可以提炼出几条可复用的工程经验衍生数据必须与主数据在同一事务写入路径中维护。闭包表、计数、索引这类由主表推导而来的数据最容易在出现第二条写入路径如同步、导入、迁移时被遗漏。本案的根因就是apply_state_change()只写了entries却漏了entry_closure。实时维护 批量兜底是处理衍生数据一致性的务实组合。实时维护保证常规路径始终正确批量重建则负责兜底乱序、遗漏等边界情况并且用is_in_backfill()之类的全局标记避免两者重复劳动。迭代式闭包重建算法自带环检测。INSERT OR IGNORE ... SELECT ... depth 1的迭代扩散天然幂等rows_affected 0即收敛而iteration 100的上限把父子关系成环这种数据损坏显式暴露为错误而不是无限循环。把重建动作注册到框架而非硬编码。当前仓库通过with_rebuild宏 post_backfill_rebuild回调 回填流程里的run_post_backfill_rebuilds()统一执行新增需要重建衍生表的模型只需一行注册即可接入避免了在回填主流程里堆砌特定业务代码。若你希望进一步深入可以继续阅读 CORE-004 闭包表架构设计、INDEX-003 设备所有权违规修复以及同步总览文档 library-sync.mdx 与索引文档 indexing.mdx。【免费下载链接】spacedriveSpacedrive is an open source cross-platform file explorer, powered by a virtual distributed filesystem written in Rust.项目地址: https://gitcode.com/gh_mirrors/sp/spacedrive创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考