Skip to content

Commit ec259cb

Browse files
authored
fix(db): bound submitter_outcome_log and alert_dedup_claims by retention policy (#10106)
Both tables are written per event with no delete path anywhere in src/, the same class #9473 already bounded: submitter_outcome_log's only reader is windowed, and alert_dedup_claims is a pure hourly-expiring idempotency claim. Adds the RETENTION_POLICY entries, their RETENTION_PK_COLUMN/composite-PK mapping, and the paired leading-column index migration. Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent 9f673b8 commit ec259cb

3 files changed

Lines changed: 92 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- #10058: submitter_outcome_log and alert_dedup_claims gained a RETENTION_POLICY entry in the same change,
2+
-- following 0193/0196's precedent of pairing every new policy table with a leading-column index on its
3+
-- retention timestamp so the batched delete's inner SELECT is an index range scan rather than a full scan.
4+
CREATE INDEX IF NOT EXISTS idx_submitter_outcome_log_recorded_at ON submitter_outcome_log(recorded_at);
5+
CREATE INDEX IF NOT EXISTS idx_alert_dedup_claims_created_at ON alert_dedup_claims(created_at);

src/db/retention.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,17 @@ export const RETENTION_POLICY: readonly RetentionRule[] = [
118118
// plus contributor content (the largest, most sensitive artifact in the replay family), the re-query mode
119119
// is a debugging tool for RECENT decisions, and the public promptDigest commitment outlives the text.
120120
{ table: "decision_replay_prompts", column: "created_at", days: 30 },
121+
// #10058: two more members of the same re-derivable/per-event class #9473 bounded, found by the same audit
122+
// sweep for tables written per event with NO delete path anywhere in src/:
123+
// - submitter_outcome_log is per (project, submitter, pull_number, outcome), appended on every PR
124+
// terminal (src/review/submitter-reputation.ts), and its only reader is already windowed
125+
// (`recorded_at >= datetime('now', ?)`) -- same "windowed reader, aged rows are pure dead weight" shape
126+
// as contributor_gate_history above, so it gets the same 90-day window.
127+
// - alert_dedup_claims is a pure hourly-expiring idempotency claim (src/review/alerts.ts hashes an hour
128+
// bucket into its dedup key), never read again once its hour passes -- same short-lived-idempotency-log
129+
// shape as webhook_events / orb_webhook_events above, so it gets the same 14-day window.
130+
{ table: "submitter_outcome_log", column: "recorded_at", days: 90 },
131+
{ table: "alert_dedup_claims", column: "created_at", days: 14 },
121132
];
122133

123134
// #9083: a real, single-column, indexable primary key for the ordered-range delete below, keyed by table
@@ -168,6 +179,8 @@ export const RETENTION_PK_COLUMN: Readonly<Record<string, string>> = {
168179
decision_replay_inputs: "record_id",
169180
// Same key shape as decision_replay_inputs above, for the same reason.
170181
decision_replay_prompts: "record_id",
182+
// #10058: alert_dedup_claims has a genuine single-column `id TEXT PRIMARY KEY` (migrations/0181).
183+
alert_dedup_claims: "id",
171184
};
172185

173186
/**
@@ -191,6 +204,9 @@ export const RETENTION_COMPOSITE_PK_TABLES: ReadonlySet<string> = new Set([
191204
"ai_review_cache",
192205
"ai_slop_cache",
193206
"linked_issue_satisfaction_cache",
207+
// #10058: PRIMARY KEY (project, submitter, pull_number, outcome) -- no genuine single-column id, so this
208+
// table pays the rowid/ctid cost noted above rather than getting a surrogate key added just for pruning.
209+
"submitter_outcome_log",
194210
]);
195211

196212
/**

test/unit/retention.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import { createApp } from "../../src/api/routes";
33
import { getDb } from "../../src/db/client";
4-
import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_COMPOSITE_PK_TABLES, RETENTION_PK_COLUMN, RETENTION_POLICY, retentionCutoffIsoForTable } from "../../src/db/retention";
4+
import { dedupeSignalSnapshots, pruneExpiredRecords, RETENTION_COMPOSITE_PK_TABLES, RETENTION_PK_COLUMN, RETENTION_POLICY, retentionCutoffIsoForTable, retentionDaysForTable } from "../../src/db/retention";
55
import { computeFleetAnalytics } from "../../src/orb/analytics";
66
import { listFleetInstances } from "../../src/orb/fleet-admin";
77
import { getOrbGlobalStats } from "../../src/orb/outcomes";
@@ -417,6 +417,69 @@ describe("pruneExpiredRecords", () => {
417417
const remaining = await env.DB.prepare("SELECT count(*) AS n FROM ai_review_cache").first<{ n: number }>();
418418
expect(remaining?.n).toBe(0);
419419
});
420+
421+
// #10058: submitter_outcome_log and alert_dedup_claims both store CURRENT_TIMESTAMP-format timestamps
422+
// (`'YYYY-MM-DD HH:MM:SS'`, no `T`, no zone) because both writers omit the column and let the DB default
423+
// supply it -- unlike every other policy table's ISO-8601 `column`. pruneExpiredRecords binds an ISO cutoff
424+
// and compares as text; seeding in the exact writer-produced format (not `daysAgo`'s ISO string) is what
425+
// proves the comparison still resolves the right rows on both sides of the cutoff.
426+
it("prunes submitter_outcome_log older than its 90-day window and keeps recent rows (#10058)", async () => {
427+
const env = createTestEnv();
428+
await env.DB.prepare(
429+
`INSERT INTO submitter_outcome_log (project, submitter, pull_number, outcome, recorded_at)
430+
VALUES
431+
('acme/widgets', 'alice', 1, 'merged', '2026-01-01 00:00:00'),
432+
('acme/widgets', 'alice', 2, 'merged', '2026-06-12 00:00:00')`,
433+
).run();
434+
435+
const rule = RETENTION_POLICY.find((r) => r.table === "submitter_outcome_log");
436+
expect(rule).toEqual({ table: "submitter_outcome_log", column: "recorded_at", days: 90 });
437+
438+
const results = await pruneExpiredRecords(env, { nowMs: NOW, policy: [rule!] });
439+
expect(results[0]?.deleted).toBe(1);
440+
const rows = await env.DB.prepare("SELECT pull_number FROM submitter_outcome_log").all<{ pull_number: number }>();
441+
expect(rows.results.map((row) => row.pull_number)).toEqual([2]);
442+
});
443+
444+
it("prunes alert_dedup_claims older than its 14-day window and keeps recent rows (#10058)", async () => {
445+
const env = createTestEnv();
446+
await env.DB.prepare(
447+
`INSERT INTO alert_dedup_claims (id, project, target_id, notification_key, status, created_at)
448+
VALUES
449+
('adc-old', 'acme/widgets', '__healthcheck__', 'hash-old', 'sent', '2026-01-01 00:00:00'),
450+
('adc-recent', 'acme/widgets', '__healthcheck__', 'hash-recent', 'sent', '2026-06-12 00:00:00')`,
451+
).run();
452+
453+
const rule = RETENTION_POLICY.find((r) => r.table === "alert_dedup_claims");
454+
expect(rule).toEqual({ table: "alert_dedup_claims", column: "created_at", days: 14 });
455+
456+
const results = await pruneExpiredRecords(env, { nowMs: NOW, policy: [rule!] });
457+
expect(results[0]?.deleted).toBe(1);
458+
const rows = await env.DB.prepare("SELECT id FROM alert_dedup_claims").all<{ id: string }>();
459+
expect(rows.results.map((row) => row.id)).toEqual(["adc-recent"]);
460+
});
461+
462+
// pkColumnFor's two arms (src/db/retention.ts:211): alert_dedup_claims has a genuine `id` mapping in
463+
// RETENTION_PK_COLUMN, while submitter_outcome_log's composite PK means it falls back to the `?? "rowid"`
464+
// arm. A small batchSize also proves the batched-delete loop's `changes < batchSize` exit (line 420) fires
465+
// on a real partial-then-final pair of iterations, not only on an empty first pass.
466+
it("submitter_outcome_log's composite PK falls back to rowid ordering across multiple batches (#10058)", async () => {
467+
const env = createTestEnv();
468+
await env.DB.prepare(
469+
`INSERT INTO submitter_outcome_log (project, submitter, pull_number, outcome, recorded_at)
470+
VALUES
471+
('acme/widgets', 'alice', 1, 'merged', '2026-01-01 00:00:00'),
472+
('acme/widgets', 'alice', 2, 'closed', '2026-01-02 00:00:00'),
473+
('acme/widgets', 'alice', 3, 'merged', '2026-01-03 00:00:00'),
474+
('acme/widgets', 'bob', 1, 'merged', '2026-06-12 00:00:00')`,
475+
).run();
476+
477+
const rule = RETENTION_POLICY.find((r) => r.table === "submitter_outcome_log");
478+
const results = await pruneExpiredRecords(env, { nowMs: NOW, policy: [rule!], batchSize: 2 });
479+
expect(results[0]?.deleted).toBe(3);
480+
const remaining = await env.DB.prepare("SELECT count(*) AS n FROM submitter_outcome_log").first<{ n: number }>();
481+
expect(remaining?.n).toBe(1);
482+
});
420483
});
421484

422485
describe("dedupeSignalSnapshots", () => {
@@ -976,6 +1039,13 @@ describe("retentionCutoffIsoForTable (#9474)", () => {
9761039
// Within a second of a locally computed 180-day cutoff -- pins the default-arg arm without clock flake.
9771040
expect(Math.abs(Date.parse(cutoff!) - (Date.now() - 180 * 86_400_000))).toBeLessThan(1000);
9781041
});
1042+
1043+
// #10058 regression: pins the two windows so a future edit that drops or shrinks either entry fails loudly
1044+
// rather than silently restoring unbounded growth on submitter_outcome_log or alert_dedup_claims.
1045+
it("submitter_outcome_log is 90 days and alert_dedup_claims is 14 days (#10058)", () => {
1046+
expect(retentionDaysForTable("submitter_outcome_log")).toBe(90);
1047+
expect(retentionDaysForTable("alert_dedup_claims")).toBe(14);
1048+
});
9791049
});
9801050

9811051
// #9783: orb_signals is a PUBLIC data source (computeFleetAnalytics' /fairness headline, #9775's weekly fleet

0 commit comments

Comments
 (0)