Skip to content

Latest commit

 

History

History
111 lines (100 loc) · 12.5 KB

ROUNDTRIPS.md

File metadata and controls

111 lines (100 loc) · 12.5 KB

Round trips are the cost model

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:

  1. Safety/liveness — the simulation suite (crates/walgit-server/tests/sim.rs) and the contract tests.
  2. 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.

1. The numbers (GCS measurements)

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

2. Budgets to defend (happy path, sequential depth → total requests)

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.

3. Rules of thumb

  • Depth before count. Two PUTs in parallel cost one round trip; the same two in sequence cost two. tokio::join! independent writes; never await uploads 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_create HEADs only after a 412; cas_landed re-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, HTTP immutable). 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-traces shows the store span tree per request (count + critical path); sim Stats::ops gives exact counts; the measurement table holds the numbers.

4. Anti-patterns seen (don't reintroduce)

  • HEAD/GET "to be safe" before a PUT that is conditional anyway.
  • Sequential .await on 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.

5. Checklist for a protocol change (paste into the PR/commit)

  • 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::ops budget assertion if the hot path changed.