fix(retention): keep the newest snapshot, and close the PK/index gaps #9415 left (#9470, #9472, #9473) - #9503
Conversation
…9415 left (#9470, #9472, #9473) 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.
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
|
Tip ✅ LoopOver review result - approve/merge recommendedReview updated: 2026-07-28 00:27:12 UTC
Review summary Nits — 6 non-blocking
Decision drivers
Context & advisory signals — never blocks the verdict
Linked issue satisfactionPartially addressed 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. 🟩 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 #9503 +/- ##
=======================================
Coverage 89.55% 89.55%
=======================================
Files 843 843
Lines 110073 110074 +1
Branches 26194 26194
=======================================
+ Hits 98573 98574 +1
Misses 10238 10238
Partials 1262 1262
Flags with carried forward coverage won't be shown. Click here to find out more.
|
Summary
Three retention defects from the 2026-07-27 audit. The first is confirmed to have been corrupting data on the production database; the other two are the unbounded-growth class that has already caused two fleet-wide outages.
Closes #9470
Closes #9472
Closes #9473
#9470 — the dedupe kept the stale row and deleted the fresh one
dedupeSignalSnapshotsselected its survivor withMAX(rowid). On the self-host Postgres backend,pg-dialect'stranslateRowidrewrites everyrowidtoctid— a physical heap location, not insertion order. Once the age-prune frees pages, a newer row inserted into a reclaimed early page gets a lowerctidthan an older row on a later page, soMAX(ctid)selected a stale snapshot and the delete removed the genuinely newest one, permanently.translateRowid's own doc says it is safe only for bookkeeping "resolved within one statement" and "never for durable application-facing row identity" — deciding which row survives aDELETEis exactly that. The invariant comment claimingrowid"can never tie" was a SQLite-only truth that silently became false when the pg shim was introduced.Production evidence (2026-07-27,
edge-nl-01). Comparing the rowctidpicks against the row recency picks, per(signal_type, target_key)with more than one row:contributor-evidence-graphcontributor-strategycontributor-outcome-historyrepo-doc-refresh-attempt/config-quality/repo-public-focus-manifest~344 keys of dedupe-eligible types. And the dedupe demonstrably runs and deletes at scale —
retention.pruneaudit rows showdeduped 8570,1977,2031,16928on consecutive days, whilepruned 0 row(s)(the age-prune deletes nothing, so the dedupe is doing all the deleting).Survivors are now chosen by
(generated_at, id), and the batched delete keys on the real primary key instead ofrowid.#9472 — #9415's five tables prune via full scan, hourly
#9415 added five tables to
RETENTION_POLICYbut shipped noRETENTION_PK_COLUMNentries and no index migration, while also moving the prune to hourly. SopkColumnFor()fell back torowid→ctid, making each batched delete's outerINa full sequential scan, plus a full scan and sort for the innerSELECT(no retention-column index existed).check_summaries(511 MB / 180,820 rows) andpull_request_files(179 MB / 63,091 rows) are the two largest tables in the database. On the synchronous SQLite adapter this also blocks the event loop.Migration
0196adds the leading-column indexes, following0193's pattern.#9473 — four more unbounded tables in the same class
Verified as written-per-event with no delete path anywhere in
src/. Two have a sibling that is already pruned, which is what makes the omission unintentional rather than a decision:pull_request_reviews— the sixth GitHub mirror alongsidepull_request_files/check_summaries, 30dpredicted_gate_calibration_ledger— per (login, project, PR, commit); siblingpredicted_gate_callsalready pruned at 90dcontributor_gate_history— per (login, project, PR, head_sha), so every push adds a row; every reader is already windowed, so aged rows are pure dead weight, 90ddecision_replay_inputs— one blob per decision record, whose parentdecision_recordsis pruned at 180d, so these were permanently orphaned; matched at 180dTwo things verification changed
orb_pr_outcomeshas a composite primary key, not anidcolumn — as do all four read-side caches (grounding_file_content_cache,ai_review_cache,ai_slop_cache,linked_issue_satisfaction_cache). I had initially mappedorb_pr_outcomestoidfrom the audit's report and the test suite caught it. Those tables genuinely cannot have a single-column PK mapping.That exposed the real defect behind #9472: an absent
RETENTION_PK_COLUMNentry meant either "composite PK, the rowid fallback is correct" or "someone forgot" — indistinguishable. That ambiguity is how #9415's five shipped unmapped and nothing noticed.RETENTION_COMPOSITE_PK_TABLESnow records the deliberate choice explicitly, with a note on the cost of being on that list.The new drift guard immediately found four pre-existing gaps (the four caches above), which is the point of adding it.
Validation
npx tsc --noEmit -p tsconfig.json— cleannpm run db:migrations:check—contiguous 0001..0196, next free 0197test/unit/retention.test.ts+test/unit/selfhost-pg-retention.test.ts— 52 passedNew tests:
generated_atsurvives even when inserted first (under the oldMAX(rowid)rule it would have been deleted); ties break deterministically onid; eachtarget_keydedupes independently; a single-row key is untouched. SQLite cannot reproduce ctid page reuse, so these pin the property that makes the bug impossible on either backend rather than simulating the physical layout.migrations/; no orphaned PK mappings.Two existing mock-based tests needed their SQL discriminator narrowed from
SELECT DISTINCTtoDISTINCT signal_type, because the rewritten stale-row condition now also contains aSELECT DISTINCT target_keysubquery. No assertion was weakened.Not included
#9474 (retention breaking the ledger verifier and the cumulative public counter) is deliberately excluded — it changes consumers rather than the policy, and needs a durable running-total rollup plus a verifier tolerance. It stays open for its own PR.