一千个 agent 同时 push:Aspen 把 Git 服务端从磁盘目录改成 Postgres + 对象存储
经典 Git 服务器把每个仓库当成磁盘上的一个目录。这个模型在「几十个人、每天几十次 push」下没问题,换成成百上千个 agent 各自推自己的分支、CI 在每次 push 后都 fetch,瓶颈立刻暴露出来:每次 push 要对仓库加锁,每次 fetch 服务器都要为这个客户端现做一次 pack。服务器的 CPU 时间花在锁和重复压缩同样的对象上,工作量随客户端数量增长,而不是随新代码量增长。
Aspen 是把这套假设整个换掉的实现。
- 项目地址:(Apache-2.0,pre-alpha)
- 线上 demo:,共享像素画布,每一笔都是一个 git push
- 设计说明:
- 灵感来源:OpenAI《Scaling Git at OpenAI》里的内部 Git 后端 Pando,以及 Cursor《Git at any scale》里的 Origin。Aspen 是独立实现,与 OpenAI / Cursor 无关联,不共享代码。
放弃的第一个保证:所有分支的同一时刻快照
普通 Git 服务器隐含一个语义:你在某一刻看仓库,所有分支处于同一时刻。Aspen 不要这个。
每个分支在 Postgres 里是一串版本,独立移动。唯一争用的写是该分支指针上的一次 compare-and-swap:「把这个分支从提交 A 移到 B,前提是它仍指向 A」。推到不同分支的操作从不互相等待。同一分支的两次推送在该行竞争,一个赢,另一个拿到 Git 常见的 "fetch first" 再 rebase。
代价很直接:同时看多个分支的读者,看到的分支可能各自处于不同时刻。对于「每个 agent 一条分支、CI 只关心自己那条」的负载,这个代价几乎为零;对于需要跨分支原子视图的场景,就得自己处理。
放弃的第二个保证:为每个客户端定制 pack
Aspen 从不解包 push。它检查 pack,补上服务器已有而客户端省略的对象(thin-pack completion),原样存入对象存储。
存储后端:
- S3 / S3 兼容
- GCS 走 S3 API + HMAC key
- 本地目录
- Azure Blob 暂不支持
fetch 时算出对方缺哪些已存 pack 直接流式发送,不重新压缩。也可以发签名 URL 让客户端从 bucket、pack cache 或 CDN 下载。启用 fetch.uriprotocols 的客户端发链接,其余客户端由 stitcher 把选中的 pack 拼成一个发出去。
代价是没有「为这个客户端优化过的 pack」,客户端拿到的是服务器上已存在的对象块。换来的是服务器不再因为客户端数量而重复做同样的事。
push 只能看到 trunk
普通服务器在 push 前会把所有分支列给客户端,好让客户端跳过服务器已有的对象。百万分支时这个列表本身就巨大,而且客户端打出来的 pack 取决于当时列了哪些分支,别人没法复用。
Aspen 只列 trunk 分支。push 因此带上自 trunk 以来的全部对象,拿到 trunk 的人用这一个 pack 就能 fetch 该分支。push 体积略大,但会话大小与分支数无关。
可见的差异是行为层面的:
git push --force和--delete需要额外加-o force,或使用 lease,或用分支级 URL。- 它不实现 OpenAI fork 里的 explicit-haves 扩展,所以裸
--force-with-lease不行。
后台合并
小 pack 在后台按层合并,像 LSM tree。层的边界同时当版本跳表用,所以规划一次 fetch 的复杂度是客户端落后距离的对数级——落后几千次 push 的客户端拿到的是几十个 pack,不是几千个。
与 Pando / Continuity 的差异
- 没有仓库级 WAL:Cursor Continuity 用一个 S3 上的 write-ahead log 给所有 ref 一个一致视图;Aspen 只给同一分支的 push 排序,因此没有单个日志的 CAS 延迟卡住整仓推送速率,也没有全分支快照。
- 磁盘上没有 Git 仓库:Continuity 在本地 NVMe 保留正常 Git 仓库当缓存并避开关系库;Aspen 完全不落仓库,pack 在对象存储、元数据在 PostgreSQL(单机可用 SQLite),任何一台 server 都能回答任何请求。
- 给原版客户端拼 pack,stock git 不改配置也能被服务。
- 自包含 pack:thin pack 入库前补齐 base。
- 精确 shallow pack:CI 常
--depth=1,对分支 tip 的--depth=N(N 上限 64)fetch 会得到一个按版本与深度构建一次并存储的 shallow pack。 - 服务端 merge queue:
ASPEN_MERGE_QUEUE(默认关),把多个改动各自作为 commit 在一次 trunk 更新里落地。 - 单进程开发模式:
aspen dev用 SQLite + 本地目录跑完整服务。
Quickstart
没有发布二进制,必须从源码构建。Rust 工具链版本锁在 rust-toolchain.toml,首次使用自动安装;需要 C 编译器(build-essential / Xcode CLT);小机器编译可能超十分钟。
git clone https://github.com/reason-machines/aspen
cd aspen
cargo install --locked --path crates/aspen-cli
aspen --version
团队用有 Docker Compose stack、Helm chart,以及 AWS / Google Cloud / Azure / Hetzner 的 Terraform。mirror mode 把 Aspen 放在 GitHub 或 GitLab 前面做代理,不用迁移就能试。
性能
在 8 核服务器 + S3 Standard + 4 核 Postgres 上,通过 Aspen 的分支 URL 往 git.git 推送,5 分钟内稳定 567 push/s 无错误。每次 push 在 Git 打印 "ok" 之前都已写入 S3 和 Postgres;推送延迟中位 208ms、p99 314ms;另一个客户端在推送确认后中位 30ms 看到它。作者明确说这是 toy model 不是 benchmark,数字是自己测的。
正确性靠差分测试:把随机 push、删除、竞争同时重放到 Aspen 和 Git 自带服务器,要求结果完全相同;每晚跑 Git 2.30 到最新版以及 JGit;另有 fuzz 和推送中途杀进程的混沌测试。项目自称主要由 agent 构建:四天、151 个已合并 PR,从首提交到公开。
Known limits
| 限制 | 表现 |
|---|---|
| partial clone | 不广告 --filter,git clone --filter=... 退化为完整克隆 |
| shallow fetch | 除分支 tip 的 --depth=N(N≤64)外,返回所请求内容的完整历史(带正确 shallow 边界) |
| 仓库类型 | 无 SHA-256 仓库、无 Web UI、无 code review / issues(用 mirror mode 留在 GitHub/GitLab) |
| tag | git fetch 不会自动跟随新 annotated tag;git fetch --tags 和 git clone 会 |
| SSH | 只接受 Ed25519 与 ECDSA |
| 限流 | 每实例内存态;没有按仓库的存储配额 |
| mirror mode | 只支持 HTTP(S) 上游(无 SSH,凭据只走 HTTPS 或 loopback),不支持 Git LFS |
| 测量缺口 | 大仓库上 compaction 与 multi-ref push 的存储放大尚未测量 |
| 状态 | pre-alpha,未过自己的发布门,没有 release,存储格式可能变 |
安全模型要点(docs/SECURITY.md)
- 请求头必须在首字节后 30s 内到齐;HTTP/2 每 30s ping,单连接最多 100 条流。
- fetch body 整体读,上限 9 MiB(gunzip 后同样计),v2 解析器上限 8 MiB;push body 流式,受每次 push 字节上限与每小时字节预算约束;body 60s 无字节失败,等待 10s 后平均速率低于 1 KiB/s 也失败。
- 并发名额:per caller 默认 16/4/8(fetch/push/stitch),超出 429;每实例
ASPEN_MAX_CONCURRENT_*默认 32/16/32,超出进有界队列(64,最多等 10s)然后 503,都带Retry-After。 /healthz不碰任何依赖;/readyz用最多 1 秒旧的数据库检查结果。- 指标在私有监听
ASPEN_METRICS_LISTEN(默认127.0.0.1:9464),只有 loopback 的aspen dev才和 Git 共用端口。 - delta 深度上限 4095,重复对象拒收,对象数与 delta 大小在分配前按 header 校验。
在 pre-alpha、没有 release、存储格式可能变的前提下,不要把它放在存不能丢的数据的地方。