Skip to content

Commit ae17e14

Browse files
committed
fix(retention): dedupe contributor-decision-pack to latest-only, not 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
1 parent 7ed3531 commit ae17e14

2 files changed

Lines changed: 48 additions & 14 deletions

File tree

src/db/retention.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,9 @@ export type SignalSnapshotDedupeResult = { signalType: string; deleted: number }
192192

193193
/** Exported so the D1 size/row-count observability probe (#3810, src/selfhost/d1-size-probe.ts) can scope its
194194
* signal_snapshots "rows per dedup key" ratio to exactly the population this dedup job converges to ~1 row
195-
* per key -- NOT the whole table, which intentionally keeps bounded multi-row history for other signal
196-
* types (queue-health, contributor-decision-pack, ...). Single source of truth: if this list changes, the
197-
* probe's ratio scope changes with it automatically. */
195+
* per key -- NOT the whole table, which intentionally keeps bounded multi-row history for the one signal
196+
* type genuinely read as a trend/change series (queue-health). Single source of truth: if this list
197+
* changes, the probe's ratio scope changes with it automatically. */
198198
export const LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES = [
199199
"repo-culture-profile",
200200
"repo-doc-refresh-attempt",
@@ -205,12 +205,25 @@ export const LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES = [
205205
// weeks at current review volume, refilling D1's 10GB cap before the 90-day age window could ever
206206
// engage. No reader consumes them as a series (the canonical latest lives in the dedicated
207207
// contributor_evidence / contributor_scoring_profiles upsert tables; nothing calls
208-
// listSignalSnapshots for contributor-* types), so latest-only is lossless for every actual consumer.
209-
// contributor-decision-pack stays EXCLUDED: the retention doc above records it as a bounded
210-
// trend/change series by design, and its volume is a fraction of these three.
208+
// listSignalSnapshots for contributor-* types other than contributor-decision-pack, which itself only
209+
// ever reads index [0] — see below), so latest-only is lossless for every actual consumer.
211210
"contributor-evidence-graph",
212211
"contributor-outcome-history",
213212
"contributor-strategy",
213+
// #9435/#9459: 2026-07-27 recurrence, and the single largest signal_snapshots offender measured to
214+
// date -- 6.3 GB across 18,549 rows (~350 KB/row; the profile/outcome-history/registry-activity payload
215+
// each build embeds), 71% of the entire hosted D1's file size, still ENTIRELY inside the 90-day age
216+
// window (the database itself is only ~65 days old, so age-based pruning had not touched a single one of
217+
// these rows) and accumulating ~700-1,300 rows/day since 2026-07-06 -- refilling the 10GB cap from empty
218+
// in roughly 3-4 weeks even with the #9415 fixes applied, since those addressed five OTHER tables
219+
// totaling well under 1 GB combined. This entry was previously excluded by a doc comment claiming
220+
// decision-pack is "a bounded trend/change series by design" with volume "a fraction of" the three
221+
// contributor-* types above -- both claims were wrong: `src/services/decision-pack.ts`'s only reader
222+
// (`buildContributorDecisionPack`) calls `listSignalSnapshots(...)[0]`, exactly the same latest-only
223+
// contract as its neighbors, and its measured volume is ~750x theirs, not a fraction. queue-health is
224+
// the one signal type that genuinely IS read as a series (src/services/maintainer-slop-duplicate-trend.ts
225+
// shapes multiple weeks of queue-health snapshots into a trend card) and correctly stays excluded here.
226+
"contributor-decision-pack",
214227
// #8900: these eight writers also INSERT a fresh row every run (persistSignalSnapshot is not an
215228
// upsert) while every consumer reads only index [0] / the latest row — same latest-only contract as
216229
// the repo-* and contributor-intelligence types above. queue-health stays EXCLUDED (feeds
@@ -230,8 +243,8 @@ export const LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES = [
230243
* target_key) on every run rather than replacing the prior one, so within RETENTION_POLICY's 90-day
231244
* age window a key can accumulate hundreds of superseded snapshots (#3810 -- 342,243 rows for 2,183
232245
* distinct keys contributed to hitting D1's size cap). Only latest-only cache signal types are
233-
* deduped; historical series such as queue-health and contributor-decision-pack keep their bounded
234-
* RETENTION_POLICY history for trend/change readers. This keeps only the latest row per
246+
* deduped; the one genuine historical series (queue-health) keeps its bounded RETENTION_POLICY history
247+
* for its trend/change reader. This keeps only the latest row per
235248
* (signal_type, target_key), batched PER signal_type (not one table-wide window-function delete) so
236249
* each statement stays within D1's per-statement CPU budget -- the same batching split used during
237250
* the incident's manual remediation. "Latest" is the highest rowid per key: signal_snapshots is

test/unit/retention.test.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,9 @@ describe("dedupeSignalSnapshots", () => {
438438
await insertSignalSnapshot(env, `${signalType}-new`, signalType, "octocat", "2026-07-02T00:00:00.000Z");
439439
await insertSignalSnapshot(env, `${signalType}-other`, signalType, "hubot", "2026-07-02T00:00:00.000Z");
440440
}
441-
// decision-pack is a bounded trend series by design — must remain untouched.
441+
// #9435/#9459: contributor-decision-pack is NOT a bounded trend series (that earlier assumption was wrong
442+
// and was itself the single largest contributor to the D1 filling up) — it dedupes to latest-per-contributor
443+
// exactly like its three neighbors above; see the dedicated test below for its own coverage.
442444
await insertSignalSnapshot(env, "pack-1", "contributor-decision-pack", "octocat", "2026-07-01T00:00:00.000Z");
443445
await insertSignalSnapshot(env, "pack-2", "contributor-decision-pack", "octocat", "2026-07-02T00:00:00.000Z");
444446

@@ -447,13 +449,14 @@ describe("dedupeSignalSnapshots", () => {
447449
expect(byType["contributor-evidence-graph"]).toBe(1);
448450
expect(byType["contributor-outcome-history"]).toBe(1);
449451
expect(byType["contributor-strategy"]).toBe(1);
452+
expect(byType["contributor-decision-pack"]).toBe(1);
450453

451454
const remaining = await env.DB.prepare("SELECT id FROM signal_snapshots ORDER BY id").all<{ id: string }>();
452455
const ids = (remaining.results ?? []).map((row) => row.id);
453456
expect(ids).toContain("contributor-strategy-new");
454457
expect(ids).toContain("contributor-strategy-other");
455458
expect(ids).not.toContain("contributor-strategy-old");
456-
expect(ids).toContain("pack-1"); // series preserved
459+
expect(ids).not.toContain("pack-1"); // now deduped away
457460
expect(ids).toContain("pack-2");
458461
});
459462

@@ -509,19 +512,37 @@ describe("dedupeSignalSnapshots", () => {
509512
expect(rows.results.map((row) => row.id)).toEqual(["private-latest", "public-latest"]);
510513
});
511514

512-
it("preserves bounded history for signal types read as historical series", async () => {
515+
it("preserves bounded history for queue-health, the one signal type genuinely read as a historical series", async () => {
513516
const env = createTestEnv();
514-
await insertSignalSnapshot(env, "decision-prev", "contributor-decision-pack", "alice", "2026-06-01T00:00:00.000Z");
515-
await insertSignalSnapshot(env, "decision-current", "contributor-decision-pack", "alice", "2026-06-02T00:00:00.000Z");
516517
await insertSignalSnapshot(env, "queue-old", "queue-health", "JSONbored/loopover", "2026-06-01T00:00:00.000Z");
517518
await insertSignalSnapshot(env, "queue-current", "queue-health", "JSONbored/loopover", "2026-06-02T00:00:00.000Z");
518519

519520
expect(await dedupeSignalSnapshots(env)).toEqual([]);
520521

521-
expect(await countSignalSnapshots(env, "contributor-decision-pack")).toBe(2);
522522
expect(await countSignalSnapshots(env, "queue-health")).toBe(2);
523523
});
524524

525+
// #9435/#9459: contributor-decision-pack was previously excluded from LATEST_ONLY_SIGNAL_SNAPSHOT_TYPES on the
526+
// theory that it's a bounded trend series -- it isn't (src/services/decision-pack.ts reads only index [0],
527+
// the same latest-only contract as its contributor-* neighbors below) and it was, measured live, the single
528+
// largest contributor to the hosted D1 filling up: 6.3 GB across 18,549 rows, 71% of the whole database, still
529+
// entirely inside the 90-day age window and accumulating ~1,000 rows/day.
530+
it("dedupes contributor-decision-pack alongside its contributor-* neighbors (#9435/#9459)", async () => {
531+
const env = createTestEnv();
532+
await insertSignalSnapshot(env, "decision-prev", "contributor-decision-pack", "alice", "2026-06-01T00:00:00.000Z");
533+
await insertSignalSnapshot(env, "decision-current", "contributor-decision-pack", "alice", "2026-06-02T00:00:00.000Z");
534+
await insertSignalSnapshot(env, "decision-other-prev", "contributor-decision-pack", "bob", "2026-06-01T00:00:00.000Z");
535+
await insertSignalSnapshot(env, "decision-other-current", "contributor-decision-pack", "bob", "2026-06-02T00:00:00.000Z");
536+
537+
const results = await dedupeSignalSnapshots(env);
538+
const byType = Object.fromEntries(results.map((r) => [r.signalType, r.deleted]));
539+
expect(byType["contributor-decision-pack"]).toBe(2);
540+
541+
expect(await countSignalSnapshots(env, "contributor-decision-pack")).toBe(2);
542+
const rows = await env.DB.prepare("SELECT id FROM signal_snapshots WHERE signal_type = 'contributor-decision-pack' ORDER BY id").all<{ id: string }>();
543+
expect(rows.results.map((row) => row.id)).toEqual(["decision-current", "decision-other-current"]);
544+
});
545+
525546
it("deletes across multiple batches per signal_type and stops at the per-type cap", async () => {
526547
const env = createTestEnv();
527548
for (let i = 0; i < 6; i++) {

0 commit comments

Comments
 (0)