1278 lines · 437.8 KB · Markdown
RawDownload

Aspen: execution plan

Aspen is a self-hostable Git server, written in Rust, built for agent-scale traffic. It follows the design OpenAI published for Pando ("Scaling Git at OpenAI", https://openai.com/index/scaling-git-at-openai/, 2026-11-02):

  • packs are stored in object storage without being unpacked; a thin pushed pack is first completed into a self-contained pack (spike S3);
  • branch history lives in a database, and each branch updates independently;
  • a push sees only the trunk branch;
  • fetches are served by selecting stored packs, never by building one per client;
  • small packs are merged in the background in levels, like an LSM tree.

It also takes inspiration from Cursor's "Git at any scale" (https://cursor.com/blog/git-at-any-scale, August 2026): push throughput is measured against Cursor's published numbers, counting a push only once it is durable, and group commit batches the write that publishes pushes the way Cursor's design does (decisions log, "Group commit"). README.md, "Where the design comes from", says which idea came from which source.

Aspen is open source under the Apache License 2.0 and published at https://github.com/reason-machines/aspen by a regular sync from the maintainers' development repository. Write everything (code, docs, commit history) as if strangers will read it. This file is the source of truth for scope and sequencing. Update it when a spike or a review changes a decision, and record why.

Owner: Aspen, a coding agent that coordinates the work: it keeps this plan, reviews and merges every pull request, and runs each node in its own agent session (the "Agent session" column below). Requested by the maintainers, 2026-11-02.

Progress

The coordinator updates this table after every merge. States: ready, in progress, in review, merged, blocked. Node IDs are defined in section 3.

Node State PR Agent session
N0 Bootstrap: workspace + fast CI merged #1; follow-up #191 (39c5ff2, a CI run) fixes #189 (the required interop smoke failed whenever Launchpad's API was down; it now installs the latest Git without that API and fails unless the Git it tests is the latest release); follow-up #235 (a45ec4a, a CI run) fixes #72 and #207 (git's automatic maintenance repacked a test repository while git fsck read it; every test git client now turns it off), follow-up issue #244; follow-up #237 (a010b3d, a CI run) fixes #224 (rustdoc reported broken and private intra-doc links; required CI's clippy + rustdoc + layering job now denies rustdoc warnings) 41f332f7
S1 Spike: stitched packs vs clients merged #12 (2a59c6c) 6cd863cd
S2 Spike: trunk-only push semantics merged #4 (7efaf66) 7e0672a1
S3 Spike: packfile-uri across clients merged #6 (0030de3) 9097be06
S4 Spike: streaming gix-pack ingest merged #15 (7fa04a5) 318cc074
S5 Spike: shallow and filter requests merged #3 (f784cb6) 8c302a46
N1 Contracts in aspen-core plus schema v1, revised from the spikes merged #18 (210a5b4) 2b241aaf
A1 aspen-proto merged #22 (e365b60) 441ded5f
A1b v0 upload-pack in aspen-proto merged #33 (93d13e2) 52dc423a
A2 aspen-pack: ingest, index, commit extraction, stitcher merged #21 (c7e673b) 5178c9ef
A3 aspen-meta: Postgres + SQLite, compare-and-swap, commit index, jobs merged #24 (224c75a) 29ac2bb8
A4 aspen-blob merged #20 (92b1ac9) 2b550ea5
A5 aspen-planner + property tests against a git rev-list --objects oracle merged #23 (7744ba7) d5146d0d
A6 testkit: differential oracle, client matrix images merged #9 (a118bec) 999a339b
A7 bench/: aspen-bench load generator plus baselines merged #7 (371fda7) 66f280fa
A8 deploy/compose, plus Terraform AWS infrastructure merged #5 (9255b3a), follow-up #10 (ddd64b6) 8091b082
A9 demo/place front end against a mock API merged #8 (c1b7fb3) 404e7c60
B1 receive-pack service; measures push latency with thin-pack completion merged #37 (cd8dd69) 83c51478
B2 upload-pack v2: ls-refs, planning, packfile-uri + stitched fallback merged #34 (fb6ac9e) 0a93a6ec
B3 Auth, multi-repo, quotas, rate limits merged #31 (ef1ef85) d8e15304
C1 Compaction worker + skip-list planning merged #73 (84da3dd) bcb02b90
C2 aspen-express + CDN mode merged #46 (ba8fceb) d574ec88
C3 SSH transport merged #52 (f1277a1) ba35b787
C4 Mirror mode merged #53 (c4656f6) b38b1af1
C5 Observability: OTel, Prometheus, Grafana JSON; dead-job and wider-trunk-version metrics; aspen doctor jobs merged #59 (019e296) 52db382b
C6 Helm; Terraform for GCP, Azure and Hetzner+MinIO; release pipeline merged #51 (14397ef) 5da83d44
C7 Demo back end: place repo rules, merger bot, live event feed merged #54 (51109f2) c6fbe5f1
C8 Exact cached shallow packs merged #104 (6578ec9), follow-ups #107, #108, #109, #110 d6e77ec2
D1 Chaos suite merged #128 (5b495b2); nightly a CI run on 5b495b2: every scenario passed (compaction kill fired 33 times in compaction, 3 in mixed); follow-up #153; follow-up #169 (0056381, a CI run) fixes #132 (a push libcurl resent after a connection reset was reported rejected although it landed); follow-up #176 (ec16b61, a CI run) fixes #131 (a transient store error left a compaction job leased to an idle worker), and the same gap in mirror mode's worker is #188; #203 (chaos mixed stored 60 to 120 times the repository in the bucket, about 4.5 GB an hour) fixed in #231 (be53c39, a CI run) (a plain-URL branch push stores only what the branch lacks, and superseded packs are reclaimed within hours); #225 (a continuous 24-hour run did not fit the 63 GB host) stays open until a continuous 24-hour run confirms disk use; the launch gate's 24-hour mixed run awaits a host and budget from the maintainers 66cce8c3
D2 Benchmark campaign + write-up merged #148 (8b6b972); first campaign in bench/campaign/ (one 4-vCPU machine, server and clients together, memory resized from 8.35 GB to 16.94 GB partway through); nightly bench now runs stale-branch on every target and the >2 GiB .idx test on local; findings #149 (push latency grows with trunk history) and #150 (fetch storms exhaust the metadata pool); #149 fixed in #201 (0d86f87, a CI run passed) (a push's search packs are computed without walking trunk history); #150 fixed in #212 (ee0ded5, a CI run passed) (store operations queue for the metadata pool, so fetch storms wait instead of failing); follow-ups #156 and #157, fixed in #166 (b738d3e) and #167 (ea68648); launch-post latency numbers await a rerun on identical instances with separate client machines d6128e95
D3 Fuzzing in nightly CI merged #130 (2e2c2cb); nightly fuzz.yml runs the 9 targets; #151 (ingest refused some valid thin pushes) fixed in #159 (26c8c64), its six inputs in fuzz/regressions/pack_ingest; #164 (property test for thin-pack completion shifts against git index-pack --fix-thin) fixed in #180 (68288d6, a CI run); #162 (ingest stored a delta with fewer than 4 bytes of data, which git refuses) fixed in #174 (5c0d243); #163 (two D1 chaos tests flaky under machine load) fixed in #168 (721f409); #129 (no test fed a known SHA-1 collision attack through the hasher) fixed in #213 (88aff5b, a CI run); follow-up issue #214; #139 (the HTTP and SSH front ends had no fuzz targets) fixed in #228 (632c116, a CI run passed), which adds four targets, so nightly fuzz.yml runs 13; follow-up #230 (the jwt target does not sign RS256/384/512 tokens) is open; launch gate: 24 h total fuzzing, at least 2.5 h per target, clean 8a83f139
D4 Security review of the public endpoint merged #115 (cf6fa05); security follow-up issues #118-#125 and #137, of which #118, #120, #124 and #125 must land before the demo goes public; follow-up #192 (ab148c5, a CI run) fixes #122 (every workflow action is pinned to a commit SHA and downloaded tools to a SHA-256, enforced in required CI by cargo xtask check-workflows); follow-up #196 (888d884, a CI run) fixes #123 (express cache fills are capped in number and reserve their pack's size against the cache budget before copying); follow-up #194 (1820597, a CI run) fixes #120 (SSH client addresses reach Aspen through the AWS NLB, which preserves client IPs, and the Helm SSH Service, with externalTrafficPolicy: Local); follow-up #199 (7ec2a01, a CI run) fixes #121 (X-Forwarded-For is read only when the TCP peer is a configured trusted proxy and is parsed strictly, so a client no longer chooses its failed-authentication bucket; follow-up issue #202); #137 (access_log's doc comment was attached to idle_bodies) fixed in #223 (ca69f1e, a CI run); follow-up #219 (5ae82cd, a CI run) fixes #124 (place's event streams are capped per client address and in all, and swarm starts are atomic); follow-up #210 (d5d37fb, a CI run) fixes #125 (every deploy path sets per-caller read and push rates by default, images are pinned by digest and checked in required CI, a secret file other users may read stops startup, and the Helm chart can limit its metrics port to listed peers with a NetworkPolicy); follow-up #236 (e199b5f, a CI run) fixes #198 (concurrent failed authentications overshot the limit; a presented credential is now charged before it is checked and refunded when it authenticates), follow-up issues #242 and #243 0c9d566a
D5 Public deployment merged #112 (944822b); not deployed: awaits isolated AWS account, subdomain, budget-alert emails, spend approval from the maintainers; public SSH was blocked on #120, fixed in #194 (1820597), and the demo still defaults to enable_ssh = false; follow-ups #140, #141, #142, #144, #145 and #146; #140 (the place server's shutdown drain left connections open) fixed in #187 (8e28b4d, a CI run); #144 (create-database.sh passed the place password in psql's argv) fixed in #205 (c46e4c5, a CI run), which reads it with psql's \getenv; follow-up issue #208; #145 (smoke.sh passed the token in git's argv) fixed in #209 (6233148, a CI run), which passes it through the environment and requires git 2.31 or later; #141 (no test for the place server's shutdown error path or its no-connections fast path) fixed in #216 (049dec8, a CI run); #142 (the public-demo runbook's SSH known limit predated #115) fixed in #220 (21a1e33, a CI run); #146 (the place web server panicked on a page/API route collision) fixed in #222 (e646656), which refuses a page file at a reserved path; its own main run was cancelled as superseded, and a CI run on ca69f1e covers it 20d32199
D6 Docs site, "run it yourself in 5 minutes", launch post draft merged #114 (ab12b95), follow-up #117 (78799f3); follow-up issue #116 d9f9138a
L0 Differential suite and client matrix against a real Aspen merged #136 (b85fd62); #134 (receive-pack refused JGit's gzip push bodies with 415) fixed in #175 (643696d, a CI run), so the nightly JGit-against-Aspen client-matrix row gates again; nightly differential-long gains an aspen, postgres row; follow-up #172 (afb2e88) fixes #154 and #161 (nightly all-tests ran no ignored tests past aspen-pack) and #155 (git-latest matrix row; verified and closed on nightly a CI run, main 4213a6a, passed); follow-up #170 (96611bd, a CI run) fixes #133 (delete of a missing ref), the oracle's last known divergence; follow-ups #178 (8b31f3a) and #184 (4213a6a) fix #177 and #183 (nightly all-tests ran out of disk in the over-2-GiB .idx test), verified by nightly a CI run; follow-up #173 (48849cf, a CI run) fixes #135 (the oracle also pushes and races through the plain repository URL) 276317e2

1. Product

"A Git server that stays fast when a thousand agents push at once, and that you can run yourself on any cloud in five minutes."

Target users: teams whose agents, CI and humans hammer one repository. They run Aspen in one of two ways:

  • Primary mode: Aspen is the Git host.
  • Mirror mode: Aspen sits in front of GitHub or GitLab. Fetches come from Aspen's own packs, and pushes are forwarded upstream either synchronously (proxy) or in the background (Aspen is primary, upstream is a downstream mirror).

Mirror mode is the main adoption path, because nobody has to migrate.

Goals

  • Works with stock git, over HTTP and SSH, with no client changes required.
  • Pushes to different branches scale horizontally. The only contended write is a compare-and-swap on one branch's tip.
  • Reads scale to the network limit. Clients that set git config fetch.uriprotocols https download packs straight from object storage or a CDN.
  • Runs anywhere: one binary, Postgres, and any S3-compatible bucket (S3, GCS, Azure Blob, R2, MinIO, or the local filesystem).
  • Reliability is proven by differential testing against stock Git, chaos testing and fuzzing, not asserted.

Not in v1 (document each one, and give a clear error where needed)

  • Partial-clone filters: don't advertise filter, so clients fall back to a full fetch (verified in S5). Lazy fetches from existing partial clones (want <blob-oid>) get a clear ERR; mirror mode may forward them upstream later.
  • Exact shallow packs for every deepen form: a plain --depth=N fetch of a ref version's tip (N up to 64) is served an exact pack, built once per version and depth (node C8). --deepen (relative), --shallow-since, --shallow-exclude, --unshallow, deeper depths and non-tip wants are still served the full history of the wants plus an exact shallow-info (S5). Operators can set shallow = refuse to answer with a clear ERR instead, or ASPEN_SHALLOW_PACKS=false to serve every shallow fetch the full history.
  • SHA-256 repositories.
  • A web UI for browsing code.
  • Code review or issues.

2. Architecture

git client ─HTTP/SSH─► aspen-server ─CAS per ref─► Postgres
                            │                      (refs, ref_versions, commit_index, covers)
                            │ stream+validate
                            ▼
                     object store (S3/GCS/Azure/R2/MinIO/fs) ◄── aspen-compactor (L0→L4 covers)
                            ▲
git client ◄─packfile-uri── aspen-express (regional pack cache) / CDN

Data model (schema v1, N1)

The tables below are schema v1, in crates/aspen-meta/migrations (Postgres and SQLite, kept identical) and described column by column in crates/aspen-meta/SCHEMA.md. The Rust contracts are in crates/aspen-core. Object IDs are 20-byte binary, timestamps are microseconds since the epoch, and every cross-table reference inside a repository is a composite foreign key through repo_id.

Table Holds
repos Repository settings, including trunk ref, force policy (allow_force: none, branches (default; never trunk) or all; a forced trunk update keeps trunk exact under B1's forced-trunk rule, decisions log) and writable ref namespaces (default refs/heads/*, refs/tags/*)
refs (repo, name) → current ref_version_id, seq; this is the only compare-and-swap target, and its foreign key on (version, repo, name, seq) plus SET seq = seq + 1 lets a swap land only on the expected version's child. A deleted ref keeps its row, pointing at the deletion version
ref_versions id, repo, ref, seq, parent_version, tip_oid, closure_exact, pack_id, trunk_dep, scoped_dep, created_at. closure_exact says whether the version's closure is exactly what its tip reaches, which decides whether a fetch have may stand for it (fetch::HaveVersion); it is computed per write against the pack that write's version names, which B1's placement rule chooses (decisions log, "where a write's objects are stored"). seq numbers a ref's versions from 1, and parent_version is the version with seq - 1. tip_oid null is a deletion; pack_id null means the push stored no pack. The deps are typed: trunk_dep is the exact trunk version the push's lookup and connectivity check searched (push path, step 4), scoped_dep the scoped ref's old version for a scoped-URL push (S2), and for a client's push of one branch through the plain repository URL that is not forced or leased, that branch's version the push searched, so it stores only what the branch's history lacks (issue #203). Parent and deps have smaller IDs, so the graph is acyclic. Invariant: every committed ref version is connected: every object reachable from tip_oid exists in its pack or its deps' closure (enforced by the push connectivity check)
packs id, repo, checksum, generation, pack_key, size, idx_key, idx_size, entries_key, entries_size, object_count, ref_delta_count, level, origin, received_bytes. Keys are repos/<repo>/packs/<checksum>.{pack,idx,entries} for generation 0 and <checksum>.g<n>.{pack,idx,entries} for generation n, the next registration of a checksum whose earlier row garbage collection swept; checksum is unique per repository among live rows, and readers always take keys from the row, never from the checksum alone. received_bytes (push packs only) against size shows completion growth (S4); ref_delta_count gates no-ref-delta (S3). Writers register, then upload, then reference: register_pack returns the row (and so its generation and keys), the pack, .idx and entry table are uploaded to those keys, and only then may a version or cover name the row; so every row a version or cover names can be served, while an unnamed row may lack its blobs (decisions log, C1 pack generations). Invariant: every stored pack is self-contained: no delta base lies outside the pack (thin pushes are completed at ingest, S3; compaction writes self-contained packs). So any selected pack can be served alone: as a packfile URI, which the client indexes on its own, or as one segment of a stitched pack. Second invariant: every stored pack is base-first: each delta's base entry comes before the delta (S1; ingest places each completion base before the first delta that uses it and moves any received delta that precedes its base to just after it, and compaction must write base-first too). With both, the stitcher deduplicates in one streaming pass. A pack is not closed under reachability: objects its trees and commits reference may live in its deps closure
commit_index (repo, commit_oid) → ref_version_id, parent oids (concatenated 20-byte IDs), committer time, written synchronously for the tip and asynchronously for the rest of a large push. ref_version_id is a version whose tip reaches the commit: it resolves a want, never a have. Parents and time serve shallow-info and fast-forward checks without reading packs (S5). A walk that reaches a commit with no row yet reads that commit from the packs of the ref version it came from (pack_id, then deps), which hold every ancestor of its tip; a missing row is never read as "no parents" or "not an ancestor". If the commit is in neither place, the request fails with a retryable error
covers repo, ref, level (1–4), first_seq, last_seq, pack_id or null (null until compacted, and then a compaction pack at the cover's level): a range of one ref's version chain, aligned by CoverLayout (level k spans 16^k versions). Migration 0005 adds cover_chains: a cover whose range is a dependency chain (every version after the first depends on its parent) records the ID of its first version, which a foreign key ties to the cover's ref and first_seq. Covers follow a ref's version history, closures follow dependencies, so a fetch treats a cover's range as held for a have only when it carries a chain (decisions log, "C1: cover chains")
shallow_packs Migration 0006 (node C8): repo, version, depth → pack_id, recorded_at_us, the exact shallow pack of a ref version at a depth: exactly the commits fewer than depth parents below the version's tip (the region), the tags the tip peels through, and every tree and blob the region's trees reach. The pack is an ordinary packs row (registered as a compaction pack at level 1 that no cover names) and obeys every stored-pack invariant. The first recording wins; the sweep counts a recorded pack as named; garbage collection evicts recordings of versions that are no longer current after a TTL (decisions log, C8 rows). Migration 0007 adds used_at_us, a recording's last use: garbage collection also evicts recordings unused for a day, current or not, and each repository's recorded packs stay under a byte cap, least recently used evicted first (issue #108)
jobs Background work queue (index_commits, compact, mirror_push on a ref version; rewrite_pack on a pack that broke base-first), written in the push's own transaction and enqueued idempotently (one job that is not done per kind and target). States queued, running (leased, fenced on worker and attempt), done, dead (after 10 attempts, failed or expired). A dead job is requeued by resolving it and enqueueing a fresh job in one transaction, never by resetting attempts

Push path

Confirmed against git 2.30, 2.39, 2.56 and OpenAI's fork by spike S2. The full rule table and the decisive wire excerpts are in bench/spikes/s2/RESULT.md.

  1. The client connects and Aspen runs v0/v1 receive-pack. It advertises trunk as the only named ref, plus earlier trunk tips as .have lines, or capabilities^{} for an empty repository: every one of the newest 8 × have_tips trunk versions (64 by default), then tips about a quarter apart down to the oldest trunk version a push searches (meta::have_tip_seqs; at most 108 lines for a million versions). Without the .have lines, a client that has not fetched the latest trunk pushes its whole history; with them, a client any number of landings behind resends at most about a quarter of them (decisions log, "item 10"). Capabilities: report-status delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent.

    • A branch-scoped URL, /<repo>.git/-/refs/heads/<branch>, also advertises that one ref. It is the opt-in route for stock --force-with-lease and git push --delete <branch>.
    • push.negotiate. Through the plain URL, stock Git resends every commit of the pushed branch that is not on trunk. With git config push.negotiate true it first runs a negotiation-only v2 fetch (wait-for-done, haves, no wants); Aspen advertises wait-for-done and acknowledges every have with a commit_index row, and Git leaves those commits out. A fast-forward of the pushed branch then needs nothing beyond its usual dependencies (issue #203's branch dependency). A push whose left-out commits are outside its dependencies (another branch's commits) is completed from their versions (decisions log, "push.negotiate").
  2. Ingest produces a self-contained pack, which packfile URIs require (spike S3), and never unpacks it. It is synchronous and takes two passes:

    • Pass 1 (receive): the incoming pack streams to a spool file on local disk (a real disk, never tmpfs) while gix-pack parses it. It inflates every entry, checks each entry's CRC32, and checks the client's trailer with SHA-1 collision detection. Memory stays under 10 MiB at any pack size; the parse runs at about 110 MB/s per core (S4). The base IDs of all REF_DELTA entries are collected as candidate bases. A candidate may be a thin base (in deps) or an object in the same pack: libgit2 sends every delta as REF_DELTA, mostly against bases in the pack (S1's push_order.py), and pass 1 cannot tell the two apart, because a deltified entry's ID is known only once it is resolved. A push whose header object count exceeds the limit is refused before its body is read.
    • Base prefetch: each candidate, with its delta chain, is looked up in parallel before completion starts. The base-object lookup probes the .idx of the covering packs of every ref version advertised on the connection: trunk, plus the branch for a branch-scoped URL, which are exactly the push's deps (see S3's README). A candidate that deps doesn't have is not an error: it is expected to be in the pack. Prefetching matters because gix asks for bases one at a time from inside its parse. A lookup that went to the blob store there would serialize thousands of round trips per push: S4 counted 5,431 entry reads for a 100-commit Linux push. The prefetch is designed, not measured: S4's harness used a synchronous lookup over a local pack, and B1 measures the prefetch against a real bucket. Every stored pack's delta chains are known before they are read (chain maps from its entry table: in memory beside a whole .idx, on the spool disk beside a sampled one), so all the entries of every candidate's chain are read in one round of parallel range reads, whatever the chain's depth (decisions log, item 11).
    • Completion and index: gix-pack re-reads the spool with a lookup that answers only from the prefetched bases. For each REF_DELTA whose base the lookup returns, gix inserts the base as a full object just before that delta and turns the delta into an OFS_DELTA. So every completion base comes before the entries that use it (S1's base-first rule); placing all of them in front of the received entries, as S1 first proposed, is not needed. A REF_DELTA the lookup has no answer for stays a REF_DELTA and gix resolves it inside the pack, in either direction: gix accepts a base that comes later in the pack. gix then rewrites the header count, re-hashes the pack (about 1 GB/s), and resolves every delta in parallel. That computes each object ID, again with SHA-1 collision detection, and writes the .idx. A REF_DELTA whose base is in neither deps nor the pack fails the push with unpack error. Every push takes this path: for a pack that needs no bases it costs 4% more CPU and returns the same bytes (S4).
    • No object twice. gix asks the lookup about every REF_DELTA base it did not insert itself, without checking whether the pack already holds that object. So when a pack carries object X plus a REF_DELTA against X, and deps also has X, completion stores X twice. S4 reproduced this (dup.sh): a full-history resend written all-REF_DELTA, as libgit2 writes deltas, came out with 92 entries for 90 objects, and git index-pack --strict rejected it. The rule: after indexing, one linear scan of the sorted .idx looks for an ID that appears twice. If any does, completion runs again over the spool with a lookup that answers "not found" for those IDs, so their deltas resolve against the copy the client sent, and the scan runs again. On the same pack that gives 90 unique objects, accepted by --strict. Filtering the lookup during pass 1 is not enough on its own: pass 1 can hash full objects but not deltified ones, so a deltified X would still be inserted twice. The scan is needed anyway as the guarantee, and the rerun is a rare path (a client resending objects the server has, with REF_DELTA against them).
    • A pack that itself holds an object twice is rejected. If the scan after the rerun still finds a repeated ID, the client sent both copies, and no lookup answer can remove either. The push fails with unpack error naming the duplicated object, and no packs row is written. This is a deliberate difference from stock git receive-pack, which accepts such a pack unless --strict (or receive.fsckObjects) is on. No tested client sends one (S1, S4), and storing it would break the no-duplicates invariant the stitcher relies on.
    • Forward bases in the received entries are repaired, never rejected (S1). A client's own pack may put a REF_DELTA before the entry it is based on; stock git receive-pack accepts that, so rejecting it would break pushes that work everywhere else. gix stores such a pack as it came, with the delta before its base, which would later trip the fetch path's forward-base ERR and its page. So the repair runs after completion, the duplicate check and the .idx, on gix's output:
      • Detection. Only an entry that is still a REF_DELTA can point forward (OFS_DELTA distances always point back). The skip decision uses the final completion run: if its lookup answered every candidate (no prefetch miss, and no ID refused by a duplicate rerun), no REF_DELTA remains and the check is skipped; this is every stock git push, which sends OFS_DELTA. A duplicate rerun refuses IDs on purpose, so the REF_DELTAs against them stay and can point forward; after a rerun the check always runs. Otherwise a header walk of the completed pack (entry boundaries from the sorted .idx offsets, no inflation) compares each remaining REF_DELTA's offset with its base's offset from the .idx.
      • Repair. If a base comes later, the pack is rewritten on local disk: entries keep their order, except that each entry whose base comes later (and, transitively, any entry based on it) moves to just after its base. Compressed data is copied unchanged; OFS_DELTA headers whose distance changed, and the CRC32 of each changed entry, are recomputed. The new pack is re-hashed (about 1 GB/s) and its .idx rebuilt from the known IDs, without resolving deltas again. S1 found no client that sends forward bases: git 2.30–2.56, JGit, libgit2 and go-git all put every base first in 32 pushes (push_order.py). So the repair is a rare path that has to be correct, not a fast one. Neither the header walk nor the rewrite has been measured.
    • Connectivity check, as git receive-pack does. Only objects that the incoming pack references (commit parents and trees, tree entries, tag targets) but does not contain are checked. Each must exist in the push's dependency closure, found with the same base-object lookup, batched. Nothing reachable from those objects is walked: every committed ref version is connected (see ref_versions), so existence implies connectivity. A missing object fails the push.
    • Pass 2 (upload): a multi-ref push may store more than one pack: B1 places each write's objects in trunk's pack, its own pack or one shared pack (decisions log, "where a write's objects are stored"). For each stored pack, one sequential read feeds a multipart upload, and the .idx and the entry table (fetch path step 4) are uploaded beside it. The trailer of each stored pack becomes its packs.checksum. Each pack is registered before its upload, which goes to the keys of the returned row (decisions log, C1 pack generations).
    • Only once the pack and .idx are durable may step 4 run. Every packs row is self-contained, for every reader, with no flag.

    The trunk version is searched because its .have tips are trunk ancestors; the scoped branch because the client built its pack against it too. The per-push probe cost bound is in bench/spikes/s2/RESULT.md.

    Thin packs stay accepted on the wire; no-thin is an optional per-repository advertisement (S2).

    Costs that S4 measured on Linux, a 6.55 GB pack with 12.0 M objects (bench/spikes/s4/README.md):

    • CPU per push, the whole pipeline: pass 1 (64 CPU-seconds with the spool) plus completion and index over the spool (282), about 346 CPU-seconds, against 680 for git index-pack, which does both in one process. The duplicate scan is one pass over the .idx. Each rare path adds its own cost: a duplicate rerun repeats the 282, and a forward-base repair adds a local copy and a re-hash. Prefetch I/O and the upload come on top and are measured by B1.
    • Memory: about 140 bytes of heap per object (1.6 GiB), plus the mmapped pack, which is reclaimable page cache.
    • Disk: about twice the push, plus its .idx.
    • Stored size: completion grows packs most for small pushes, from 320 B to 37 kB for one commit and from 10.5 MB to 57 MB for 100 commits. Git's --fix-thin grows them the same way.

    B1 measures push latency end to end, against a real bucket.

    Test obligations for step 2. A2 covers each with a unit test on constructed packs, and B1 covers each end to end through git push and the libgit2 client in A6's matrix. Each stored pack must index under stock git index-pack without --fix-thin, and no ID may repeat in its .idx (--strict also rejects a repeat, but it checks connectivity too, so it only fits a test pack that is closed):

    • an all-REF_DELTA pack whose bases are in the pack and not in deps (an ordinary libgit2 push): every candidate misses in deps and the push is accepted;
    • an all-REF_DELTA resend of objects deps already has, including a REF_DELTA against a deltified object of the pack (S4's dup.sh is the model): no duplicate is stored;
    • a REF_DELTA before its in-pack base, with and without that base also in deps: stored base-first, no duplicate;
    • a REF_DELTA whose base is in neither the pack nor deps: unpack error, and no packs row;
    • a received pack that holds the same object twice: unpack error naming that object, and no packs row.
  3. The commit check runs. The client never saw any branch except trunk, so (full table in RESULT.md):

    • Create: accepted.
    • Existing branch, zero old-oid (every update of an unadvertised branch looks like this): Aspen runs the fast-forward check itself against commit_index, falling back to the ref version's packs for commits not indexed yet (see the commit_index row). If ancestry can't be decided, the update gets a retryable ng, never a guessed verdict. If new equals the current tip, it is a no-op.
    • Non-fast-forward: git push --force is invisible to the server. Accept only with push option -o force (or force=<ref>) or a matching -o lease=<ref>:<oid>, and only where allow_force permits.
    • Delete: clients refuse --delete <short-name> for unadvertised branches. --delete refs/heads/<branch> arrives with a zero old-oid, so it is unconditional: like a force, it needs -o force and allow_force. A delete with a real old-oid (branch-scoped URL, or a lease) is a compare-and-swap delete and needs no force. Trunk cannot be deleted.
    • Tags never fast-forward: replacing one needs -o force.
    • Exact replay: a command refused only because the ref already moved can still be ok. It is when the ref's current tip is the command's new value (or it is deleted, for a delete), the version just before it had the command's old value (or its lease's), and the command's own rules (lease, force signal and policy, fast-forward, tag and trunk protection), checked against that previous version, accept it: answer ok and write nothing. libcurl resends a receive-pack POST when a reused connection dies before any answer, so the first copy can land with its answer lost (#132). This matches the outcome, not the request: another push that made the same move, and that this command would also have been allowed to make, counts too. Only the current version's parent counts; if the ref moved again, the parent cannot be read, or the command's rules refuse the move, the command keeps its refusal. Creates take no part: a zero old value claims nothing, so a resent create is checked like any zero-old command (a plain no-op while the ref is still at its value).
    • Atomic: all commands in one transaction, or all rejected.
    • Rejection reasons go in the ng line and on side-band 2, even under quiet (every non-terminal client asks for it).

    Every verdict (fast-forward, lease, delete, no-op) is computed against one exact ref_version of the target ref, and that version is the one the step 4 compare-and-swap expects.

  4. One transaction inserts the ref_version, sets the refs row with compare-and-swap, and enqueues jobs. That makes the push read-your-own-write with O(1) metadata reads and writes. If the compare-and-swap loses because the ref moved, Aspen recomputes the step 3 verdict against the new version (a bounded number of times), or returns a retryable ng. It never commits a verdict computed against a stale version. A3's and B1's tests, and the differential suite, cover two concurrent pushes to one branch.

    The pack's trunk dependency, recorded in deps, is the exact trunk version that step 2's lookup and connectivity check searched. It is never re-read at commit time: where a repository allows trunk force, trunk can be rewound between step 2 and this step, and a re-read would record a version whose packs were never searched. Smart HTTP does not tell the server which trunk tip it advertised, so a pack built against a rewound tip is caught by that connectivity check, not assumed away; trunk being fast-forward-only by default only makes this rare.

    A forced trunk update (only with allow_force = all) is the one exception, fixed at push time by B1 (decisions log): its trunk_dep is the newest trunk version whose tip is an ancestor of the new tip, its pack also holds the objects the new tip reaches that are only in the skipped versions' closures, and orphan history stores its full closure with no trunk dependency. The new version is then exact.

Fetch path (protocol v2)

  1. ls-refs honours ref-prefix, so the server never sends a 100 MB ref list. Each instance keeps a repository's refs in memory once it has listed them, and every later ls-refs asks the store only which refs changed since (MetaStore::ref_changes, one statement over ref_versions.txid), so polls by stock git ls-remote <url> <ref>, which sends no ref-prefix, cost one statement and no ref rows. A listing is never stale: each request is answered from a store read that began after it arrived (decisions log, "ls-refs snapshots").

  2. fetch maps the client's wants to ref versions through commit_index, and each have to a version only when that version's closure is exact and its tip is the have or an ancestor of it (fetch::HaveVersion); other haves are dropped, which only costs bytes. The planner then picks the covering packs, using the cover levels as a skip list, so the pack count grows logarithmically with the versions sent. On the have side the walk skips through chained covers (C1): when it expands a held version at seq = s that lies inside a cover carrying a chain, every version of that ref from the cover's first to s is held (each depends on its parent, so its closure contains the earlier ones'), and the walk jumps to the cover's first version instead of stepping down one version at a time. A client current on trunk fetching a branch created N trunk pushes ago visits a handful of versions per cover level plus the uncompacted tail, not N. The want side is still linear: a full clone visits every version of its closure. B2 exports PlanStats.visited as a metric; PlanStats.chain_skips (how many cover chains the walk jumped through) is in the planner's API for its tests and for D2, which benchmarks the stale-branch fetch (section 4). A cover without a chain never changes what the walk treats as held (decisions log, cover-subtraction rule and "C1: cover chains").

  3. Clients that set fetch.uriprotocols get an empty inline pack plus packfile-uris that point at presigned URLs or the CDN. Rules from spike S3 (bench/spikes/s3/README.md):

    • the advertised hash is the pack's trailing SHA-1 (packs.checksum);
    • only URIs whose scheme the client listed (clients do not filter);
    • always a packfile section, even when it is empty;
    • URLs are direct (no redirect), self-authorizing and honour Range;
    • direct-to-bucket URIs go only to clients whose agent= parses as git/2.56 or later. Older opted-in clients get URIs only on an endpoint with the 206 rule below: aspen-express, or a CDN tier declared ASPEN_CDN_PAST_END=206. A CDN tier declared 416 (the default) is for git/2.56 and later only. With no such endpoint they get the stitched pack. The agent string is self-reported, so this is a reliability choice, not security;
    • each planned pack goes out as an express or CDN URI first, then as the store's own URL, then stitched into the inline pack (decisions log, "C2: delivery order per planned pack");
    • an Aspen-served blob endpoint answers a range at or past the end with 206 and an empty body, not 416 (git ≤ 2.55 gets stuck on 416);
    • advertise no-ref-delta only when every planned pack is REF_DELTA-free (open for B2: over smart HTTP the advertisement precedes the request, so B2 sets the policy; see the decisions log);
    • soft cap on URIs per response, with a stitched pack beyond it.
  4. Every other client gets a stitched pack, built from the selected packs' entries in plan order:

    • drop every entry whose object id was already emitted (keep the first copy);
    • copy every other entry's compressed data unchanged; rewrite only the OFS_DELTA base distance, against wherever the base copy was emitted;
    • write the header object count (known from the plan before the first byte) and recompute the trailing SHA-1.

    Invariants of every stitched pack, enforced by A2 and its fuzz target: each object id at most once, every delta's base earlier in the pack, and never thin (stored packs are self-contained, and go-git never asks for thin-pack). A2 also measures the maximum delta chain depth of stitched packs: keep-first can hang a delta in a later segment off an earlier segment's copy of its base that is itself a delta, so depth can approach the sum of the segments' depths. A2 checks that git 2.30 index-pack accepts the worst case the compaction levels allow. Spike S1 (bench/spikes/s1/) showed why: plain concatenation is accepted by every client only when no object repeats. Overlapping covers and completed push bases do repeat objects, and then libgit2 rejects the pack, git 2.30–2.47 reject it when the repeated object is a REF_DELTA base, every git rejects it with fsckObjects, and go-git writes a pack stock git can't read. Naive deduplication can make delta cycles that exhaust the client's memory. With base-first stored packs (packs invariant), keep-first deduplication emits every base before its deltas in a single pass.

Lines 1–500 of 1278.