fix(retention): bound the five unpruned tables that filled the hosted D1 - #9415
Conversation
The hosted D1 reached its 10GB ceiling, after which every write failed with `D1_ERROR: Exceeded maximum DB size` -- including recordOrbWebhookEvent's INSERT, so /v1/orb/webhook returned 500 to GitHub and inbound webhook delivery stopped fleet-wide until rows were pruned by hand. Three compounding causes, all fixed here: - check_summaries, pull_request_files, repo_github_totals_snapshots, recent_merged_pull_requests and orb_pr_outcomes had no retention rule at all and grew without bound. check_summaries (100,459 rows / 0.30GB of payload_json) and pull_request_files (54,532 rows / 0.20GB) were the two largest single consumers in the database. - MAX_DELETED_PER_TABLE was 50k against a measured ~4.3M rows written per day, so a daily prune could delete at most ~1.25M rows/day across the whole policy -- a permanent ~3.5x deficit in which the cap, not the cutoff, was the binding constraint. Raised to 250k. - prune-retention ran once a day. It now runs hourly, so each pass stays small and the drain rate exceeds the write rate. Both webhook idempotency logs also drop 90d -> 14d; the dedup lookups they serve are minutes old at most.
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
|
Warning ⏸️ LoopOver review result - manual review recommendedReview updated: 2026-07-27 18:06:42 UTC
Review summary Nits — 5 non-blocking
Concerns raised — review before merging
📋 Copy for AI agents — paste into your coding agentDecision drivers
Context & advisory signals — never blocks the verdict
Review context
Contributor next steps
Signal definitions
🧪 Chat with LoopOverAsk LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.
Full command reference: https://loopover.ai/docs/loopover-commands 🧪 Experimental — new and may change. Decision record
🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9415 +/- ##
==========================================
+ Coverage 89.52% 93.59% +4.07%
==========================================
Files 840 738 -102
Lines 109777 60119 -49658
Branches 26147 21249 -4898
==========================================
- Hits 98275 56268 -42007
+ Misses 10239 2900 -7339
+ Partials 1263 951 -312
Flags with carried forward coverage won't be shown. Click here to find out more.
|
The three scheduled-enqueue assertions in index.test.ts pin the exact job list per tick, so moving prune-retention from a 03:00-only slot to every hourly tick correctly failed them. Add it to each list at its dispatch position (after rollup-product-usage).
…a preserved series contributor-decision-pack was excluded from LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES on the theory that it's a bounded trend/change series read by history, with volume "a fraction of" its three contributor-* neighbors that already dedupe. Both claims were wrong: - src/services/decision-pack.ts's only reader calls listSignalSnapshots(...)[0] -- the exact same latest-only contract as contributor-evidence-graph/outcome-history/strategy, which already dedupe correctly. - Measured against a live export of the hosted D1: 18,549 rows, 6.3 GB, ~350 KB/row -- 71% of the database's entire 8.96 GB file size, and roughly 750x the combined size of the three types it was assumed to dwarf. Every row is still inside the 90-day RETENTION_POLICY age window (the database itself is only ~65 days old), so age-based pruning had not removed a single one, and the type has accumulated ~700-1,300 rows/day since 2026-07-06 -- large enough to refill the 10 GB cap from empty in 3-4 weeks on its own, even with #9415's five-table fix applied (that PR addressed other tables totaling well under 1 GB combined). queue-health is the one signal type genuinely read as a historical series (maintainer-slop-duplicate-trend.ts shapes multiple weeks into a trend card) and stays excluded, unchanged. Refs #9435
…a preserved series (#9459) contributor-decision-pack was excluded from LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES on the theory that it's a bounded trend/change series read by history, with volume "a fraction of" its three contributor-* neighbors that already dedupe. Both claims were wrong: - src/services/decision-pack.ts's only reader calls listSignalSnapshots(...)[0] -- the exact same latest-only contract as contributor-evidence-graph/outcome-history/strategy, which already dedupe correctly. - Measured against a live export of the hosted D1: 18,549 rows, 6.3 GB, ~350 KB/row -- 71% of the database's entire 8.96 GB file size, and roughly 750x the combined size of the three types it was assumed to dwarf. Every row is still inside the 90-day RETENTION_POLICY age window (the database itself is only ~65 days old), so age-based pruning had not removed a single one, and the type has accumulated ~700-1,300 rows/day since 2026-07-06 -- large enough to refill the 10 GB cap from empty in 3-4 weeks on its own, even with #9415's five-table fix applied (that PR addressed other tables totaling well under 1 GB combined). queue-health is the one signal type genuinely read as a historical series (maintainer-slop-duplicate-trend.ts shapes multiple weeks into a trend card) and stays excluded, unchanged. Refs #9435
…9415 left (#9470, #9472, #9473) (#9503) dedupeSignalSnapshots picked its survivor with MAX(rowid), which pg-dialect rewrites to MAX(ctid) -- a physical heap location, not insertion order. Once the age-prune frees pages a newer row lands on a reclaimed early page with a LOWER ctid than an older row, so the dedupe kept the STALE snapshot and deleted the genuinely newest one. Confirmed on production Postgres 2026-07-27: ~36% of multi-row keys for contributor-evidence-graph had ctid order disagreeing with recency (~344 keys across dedupe-eligible types), while the daily run was deleting thousands of rows. translateRowid's own doc says it is only safe for bookkeeping resolved within a single statement and never for durable row identity -- choosing which row survives a DELETE is exactly that. Survivors are now chosen by (generated_at, id), and the batch delete keys on the real primary key rather than rowid. #9415 added five tables to RETENTION_POLICY but no RETENTION_PK_COLUMN entries and no index migration, so each batched delete fell back to rowid/ctid and ran as a full sequential scan plus a full sort -- hourly, against the two largest tables in the database. Four more per-event tables with no delete path anywhere in src/ are now bounded too, two of which already had a pruned sibling. Migration 0196 adds the leading-column indexes 0193 established as the pattern. Verifying the PK map turned up two things worth recording: orb_pr_outcomes and the four read-side caches have COMPOSITE primary keys, so they cannot be mapped at all -- an absent entry previously meant either that or an oversight, indistinguishably, which is how #9415's five shipped unmapped. RETENTION_COMPOSITE_PK_TABLES now records the deliberate choice, and a drift guard asserts every policy table is accounted for in one of the two and carries a retention-column index.
Summary
The hosted D1 reached its 10GB ceiling. Every write then failed with
D1_ERROR: Exceeded maximum DB size— includingrecordOrbWebhookEvent's INSERT, so/v1/orb/webhookreturned 500 to GitHub and inbound webhook delivery stopped fleet-wide until rows were pruned by hand. Reviews only kept moving because the internal regate sweep does not depend on webhooks.Three compounding causes, all addressed:
1. Five high-growth tables had no retention rule at all and grew without bound. Measured during the incident:
check_summariespull_request_filesrepo_github_totals_snapshotsrecent_merged_pull_requestsorb_pr_outcomesAll five are re-derivable GitHub mirrors or superseded snapshots, re-fetched on the next sync of the PR that needs them — never a contributor's evidentiary trail (
decision_records, 180d, remains the table carrying that).2. The per-run delete cap made retention structurally unable to keep up. At 50k/table against a measured ~4.3M rows written/day, a daily prune could delete at most ~1.25M rows/day across the whole policy — a permanent ~3.5x deficit. The cap, not the cutoff, was the binding constraint, so no window tightening alone could close it. Raised to 250k.
3. Cadence.
prune-retentionmoves from daily to hourly, so each pass stays small and the drain rate exceeds the write rate. Cheap when idle: each rule is an indexed range delete matching zero rows once a table is inside its window.Both webhook idempotency logs also drop 90d -> 14d — the dedup lookups they serve are minutes old at most, and at fleet write rates a 90-day window on a pure idempotency log was the single largest avoidable contributor.
Test plan
check_summaries(largest single consumer) exercising the real policy entry rather than an ad-hoc overrideorb_webhook_eventstest for the intentional 90d -> 14d changeseed()helper: its "recent" webhook row was anchored to a fixedNOWwhileprocessJob/the preview route evaluate against real wall-clock time, so that row survived the old 90-day window only by accident. Now anchored to real now, keeping each test's "recent rows are kept" intent independent of window width.npx vitest run test/unit/retention.test.ts— 36 passedselfhost-pg-retention,selfhost-cron-alignment,selfhost-queue-common,selfhost-pg-dialect— 119 passednpm run typecheckclean