Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/db/retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { nowIso } from "../utils/json";

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

export type PruneResult = { table: string; column: string; cutoff: string; deleted: number };
Expand Down
31 changes: 22 additions & 9 deletions test/unit/retention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ const daysAgo = (n: number) => new Date(NOW - n * 86_400_000).toISOString();

async function seed(env: Env) {
const db = getDb(env.DB);
// webhook_events are durable replay/idempotency records and must not be pruned.
// webhook_events: 90d window (#8381) — seed past-cutoff + recent rows.
await db.insert(webhookEvents).values([
{ deliveryId: "wh-old-1", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(40) },
{ deliveryId: "wh-old-2", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(35) },
{ deliveryId: "wh-old-1", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(100) },
{ deliveryId: "wh-old-2", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(95) },
{ deliveryId: "wh-recent", eventName: "push", payloadHash: "h", status: "processed", receivedAt: daysAgo(1) },
]);
// ai_usage_events window = 90d; one old + one recent.
Expand Down Expand Up @@ -48,7 +48,7 @@ describe("pruneExpiredRecords", () => {
await seed(env);
const results = await pruneExpiredRecords(env, { dryRun: true, nowMs: NOW });
const ai = results.find((r) => r.table === "ai_usage_events");
expect(results.find((r) => r.table === "webhook_events")).toBeUndefined();
expect(results.find((r) => r.table === "webhook_events")?.deleted).toBe(2);
expect(ai?.deleted).toBe(1);
expect(await countWebhook(env)).toBe(3); // nothing actually deleted
});
Expand All @@ -57,9 +57,9 @@ describe("pruneExpiredRecords", () => {
const env = createTestEnv();
await seed(env);
const results = await pruneExpiredRecords(env, { nowMs: NOW });
expect(results.find((r) => r.table === "webhook_events")).toBeUndefined();
expect(results.find((r) => r.table === "webhook_events")?.deleted).toBe(2);
expect(results.find((r) => r.table === "ai_usage_events")?.deleted).toBe(1);
expect(await countWebhook(env)).toBe(3);
expect(await countWebhook(env)).toBe(1);
const aiCount = await env.DB.prepare("SELECT count(*) AS n FROM ai_usage_events").first<{ n: number }>();
expect(aiCount?.n).toBe(1);
});
Expand Down Expand Up @@ -123,10 +123,23 @@ describe("pruneExpiredRecords", () => {

it("the policy only targets append-only/log/snapshot tables (no current-state tables)", () => {
const tables = RETENTION_POLICY.map((r) => r.table);
for (const protectedTable of ["webhook_events", "repositories", "repository_settings", "pull_requests", "issues", "repository_ai_keys", "contributors"]) {
expect(tables).toContain("webhook_events");
for (const protectedTable of ["repositories", "repository_settings", "pull_requests", "issues", "repository_ai_keys", "contributors"]) {
expect(tables).not.toContain(protectedTable);
}
});

it("prunes webhook_events older than 90d and keeps recent deliveries (#8381)", async () => {
const env = createTestEnv();
await seed(env);
const results = await pruneExpiredRecords(env, {
nowMs: NOW,
policy: [{ table: "webhook_events", column: "received_at", days: 90 }],
});
expect(results[0]?.deleted).toBe(2);
const rows = await env.DB.prepare("SELECT delivery_id FROM webhook_events").all<{ delivery_id: string }>();
expect(rows.results.map((row) => row.delivery_id)).toEqual(["wh-recent"]);
});
});

describe("dedupeSignalSnapshots", () => {
Expand Down Expand Up @@ -281,7 +294,7 @@ describe("runRetentionPrune + processJob", () => {
await insertSignalSnapshot(env, "s-1", "repo-culture-profile", "JSONbored/loopover", "2026-06-01T00:00:00.000Z");
await insertSignalSnapshot(env, "s-2", "repo-culture-profile", "JSONbored/loopover", "2026-06-02T00:00:00.000Z");
await processJob(env, { type: "prune-retention", requestedBy: "schedule" });
expect(await countWebhook(env)).toBe(3);
expect(await countWebhook(env)).toBe(1);
expect(await countSignalSnapshots(env, "repo-culture-profile")).toBe(1);
const audit = await env.DB.prepare("SELECT outcome, detail FROM audit_events WHERE event_type = ?").bind("retention.prune").first<{ outcome: string; detail: string }>();
expect(audit?.outcome).toBe("success");
Expand All @@ -305,7 +318,7 @@ describe("retention preview route", () => {
signalSnapshotDuplicates: Array<{ signalType: string; deleted: number }>;
};
expect(body.totalEligible).toBeGreaterThanOrEqual(1);
expect(body.eligible.find((r) => r.table === "webhook_events")).toBeUndefined();
expect(body.eligible.find((r) => r.table === "webhook_events")?.deleted).toBe(2);
expect(body.totalSignalSnapshotDuplicates).toBe(1);
expect(body.signalSnapshotDuplicates).toEqual([{ signalType: "repo-culture-profile", deleted: 1 }]);
expect(await countWebhook(env)).toBe(3); // preview is read-only
Expand Down
1 change: 1 addition & 0 deletions test/unit/selfhost-d1-size-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe("resolveD1SizeProbeConfig / isD1SizeProbeEnabled", () => {
expect(config?.databaseId).toBe("db-1");
expect(config?.apiToken).toBe("token-1");
expect(config?.tables.length).toBeGreaterThan(0);
expect(config?.tables).toContain("webhook_events"); // derives from RETENTION_POLICY (#8381)
expect(isD1SizeProbeEnabled(FULL_ENV)).toBe(true);
});

Expand Down