Skip to content

Commit 32f37d5

Browse files
fix(db): age-prune webhook_events via RETENTION_POLICY (#8449)
Complete the unfinished #3896 retention coverage so inbound webhook delivery rows stop growing unbounded and the D1 size probe monitors them (#8381). Co-authored-by: marktech0813 <marktech0813@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 061c22c commit 32f37d5

3 files changed

Lines changed: 30 additions & 13 deletions

File tree

src/db/retention.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { nowIso } from "../utils/json";
22

33
/**
44
* Data-retention policy for the high-volume, append-only / log / superseded-snapshot tables. These hold
5-
* pure history (logs, usage metrics, ephemeral observations) or snapshots where only the latest matters,
6-
* so rows older than the window can be safely deleted. Current-state and reference tables (repositories,
7-
* repository_settings, pull_requests, issues, contributors, registry/scoring snapshots, repository_ai_keys,
8-
* focus manifests, webhook delivery idempotency records, etc.) are intentionally EXCLUDED — they are not append-only logs.
5+
* pure history (logs, usage metrics, ephemeral observations, webhook delivery traces) or snapshots where
6+
* only the latest matters, so rows older than the window can be safely deleted. Current-state and reference
7+
* tables (repositories, repository_settings, pull_requests, issues, contributors, registry/scoring snapshots,
8+
* repository_ai_keys, focus manifests, etc.) are intentionally EXCLUDED — they are not append-only logs.
99
*
1010
* `column` is the row's primary timestamp (ISO-8601). Windows are deliberately conservative.
1111
*/
@@ -24,6 +24,9 @@ export const RETENTION_POLICY: readonly RetentionRule[] = [
2424
// One payloadJson blob per agent run (#3896); a per-run diagnostic snapshot with no cross-run rollup
2525
// depending on it, so a shorter window than the audit/usage-log tables above is appropriate.
2626
{ table: "agent_context_snapshots", column: "created_at", days: 30 },
27+
// One row per inbound webhook delivery (#8381 / unfinished #3896); short-lived idempotency lookups,
28+
// not durable history — same 90d window as audit/ai_usage logs.
29+
{ table: "webhook_events", column: "received_at", days: 90 },
2730
];
2831

2932
export type PruneResult = { table: string; column: string; cutoff: string; deleted: number };

test/unit/retention.test.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ const daysAgo = (n: number) => new Date(NOW - n * 86_400_000).toISOString();
1212

1313
async function seed(env: Env) {
1414
const db = getDb(env.DB);
15-
// webhook_events are durable replay/idempotency records and must not be pruned.
15+
// webhook_events: 90d window (#8381) — seed past-cutoff + recent rows.
1616
await db.insert(webhookEvents).values([
17-
{ deliveryId: "wh-old-1", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(40) },
18-
{ deliveryId: "wh-old-2", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(35) },
17+
{ deliveryId: "wh-old-1", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(100) },
18+
{ deliveryId: "wh-old-2", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(95) },
1919
{ deliveryId: "wh-recent", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(1) },
2020
]);
2121
// ai_usage_events window = 90d; one old + one recent.
@@ -48,7 +48,7 @@ describe("pruneExpiredRecords", () => {
4848
await seed(env);
4949
const results = await pruneExpiredRecords(env, { dryRun: true, nowMs: NOW });
5050
const ai = results.find((r) => r.table === "ai_usage_events");
51-
expect(results.find((r) => r.table === "webhook_events")).toBeUndefined();
51+
expect(results.find((r) => r.table === "webhook_events")?.deleted).toBe(2);
5252
expect(ai?.deleted).toBe(1);
5353
expect(await countWebhook(env)).toBe(3); // nothing actually deleted
5454
});
@@ -57,9 +57,9 @@ describe("pruneExpiredRecords", () => {
5757
const env = createTestEnv();
5858
await seed(env);
5959
const results = await pruneExpiredRecords(env, { nowMs: NOW });
60-
expect(results.find((r) => r.table === "webhook_events")).toBeUndefined();
60+
expect(results.find((r) => r.table === "webhook_events")?.deleted).toBe(2);
6161
expect(results.find((r) => r.table === "ai_usage_events")?.deleted).toBe(1);
62-
expect(await countWebhook(env)).toBe(3);
62+
expect(await countWebhook(env)).toBe(1);
6363
const aiCount = await env.DB.prepare("SELECT count(*) AS n FROM ai_usage_events").first<{ n: number }>();
6464
expect(aiCount?.n).toBe(1);
6565
});
@@ -123,10 +123,23 @@ describe("pruneExpiredRecords", () => {
123123

124124
it("the policy only targets append-only/log/snapshot tables (no current-state tables)", () => {
125125
const tables = RETENTION_POLICY.map((r) => r.table);
126-
for (const protectedTable of ["webhook_events", "repositories", "repository_settings", "pull_requests", "issues", "repository_ai_keys", "contributors"]) {
126+
expect(tables).toContain("webhook_events");
127+
for (const protectedTable of ["repositories", "repository_settings", "pull_requests", "issues", "repository_ai_keys", "contributors"]) {
127128
expect(tables).not.toContain(protectedTable);
128129
}
129130
});
131+
132+
it("prunes webhook_events older than 90d and keeps recent deliveries (#8381)", async () => {
133+
const env = createTestEnv();
134+
await seed(env);
135+
const results = await pruneExpiredRecords(env, {
136+
nowMs: NOW,
137+
policy: [{ table: "webhook_events", column: "received_at", days: 90 }],
138+
});
139+
expect(results[0]?.deleted).toBe(2);
140+
const rows = await env.DB.prepare("SELECT delivery_id FROM webhook_events").all<{ delivery_id: string }>();
141+
expect(rows.results.map((row) => row.delivery_id)).toEqual(["wh-recent"]);
142+
});
130143
});
131144

132145
describe("dedupeSignalSnapshots", () => {
@@ -281,7 +294,7 @@ describe("runRetentionPrune + processJob", () => {
281294
await insertSignalSnapshot(env, "s-1", "repo-culture-profile", "JSONbored/loopover", "2026-06-01T00:00:00.000Z");
282295
await insertSignalSnapshot(env, "s-2", "repo-culture-profile", "JSONbored/loopover", "2026-06-02T00:00:00.000Z");
283296
await processJob(env, { type: "prune-retention", requestedBy: "schedule" });
284-
expect(await countWebhook(env)).toBe(3);
297+
expect(await countWebhook(env)).toBe(1);
285298
expect(await countSignalSnapshots(env, "repo-culture-profile")).toBe(1);
286299
const audit = await env.DB.prepare("SELECT outcome, detail FROM audit_events WHERE event_type = ?").bind("retention.prune").first<{ outcome: string; detail: string }>();
287300
expect(audit?.outcome).toBe("success");
@@ -305,7 +318,7 @@ describe("retention preview route", () => {
305318
signalSnapshotDuplicates: Array<{ signalType: string; deleted: number }>;
306319
};
307320
expect(body.totalEligible).toBeGreaterThanOrEqual(1);
308-
expect(body.eligible.find((r) => r.table === "webhook_events")).toBeUndefined();
321+
expect(body.eligible.find((r) => r.table === "webhook_events")?.deleted).toBe(2);
309322
expect(body.totalSignalSnapshotDuplicates).toBe(1);
310323
expect(body.signalSnapshotDuplicates).toEqual([{ signalType: "repo-culture-profile", deleted: 1 }]);
311324
expect(await countWebhook(env)).toBe(3); // preview is read-only

test/unit/selfhost-d1-size-probe.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe("resolveD1SizeProbeConfig / isD1SizeProbeEnabled", () => {
7575
expect(config?.databaseId).toBe("db-1");
7676
expect(config?.apiToken).toBe("token-1");
7777
expect(config?.tables.length).toBeGreaterThan(0);
78+
expect(config?.tables).toContain("webhook_events"); // derives from RETENTION_POLICY (#8381)
7879
expect(isD1SizeProbeEnabled(FULL_ENV)).toBe(true);
7980
});
8081

0 commit comments

Comments
 (0)