Context: who needs to read this, and when. Anyone (human or agent) touching a protocol that talks to the
bucket: the publish path (walgit-wal/src/publish.rs), sync levels and freshness (sync.rs, handle.rs),
checkpoints, compaction and its lease (walgit-server/src/ops.rs, coord.rs), pack delivery,
the remote reader, LFS, or the ObjectStore backends themselves — and anyone writing or triaging simulation
scenarios (crates/walgit-server/tests/sim.rs), because a sim fix that is correct but adds a happy-path request
is a regression, not a fix. Read it before designing the change, use §5 when writing the commit/PR, and
keep §2 current. It exists because walgit runs on a tmpfs host instances that own nothing but a bucket: every
user-visible latency is a sum of sequential GCS requests, and the manifest is a single CAS'd object with a hard
write-rate cap — so the number and shape of round trips is the performance design. Referenced from AGENTS.md
(read order and §5 working rules) and from the sim harness briefs.
Correct is necessary, not sufficient. walgit's only durable primitive is a bucket with ~60–80 ms per request, one serialized overwrite per second per object, and no transactions beyond single-object CAS. Every protocol we design on top (publish, sync, compaction, checkpoints, pack delivery, leases) is judged on two axes at once:
- Safety/liveness — the simulation suite (
crates/walgit-server/tests/sim.rs) and the contract tests. - Critical-path round trips — how many sequential bucket requests a user-visible operation needs, and how many requests in total (cost, contention on CAS'd objects).
A change that is correct but adds a sequential round trip to a hot path is a regression. A fix for a liveness bug that moves work onto the failure path and leaves the happy path at the same depth is the right shape. This document is the thinking tool; apply it to every protocol change and say so in the commit.
| Primitive | Cost | Notes |
|---|---|---|
| GET / PUT small object | 60–80 ms p50/p99 | one request = one round trip |
Conditional GET (If-None-Match) → 304 |
15–18 ms | the cheapest "is anything new?" |
| HEAD | ≈ GET | never add one on a happy path (download_object skips it when PackRef already has the size) |
| 404 | free | probe, don't list |
| LIST | slow, paged, eventually-ish | never on a hot path (rule in AGENTS §5) |
| CAS overwrite of one object | serialized, ~1 write/s | a CAS'd object is a throughput cap; 412 is the normal contention signal |
| Range read of a big object | ~100 MB/s per connection | stripe for more; bulk bytes on their own pool |
| Compose | 1 request, no data transfer | ≤ 32 sources |
| Operation | Depth | Requests | Where |
|---|---|---|---|
Any read (info/refs, ls-refs, web refs/resolve) |
1 cond GET (or 0 within freshness_ttl) |
1 | sync.rs::freshness_check |
| Cold Refs sync | 1 manifest GET → 1 round (checkpoint refs ∥ log tail segments) | no checkpoint: 1 + tail (2 with one segment); checkpoint: 2 + tail | registry.rs::open, sync.rs |
| Old checkpoint without inline refs descriptor | manifest GET → committed checkpoint metadata GET → its refs GET (tail can overlap) | one additional GET compared with a new descriptor; the old address must come from committed metadata, never a guessed sequence path | snapshots.rs::checkpoint_snapshot |
Push request / publish (process_batch) |
1 freshness GET → pack PUT ∥ idx PUT ∥ log PUT (1 round) → manifest CAS (1 round) | request: 5; already-synced publish: 4 | publish.rs |
| Compaction publish | same shape as push | — | publish.rs::publish_compact_impl |
| Conserving lifecycle | acquire lease → captured refs/full sync → each output publishes additively → final seal (lease heartbeat → refs revalidation → immutable log PUT → manifest CAS) | one additive publication per new physical checksum and one final CAS; heartbeat GET/CAS calls and full-sync downloads are additional maintenance costs; no change to ordinary push/read budgets | pack_lifecycle.rs, classification.rs::seal_pack_replacement |
| Coverage repair | captured snapshot Create → bounded batches of classification CAS → final certificate CAS | snapshot equality GET only on Create conflict; up to 128 classifications per batch; every certificate rechecks current policy and live membership | pack_lifecycle.rs::repair_metadata |
| Native URI selection | existing fetch sync → exact coverage snapshot GET | normally +1 GET, shared maximum 3 attempts across all groups; current manifest/ref view capture adds no GET; optional index trailers are local bounded reads | packfile_uri.rs::select, snapshots.rs::fetch_view |
| Static pack/index GET | manifest revalidation → object HEAD → body/range GET | 3 warm requests; conditional 304/HEAD omits the body GET; cold registry open may add its normal snapshot/tail reads; no pack materialization or LIST | packfile_uri.rs::get, static_object.rs |
| Checkpoint | 1 cond GET (freshness) → refs PUT ∥ checkpoint PUT → manifest CAS | 3 rounds, 4 requests (was 6/6 until 2026-08-22: a bundle-list GET before the checkpoint PUT and a log GET for provenance times sat in the chain; times now come from the writer's own applied state, bundle_key is no longer looked up) |
checkpoint.rs |
| Validated remote index admission | warm valid index: 0 store requests; absent/corrupt index: index GET (HEAD first only for old descriptors with unknown size), at most four indexes downloading concurrently | unchanged healthy store depth; corruption now triggers repair instead of admitting bad evidence. Checksums, lock, mmap and cleanup are local work; no LIST. Per-index checksum CPU cost occurs when constructing a new remote inventory, not per object lookup. | index_cache.rs, remote.rs |
| Final replacement evidence | coherent refs-level manifest capture → verified current index mappings → conservation/current-tip scan → existing log claim/CAS | warm indexes add 0 store requests; cold/bad evidence adds index GETs (HEAD only for unknown sizes), four concurrent downloads. O(refs) local capture and streaming index-union scan per CAS attempt; no pack download or LIST. This cost is not zero-read or constant-time seal certification. | classification.rs, closure.rs, index_cache.rs |
| Lost CAS response resolution | fresh manifest GET; only if the exact segment descriptor is listed, GET and compare the claimed log bytes | normally +1 GET on this failure path compared with key/sequence-only resolution; no added successful-push requests. Missing/folded evidence remains unknown. Per-attempt nonce and actual frame sizes are computed locally. | publish.rs::cas_landed |
| Settings publish (D24) | refs sync (conditional GET) → log slot PUT → manifest CAS; readers pay nothing extra (settings ride inline on the manifest) | 3 rounds; read: 0 | publish.rs::publish_settings_impl |
| Lease acquire | 1 GET → 1 CAS put (or 1 Create when absent) | 2 | coord.rs::try_acquire |
| Publish, local commit (2026-08-23) | unchanged in round trips: after the manifest CAS the ref txns are applied to the local copy before the new manifest version is advertised, both under sync_mutex (the refs phase of every sync); the reverse order let a reader cache the old refs under the new version, and without the lock a concurrent sync replayed the same entry (two update-ref, a lock collision). A landed CAS is answered ok whatever the local apply does — the next sync replays (one conditional GET that then returns 200, no extra write). |
0 extra | publish.rs::process_batch |
Repository listing (/api/v1/owners*, /services/api/owners*, maintainer/bridge passes) |
0 within LIST_TTL (30 s, per instance); else delimited repos/ → (delimited repos/<o>/ ∥ owners) → (HEAD manifest.pb ∥ repos): 3 rounds |
1 + owners + repos | registry.rs::list |
| Bundle removal (2026-09-11) | v2 capabilities and narrated fetch: removed optional list GET (1 → 0 extra); maintenance no longer reads/CASes a bundle list; direct import no longer composes a wrapper or reads/CASes a bundle list | no new store requests; checkpoint and push budgets unchanged | smart.rs, maintain.rs, import_direct.rs |
| Canonical checkpoint publication | unchanged: freshness → content-addressed refs PUT ∥ attempt-specific metadata PUT → manifest CAS | 4 healthy requests; equality verification GET only after immutable Create conflict | checkpoint.rs, snapshots.rs |
| Orphan log slot (failure path only) | +1 fresh manifest GET, +HEAD per probe, +Create at next seq | — | publish.rs::claim_log_slot |
healthy_request_round_trip_budgets in crates/walgit-server/tests/sim.rs pins the healthy MemoryStore
counts at push 5, warm refs 1, cold refs with one tail segment 2, and checkpoint 4. Cold open used to spend an
extra unconditional manifest GET (3 requests, 3 sequential rounds); it now applies the manifest it already
fetched directly (2 requests, 2 rounds). claim_log_slot, cas_landed, and put_immutable_create add probes
only after Create/CAS failure, so the measured happy-path counts remain unchanged.
Keep this table current; when you change a protocol, update the row and put the before/after depth in the
commit message. The sim harness can enforce it: FaultStore::stats().ops counts exact store requests per
link, so a scenario can assert "a push on a healthy link is ≤ N requests" as a regression test.
- Depth before count. Two PUTs in parallel cost one round trip; the same two in sequence cost two.
tokio::join!independent writes; neverawaituploads one after the other. - Let the conditional write be the read.
PutMode::Create→ 412 is "it exists";Update(v)→ 412 is "someone moved it" and GCS tells you the current generation. Don't GET to decide what a conditional write will tell you for free. - Verification goes on the failure path. e.g.
put_immutable_createHEADs only after a 412;cas_landedre-reads the manifest only after a non-412 error. The happy path must not pay for rare cases. - Carry state in the object you already fetch. The manifest is the one GET every request makes: anything a reader needs at refs level (pack set + side-file inventory, checkpoint pointer, log segments, revision, writer) belongs in it, so no second request is needed to know what to fetch.
- Re-use version tokens you hold. After a CAS success you have the new generation: don't re-GET to learn it.
After a 412 you may have
current: skip the GET when the store provides it. - Batch at the CAS. The single-flight publisher (group commit,
wal.batch_window) turns N concurrent pushes into one log PUT + one CAS. The push broker makes one writer per hot repo so 412s stop being the common case. - Never pay per ref or per pack on a hot path. O(1) manifest, O(k) resolve; pack data by range, never by "download and see".
- Immutable means cache forever, on every layer (process LRU, bucket
cache/api/v1, HTTPimmutable). One instance computing something all instances need = a shared-cache write, not N recomputations. - Jitter every retry that targets a shared CAS'd object; a synchronized retry storm is a self-inflicted serialization on the 1 write/s object.
- Measure, don't guess:
walgit-tracesshows the store span tree per request (count + critical path); simStats::opsgives exact counts; the measurement table holds the numbers.
- HEAD/GET "to be safe" before a PUT that is conditional anyway.
- Sequential
.awaiton independent uploads. - Re-sync (GET) after a CAS success to "refresh".
- LIST to find the latest checkpoint/segment (the manifest knows).
- An undelimited LIST over
repos/to enumerate repositories: it walks every object in the bucket (122 k keys, 8–9 s, GCS deadline retries on the broker, 2026-08-22). Enumerate prefixes (list_prefixes), probe manifests, cache. - Fixing a liveness bug by adding a wait or a probe to the happy path.
- A retry loop without backoff+jitter on
manifest.pb.
- Depth/requests before → after for each affected row in §2.
- What moved to the failure path, and how often that path runs (measured or reasoned).
- Which CAS'd object's write rate changes.
- Sim scenario(s) covering the new failure mode;
Stats::opsbudget assertion if the hot path changed.