Blob: docs/streaming-push-cutover-plan.md
Phase 5: Streaming-by-Default Cutover With Legacy Rollback Window
Historical document. The cutover described here has been completed and the closure release has removed all legacy code paths. This plan is preserved for historical context only. See
MIGRATION-STREAMING-PUSH.mdfor current deployment guidance anddocs/streaming-push-closure-plan.mdfor the closure implementation plan.
Context
Phases 1-4 of the streaming push migration are landed. The pack-first object store, streaming receive, and queue-driven compaction are all operational.
Normal push-created repos always have at least one pack in R2 (the legacy receive path writes a .pack on every non-delete push). Zero-pack repos were not a normal operator-facing steady state by the time streaming fetch became the default. However, legacy loose-object fallback paths did exist historically, so repos with refs but no active packs should be treated as an operator exception requiring manual intervention — not as something that never existed in code.
Phase 5 retires the legacy code paths. It is split into two named deploy checkpoints:
- Cutover release (this session):
shadow-readis retired from the type system. New repos default tostreaming. Dead DO RPCs and shadow-read validation plumbing are removed. Legacy receive/alarm branches remain callable for repos explicitly set tolegacymode, preserving a working rollback path without requiring a full deploy rollback. - Closure release (future session): after the rollback window closes, remove all remaining compatibility-only surfaces:
/admin/hydrate, rollback backfill, legacy receive/unpack/hydration code, legacy wrangler vars, and compatibility-only tests.
This plan covers the cutover release only. The closure release is documented as future work at the end.
Cutover Release
Step 1: Narrow RepoStorageMode type and normalize shadow-read
src/do/repo/repoState.ts:
- Change
RepoStorageModefrom"legacy" | "shadow-read" | "streaming"to"legacy" | "streaming"
src/do/repo/catalog/shared.ts — ensureRepoMetadataDefaults():
- When
modeis undefined (no stored key), the default depends on repo state:- If the repo is truly empty (no refs, no
lastPackKey, nopackList, ANDpacksetVersionis 0 or undefined): default to"streaming" - If any of those signals indicate prior activity (
packsetVersion > 0, refs present,lastPackKeyset, non-emptypackList): default to"legacy"— this prevents silently auto-promoting cold existing repos that predate the mode key - Using
packsetVersion > 0as an additional signal makes the heuristic more robust than relying only on refs/packList/lastPackKey
- If the repo is truly empty (no refs, no
- Document this rule explicitly in
MIGRATION-STREAMING-PUSH.md: cold repos with data but no stored mode key will come up aslegacyand must be promoted manually - Note:
ensureRepoMetadataDefaults()has no logger parameter. Shadow-read normalization happens in a different site — see below.
Shadow-read normalization — add a dedicated helper sanitizeRawStorageMode() in src/do/repo/catalog/shared.ts:
/**
* Reads the raw persisted storage mode and normalizes stale values.
* Must run AFTER ensureRepoMetadataDefaults() (which handles missing keys)
* and BEFORE any typed read of repoStorageMode.
*
* Returns the canonical RepoStorageMode after normalization.
*/
export async function sanitizeRawStorageMode(
storage: DurableObjectStorage,
logger: Logger
): Promise<RepoStorageMode> {
const raw = (await storage.get("repoStorageMode")) as string | undefined;
if (raw === "shadow-read") {
await storage.put("repoStorageMode", "streaming");
logger.info("mode:normalize-shadow-read", { previous: raw });
return "streaming";
}
return (raw as RepoStorageMode) ?? "legacy";
}- Call this in
src/do/repo/repoDO.tsconstructor'sblockConcurrencyWhile, afterensureRepoMetadataDefaults()and before any other work - The helper reads raw storage (untyped), normalizes, and returns a typed
RepoStorageMode— keeping the type boundary clean ensureRepoMetadataDefaults()handles the missing-key case;sanitizeRawStorageMode()handles the stale-key case. They compose in sequence.
src/do/repo/catalog/storageMode.ts:
- Remove all
shadow-readbranches fromcanTransition(),buildRepoStorageModeBlockers(),buildModeMessage(),isRepoStorageMode() - New transition rules:
legacy → streaming: requiresactivePackCount > 0(repos with pack data), OR the repo is truly empty (refsis empty ANDactivePackCount === 0ANDpacksetVersion === 0)streaming → legacy: requires rollback backfill ready, OR the repo is truly empty (same condition: no refs, no packs,packsetVersion === 0)
- The empty-repo gate is NOT just "zero packs" — it requires no refs either, to avoid the unsupported loose-only-with-refs case
- Remove
ALL_REPO_STORAGE_MODESconstant (now just two modes)
src/contracts/repoStorageMode.ts:
- Update the type to
"legacy" | "streaming". Removeshadow-readfrom all contracts.
Admin UI — src/client/islands/repo-admin/StorageModeCard.tsx:
- Remove the three-step pipeline visualization (
legacy → shadow-read → streaming) - Replace with a two-state toggle:
legacy(rollback) ↔streaming(active) - Remove any
shadow-readreferences in button labels, descriptions, and blocker messages
Step 2: Remove dead DO RPCs
These RPCs have zero production callers. Remove the public methods from RepoDurableObject and their backing implementations.
src/do/repo/repoDO.ts — remove these methods and their imports:
getObjectStream()→ backing:src/do/repo/storage.ts:getObjectStreamgetObjectSize()→ backing:src/do/repo/storage.ts:getObjectSizehasLooseBatch()→ backing:src/do/repo/storage.ts:hasLooseBatchgetObjectRefsBatch()→ backing:src/do/repo/storage.ts:getObjectRefsBatchgetPackLatest()→ backing:src/do/repo/packs.ts:getPackLatestgetPackOids()→ backing:src/do/repo/packs.ts:getPackOidsgetPackOidsBatch()→ backing:src/do/repo/packs.ts:getPackOidsBatch
src/do/repo/storage.ts — delete: getObjectStream, getObjectSize, hasLooseBatch, getObjectRefsBatch
src/do/repo/packs.ts — delete the RPC-facing wrapper functions: getPackLatest, getPackOids, getPackOidsBatch. Note: src/do/repo/hydration/helpers.ts calls the DB-layer getPackOids from db/index.ts directly, NOT these packs.ts wrappers. The wrappers are exclusively called from their repoDO.ts RPC methods. Safe to delete.
src/git/pack/loose-loader.ts — delete createStubLooseLoader (exported but zero callers). Keep createLooseLoader (still used by legacy indexPackOnly during rollback window).
Step 3: Remove shadow-read validation and compatibility fallback from the read path
src/git/operations/read/objects.ts:
- Delete
readCompatibilityLooseObject()(lines 61-120) - Delete
maybeValidateShadowRead()(lines 122-143) - Simplify
readLooseObjectRaw(): remove thereadCompatibilityLooseObjectfallback branch insideloadPackedFirst(), removecompatLegacy, removemaybeValidateShadowRead()calls. The function now only callsreadObject()from the pack-first store. - Remove the local
logOncehelper (duplicate of the one inobject-store/support.ts) - Remove imports:
loadRepoStorageMode,validatePackedObjectShadowRead
src/git/object-store/shadow.ts — delete entire module
src/git/object-store/catalog.ts — remove loadRepoStorageMode() (no read-path code queries mode anymore)
src/git/object-store/index.ts — remove export * from "./shadow.ts"
Step 4: Remove getObject compatibility RPC
Depends on step 3 (sole production caller readCompatibilityLooseObject is now gone).
src/do/repo/repoDO.ts — remove getObject() method and its import from storage.ts
src/do/repo/storage.ts — delete getObject() function
Step 5: Remove loaderCap/loaderCalls and shadow-read memo fields
Depends on step 3.
src/cache/cache.ts — remove from RequestMemo:
loaderCalls?: number(line 47)loaderCap?: number(line 49)repoStorageMode?: RepoStorageMode(line 33) — no read path queries mode anymorerepoStorageModePromise?: Promise<RepoStorageMode>(line 35) — same- Remove the
RepoStorageModeimport
Step 6: Keep legacy receive/alarm branches callable (rollback window)
Key design decision: unlike the previous plan revision, the legacy receive path in src/routes/git.ts and the unpack/hydration alarm branches in src/do/repo/repoDO.ts are NOT removed in the cutover release. This ensures that legacy mode is a real working rollback mode — an operator can set a repo to legacy and it immediately starts using the buffered receive and unpack/hydration pipeline, without requiring a full deploy rollback.
src/routes/git.ts — handleReceivePackPOST():
- Keep the existing mode branch:
streamingroutes tohandleStreamingReceivePackPOST,legacyroutes to the DO/receivepath - Remove only the
shadow-readreference (it was handled the same aslegacy; now there's justlegacy) - Simplify the mode query:
getRepoStorageMode()now returns"legacy" | "streaming"(no shadow-read case)
src/do/repo/repoDO.ts — alarm():
- Keep the existing branch:
streamingruns compaction re-arm,legacyrunshandleUnpackWorkandhandleHydrationWork - Remove only the
shadow-readhandling (it was in theelsebranch withlegacy) - Do NOT auto-clear stale unpack/hydration state — that state may be needed if the repo is rolled back to
legacy
src/do/repo/repoDO.ts — fetch():
- Keep the
/receiveroute (line 142-144) — it's still needed when a repo is inlegacymode
src/do/repo/scheduler.ts:
- Keep the legacy
elsebranch (lines 51-101) fully functional - Remove
shadow-readfrom the mode check (it was part of theelsewithlegacy)
Step 7: Simplify capability advertisement
src/git/core/protocol.ts — in the git-receive-pack branch:
- The
getRepoStorageMode()call stays (it still determines whether to advertiseside-band-64kandquiet, which only the streaming path supports) - Remove
shadow-readfrom the mode check:supportsStreamingReceiveSideband = storageMode === "streaming"(already correct, just clean up any shadow-read reference if present)
Note: we do NOT unconditionally advertise side-band-64k/quiet because repos in legacy mode during the rollback window still use the DO buffered path which does not support sideband. If we advertised sideband for legacy repos, clients would expect sideband framing and get raw bytes instead.
Step 8: Delete compatibility-only tests
Delete entirely (test removed RPCs or shadow-read-only behavior):
test/has-loose-batch.worker.test.ts— tests removed RPCtest/do-packoids-batch.worker.test.ts— tests removed RPCtest/progress-queued.worker.test.ts— testsgetUnpackProgressvia a removed route-level callertest/object-store-shadow.worker.test.ts— tests shadow-read validationtest/packed-object-store.shadow.worker.test.ts— tests shadow-read packed store
Keep as rollback-window compatibility coverage:
test/unpack-progress.worker.test.ts—getUnpackProgress()is still a callable RPC during the rollback window. Keep to confirm the RPC works for legacy repos.test/hydration-coverage-epochs.worker.test.ts— hydration coverage logic is still used by the legacy path. Hydration packs help make the serving set complete for legacy fetch; the coverage helpers insrc/do/repo/hydration/helpers.tsand the re-enqueue logic insrc/do/repo/maintenance.tsare still callable for repos inlegacymode.test/hydration-clear-deletes-packobjects.worker.test.ts—clearHydration()is still a callable RPC via the admin/hydrateDELETE alias. Keeps the compatibility surface tested.
Keep until closure release (legacy paths are still callable):
test/receive-push.worker.test.ts— tests legacy receive e2e (still callable forlegacyrepos)test/receive-queue.worker.test.ts— tests legacy unpack queueing (still runs forlegacyrepos)test/fetch-during-unpack.worker.test.ts— tests fetch during legacy unpack (still possible)test/create-mem-pack-fs.test.ts(AVA) — rollback-only pack/index helpers still existtest/maintenance.worker.test.ts— keep fully; hydration assertions still valid forlegacyrepostest/calculate-stable-epochs.worker.test.ts— covers logic still used bysrc/do/repo/maintenance.tsviasrc/do/repo/packs.tsfor legacy repostest/migrate-packkeys.worker.test.ts—src/do/repo/db/migrate.tsstill runs in the DO constructor, and the migration guide supports upgrades from pre-phase-1 commits
Rewrite (not delete):
test/multipack-union.worker.test.ts— still valid multi-pack fetch coverage, but uses isomorphic-git toolchain. Rewrite to use streaming pack-first toolchain (pack catalog + idx views).test/pack-first-read-path.storage-mode.worker.test.ts— removeshadow-readtest cases. Keeplegacyandstreamingtransition tests for the two-mode contract.
Step 9: Add new test coverage
New tests or additions to existing test files:
- Empty repo streaming default: new repo defaults to
streaming, receives first push via streaming pipeline without manual mode flip, fetch works afterward - Shadow-read normalization: repo with persisted
shadow-readmode is normalized tostreamingon first DO instantiation, with structured log - Empty-repo escape hatch: truly empty repo (no refs, no packs,
packsetVersion === 0) instreamingcan transition tolegacywithout rollback backfill (and vice versa) - Non-empty zero-pack repo blocked: a repo with refs but no active packs cannot transition
legacy → streaming(the unsupported loose-only case) - Rollback compatibility: one suite proving
legacymode still works for receive, unpack, and fetch after cutover deploy — keeptest/streaming-receive.rollback.worker.test.tsand extend if needed
Step 10: Rename readLooseObjectRaw → readObjectRaw (optional cleanup)
This is treated as optional post-behavioral-cutover cleanup. It adds import churn across many files for no risk reduction. If done, keep it as the last code change before docs.
src/git/operations/read/objects.ts — rename function, update JSDoc.
Callers: tree.ts, commits.ts, objects.ts (internal), plus test files.
If deferred: leave the rename for the closure release when the churn is already happening.
Step 11: Update repoDO.ts class docstring and comments
Remove references to:
shadow-readmode- Loose-object writes as a primary path
Update to describe:
- Streaming receive is the default path
- Legacy receive/unpack/hydration remain callable for repos in
legacymode during the rollback window. Hydration packs (pack-hydr-*) and hydration coverage help make the serving set complete for legacy fetch — they are not merely deprecated admin baggage whilelegacymode exists. - The DO is metadata-authority; data plane lives in R2 packs
Step 12: Write MIGRATION-STREAMING-PUSH.md
New file: MIGRATION-STREAMING-PUSH.md (repo root, not docs/, for operator visibility)
Operator runbook. Structure:
Section 1: Upgrade Path by Starting Version
| Upgrading from | Required steps |
|---|---|
Before c202a4a (pre-phase-1) |
Deploy phases 1-4 first (a76650c). Run repos through phase 1-4 validation gates before cutover. |
Between c202a4a and a76650c (phases 1-3) |
Deploy a76650c (phase 4) first. Verify compaction works. Then deploy cutover. |
At a76650c (phase 4, current) |
Deploy cutover directly. |
Warn: do NOT skip intermediate phases. Each validates correctness for the next:
- Phase 1 seeds
pack_catalogand validates pack-first reads - Phase 2 cuts over all read paths to pack-first
- Phase 3 enables streaming receive for canary repos
- Phase 4 enables queue-driven compaction
Section 2: Cutover Deploy
- Deploy the cutover commit
- New repos automatically default to
streaming - Repos previously on
shadow-readauto-normalize tostreamingon next DO access (logged) - Inventory existing repos by mode:
GET /:owner/:repo/admin/storage-mode - For
legacyrepos with active packs:PUTwith{"mode": "streaming"} - For
legacyrepos that need break-glass rollback: first runPOST /admin/storage-mode/backfill, pollGET /admin/storage-modeuntilrollbackCompat.status === "ready"(backfill is async), thenPUTtostreaming - Truly empty repos (no refs, no packs,
packsetVersion === 0):PUTdirectly tostreaming, or leave onlegacy— they return 503 on fetch regardless because there's nothing to serve - Unsupported: non-empty zero-pack repos (refs exist but no active packs — e.g., loose-only repos from non-standard seeding): these cannot be promoted to
streaming. The adminPUT /admin/storage-modeendpoint will reject the transition with a blocker message. The operator must either push a pack to the repo first (viagit pushwhile inlegacymode, which creates a pack) or purge and recreate the repo. - Cold repos with no stored mode key: existing repos that predate the
repoStorageModekey will default tolegacyif they have any data signal (packsetVersion > 0, refs,lastPackKey, orpackList), orstreamingif truly empty. This prevents silent auto-promotion. Operators must manually promote data-bearing repos. - Note: repos in
legacymode still function normally (receive, fetch, unpack). Legacy is a working mode, not just a label.
Section 3: Rollback Procedure
Per-repo rollback does not require a full deploy rollback:
- For affected repos:
POST /admin/storage-mode/backfillto queue rollback compatibility data preparation. This backfillsobj:*andpack_objectsfrom active packs so the legacy path can serve correctly. Backfill is async (batched via the maintenance queue) — pollGET /admin/storage-modeuntilrollbackCompat.status === "ready"before proceeding. PUT /admin/storage-modewith{"mode": "legacy"}- The repo immediately starts using legacy receive/unpack/hydration paths. Hydration packs (
pack-hydr-*) and hydration coverage state remain part of the legacy compatibility surface — they help make the serving set complete for legacy fetch. The legacy alarm will schedule hydration work as needed. - Full deploy rollback to
a76650cis available as a last resort but not required for per-repo rollback.
Section 4: Closure Deploy (future)
Operator-facing summary of what changes when the rollback window closes:
- Confirm all repos stable on
streamingfor the window duration - Deploy the closure release commit
/admin/hydrate,/admin/storage-mode/backfill, andPUT /admin/storage-modeare removed- Rollback to pre-streaming is no longer possible
- All repos are implicitly streaming
Step 13: Update documentation
README.md:
- Replace "Time-budgeted background unpacking" with streaming push description
- Remove unpack-as-primary-mechanism language
- Add upgrade notice: "If upgrading from a commit before
a76650c, readMIGRATION-STREAMING-PUSH.mdfor the required deployment sequence."
AGENTS.md:
- "Validation By Change Type": replace "hydration" with "compaction"
- Update
repoDO.tsdescription in First Files To Read - Core Invariants: replace "Receive-pack queuing is intentionally bounded: one active unpack plus one queued" with streaming receive lease model
- Avoid section: remove "Converting streaming paths to buffered implementations"
docs/streaming-push.md:
- Add "Phase 5 cutover completed" status note at the top
- Append a "Closure Release Implementation Checklist" section (agent-facing) with the full task list:
- Remove emergency backfill tool:
src/maintenance/legacyCompatBackfill.ts,src/do/repo/catalog/legacyCompat.ts, backfill RPCs,POST /admin/storage-mode/backfill,LegacyCompatBackfillState,test/streaming-receive.rollback.worker.test.ts - Remove
/admin/hydratealiases andstartHydration/clearHydrationRPCs - Remove
mirrorLegacyPackKeys()andlastPackKey/lastPackOids/packListfromRepoStateSchema - Remove
REPO_UNPACK_*fromwrangler.jsonc,repoConfig.ts,vitest.bindings.ts; runnpm run cf-typegen - Delete
src/do/repo/unpack.ts,src/do/repo/repoDO/receive.ts,src/do/repo/repoDO/hydration.ts,src/do/repo/hydration/,src/git/operations/receive.ts,src/git/pack/loose-loader.ts,src/git/pack/unpack.ts(rewriteseeding.tsfirst) - Remove
unpackWork,unpackNext,hydrationWork,hydrationQueueand types fromRepoStateSchema - Drop
pack_objects,hydr_cover,hydr_pendingtables vianpm run db:gen - Remove
isomorphic-gitfrompackage.jsonandvitest.config.ts - Remove
RepoStorageModetype and all storage-mode RPCs/admin endpoints - Delete remaining legacy tests:
receive-push,receive-queue,fetch-during-unpack,create-mem-pack-fs,calculate-stable-epochs,migrate-packkeys,unpack-progress,hydration-coverage-epochs,hydration-clear-deletes-packobjects,maintenancehydration assertions - Final docs cleanup: mark closure complete, remove all legacy references
- Remove emergency backfill tool:
docs/architecture.md and docs/data-flows.md:
- Update receive path description from buffered to streaming as the default
- Mark unpack/hydration as rollback-window-only in data flow descriptions
- Mark older rollout-context sections as archival
docs/caching.md:
- Remove references to
loaderCap/loaderCalls - Remove references to
getPackLatestas the former seeding path
docs/api-endpoints.md:
- Update receive-pack endpoint description
- Note
/admin/hydrateas compatibility alias (rollback window only)
docs/storage.md:
- Update storage model to describe streaming as the default path
Verification
After all steps:
npm run typecheck
npm run test # AVA unit tests
npm run test:workers # Vitest worker integration tests
npm run test:auth # Auth-specific tests
npm run format:checkConfirm:
- No PRIMARY correctness test exercises shadow-read validation (shadow-read plumbing is deleted)
- Kept hydration/unpack tests (
hydration-coverage-epochs,hydration-clear-deletes-packobjects,unpack-progress,receive-push,receive-queue, etc.) are rollback-path compatibility coverage, not primary correctness tests — they prove the legacy rollback mode works, not the streaming path repoDO.tsremains a thin delegator- The streaming receive path handles empty repos correctly
- New repos default to
streamingwithout manual intervention - Repos previously on
shadow-readauto-normalize tostreaming - Legacy mode is still functional for repos explicitly set to
legacy(rollback story works) test/receive-push.worker.test.tsandtest/receive-queue.worker.test.tsstill pass (legacy paths callable)getUnpackProgressRPC stays (still called by the legacy branch insrc/routes/git.ts:231)
Closure Release (future session)
NOT implemented in this session. The agent-facing implementation checklist is appended to docs/streaming-push.md (step 13). The operator-facing closure steps are in Section 4 of MIGRATION-STREAMING-PUSH.md (step 12).