Cloudflare Computer同步协议30分钟深入:从ChangeEntry到applyChanges全流程
【免费下载链接】computerGive your agent a computer 👾项目地址: https://gitcode.com/GitHub_Trending/computer1/computer
Cloudflare Computer 是一个跑在 Cloudflare Durable Object 里的虚拟文件系统,它的**同步协议(Sync Protocol)**负责把云端 SQLite 文件树与沙箱容器里的 FUSE 挂载双向同步。这篇文章带你用 30 分钟走完全流程:从一条ChangeEntry记录如何产生、如何在网络上流转,到最终被applyChanges写进本地存储的每一步,帮你彻底搞懂这套增量双向同步的设计。
同步协议要解决什么问题?
Cloudflare Computer 的架构里,同一个文件系统有两份副本:
- DO 侧(Durable Object):基于 SQLite 的虚拟文件系统,是重启后仍然存在的"事实来源"(source of truth);
- 容器侧:通过 FUSE 挂载暴露给沙箱的真实文件系统,供
computerd守护进程和 Agent 使用。
难点在于:两边都可能随时被修改——你在容器里跑npm install,DO 侧也可能会收到外部写入。如果每次都全量传输文件树,网络成本会爆炸。
所以同步协议是增量的、双向的:每侧都维护一个单调递增的修订号计数器(rev),双方只交换"对方还没见过的那部分变化"。
核心构件:ChangeEntry 同步记录
整套协议的"最小传输单元"就是 ChangeEntry,一条描述"某个路径的最终状态"的记录。它有四种形态:
| 类型 | 携带的信息 | 说明 |
|---|---|---|
file | 路径、权限、mtime、大小、分块哈希列表 | 文件内容不在记录里,只有每块的sha256哈希 |
dir | 路径、权限、mtime | 目录元数据 |
symlink | 路径、链接目标、权限 | 符号链接永不解引用 |
delete | 路径、修订号 | 删除操作的"墓碑"记录 |
两个关键设计值得新手特别注意:
- 基于最终状态,而非操作日志。协议里没有
rename这类操作指令——目录改名只是把子树里每个 inode 都盖上新的修订号,再为旧路径补一条墓碑。这样接收方不需要按顺序重放操作,重复应用也天然幂等。 - 字节从不内联传输。文件按固定 512 KiB 分块,每块用内容寻址(
sha256)。同一内容(比如node_modules里被多处引用的库)无论在几个路径出现,网络上只传一次。
一条ChangeEntry是怎么来的?当路径被修改后,materialiseChange 会读取该路径的当前状态——存活的 inode 优先于墓碑,正确处理了"删除后重建"的场景——然后把它打包成线上记录。
Push 流程:DO 如何把变更推给容器
每次执行命令前,DO 会先做一次push,把容器还没见过的变更流过去。流程分三步:
- 合并(coalesce)。coalesceChanges 扫描修订号区间内的所有脏路径,同一路径合并成一条(最新状态胜出)——两次执行命令之间重写同一个文件 5 次,网络上只花 1 条记录。条目按
rev升序、路径升序输出,为后面的断点续传铺路。 - 哈希探测(hasObjects)。发送方先问接收方:"这些块哈希你有哪些?"——相当于 git 的
have协商,用一次集合差运算就跳过所有已有字节,没有任何多余的元数据往返。 - 补传对象(pushObjects)+ 推条目流(push)。只传输缺失的块,然后流式发送合并后的
ChangeEntry批次。
接收方(容器)把整批变更放进单个同步事务里原子应用——半途失败就整批回滚,接收方永远不会看到"半截"的推送。
Pull 全流程:fetchChanges 到 applyChanges
命令执行完后,方向反过来:DO 把容器里 FUSE 捕获的写入拉回来。这是 pullOnce 实现的六步循环,也是全文的精华:
| 步骤 | 动作 | 一句话解释 |
|---|---|---|
| ① Fetch | fetchChanges({ after: 游标 }) | 从上次停下的(rev, path)游标处,按修订号排序流出ChangeEntry |
| ② 批处理 | 缓冲最多 256 条(PULL_BATCH_SIZE) | 峰值内存被批大小封顶,而不是整个流 |
| ③ Diff | 探测本地vfs_blobs+fetchObjects拉取缺失块 | 32 字节哈希的集合差,零内容重复传输 |
| ④ Apply | 批次交给applyChanges | 逐条写入 SQLite,见下方详解 |
| ⑤ 检查点 | 游标推进到该批最后一条的(rev, path) | 崩溃后从最后提交批次恢复,最多重做 256 条 |
| ⑥ 循环 | 回到 ② 处理下一批 | 直到流耗尽,最后写入currentCursor |
applyChanges 的三条防御线
applyChanges 是变更真正落地的地方,它内建了三道安全机制:
- 幂等跳过(alreadyApplied)。应用前逐条比对本地状态:文件比 manifest 哈希,目录比权限位,符号链接比目标+权限。已经一致的条目直接丢弃——这让"重复拉取"既便宜又无副作用,是断点恢复能安全工作的根基。
- 只读挂载守卫。落在只读挂载点下的条目不会被应用,而是收集进
skipped返回给调用方,保证挂载点内容不被静默篡改。 - 结构冲突清理。当远端发来一个文件、而本地该路径是个目录时,接收方会删掉本地子树再应用远端条目——这是**最后写入者胜出(last-writer-wins)**的冲突处理,树永远收敛,但不会做内容合并。
水位线(Watermark):崩溃恢复的秘密
协议双方交换的"进度表"有五个水位线:pushRev(DO 已推到的修订号)、fetchCursor(DO 已拉取到的容器游标)、currentRev(本地最新修订号)、appliedPushCursor(接收方已应用到的推送游标)。
两个亮点:
(rev, path)双坐标游标。一个大的目录改名可能在单个rev内产生成百上千条记录。纯数字游标崩溃后只能重放整个 rev;加入path坐标后,崩溃可以在 rev 内部精确恢复,而不用伪造从未整体提交的"幽灵修订号"。详见 watermarks.ts。- 跨侧不变量。每次 push 和 pull 的响应都会回声接收方的
appliedPushCursor,发送方断言它覆盖了自己刚推的修订号。一旦断言失败,说明接收方丢过状态——协议选择大声失败而不是静默腐坏。
重连场景更贴心:reconcileWatermarks 在连接建立时先问对方"你到哪了",发现落后就把本地游标归零,从 rev-0 基线全量重发——由于幂等检查,重复传输几乎零成本。
并发与冲突:什么时候安全?
- 单个 Workspace 内:所有写入被 DO 运行时串行化,不存在写-写冲突;
- 两个容器共享一个 Workspace:可能出现真冲突,语义是同步粒度上的 last-writer-wins——最后到达 DO 的推送胜出,先写的变更会被静默覆盖。这和"无锁 NFS 挂载"同语义。
实践建议(来自官方文档):一个 workspace 同时只有一个活跃写者、给多 Agent 划分互不相交的子树、把共享状态放到 DO 的 RPC 面上而不是共享文件里。完整的冲突语义分析见 docs/02_sync_protocol.md。
另一个对新手很实用的机制是ignore 列表:默认排除node_modules,否则一次npm install会把数万个小文件推进同步网络。被忽略的路径在容器内照常工作,只是字节永远不上线。
想继续深挖?三个入口
- 协议规范与全部设计取舍(为什么不要 rename 操作码、为什么借鉴 git 的 haves/wants):docs/02_sync_protocol.md
- 线上记录的定义与物化逻辑:packages/dofs/src/sync/changes.ts
- 批量应用与幂等检查的完整实现:packages/dofs/src/sync/apply.ts
- 拉取/推送/水位线对账的驱动循环:packages/rpc/src/sync-driver.ts
- 文件系统表结构与 rev 递增机制:docs/03_filesystem_schema.md
小结:Cloudflare Computer 的同步协议用四个概念解决了"两份文件树如何廉价且安全地收敛"——ChangeEntry状态化记录、内容寻址分块、(rev, path)断点游标、applyChanges幂等应用。它不追求合并冲突,而是用幂等和最终状态保证任何崩溃、重连、重复传输下系统都能收敛到正确状态。这正是分布式文件系统"简单优于聪明"的经典取舍。
【免费下载链接】computerGive your agent a computer 👾项目地址: https://gitcode.com/GitHub_Trending/computer1/computer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考