File
Blob: docs/sidecar-backed-fetch-closure-proposal.md
One-Shot Plan: Sidecar-Backed Fetch Closure
Summary
Implement per-pack logical object-reference sidecars and use them for final git-upload-pack closure planning. This directly addresses the incident: fetch planning must not read thousands of packed objects from R2 just to discover commit/tree/tag edges.
The pressure test changed the plan in four important ways: sidecar readiness is checked before streaming starts, .refs becomes a required artifact for newly active packs, backfill uses the streaming indexer with existing .idx help for tricky REF_DELTA bases, and tree gitlinks are excluded from closure edges.
Design Decisions
- Add an R2 sidecar beside every pack:
packKey.replace(/\.pack$/, ".refs"), exposed throughpackRefsKey(packKey)insrc/keys.ts. - Keep sidecars as derived R2 artifacts. Do not add SQLite tables and do not add DO methods unless an existing RPC cannot provide the current active catalog.
- Sidecars are required for final fetches with haves. Initial clone with no haves stays idx-only via
buildInitialCloneNeeded(). - Missing/corrupt/stale sidecar on final fetch returns
503 Retry-After: 10beforepackfile\nis emitted, and queues idempotent backfill. - The production fetch planner must not fall back to
readObjectRefsBatch()for active packs. That path is the scalability problem.
Sidecar Format and Types
- Create a focused module, e.g.
src/git/pack/refIndex.ts, with named types:PackRefViewPackRefBuildResultPackRefSnapshotEntryPackRefSnapshotLoadResult
- Binary format v1:
- magic/version
objectCountpackBytes- pack checksum from the
.idxtrailer - idx checksum from the
.idxtrailer typeCodesin idx OID order, using canonical Git object type codes for commit/tree/blob/tagrefStartslengthobjectCount + 1- flat raw 20-byte ref OIDs
- Parser rejects bad magic/version, count mismatch, pack byte mismatch, checksum mismatch, truncation, non-monotonic
refStarts, and invalid final offset. - Extend
IdxViewto exposepackChecksumandidxChecksum. - Extend
RequestMemowithpackRefViewsandpackRefViewPromises. - Add a byte-capped isolate LRU for
PackRefView, keyed bypackKey + idxChecksum. Do not keep unbounded global string maps.
Indexer Changes
- Refactor the pack indexer around a named
PackRefsBuilder. - Add final object type storage to
PackEntryTable; do not overwrite existing pack type fields because rewrite/idx logic still needs pack type codes. - Feed
PackRefsBuilderwhenever a full logical object payload is available:- non-delta objects in
scanPack() - resolved delta objects in
resolveDeltaEntry() - external REF_DELTA fallback objects after delta application
- non-delta objects in
- Add a new tree parser for closure refs that includes mode parsing and excludes gitlinks (
160000). Keep existingparseTreeChildOids()behavior unless all callers are reviewed. - Extract one shared OID-order helper from
writeIdxV2()and use it for both.idxand.refs. Tie-break duplicate OIDs deterministically by pack entry index. - Replace or wrap
resolveDeltasAndWriteIdx()with an artifact-writing API that writes.idxand.refstogether and returns{ objectCount, idxBytes, refIndexBytes, idxView }.
Receive and Compaction
- Receive with a non-delete pack:
- stage
.pack - scan/resolve
- write
.idx - write
.refs - run connectivity
- call
finalizeReceive()
- stage
- If receive fails after staging, cleanup deletes
.pack,.idx, and.refs. - Delete-only receive does no sidecar work.
- Compaction writes
.refsfor the compact target beforecommitCompaction(). - Compaction retry/conflict/error cleanup deletes staged
.pack,.idx, and.refs. - Delayed superseded-pack delete deletes
.refswith.packand.idx. - Admin
removePack()deletes.refsfor superseded packs. Missing.refsis a successful no-op. - Purge remains prefix-based and needs no special-case sidecar logic.
Fetch Path
- Move final fetch planning before creating the response stream. The stream should only start after snapshot, idx, sidecar, and closure planning are ready.
- Add
loadPackRefSnapshot(env, repoId, snapshot, cacheCtx):- loads one sidecar per active pack
- validates against the corresponding
IdxViewandPackCatalogRow - uses
limiter.run("r2:get-pack-refs", ...) - logs
stream:plan:ref-snapshot
- Add sidecar-backed closure planner:
- preserve current have cap behavior unless tests intentionally change it
- common haves are active-pack membership checks from idx snapshots
- stop set starts with acknowledged haves and retains the existing first-parent enrichment
- walk wants through sidecar refs with a cursor queue
- use request-local maps bounded by active indexed object count
- compute reachable want closure minus stop-set reachability
- If planner budget is exceeded, return
503 Retry-After: 10before streaming and logstream:plan:closure-budget-exceeded. - Keep
readObjectRefsBatch()for non-fetch callers/tests only, or leave it as an explicitly non-production fallback with tests proving fetch does not call it.
Backfill
- Add queue message
pack-ref-backfilltoRepoMaintenanceQueueMessage. - Fetch queues one message per missing/corrupt/stale active pack using
ctx.waitUntil; duplicate messages are allowed. - Queue handler:
- gets current active catalog through existing DO RPC with limiter/counting
- acks if target pack is no longer active
- loads target
.idx; if valid.refsalready exists, ack - runs the streaming scan/resolve machinery with the target pack excluded from the external-base catalog
- seeds same-pack
REF_DELTAdependency edges from the existing.idxso backfill does not fall back to recursive packed-object reads for objects already in the target pack - tries duplicate external-base candidates in catalog order so a newer unusable duplicate does not hide an older materializable base
- writes only the
.refssidecar; the deterministic.idxis already present and must not be rewritten by backfill - retries transient R2/DO failures
- acks deterministic invalid pack/idx failures after logging
- The DO never reads or writes R2 for backfill.
State Matrix
| State | Behavior |
|---|---|
| Initial clone, no haves | Existing idx-only path; sidecar not required |
Negotiation request, done=false |
Existing ACK-only behavior; sidecar not required |
| Final fetch, all sidecars valid | Sidecar closure, then stream pack |
| Final fetch with duplicate wants | Queue and send each canonical active object once |
| Final fetch reaches duplicate OID in packs | Newest snapshot-order pack is canonical |
| Force-push leaves overlapping active packs | Traverse newest pack first; old overlap does not grow queue |
| Final fetch has no common haves | Sidecar closure runs with an empty stop set |
| Final fetch, sidecar missing/corrupt/stale | Queue backfill, return 503 Retry-After: 10 before stream |
| Final fetch exceeds closure timeout | Return 503 Retry-After: 10 before stream |
| Final fetch exceeds missing-ref cap | Return 503 Retry-After: 10 before stream |
| Active pack has idx missing/corrupt | Existing repository-not-ready behavior |
Newly received pack .refs write fails |
Abort receive, cleanup staged artifacts, no catalog commit |
| Receive connectivity rejects | Cleanup staged .pack/.idx/.refs, no catalog commit |
| Finalize receive conflict/lease mismatch | Cleanup staged .pack/.idx/.refs |
Compaction target .refs write fails |
Abort/retry compaction, cleanup staged artifacts |
Superseded pack delete sees missing .refs |
Log debug/info and continue |
| Superseded pack delete | Delete .pack, .idx, and .refs together |
| Admin remove superseded pack | Delete .pack, .idx, .refs; report artifact outcomes |
| Backfill enqueue fails | Log warning; still return retry readiness response |
| Backfill target no longer active | Ack as stale |
| Backfill sees valid sidecar already present | Ack as duplicate/no-op |
| Backfill transient R2/DO failure | Retry with delay |
| Backfill deterministic invalid pack/idx | Log invalid-pack and ack |
Logging and Limits
- Add structured logs for:
ref-index:write-startref-index:write-completeref-index:write-errorstream:fetch:ref-index-missingstream:plan:ref-snapshotref-index:backfill-startref-index:backfill-completeref-index:backfill-stale-packref-index:backfill-invalid-pack
- Every new R2 and DO call goes through the existing limiter/counting pattern:
r2:get-pack-refsr2:put-pack-refsr2:delete-pack-refsdo:get-active-pack-catalog
- Queue sends are logged and scheduled with
waitUntil; queue worker R2/DO work uses the same limiter rules as compaction.
Tests
- Unit tests:
- sidecar encode/decode round trip
- every validation rejection case
- tree parser excludes
160000gitlinks - commit/tree/tag/blob edge encoding
- duplicate OID deterministic ordering
- Worker tests:
- receive writes
.refs - compaction writes target
.refs - cleanup deletes
.refson receive rejection, finalize conflict, lease mismatch, compaction retry, and superseded delete - final fetch with valid sidecars does not call per-object
r2:get-rangeduring closure - missing sidecar returns
503beforepackfile\n - backfill creates sidecar and subsequent fetch succeeds
- corrupt/stale sidecar is rebuilt
- annotated tag chain closure works
- submodule gitlink is not required as an object in the superproject
- receive writes
- Large fixture:
- opt-in test using
uncommitted-fixture/pack-395a180893e59dad8ef9d7fa135ecd8b1b399bb1.pack - assert sidecar generation handles 97,409 objects
- assert closure planning uses sidecar reads and stays within request subrequest/memory expectations
- opt-in test using
- Validation commands:
npm run typecheck- targeted fetch/indexer/receive/compaction worker tests
- opt-in large fixture test when validating scale
Assumptions
- Temporary
503 Retry-Afteris acceptable for incremental fetches while legacy active packs are backfilled. - No protocol support for shallow/filter fetch is added in this change.
- Sidecars are derived artifacts; correctness still depends on
.pack,.idx, and the DO active pack catalog.