Archive sharding: streaming hash, abandon, and size-ordered budget - #2024
Conversation
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
…don, budget the sweep by size Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
…and size-budgeted selection - hashing unit test proves createCanonicalRowsHasher matches the one-shot digest - DO tests record rows per SELECT and fail against the one-shot hash (1205 > 500) - coordinator tests cover largest-first ordering, the message budget, and abandon - Workers-runtime tests migrate a 1,001-message session and abandon a fenced one - docs: env reference, configuration reference, API reference, CLAUDE.md, rule 69 Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
…ease is live Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
…bandon Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
… route Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
…asons, and selection tie-breaks Also documents the DO-owned hash page size, the single sanctioned sourceIntactVerified caller, the second consumer of ACTIVE_RECLAIMABLE_STATES, and why grouped-row paging keys on rowid. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
… intent before the shard copy Abandon now reserves the journal lease (CAS, epoch bump) before touching any object, runs the lock-protected source abandon before deleting the shard copy, releases the reservation when a step fails, and guards the D1 freeze with the reserved epoch. A sweep finalizing concurrently can no longer leave a session with zero copies. The sweep journals each candidate only immediately before processing it, so a tick that runs out of wall time never leaves unreached sessions fenced until the next cadence-gated sweep. Grouped-row paging seeks on the indexed (created_at, id) order instead of rowid, which planned as a per-page sort of the whole session. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Moves the completed task file to tasks/archive/ and points the rule 69 reference at its archived path. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa
|



Summary
The SAM root ProjectData Durable Object sits at 10.256 GB against the 10 GB SQLite ceiling and is being reset for CPU and memory. The archive-sharding sweep that should drain it could not: its terminal-version hash loaded every row of a session with
toArray()and hashed one string, so a 100,000-message session and a tool-heavy 9,906-message session both reset the object withDurable Object's isolate exceeded its memory limit. Three production sessions are now fenced (migrating/frozen) withfailed/poisonedjournals that copy-back cannot fix because it requiressource_deleted.This PR makes three changes:
computeTerminalVersion→tableAggregateSha256pagesPROJECT_DATA_ARCHIVE_HASH_PAGE_ROWS(default 500) rows per statement throughcreateCanonicalRowsHasher(node:cryptoincremental SHA-256), which is byte-identical to the one-shotsha256Hex(canonicalizeArchiveRows(...)), so every recorded proof stays valid.finalizeSourceDelete,rebuildTargetFts, and the new abandon path page grouped rows the same way, seeking on the indexed(created_at, id)order.POST /api/admin/project-data/storage/:projectId/archive-sharding/migrations/:migrationId/abandonreserves the journal lease (CAS, epoch bump), removes the root source intent under the source transcript lock (refuses once the payload is deleted), only then drops the partial shard copy, freezes the journal asoperator_abandonedwithout opening the project breaker, and returns the D1 location toroot.session_summaries.message_count DESC) under a per-tickPROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET(default 20,000; a single oversize session is still selected alone).PROJECT_DATA_ARCHIVE_SWEEP_SESSIONSbecomes the hard ceiling (default 1 → 10), and the sweep journals (fences) each candidate only immediately before processing it, so a tick that runs out of wall time never leaves unreached sessions fenced until the next cadence-gated sweep.Rule 47 candidate-selection statement.
selectCandidates/selectScopedCandidateschanged only theirORDER BY(updated_at ASC→message_count DESC, updated_at ASC, id ASC) and gained the in-memory budget filter; theWHEREpredicate is unchanged. Expected candidate volume per tick: at mostsweepSessions(10) rows after the budget, from a global scan of terminal sessions past the 7-day grace (staging today: ~700 such sessions across projects; production SAM project alone holds 6.7M of 8.9M messages in terminal sessions past grace). Worst-case per-candidate cost is one migration (~4 ms/message observed on the production canary), bounded by the existing wall-time break, which now runs before each journal row is created. The scan itself is unindexed onended_at/message_count(pre-existing; the global sweep runs once perPROJECT_DATA_ARCHIVE_GLOBAL_SWEEP_INTERVAL_MS, 24 h in production); an index is a follow-up, not a change in this PR.Config:
PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET,PROJECT_DATA_ARCHIVE_HASH_PAGE_ROWS(new, plumbed throughenv.ts, DOtypes.ts, top-levelwrangler.toml,sync-wrangler-config.ts, bothwrangler_sync_envblocks,.env.example, env-reference skill, configuration docs). No D1 or DO migration:frozenanderror_codealready exist.Validation
pnpm lintpnpm typecheckpnpm test(api: 665 files / 8,984 tests; web: 302 files)pnpm check:fast;apps/apiWorkers-pool suitetests/workers/project-data-archive-sharding.test.ts(real DO SQLite, 7/7 incl. a 1,001-message multi-page migration and abandon-then-reselect); mutation checks recorded belowMutation checks (each guard deleted, exactly the intended test went red, then restored):
toArray()hash restored →never materialises more than one page per statementfailsexpected 1205 to be less than or equal to 500keeps the grouped-row and FTS teardown pagedfailsstate !== 'failed'lease conjunct deleted →does not let a stale lease on an already-failed migration block abandonfailsnever drops the shard copy when the source is finalized between the inspect read and the abandonfailsfences the journal before touching any objectfailleaves sessions it never reached unfenced when wall time runs out mid-tickfailsEXPLAIN QUERY PLANtest asserts the index range scan, with the rejected rowid shape as a per-page-sort controlStaging Verification (REQUIRED for all code changes — merge-blocking)
Deploy Stagingrun 33987974789 for870f73f3succeeded (19:59Z);https://api.sammy.party/healthhealthy; staging D1 ledger current (no migration in this PR), 0 active / 0 fenced archive migrations before the feature passSAM_PLAYWRIGHT_PRIMARY_USERis not set in this session; script.tmp/staging-archive-verify.mjsis prepared and runs as soon as the token is availableStaging Verification Evidence
Deploy: run 33987974789 green for
870f73f3. Health:{"status":"healthy"}at 20:01Z. D1 (via Cloudflare API):d1_migrationshead0143_project_data_storage_relief_preflight.sql;project_data_archive_migrations2 rows, none active;project_data_session_locations0migrating. Feature pass not yet run — blocked on the staging smoke token (rule 13: not substituting page-load checks). This section will be updated with canary/abandon evidence before merge; the PR must not merge until then.UI Compliance Checklist (Required for UI changes)
N/A: no UI changes (
apps/web,packages/ui,packages/terminaluntouched).UI Screenshot Evidence
N/A: no UI changes.
End-to-End Verification (Required for multi-component changes)
tests/workers/project-data-archive-sharding.test.ts: coordinator → root DO → shard DO → D1 through real RPC)Data Flow Trace
Abandon:
POST .../migrations/:migrationId/abandon→apps/api/src/routes/admin/project-data-storage.ts(superadmin viaadminRoutes.useinroutes/admin.ts;jsonValidator(ProjectDataArchiveRecoveryControlSchema))abandonProjectDataArchiveMigration(apps/api/src/scheduled/project-data-archive-sharding.ts): journal read, project match, state/lease guards,reserveAbandonLease(D1 CAS)archiveSourceInspectIntent(early refusal) thenarchiveSourceAbandonIntent(durable-objects/project-data/index.ts, underwithArchiveTranscriptLock) →abandonArchiveSourceIntent(archive-sharding.ts) deletes the intent row, refusessource_deleted/rehome_exportedarchiveTargetAbandonSession→abandonArchiveTargetSessionpages grouped rows for FTS delete markers, deletes messages/tool archives/chunks/target session/anchorfrozen/operator_abandoned(guarded by reservedlease_epoch), location →rootrootviaresolveExactReadOwnerand succeedsSweep selection: cron
runProjectDataArchiveSharding→selectMigrationWork(reclaimable + budgeted unjournaledpending) →processArchiveMigrationBatch(wall-time check →createCandidateJournal→migrateCandidate, one at a time) →prepareArchiveSourceIntent→computeTerminalVersion(pagedtableAggregateSha256) → chunk export/commit →sealArchiveTarget(paged) → manifest →finalizeSourceDelete(paged) → publish.Untested Gaps
Post-Mortem (Required for bug fix PRs)
What broke
Archive sharding could not drain the largest sessions from the SAM root ProjectData object: every session above one page of rows reset the object with a memory-limit error, and the failed attempts left three sessions unreadable (
migrating/frozen) with no recovery path.Root cause
tableAggregateSha256(introduced with the archive-sharding bridge) selected every row of a session per table with noLIMIT, calledtoArray(), and canonicalised one string; memory scaled with session bytes. The 2026-09-04 bind-parameter fix paged the chunk export but not the hash.Class of bug
Harness-ceiling divergence (rule 69): a platform limit (the isolate memory ceiling) that no test engine enforces, exercised only by fixtures far below it. Plus an unbounded per-session scan in a control loop (rule 47). The review round surfaced a second class in the fix itself: a cross-object check-then-act whose destructive step ran on the wrong side of the only serialization point (rules 45/58), fixed before merge.
Why it wasn't caught
All DO fixtures were ≤ 12 rows; the canary's nine "successes" were two-message sessions. The suite never measured how many rows a statement materialised.
Process fix included in this PR
.claude/rules/69-emergency-config-paths-need-their-own-coverage.mdgains "Memory Is A Ceiling Too, And It Scales With The Row You Did Not Page": unboundedSELECT ... WHERE <scope> = ?over user-growing tables must page, and the discriminating test asserts the page shape and digest parity because no harness can observe the reset.Post-mortem file
tasks/archive/2026-09-05-archive-sharding-streaming-hash-abandon-and-size-budget.mdSpecialist Review Evidence (Required for agent-authored PRs)
needs-human-reviewlabel added and merge deferred to human — not applicable, all nine completedabandon_reason_required→ 400 + route test (808fde8), resolver clamp tests (a8e4391)publishedtarget guard documented in code; MEDIUM real-middleware auth test deferred (repo-wide mock pattern; mount order verified by readingroutes/admin.ts); LOW blank reason fixed (808fde8); LOWproject_idon shard deletes not applicable (tables carry noproject_id, shard DO is per-project, identity checked byvalidateTargetOwner)state !== 'failed'conjunct tests, both mutation-verified (412e471); LOW tie-break determinism + blank-reason coordinator tests (412e471)resolveArchiveHashPageRowsadded (a8e4391)sync-wrangler-config, both workflow env blocks, and all three docs;quality:wrangler-bindingsand the deploy-workflow env-parity test pass; LOW root.env.exampledrift pre-existinghashPageRows, single sanctionedsourceIntactVerifiedcaller, second consumer ofACTIVE_RECLAIMABLE_STATES, and the rowid-vs-cursor paging reason (412e471); file split of the two >2,500-line modules deferred as follow-up(created_at, id)seek withEXPLAIN QUERY PLANtest (870f73f); MEDIUMsession_summariesindex deferred, candidate volume stated above; LOW redundantensureProjectIdpre-flight pre-existingCodeRabbit Review Evidence (Required for agent-authored PRs)
coderabbit-reviewlabel applied after local review, staging if applicable, and CI gates passedCodeRabbit Notes
Not yet requested: the
coderabbit-reviewlabel is applied only after CI and the staging feature pass are green (Phase 7 gate). CodeRabbit's auto-review is label-gated in this repo (see its skip notice on this PR).Exceptions (If any)
Agent Preflight (Required)
Classification
External References
Cloudflare Durable Object SQLite limits (10 GB per object, ~128 MB isolate memory) and
nodejs_compatnode:cryptoavailability, checked via the Cloudflare docs MCP; production D1 journal/location rows and the two failing canary results (Durable Object's isolate exceeded its memory limit and was reset) queried via the Cloudflare API before coding.Codebase Impact Analysis
apps/api/src/project-data-archive/hashing.ts(incremental hasher),apps/api/src/project-data-archive/contract.ts(defaults),apps/api/src/durable-objects/project-data/archive-sharding.ts(paged hash, paged grouped loops, abandon primitives),apps/api/src/durable-objects/project-data/index.ts(RPC wrappers, env-resolved page size),apps/api/src/scheduled/project-data-archive-sharding.ts(size-ordered budgeted selection, lazy journaling, abandon coordinator with lease reservation),apps/api/src/routes/admin/project-data-storage.ts(abandon route), env plumbing (env.ts, DOtypes.ts,wrangler.toml,scripts/deploy/sync-wrangler-config.ts,.github/workflows/deploy-reusable.yml).Documentation & Specs
apps/www/src/content/docs/docs/reference/configuration.md,apps/api/.env.example,.claude/skills/env-reference/SKILL.md,.claude/skills/api-reference/SKILL.md,CLAUDE.md(Recent Changes),.claude/rules/69-emergency-config-paths-need-their-own-coverage.md.Constitution & Risk Check
Principle XI: every new limit has a
DEFAULT_*constant, an env override, and a clamp (MAX_*). Data-safety risk: abandon is destructive on the shard side; it is gated on a lease reservation, the lock-protected source abandon running first, refusal once the source is deleted, and an epoch-guarded D1 freeze; the breaker is untouched. Rule 31: no schema changes. Tradeoff: thesourceIntactVerifiedflag remains a caller assertion (single sanctioned caller documented) because the shard cannot see the root object.🤖 Generated with Claude Code
https://claude.ai/code/session_01BZCe9xphZ9LWBK64sz5PWa