Fix deadlocked ProjectData archive sweep: derive the selection ceiling from the write budget - #2069
Conversation
Root-caused from production evidence: the selection ceiling (PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET=5000) exceeds the affordability ceiling derived from the deployed DAILY_WRITE_BUDGET=100000 (3093 units), and selection is largest-first, so every hourly tick picks a session it can never reserve and mutates nothing while reporting succeeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two independently configured ceilings had to agree and drifted apart on 2026-09-08: PROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGET was lowered to 100000 via a GitHub production Environment override while PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET stayed at the checked-in 5000. Largest-first selection then picked the same 4994-message session every hour for four days, estimated ~160,808 writes against a 100,000 allowance, was refused before reserveArchiveWrites touched D1, and reported succeeded with a null skip reason while the root DO climbed to 96.7%. - selectCandidates now filters on a ceiling derived from the allowance - a refused candidate falls through to the next-smaller one instead of ending the tick - reserveArchiveWrites distinguishes 'exceeds_allowance' from 'window_exhausted' - consecutive unaffordable stalls surface as a non-succeeded cadence status Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nc docs Four Workers-pool tests drive the real runProjectDataArchiveSharding against real DO SQLite and real D1 with a mixed candidate set. Each was verified discriminating by reverting one fix at a time: - derived ceiling -> sweepMessageBudget => 'migrates an affordable session...' RED - fall-through over-fetch removed => 'falls through to a smaller candidate...' RED - refusal categories collapsed => 'does not count an exhausted daily pool...' RED - stall status escalation removed => 'stops reporting succeeded...' RED Also updates .env.example and the public configuration reference for the three new vars and the changed wrangler.toml values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…path A Worker var the code reads but sync-wrangler-config.ts does not forward is silently unoverridable: the GitHub Environment variable is accepted, the deploy is green, and the deployed value is still the checked-in one. The three new vars govern the sweep's candidate ceiling, fall-through depth and stall alert, which are exactly what an operator reaches for when the sweep misbehaves. They are also added to the top-level [vars] so listEnvironmentVarOverrides can compare them and log any override — without a checked-in value it logs nothing, which is the rule-70 failure mode verbatim. Covered by a new deploy-reusable-workflow test, verified discriminating by deleting one forwarding line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…O budget Eight local reviewers ran. Substantive fixes: - pendingSlots was computed but never passed to processArchiveMigrationBatch, so the session-slot bound fell back to the whole over-fetched list. Found by architecture-reviewer and confirmed independently by test-engineer (which reproduced it live: 6 migrated against sweepSessions=2). Fixed, and the parameter is now REQUIRED rather than optional-with-silent-fallback (rule 73). - The stall counter read the cadence row before writing it, so a transient read failure silently restarted a streak in progress. Increment, threshold and status escalation now happen in one atomic UPDATE ... RETURNING. - Round-trip budget: owner stubs memoised per tick, fallthrough depth 8 -> 4, SWEEP_SESSIONS 4 -> 2, and the estimate scan cap bounded by ARCHIVE_MAX_ESTIMATE_INVENTORY_UNITS so it cannot grow with the allowance. - stats.selected no longer counts the fall-through padding. - Stale env docs corrected: .claude/skills/env-reference/SKILL.md (which CLAUDE.md routes every env question to) and the root .env.example both stated pre-fix values. Plus a /changelog entry and configuration.md precision fixes. Two new discriminating tests close the coverage gaps test-engineer proved were open: the packer's commit-on-success invariant and the operator-scoped ceiling bypass. All 7 reverts now redden exactly one test each. The cloudflare-specialist's index finding was measured rather than assumed: production session_summaries holds 5,526 rows, so the hourly scan-and-sort is negligible and an index would tax every session-summary write. Not added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migration 0156 added the column to D1, but apps/api/src/db/schema.ts is a separate hand-maintained definition and the unit suite builds its in-memory tables from it (createSchemaTables). The drift failed 20 tests in tests/unit/scheduled/project-data-archive-sharding.test.ts — every sweep there skipped outright because the cadence write referenced a column its table did not have. Caught by the full apps/api unit suite, which the targeted Workers-pool runs do not cover: those bind real D1 with the real migrations applied, so they could not see it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced 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 |
|
|
@coderabbitai review |
|
Merge held at the CodeRabbit gateEverything else is green, but I am not self-merging this. State: Blocker: CodeRabbit is rate limited on the free OSS tier and has produced zero reviews and zero inline comments.
I have not re-posted the review command — the standing project rule is to trigger once and monitor rather than retry into a rate limit. The Per @raphaeltm — you have waived CodeRabbit before for #1984, #2030 and #2035 when it was unavailable. If you are comfortable on the strength of the local reviewers, say so and I will merge and drive the production deploy plus the reclamation proof. Separately and more importantly: this fix ends the deadlock but cannot reverse the storage curve. Production is at 96.86% and still climbing (9,686,147,072 bytes as of 11:14Z). At the deployed budget the restored sweep reclaims ~0.89 MB/day against ~73.9 MB/day of growth — 83x short, leaving roughly 4 days of headroom. Break-even needs ~8.2M writes/day; even assuming the write-estimate factor is 8x too conservative it still needs 10x the current budget. That is a spend/architecture decision I have deliberately not made — details in the open human-input request. |



Summary
The production ProjectData archive sweep reclaimed nothing for four days while reporting
succeededon all 226 hourly runs, and the root Durable Object climbed from 94% to 96.7% of its 10 GB ceiling (~5 days of headroom).Two independently configured ceilings had to agree, and drifted:
selectCandidatesfilteredss.message_count <= PROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGETand orderedmessage_count DESC ... LIMIT sweepProjects * sweepSessions(deployed1 x 1).reserveArchiveWritesrefuses any estimate abovePROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGET— an affordability ceiling offloor((100000 - 1000) / 32)= 3093 write units, versus a selection ceiling of 5000 messages.On 2026-09-08 a GitHub
productionEnvironment override lowered the write budget to 100000 to bound cost. Nobody lowered the selection ceiling with it. From then on every tick picked the same 4994-message session (5f4ac04c-153f-4add-b96b-629bffaf0b2c, ended 2026-04-23), estimated ~160,808 writes, was refused beforereserveArchiveWritestouched D1 — so the UTC budget window also froze at2026-09-08T00:00:00Z— andcontinued out of a one-element list. No journal row, no location change, no error, no skip reason.What changed
archiveAffordableWriteUnits/archiveAffordableMessageCeilinginapps/api/src/project-data-archive/write-budget.ts), used as themessage_count <= ?bind. Two hand-set numbers that must agree will drift again; one derivation cannot. An explicit session-scoped operator canary still bypasses it —reserveArchiveWritesretains the final say on cost.selectCandidatesover-readsPROJECT_DATA_ARCHIVE_SWEEP_FALLTHROUGH_DEPTH(4) spare candidates, and both per-tick bounds — session slots and the cumulative message budget — moved into the journaling loop so a refused candidate, which opens nomigratingfence and moves no rows, consumes neither..claude/rules/72):exceeds_allowance(waiting cannot help) vswindow_exhausted(normal end-of-day backpressure, which happens on ~23 of 24 ticks once the pool is spent).PROJECT_DATA_ARCHIVE_BUDGET_STALL_ALERT_SWEEPS(3) consecutive ticks that migrate nothing and see onlyexceeds_allowanceflip the cadence row topartialwith an actionablelast_error. Migration0156addsconsecutive_budget_stalls; the increment, threshold comparison and status escalation all happen inside one atomicUPDATE ... RETURNING.wrangler.toml:DAILY_WRITE_BUDGET250000 → 100000 (matching the deployed production override, so staging and self-hosts derive the same ceiling instead of only production carrying it),SWEEP_MESSAGE_BUDGET5000 → 2000,SWEEP_SESSIONS1 → 2.IMPORTANT — this fix does not reverse the storage curve, and is not meant to
Within the 100000/day allowance the sweep can spend at most
floor((100000 - 1000) / 32)= 3093 write units/day ≈ ~1500–2000 messages/day. Measured reclaim from the 2026-09-05→09-08 drain was ~500–600 bytes/message (849 MB recovered across ~1.77 M messages), so the restored sweep reclaims on the order of 1–1.5 MB/day against ~66 MB/day of growth.The deadlock is real and fixing it is necessary regardless. But this PR cannot stop the object filling. Raising
PROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGETis deliberately out of scope — Raphaël rejected the projected ~$100/mo DO SQLite bill on 2026-09-08 — and no additional reclaimer is enabled (EVENT_LOG/GROUPED_FTS/TOOL_PAYLOADcleanup all remainfalse, verified unchanged in the diff). That is a spend decision and it needs one. Options, cheapest first: lowerPROJECT_DATA_ARCHIVE_WRITE_ESTIMATE_FACTOR(32 is a conservative scheduling estimate, not a measurement — if real amplification is lower, the budget is under-spending real capacity); raise the daily budget; or enable one of the other reclaimers.Control-loop load review (
.claude/rules/47)Candidate volume per tick:
min(remaining, sweepProjects * sweepSessions) + sweepFallthroughDepth= 6 rows (was 1). Worst case — every candidate refused, which is the steady state during the incident this fixes — is roughly 33 D1 + DO round-trips: ~11 fixed (cadence claim/finish, crash-gap, two selects), ≤6 estimate DO RPCs, ~1–2ensureProjectIdRPCs (now memoised per tick rather than per candidate), ≤6 D1 reservation upserts, ≤6 D1 journal writes for the ≤2 fenced sessions. The common case (first candidate affordable) is ~15. This runs hourly, not per-request. Pre-PR worst case was ~15; the first draft measured ~50 and was brought down by memoising owner stubs, lowering the fall-through depth 8 → 4, and loweringSWEEP_SESSIONS4 → 2.selectCandidateshas no covering index for(status, ended_at, message_count DESC). Measured rather than assumed: productionsession_summariesholds 5,526 rows (5,289 terminal), so an hourly scan-and-sort is negligible and an index would tax every session-summary write. Not added.Validation
pnpm lint— clean (apps/api, andpnpm check:fast13/13 tasks)pnpm typecheck— cleanpnpm test— see belowpnpm quality:migration-safety(183 FK relationships, 0 violations),pnpm quality:do-migration-safety,pnpm quality:wrangler-bindingsDiscrimination proof (
.claude/rules/62)Seven fixes, reverted one at a time; each reddened exactly one test, then restored:
sweepMessageBudgetmigrates an affordable session when the largest eligible one exceeds the whole allowancefalls through to a smaller candidate when the top candidate passes the ceiling but the budget refuses itbudgetDeferreddoes not count an exhausted daily pool as a stallstops reporting succeeded after consecutive sweeps that can afford nothing, and resets once one landspendingSlotsnot passedjournals at most sweepSessions candidates even when the over-fetch returns more affordable onesdoes not charge the cumulative message budget for a candidate the write budget refusedlets an operator-named session bypass the derived ceiling, but not a project-wide canaryAll tests enter through the real
runProjectDataArchiveShardingagainst real DO SQLite and real D1, with a mixed candidate set. Fixtures measure the DO's own cost model to size the allowance; they never tell the sweep which candidate to take. A fixture seeded only with affordable candidates would have passed throughout the outage.Staging Verification (REQUIRED for all code changes — merge-blocking)
34684491860and34687207699, both successhttps://api.sammy.party/api/auth/token-login(200), then navigatedhttps://app.sammy.partyN/A: no infra changes. This PR touches nopackages/cloud-init/,packages/vm-agent/, DNS, TLS or VM provisioning path; it provisioned no VMs and consumed no Hetzner capacity.N/A: no UI changesStaging Verification Evidence
Staging could not exercise this on its shipped config: it runs
PROJECT_DATA_ARCHIVE_COMPACT_ENABLED=false, which makes the whole write-budget path inert. Four temporarystagingEnvironment variables were set to make the path reachable and to force the affordability boundary onto staging's live candidate set. None of them overrides a value this PR changes —SWEEP_MESSAGE_BUDGETandDAILY_WRITE_BUDGETwere deliberately left un-overridden so they deploy fromwrangler.tomland stay verifiable per.claude/rules/70. All four are removed after verification:Deployed values, read from the Cloudflare script-settings API for
sam-api-staging(not the diff):The three new vars arriving at their defaults proves the
sync-wrangler-config.ts+deploy-reusable.ymlwiring end to end. The deploy log'sEnvironment override:lines listed exactly the temporary vars and nothing else.Migration
0156applied to real staging D1 —consecutive_budget_stallsreads0on the live cadence row, which carriesrun_countin the 240s and a lease history.The first sweep after deploy migrated nothing — investigated before assuming, and it was correct. Staging had zero eligible candidates: all 22 of its in-range terminal sessions are frozen
precopy_refusedinside the 7-day retry window. Replaying the real selector predicate includingPRECOPY_REFUSAL_EXCLUSION_SQLreturned 0 rows at the derived ceiling of 55 and 0 rows at 2000 — the pre-fix code would have selected nothing either. The new stall detector correctly did not fire: an empty candidate set is not a budget stall, which is precisely the distinction.claude/rules/72asks for. LoweringSESSION_GRACE_MSto 1 day produced 8 real candidates.Real reclamation, tick
run_count=248@ 2026-09-12 10:10:54Z:state=published,attempt_count=1,error_code=nullproject_data_session_locations.location_statemovedroot->archive_shard— the data actually movedSWEEP_SESSIONS=2, while the over-fetch read 6 candidates. This is live confirmation of thependingSlotsfix; without it the tick would have journaled all 6.project_data_archive_write_budgetcame unstuck — the production symptom, resolved:Next tick
run_count=249@ 10:20:53Z: two 2-message sessions refused by the pre-existing pre-copy eligibility invariant (frozen/precopy_refused) and returned torootin the same tick. Not a budget refusal, no failure, no poison — the existing unwind path still behaves.Cadence throughout:
last_status=succeeded,consecutive_budget_stalls=0,last_error=null.Post-Mortem
What broke
The production ProjectData archive sweep stopped reclaiming storage on 2026-09-08 and reported success for four days while the root Durable Object filled toward its 10 GB ceiling.
Root cause
A GitHub
productionEnvironment override loweredPROJECT_DATA_ARCHIVE_DAILY_WRITE_BUDGETto 100000 on 2026-09-08T14:05Z (bounding DO SQLite cost) without loweringPROJECT_DATA_ARCHIVE_SWEEP_MESSAGE_BUDGET, which ships fromwrangler.tomlat 5000 and has no override. Largest-first selection withLIMIT 1then deterministically picked a session whose estimate exceeded the entire daily allowance, every hour, forever.Class of bug
Two independently configured ceilings that must agree, with no derivation linking them — combined with a refusal path that neither descends nor reports. The refusal was structurally permanent (
exceeds_allowance) but was counted in the same bucket as a transient one (window_exhausted), so the only signal was an absence.Why it wasn't caught
reserveArchiveWritesreturned a bare boolean, so no caller could distinguish "never affordable" from "pool spent". The sweep's success status was computed fromfailed/poisonedcounts, which are both zero in a stall. And the existing tests seeded only affordable candidates — they would have passed unchanged throughout the outage.Process fix included in this PR
.claude/skills/env-reference/SKILL.mdcorrected (it stated the pre-fix deployed values and is whatCLAUDE.mdroutes every env question to) and the three new vars documented..env.examplecorrected — it carried live, uncommentedDAILY_WRITE_BUDGET=250000/SWEEP_SESSIONS=1values that the quickstarts tell users tocp./changelogentry so the two superseded historical entries are no longer the newest word on these values.scripts/quality/deploy-reusable-workflow.test.tsnow machine-checks that the new tunables reach both deploy sync blocks and the top-level[vars]— without the latter,listEnvironmentVarOverridescannot log an override, which is the.claude/rules/70failure mode verbatim.tasks/backlog/2026-09-12-split-project-data-archive-sharding-module.mdfor the rule-18 file-size violation (3,663 lines, pre-existing; splitting during an urgent production fix would have made the diff unreviewable).Post-mortem file
tasks/active/2026-09-12-deadlocked-projectdata-archive-sweep-budget-mismatch.mdSpecialist Review Evidence
needs-human-reviewlabel added and merge deferred to human — N/A, all eight completedARCHIVE_WRITE_FIXED_RESERVATIONin tests instead of a bare1000; document theestimateCapfloorenv-referenceskill stated pre-fix values, omitted the 3 new vars) + 1 MEDIUM (root.env.examplelive stale values) — both fixed/changelogentry added;configuration.mddouble-floor formula and compact-only scoping corrected;env-referencefixedpendingSlotscomputed but never passed; fixed and made REQUIRED (.claude/rules/73). Spread inlined. File split deferred to a backlog taskpendingSlotsbug live (6 migrated againstsweepSessions=2). Its two proven coverage gaps now have discriminating testsThe
pendingSlotsbug is worth calling out: it was introduced by the first cut of this change, found independently by two reviewers and by tracing consumers, and was invisible to all four original tests because none had more affordable candidates than slots. The parameter is now required rather than optional-with-a-silent-fallback.CodeRabbit Review Evidence
coderabbit-reviewlabel applied after local review, staging verification and CI gates passed — applied 2026-09-12 ~10:25Z, which dispatchedcoderabbit-bot-review.yml(runs34688337501,34688387290, both success) and posted the review command through the repository's human-scoped PAT.CodeRabbit Notes
CodeRabbit is rate limited and has produced zero reviews and zero inline comments on this PR.
2026-09-12T09:16:17Z— CodeRabbit commented: "Review limit reached. Next included review available in 58 minutes... You've used all free OSS reviews for now." It enumerated all 21 changed files and the commit range, so it saw the PR, but emitted no findings.2026-09-12T10:25:06Z— thecoderabbit-reviewlabel dispatched the bot workflow, which posted@coderabbitai reviewvia the human PAT.2026-09-12T10:25:12Z— CodeRabbit replied: "Action not completed. Review rate limited."State at the time of writing:
gh pr view 2069 --json reviewsreturns0;pulls/2069/commentsreturns0. The PR itself isMERGEABLE/CLEANwith all 18 CI checks green and the SonarCloud Quality Gate passed (1 new issue, 0 security hotspots).Per
.claude/rules/25-review-merge-gate.mdthis is a hold, not a pass: CodeRabbit is unavailable, so its unresolved-feedback state cannot be inspected and the agent must not self-merge. Thecoderabbit-reviewlabel is deliberately left on so CodeRabbit performs an incremental review when its window resets, and the command has not been re-posted (the standing project rule is to trigger once and monitor rather than retry into a rate limit).Context for the human decision: eight local specialist reviewers completed and their findings are addressed (table above), three of which found real defects. Raphaël has previously waived CodeRabbit for PRs #1984, #2030 and #2035 when it was unavailable or over its file limit. Production is at 96.84% of its Durable Object storage ceiling with roughly four days of headroom.
Exceptions
apps/api/src/scheduled/project-data-archive-sharding.tsremains 3,663 lines, far over the.claude/rules/18mandatory 800-line split threshold.tasks/backlog/2026-09-12-split-project-data-archive-sharding-module.md, to be done before the next substantial change to the archive sweep.Agent Preflight (Required)
Classification
External References
N/A: no external API involved. All evidence came from first-party production state — the Cloudflare Worker script-settings API (deployed
[vars]forsam-api-prod), the GitHub Environment variables API (override listing forproductionandstaging), and D1sam-prod(project_data_archive_write_budget,project_data_archive_migrations,project_data_archive_global_sweep_cadence,project_data_storage_telemetry_history,session_summaries).Codebase Impact Analysis
apps/api/src/scheduled/project-data-archive-sharding.ts— candidate selection, the journaling loop, cadence finishapps/api/src/project-data-archive/write-budget.ts— affordability derivation, discriminated reservation outcomeapps/api/src/project-data-archive/contract.ts,apps/api/src/env.ts,apps/api/src/db/schema.tsapps/api/src/db/migrations/0156_archive_sweep_consecutive_budget_stalls.sqlapps/api/wrangler.toml,scripts/deploy/sync-wrangler-config.ts,.github/workflows/deploy-reusable.yml(both sync blocks)apps/api/.env.example, root.env.example,apps/www/src/content/docs/docs/reference/configuration.md,.claude/skills/env-reference/SKILL.md,.claude/skills/changelog/SKILL.mdConsumers of the changed
reserveArchiveWritessignature were enumerated: one production caller (processArchiveMigrationBatch.reserve) and the direct assertions intests/workers/project-data-compact-archive.test.ts, all updated.ArchiveWriteReservation.maxMessagesis consumed by the DO-side re-check indurable-objects/project-data/archive-sharding.ts:1101— verified the coordinator and the DO still pass the same cap, so the re-check semantics are unchanged.Documentation & Specs
apps/www/src/content/docs/docs/reference/configuration.md(table rows for the three new vars, the changed shipped values, and prose covering the derivation, the fall-through, the refusal split and the stall status). Nospecs/file describes the archive sweep cadence or these vars.Constitution & Risk Check
Principle XI (No Hardcoded Values): every new tunable ships as a
DEFAULT_*+MAX_*pair consumed throughenvInt, declared inEnv, shipped in[vars], forwarded through both deploy sync blocks, and documented in four places — verified byconstitution-validator(PASS) and a new machine-checked test.Principal risk is the migration.
0156isALTER TABLE ADD COLUMNwith a constant default on a single-row table — metadata-only in SQLite, no rewrite, no backfill needed, and.claude/rules/31's DROP-TABLE prohibition is not approached.pnpm quality:migration-safetyandquality:do-migration-safetyboth pass. The drizzle definition inschema.tswas updated to match; the drift between the two is what failed 20 unit tests and was caught by the full suite rather than the targeted runs.