walgit 的源码里有一组故障注入的仿真测试,其中一个叫 healthy_request_round_trip_budgets,它数的是一次操作对对象存储发了几个请求(crates/walgit-server/tests/sim.rs,断言整理成一行)1:
ensure!(push_ops <= 5, "healthy push used {push_ops} requests, budget 5");ensure!(warm_ops <= 1, "warm refs read used {warm_ops} requests, budget 1");一次 push 最多 5 个请求,一次读最多 1 个。超过了,测试就失败。
walgit 是 Tobias Lütke 用 Rust 写的 Git 服务器,README 的第一句是“一个 git 服务器,就是对象存储前面的一个二进制文件”2。它没有数据库,没有主节点,可以有任意多台机器同时指向同一个桶(bucket,对象存储里装对象的容器),README 还说:把这些机器全关掉,丢掉的只是缓存的热度。
这两个数字让人有点不安。5 个请求里,没有一个是在和别的机器商量,那多台机器同时接受 push 时,谁说了算?读只问 1 次,凭什么敢说读到的就是最新的?对象存储一次请求要几十毫秒,git 读 packfile 却是成千上万次随机访问,仓库真的放在桶里吗?
《从 git clone 说起》的第五节用几段话介绍过 walgit 的思路。这一篇读它的源码,把这几个问题一层一层追下去,答案都落在桶里一个很小的文件上。
一、为什么要换一种存法
现象:副本越多,push 越慢
托管 Git 的老办法是把仓库放在本地 NVMe 盘上,再复制几份。GitHub 的 Spokes 每次 push 都要对引用更新做一次三阶段提交(3PC,让所有节点对“提交还是回滚”达成一致的协议),多数副本同意才算数。Cursor 的 Vicent Martí 指出,这样每一步都要等最慢的副本,副本越多 push 越慢;而每份副本都是真相的一部分,坏一份就得赶紧修,只能当“宠物”养3。
第一次尝试:让 git 直接读对象存储
对象存储(S3、GCS 这类)容量几乎无限、持久性极高,最直接的想法是把仓库放上去,让 git 直接读。图 1 说明了为什么不行:packfile 的排布只为“压得小”,读一个文件要沿着提交图跳好几次,大多数对象还是依赖别的对象的 delta,而每一跳的位置都要读完上一跳才知道。
按 walgit 在 GCS 上的测量,一次小对象 GET 要 60 到 80 毫秒4,而一次 clone 或 git log 要走成千上万跳。NFS 失败也是同一个原因;Cursor 还提到,Google 曾把 JGit 的对象放进分布式哈希表,最后因为 clone 太慢放弃了3。
解决:桶里放日志,磁盘只当缓存
Cursor 的系统 Continuity 换了一个角度:git 照旧在本地磁盘上跑,但本地仓库只是缓存,唯一的真相是对象存储里的一份预写日志(write-ahead log,WAL:先把“要做什么”持久地记下来,再去改实际状态)。walgit 是这个架构的开源实现,又补上了“机器比仓库还小”时需要的东西2。
图 2 里实例之间没有任何连线,只靠桶里的 manifest.pb 协调,用的是对象存储的两种条件请求(walgit 的 ObjectStore 抽象在 S3 上直接用这两个请求头,在 GCS 上换成对象的 generation 编号5):
- 条件写:
PUT时带上If-Match: <ETag>。ETag 是对象存储给每个对象版本的标识,内容一变它就变。只有对象的当前版本还是我读到的那个,这次覆盖才会成功,否则返回 412。这就是比较并交换(compare-and-swap,CAS)。 - 条件读:
GET时带上If-None-Match: <ETag>。对象没变就返回 304,不带正文。
哪台机器负责哪个仓库,由配置里的 [placement] 决定;只读引用的请求在任何一台上都能回答6。
既然任何一台空白的机器“读一遍日志就有了仓库”,桶里到底存了些什么?
二、桶内布局
只有一个对象会被改写
每个仓库在桶里占一个前缀 repos/<owner>/<repo>/。下面的对象分成两类:会被改写的只有 manifest.pb 和几个控制对象(比如租约),其余的写一次就再也不改7。
manifest 是目录,其余对象按序号或内容命名。 图里没画出来的细节有三点:manifest 连仓库级配置也内联在里面(D24),读的时候不必为配置多发请求;日志段的键是第一个序号的 16 位十六进制,条目有 PUSH、REF_UPDATE、COMPACT、CHECKPOINT、SETTINGS 五种;pack 和引用快照都按内容寻址,两个写者写同一份内容,写的是同一个键、同样的字节7。
读者从不 LIST
对象存储支持按前缀列出对象(LIST),但 walgit 规定热路径上不准用:它慢、要翻页,ROUNDTRIPS.md 还说它的一致性“有点像最终一致”4。读者只认 manifest 里列出的键。这条规矩其实是整个设计的地基:写进桶里却没被 manifest 引用的对象,对任何人都不可见,所以一次 push 可以先把数据慢慢写进桶,最后改一下 manifest 再“亮出来”。
那么一次 push 具体是怎么把东西放进来的?
三、一次 push 的完整流程
现象:5 个请求,3 轮往返
walgit 没有用 git 自带的 receive-pack,而是自己实现了它,这样才能把“提交”换成对桶的操作。图 4 是一次正常 push 的全过程89。
能在本地做的,都先在本地做完。 收包在一个临时目录里进行(默认还带 --fsck-objects),连通性和 policy.json 里的推送规则(保护分支、只许快进之类)也在这时检查,任何一步失败,临时目录一删,桶里什么都没留下。verify_txn 拿每条命令带的旧值和本地引用对比,这和 git 自己更新引用时“比对旧值、再写新值”是同一个检查。
对桶只发 3 轮。 ROUNDTRIPS.md 把图里的 ①②③ 算作 5 个请求、3 轮串行往返,这就是开头那个 5;读 policy.json 那一次属于 HTTP 层,不在这份预算里48。第 ② 轮里 pack 若返回 412,说明别人传过同样的字节,直接当成功;日志段用 Create 抢的是 head_seq + 1 这个序号。
为什么 CAS 就是提交点
CAS 成功之前,新的 pack 和日志段已经在桶里,但 manifest 不引用它们,对谁都不可见;CAS 成功的那一刻,它们一起出现。两台实例同时做 CAS,对象存储只让一个成功,所以所有 push 能排成一个全序,每个都好像发生在自己 CAS 成功的那一瞬间。这叫线性化(linearizable),walgit 的文档直接把 manifest 叫作 “the linearization point”6,Cursor 的原话是“这次 CAS 就是共识”3。所以 walgit 规定:桶确认之前,绝不给客户端回 ok;反过来,CAS 一旦成功,就一定回 ok(原因在第八节)。
一台实例这样做没有问题。可如果两台实例同时收到 push 呢?
四、并发 push 的冲突处理
现象:两个写者抢同一个序号
两台实例都同步到了 head_seq = 41,又同时收到 push。它们都想用 seq 42,都会去创建 log/000000000000002a.pb。图 5 是接下来发生的事9。
冲突先出现在日志段上。 B 创建 …2a.pb 得到 412 时,还分不清这个段是已经提交,还是对方还没来得及 CAS,所以它重读 manifest:head_seq 到了 42,就重新同步、对批次里每个请求重跑 verify_txn;还是 41,就隔 100 毫秒再看。图里推 main 的请求被拒,ng 的原因写着 conflict: expected 5be1…, got 0a77…;推 feat 的请求换 seq 43 提交9。
manifest 也可能被别人改了。 检查点、压缩、改配置也会 CAS manifest,它们不占新序号。这时 B 的 If-Match 返回 412,它带着版本号条件删除自己刚写的段(不会误删别人后来写在同一个键上的东西),再重新同步、校验、重试,默认最多 16 次(wal.cas_max_retries)10。
冲突是正确的,但不便宜。 Cursor 用 rendezvous hashing(把仓库 ID 稳定映射到一组节点上的哈希)让一个仓库总落在同一台主节点上3;walgit 让维护某个仓库的主机当它的写者(D28),其他主机可以把 receive-pack 转发给一个 push broker。文档强调这只是优化,不是正确性的依赖,因为发布本身是 CAS 安全的6。
可即使只有一个写者,问题也没有消失:manifest 是一个对象,而对象存储对同一个对象的写入速度有上限。
五、每秒一次写与 group commit
现象:一个对象每秒只能改一次
GCS 的配额文档写得很直白:同一个对象名,写入上限是每秒一次,超过了可能被限流11。walgit 在 GCS 上测到的也是“同一对象的覆盖写是串行的,约每秒一次”,并在文档里写明:一个被 CAS 改写的对象,就是一个吞吐上限4。
manifest 恰好是每次 push 都要 CAS 的那个对象。如果一个 push 一次 CAS,一个热门仓库每秒就只能接受大约一个 push。
解决:把并发的 push 合成一批
walgit 给每个仓库在每台实例上只开一个发布任务 publisher_task,所有 push 都排进它的队列。它一次取出一批,整批只写一个日志段、只做一次 CAS。这就是数据库里常说的 group commit。
批里的每个请求各自校验。 校验在一张“工作引用表”上依次进行:请求 1 通过后,它的新值立刻写进这张表,所以请求 2 再拿旧值 5be1… 来比,就会发现已经变了。被拒的请求不分配序号;一个请求里有一条引用被拒,同一请求的其他引用也一起被拒(错误信息是 “another ref in the submission was rejected”),但不会影响别的请求9。
单独一个 push 不会被拖慢。 默认窗口 batch_window = 5ms,一批最多 max_batch = 64 个10。代码注释特意写了:只有队列里已经有别的请求在等时,才会等满这个窗口;一个孤零零的 push 直接进入 CAS。
walgit 的文档没有给出它自己的 push 吞吐数字。Cursor 的文章给了 Continuity 的数据:按他们自己的测量,在 S3 Standard 上每秒能接受 120 个 push,在延迟更低的 S3 Express One Zone 上超过 300 个,瓶颈变成了 git 压缩磁盘数据的速度3。另外,“每秒一次”是 GCS 文档写明的限制,S3 的文档里我没有找到同样的条款;walgit 的设计是按 GCS 的数字定的。
写的一侧到这里就收拾好了。读的一侧,那个“只问一次”又是怎么做到的?
六、读路径:条件 GET 与分级同步
每个请求先问一句“有新东西吗”
实例在本地记着自己上次看到的 manifest ETag。每个读请求开始前,freshness_check 用 If-None-Match 带上它去问桶:返回 304,本地就是最新的;返回 200,就带回了新 manifest,先追平再服务12。按 walgit 在 GCS 上的测量,304 只要 15 到 18 毫秒,大约是普通 GET 的四分之一4。这就是开头那个 1。
这一步默认从不省略。 wal.freshness_ttl 默认是 010,对应 walgit 的原则“每次读都重新校验,不存在最终一致”6:每个请求多一个 15 毫秒级的往返,换来的是 push 被确认之后,下一个请求不管落在哪台机器上都能看到它。Continuity 另用 UDP 广播提醒副本有新 push,丢了也无妨3;walgit 连广播也不做,实例之间没有任何网络通信。
追平的成本被压到最低。 返回 200 时,apply_delta 按需加载检查点的引用快照,把日志尾巴的各段以每批 16 个并行取回,最后合并所有引用事务、一次性改写 packed-refs。代码注释记了一笔账:一个 50 万引用的仓库,如果每段都改写一次 packed-refs,每段要 500 毫秒12。
解决:按请求需要分级
“追平”到什么程度,取决于请求要什么。walgit 把同步分成几级,图 7 从左到右拿得越来越多612。
每一级只拿请求需要的那部分。 回答 ls-remote 只要 Refs 级,冷启动 2 个请求,验收标准是最大的仓库也在 1 秒内回答;Full 级放不下时返回 TooLarge(HTTP 503),绝不硬拉一个装不下的 pack 把机器撑爆。下载 pack 跑在单独的运行时上,不拿 Refs 阶段的锁:AGENTS.md 记过一次事故,一个 24 分钟的 clone 加上一个排队的写锁,让整台实例的 info/refs 等了几分钟(D19)6。
仓库比机器还大:remote reader
walgit 的参考负载是一个 57 GiB、7300 万个对象、140 万个提交、46.6 万个引用的 monorepo,跑它的机器所谓的“磁盘”是 20 GiB 的内存盘(tmpfs),而 base pack 本身就超过 30 GB6。Objects 级的 remote reader 就是为这种情况准备的,图 8 是它读一个文件的过程13。
读到对象之后,Web 界面把这条 git 命令会碰到的对象写进本地的松散对象库,再照常运行未经修改的 git。history pack(D18)只含提交和树,每台实例都放在本地,遍历历史就不用碰网络。按项目自己的测量,CI 常用的 clone --filter=blob:none --depth=1 --sparse --single-branch 从 2075 秒降到了 8 秒6。
日志每次 push 都在变长。新机器冷启动时要从头重放吗?pack 越来越多,谁来合并?
七、日志的折叠:检查点与压缩
检查点:冷启动 = 快照 + 尾巴
日志不能无限长,否则冷启动要重放所有条目。checkpoint_due 定了三个触发条件,满足任一个就写检查点:距上次已有 256 条,或者上次已过 1 小时,或者日志尾巴超过 8 MiB1410。
写检查点只需要 Refs 级的状态:把当前引用快照和 pack 集合并行写成两个不可变对象,然后 CAS manifest,让 checkpoint 指向它,把 min_seq 设成检查点序号加一,并从 log_segments 里去掉已经折叠进去的段。和 push 一样,CAS 是唯一的提交点;写了一半就死掉的检查点,只留下没人引用的对象。每次 push 后,写者若发现条件满足会顺手写一个,没人 push 的仓库则由 maintain 角色按时间补上9。
压缩:一台机器做,所有机器用
git 自己也要定期把很多小 pack 合并(repack),否则查一个对象要挨个翻几百个索引。在 Spokes 里 repack 必须在每个副本上各做一次,而且在两个副本上同时做同一个仓库的维护,很容易触发故障切换3。
walgit 让一台机器做一次,结果作为一条 COMPACT 日志发布出去:新 pack 是什么,supersedes 列出它取代了哪几个旧 pack。其他实例下次同步时直接下载合并好的 pack,用带宽换 CPU。
谁来做,由租约决定。 租约是桶里的一个小对象 leases/compact.pb:不存在时用 Create 抢;已过期(再加 2 秒的时钟误差容忍)时用 If-Match 覆盖接手;持有者定期用 CAS 心跳,把 epoch 加一、把过期时间往后推,心跳返回 412 就说明租约被别人拿走了15。默认 TTL 是 10 分钟10。仿真测试里专门有一个场景:持有租约的实例死掉,另一台在租约过期后接手完成压缩1。
租约只决定谁来干活,决定结果是否生效的仍是 manifest 的 CAS。 压缩的发布和 push 走同一条路:抢日志序号、CAS manifest。图 9 里,压缩想占 seq 44,却被一个 push 抢先;M 重新同步后,基于新的 manifest 重新计算:只去掉自己的三个输入,把 push 带来的 5f30… 原样保留,以 seq 45 提交9。所以即使租约机制出了问题、两台机器都在压缩,从发布代码看,结果也只会是其中一个输了 CAS、重新计算,不会丢掉别人的 pack。这是我根据 publish_compact_classified 的实现得出的判断,文档里的说法是“发布是 CAS 安全的”。
旧 pack 不会马上消失。 本地的旧 pack 要等所有正在使用它的读请求结束才删;桶里的字节则一直保留,记在 manifest 的 retired_packs 里,没有按时间清理的开关。最近的几条设计决策(D45、D49)还要求,最终提交前必须重新核对:每个被取代的 pack 里的每个对象,都还能在新的 pack 里找到6。
到这里,每一种写(push、检查点、压缩)都遵循“先写不可变对象,再 CAS manifest”。如果进程正好死在中间呢?
八、崩溃恢复
一次 push 有好几个步骤,进程可能死在任何一步之间。图 10 把四个崩溃点和各自的结局放在一起。
提交点之前死掉,留下的只是垃圾;之后死掉,提交已经生效。 ② 的孤儿段被下一个写者探测 3 次后跳过(代码里叫 burn),所以日志序号严格递增但不一定连续,读者顺着 manifest.log_segments 走,不在乎缺号9。④ 的情况下只要 CAS 成功就一律回 ok:代码注释记录过,曾经在本地应用失败时向客户端报错,结果出现了“push 实际已经持久、git 却报告失败”的情况。
③ 最难:CAS 发出去了,响应丢了。 那次写可能已经生效,所以 cas_landed 要把段的字节取回来完整比对。S3 普通上传的 ETag 就是内容的 MD5,同样的内容删掉重写,版本号可能一模一样,因此每次抢序号都会在条目里塞一个随机的 walgit.publication_nonce95。找不到证据就报告 CommitUnknown,绝不能回 ng(D47)6。
这些结论都有测试守着:仿真测试给每个实例接一条可注入故障的链路,模拟崩溃、分区、过期读取、丢失的响应、孤儿段等场景1;docs/spec/ 下还有一组 TLA+ 有界模型,文档也坦白说明,它们只探索有限的交错,并不证明 Rust 代码本身正确16。
walgit 选择了“一个可变的 manifest 加 CAS”。同样想把 Git 放进对象存储,别人是怎么做的?
九、与 GitLab 对象存储后端的对照
GitLab 的 Gitaly 里也在做一个对象存储后端,属于实验性的 MVCC 后端,代码在 ee/mvcc/objectstorage/ 下,相关设计文档自称描述的是 POC 分支上的流程17。它遇到了同一个“每秒一次写”的限制,包注释里写得很明白:为了不把吞吐卡在“同一对象的更新上限”上,它不改写任何指针对象18。
它的做法是只追加:每次提交都新建一个不可变的 generation 对象 generations/<16 位十六进制>,用“不存在才创建”写下一个编号,创建成功就是提交成功。编号存成 MaxUint64 - generation,让最新的那个在字典序里排第一;读者要找当前版本,就对这个前缀做一次只取 2 条结果的 LIST,第二条是一个把 manifest 哈希写在键名里的兄弟对象,省掉一次读正文18。
| walgit | GitLab MVCC 对象存储后端 | |
|---|---|---|
| 提交点 | 对 manifest.pb 做 If-Match 覆盖 | 用“不存在才创建”写下一个 generation |
| 每秒一次写的限制 | 存在,用 group commit 摊薄 | 不存在,每次提交写的是新对象 |
| 读者怎么知道最新版本 | 条件 GET,没变就 304 | LIST 一次(最多 2 条) |
| 热路径上的 LIST | 禁止 | 每次确认最新版本都要一次 |
| 冲突信号 | 412 | 创建冲突(S3 上还要特殊识别错误类型) |
有意思的是,walgit 的日志序号用的正是 GitLab 的那种原语:log/<seq>.pb 也是“不存在才创建”,抢到序号的人才能继续。walgit 完全可以把“抢到序号”当成提交点,可它没有,而是在后面再加一次 manifest 的 CAS。我的推断是,这是为读路径付的代价:只有一个固定的、可变的对象,读者才能用条件 GET 拿到廉价的 304,而不用每次都 LIST;manifest 还能顺带告诉读者该去取哪些段、哪些 pack。这正是 ROUNDTRIPS.md 里“把状态放进你反正要取的那个对象”这条经验4。代价则是写的一侧要面对每秒一次的上限,于是有了 group commit 和单写者的部署习惯。
两者其实是同一个框架:不可变对象,加上一个原子的提交点。区别只在于提交点是“改写同一个对象”,还是“新建下一个对象”,一边把成本放在写上,一边放在读上。
十、回到开头的 5 和 1
一次 push 的 5 个请求,现在可以说清楚了: 1 次确认新鲜的条件 GET、3 个并行的“不存在才创建”(pack、idx、日志段)和 1 次 manifest 的 CAS。没有一个请求是在和别的机器商量,因为不需要商量:多台实例同时提交,对象存储只会让一个 CAS 成功,输的一方重新同步、重新校验旧值再试。
一次读的 1 个请求,是对 manifest 的条件 GET。manifest 是所有写入唯一的提交点,它的 ETag 没变,就说明自上次以来没有任何提交生效,本地缓存可以放心使用;变了,就顺着新 manifest 追平。
仓库也确实在桶里,但 git 从不直接读桶。它读的是本地磁盘上的缓存,缓存放不下的部分,才用 1 MiB 一块的范围读补上。
最后说明几点边界。walgit 是一个很新的项目,版本号还没到 1.0,AGENTS.md 开头就声明不保证向后兼容,只有桶里的数据格式保持可重放;本文读的 2026 年 9 月版本里,pack 分发方式刚刚从 bundle 换成可复用的 pack(D42),相关部分还在演进6。文中所有性能数字都是项目自己的测量,我没有复现:本机缺少构建所需的 protoc,没有实际跑起来。Hacker News 的讨论里也有人质疑它的安全性19。
小结
- 为什么不让 git 直接读对象存储? packfile 的读取是大量串行随机访问,对象存储一次请求就要几十毫秒。所以 git 仍然在本地磁盘上跑,本地仓库只是缓存,真相是桶里的预写日志。
- 桶里有什么? 一个会被 CAS 改写的
manifest.pb,加上按序号或内容寻址、写一次就不再改的日志段、pack 和检查点。读者只认 manifest,从不 LIST。 - push 什么时候算提交? manifest 的 CAS 成功的那一刻。之前写进桶的东西没人引用,对谁都不可见;之后才回
ok。 - 并发 push 怎么办? 先在日志序号上相撞,输的一方重新同步、逐个请求重新校验旧值,过期的回
ng,其余的换下一个序号再提交。 - 每个对象每秒只能写一次怎么办? 每个仓库一个发布任务,把并发的 push 合成一批,只写一个日志段、只做一次 CAS。
- 读怎么保证最新? 每次先对 manifest 做条件 GET,304 就直接服务;再按请求需要分成 Refs、Serve、Full、Objects 几级补齐,放不下的用范围读。
- 日志和 pack 越来越多怎么办? 检查点把日志折叠成快照;压缩由持有租约的一台机器做,结果作为 COMPACT 条目发布,但生效与否仍由 CAS 决定。
- 崩溃了怎么办? 提交点之前死掉,留下的是没人引用的垃圾;响应丢失时比对字节判定,找不到证据就报告“结果未知”;提交点之后死掉,下次同步自动重放。
- 还有别的做法吗? GitLab 的实验性后端用“只追加新的 generation 对象”避开了写入上限,代价是读者要靠 LIST 找最新版本。
参考资料
Footnotes
-
walgit 仿真测试 sim.rsgithub.com ↗
healthy_request_round_trip_budgets等故障注入场景https://github.com/tobi/walgit/blob/main/crates/walgit-server/tests/sim.rs ↩ ↩2 ↩3 -
tobi/walgitgithub.com ↗本文读的是 2026-09-12 的提交
80e9a20,README 与docs/announcing-walgit.mdhttps://github.com/tobi/walgit ↩ ↩2 -
Git at any scalecursor.com ↗https://cursor.com/blog/git-at-any-scale ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
walgit docs/ROUNDTRIPS.md:往返次数就是成本模型github.com ↗§1 GCS 实测数字,§2 各操作的请求预算https://github.com/tobi/walgit/blob/main/docs/ROUNDTRIPS.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
walgit-store:ObjectStore 抽象与 S3 映射github.com ↗
lib.rs的GetOptions、PutMode;s3.rs开头注释说明Create→If-None-Match: *、Update→If-Matchhttps://github.com/tobi/walgit/blob/main/crates/walgit-store/src/lib.rs ↩ ↩2 -
walgit AGENTS.md:架构与运维手册github.com ↗§1 约束、§2 WAL、§3 原则、§4 决策 D1–D49https://github.com/tobi/walgit/blob/main/AGENTS.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
walgit wal.proto:桶内对象的格式github.com ↗
Manifest、LogEntry、PackRef、Checkpoint、Lease;键名在walgit-proto/src/lib.rs的keys模块https://github.com/tobi/walgit/blob/main/crates/walgit-proto/proto/walgit/v1/wal.proto ↩ ↩2 -
walgit smart.rs:receive-pack 的 HTTP 处理github.com ↗
receive_pack_process:收包、连通性检查、读policy.json、发布https://github.com/tobi/walgit/blob/main/crates/walgit-server/src/smart.rs ↩ ↩2 -
walgit publish.rs:发布路径github.com ↗开头注释 1–36 行;
claim_log_slot、cas_landed、verify_txn、publisher_task、process_batch、publish_compact_classifiedhttps://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/publish.rs ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 -
walgit-config:默认配置github.com ↗
WalConfig、CacheConfig的Default;租约 TTL 在packs.rshttps://github.com/tobi/walgit/blob/main/crates/walgit-config/src/lib.rs ↩ ↩2 ↩3 ↩4 ↩5 -
Cloud Storage quotas & limitsdocs.cloud.google.com ↗同一对象名的写入上限为每秒一次https://docs.cloud.google.com/storage/quotas ↩
-
walgit sync.rs 与 handle.rs:读路径github.com ↗
freshness_check、apply_delta、replay_log、SyncLevel;分级入口sync_refs/sync/sync_full/sync_objects在handle.rshttps://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/sync.rs ↩ ↩2 ↩3 -
walgit remote.rs:remote readergithub.com ↗
BLOCK_SIZE = 1 MiB,pack 索引本地、数据范围读https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/remote.rs ↩ -
walgit checkpoint.rs:检查点github.com ↗
checkpoint_due的三个触发条件,write_checkpoint_inner的两轮写https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/checkpoint.rs ↩ -
walgit coord.rs:CAS 循环与租约github.com ↗
try_acquire、LeaseGuard::heartbeat、LEASE_SKEW_TOLERANCE = 2shttps://github.com/tobi/walgit/blob/main/crates/walgit-store/src/coord.rs ↩ -
walgit docs/spec:有界模型检查github.com ↗TLA+ 模型
WALContract.tla与契约编号 C1–C16https://github.com/tobi/walgit/blob/main/docs/spec/README.md ↩ -
Gitaly doc/mvcc_rpc_flow.mdgitlab.com ↗文档自称描述的是 POC 分支上的流程https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md ↩
-
Gitaly ee/mvcc/objectstorage/objectstorage.gogitlab.com ↗包注释 “Manifest generations”,
head、Commit、createGeneration;读的是 2026-10-14 的提交77bee05https://gitlab.com/gitlab-org/gitaly/-/blob/master/ee/mvcc/objectstorage/objectstorage.go ↩ ↩2 -
Walgit: a Git server that runs as one binary in front of an object storenews.ycombinator.com ↗https://news.ycombinator.com/item?id=49420598 ↩