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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ STELLAR_CORE_MAX_RESTARTS=2
# Only relevant when DATABASE_URL is set.
# -----------------------------------------------------------------------------

# Enable or disable the automatic retention scheduler.
# Set to "false" to prevent any rows from being pruned automatically.
# The npm run retention CLI script is unaffected by this flag.
# Default: true
RETENTION_ENABLED=true

# Number of days to keep Event rows in the hot PostgreSQL table.
# Events older than this are exported to a gzip-compressed CSV file
# (see ARCHIVE_OUTPUT_DIR) and then deleted from the database.
Expand Down
232 changes: 232 additions & 0 deletions lib/retention/__tests__/pruner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* lib/retention/__tests__/pruner.test.ts
*
* Unit tests for the retention pruner.
*
* Tests use a mock PrismaClient so no database is required. The mock
* simulates seeded rows at various ages so the pruner's selection logic
* can be exercised in isolation.
*
* Test coverage:
* 1. Age threshold: only rows past the cutoff date are deleted.
* 2. RETENTION_ENABLED=false: startRetentionScheduler returns undefined (no-op).
* 3. Batch-cap safety rail: no more than batchCap rows are deleted per run.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { pruneOldData } from "../pruner";
import type { PruneOptions } from "../pruner";
import type { PrismaClient } from "@prisma/client";

// ---------------------------------------------------------------------------
// Mock PrismaClient builder
// ---------------------------------------------------------------------------

/**
* Builds a minimal Prisma mock that holds rows for the three retention
* tables in memory. Supports the subset of the Prisma API that the pruner
* uses: count, aggregate (_min/_max createdAt), findMany (with take + orderBy),
* and deleteMany (with id in).
*/
function buildMockDb(initialRows: {
events?: Array<{ id: string; createdAt: Date }>;
deadLetterEvents?: Array<{ id: string; createdAt: Date }>;
webhookDeliveries?: Array<{ id: string; createdAt: Date }>;
}) {
let events = [...(initialRows.events ?? [])];
let deadLetterEvents = [...(initialRows.deadLetterEvents ?? [])];
let webhookDeliveries = [...(initialRows.webhookDeliveries ?? [])];

function makeTableMock(getRows: () => Array<{ id: string; createdAt: Date }>) {
return {
count: vi.fn(({ where }: { where: { createdAt: { lt: Date } } }) => {
return Promise.resolve(
getRows().filter((r) => r.createdAt < where.createdAt.lt).length
);
}),
aggregate: vi.fn(({ where, _min, _max }: any) => {
const matching = getRows().filter((r) => r.createdAt < where.createdAt.lt);
const dates = matching.map((r) => r.createdAt.getTime());
return Promise.resolve({
_min: _min ? { createdAt: dates.length ? new Date(Math.min(...dates)) : null } : undefined,
_max: _max ? { createdAt: dates.length ? new Date(Math.max(...dates)) : null } : undefined,
});
}),
findMany: vi.fn(({ where, take, orderBy }: any) => {
let rows = getRows().filter((r) => r.createdAt < where.createdAt.lt);
// Apply orderBy createdAt asc
rows = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
return Promise.resolve(rows.slice(0, take).map((r) => ({ id: r.id })));
}),
deleteMany: vi.fn(({ where }: { where: { id: { in: string[] } } }) => {
const ids = new Set(where.id.in);
const deleted = getRows().filter((r) => ids.has(r.id)).length;
// Mutate the array in place
const arr = getRows();
const toRemove = arr.filter((r) => ids.has(r.id));
toRemove.forEach((r) => arr.splice(arr.indexOf(r), 1));
return Promise.resolve({ count: deleted });
}),
};
}

const db = {
event: makeTableMock(() => events),
deadLetterEvent: makeTableMock(() => deadLetterEvents),
webhookDelivery: makeTableMock(() => webhookDeliveries),
} as unknown as PrismaClient;

return { db, events, deadLetterEvents, webhookDeliveries };
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function daysAgo(n: number): Date {
const d = new Date();
d.setDate(d.getDate() - n);
return d;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("pruneOldData", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.restoreAllMocks();
});

// -------------------------------------------------------------------------
// Test 1: Age threshold — only rows past the cutoff are deleted
// -------------------------------------------------------------------------
it("deletes only rows older than the cutoff date and leaves newer rows intact", async () => {
const { db, events, deadLetterEvents } = buildMockDb({
events: [
{ id: "evt-old-1", createdAt: daysAgo(200) }, // older than 180 days → should be deleted
{ id: "evt-old-2", createdAt: daysAgo(190) }, // older than 180 days → should be deleted
{ id: "evt-new-1", createdAt: daysAgo(10) }, // newer → should survive
{ id: "evt-new-2", createdAt: daysAgo(1) }, // newer → should survive
],
deadLetterEvents: [
{ id: "dle-old-1", createdAt: daysAgo(365) }, // older → should be deleted
{ id: "dle-new-1", createdAt: daysAgo(5) }, // newer → should survive
],
webhookDeliveries: [],
});

const cutoffDate = daysAgo(180);
const result = await pruneOldData({ db, cutoffDate, batchCap: 1000 });

// Three rows should have been deleted (2 events + 1 dead-letter).
expect(result.totalDeleted).toBe(3);
expect(result.tables.Event.deleted).toBe(2);
expect(result.tables.DeadLetterEvent.deleted).toBe(1);
expect(result.tables.WebhookDelivery.deleted).toBe(0);

// Eligible counts match what was seeded past the cutoff.
expect(result.tables.Event.eligible).toBe(2);
expect(result.tables.DeadLetterEvent.eligible).toBe(1);

// Newer rows must still be present in the in-memory arrays.
expect(events.map((r) => r.id)).toContain("evt-new-1");
expect(events.map((r) => r.id)).toContain("evt-new-2");
expect(events.map((r) => r.id)).not.toContain("evt-old-1");
expect(events.map((r) => r.id)).not.toContain("evt-old-2");

expect(deadLetterEvents.map((r) => r.id)).toContain("dle-new-1");
expect(deadLetterEvents.map((r) => r.id)).not.toContain("dle-old-1");
});

// -------------------------------------------------------------------------
// Test 2: RETENTION_ENABLED=false — see scheduler.test.ts
// (That test requires a top-level vi.mock of lib/db/client so it lives
// in its own file to allow Vitest to hoist the mock correctly.)
// -------------------------------------------------------------------------

// -------------------------------------------------------------------------
// Test 3: Batch-cap safety rail
// -------------------------------------------------------------------------
it("deletes no more than batchCap rows in total across all tables", async () => {
const { db } = buildMockDb({
events: Array.from({ length: 20 }, (_, i) => ({
id: `evt-${i}`,
createdAt: daysAgo(200),
})),
deadLetterEvents: Array.from({ length: 20 }, (_, i) => ({
id: `dle-${i}`,
createdAt: daysAgo(200),
})),
webhookDeliveries: Array.from({ length: 20 }, (_, i) => ({
id: `wh-${i}`,
createdAt: daysAgo(200),
})),
});

const cutoffDate = daysAgo(180);
const batchCap = 15;

const result = await pruneOldData({ db, cutoffDate, batchCap });

// Total deleted must never exceed batchCap.
expect(result.totalDeleted).toBeLessThanOrEqual(batchCap);
expect(result.totalDeleted).toBe(batchCap);

// All 60 rows are eligible; the cap is reported faithfully.
expect(result.totalEligible).toBe(60);
expect(result.batchCap).toBe(batchCap);
});

// -------------------------------------------------------------------------
// Test 4: Dry-run mode — no rows touched, counts are accurate
// -------------------------------------------------------------------------
it("dry-run mode reports the correct deletion counts without deleting anything", async () => {
const { db, events } = buildMockDb({
events: [
{ id: "evt-1", createdAt: daysAgo(200) },
{ id: "evt-2", createdAt: daysAgo(190) },
{ id: "evt-3", createdAt: daysAgo(5) }, // too new
],
deadLetterEvents: [],
webhookDeliveries: [],
});

const cutoffDate = daysAgo(180);
const result = await pruneOldData({ db, cutoffDate, batchCap: 1000, dryRun: true });

expect(result.dryRun).toBe(true);
expect(result.totalDeleted).toBe(2); // would delete 2
expect(result.totalEligible).toBe(2);

// Nothing was actually removed from the in-memory store.
expect(db.event.deleteMany).not.toHaveBeenCalled();
expect(events).toHaveLength(3);
});

// -------------------------------------------------------------------------
// Test 5: Boundary rows (exactly at cutoff) are not deleted
// -------------------------------------------------------------------------
it("rows created exactly at the cutoff date are NOT deleted (lt, not lte)", async () => {
const cutoffDate = new Date("2024-01-01T00:00:00.000Z");

const { db, events } = buildMockDb({
events: [
// Exactly at cutoff — should NOT be deleted (pruner uses lt, not lte).
{ id: "evt-boundary", createdAt: new Date("2024-01-01T00:00:00.000Z") },
// One millisecond before cutoff — SHOULD be deleted.
{ id: "evt-just-before", createdAt: new Date("2023-12-31T23:59:59.999Z") },
],
});

const result = await pruneOldData({ db, cutoffDate, batchCap: 1000 });

expect(result.totalDeleted).toBe(1);
expect(events.map((r) => r.id)).toContain("evt-boundary");
expect(events.map((r) => r.id)).not.toContain("evt-just-before");
});
});
47 changes: 47 additions & 0 deletions lib/retention/__tests__/scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* lib/retention/__tests__/scheduler.test.ts
*
* Unit tests for the retention scheduler.
*
* The scheduler imports lib/db/client which instantiates a PrismaClient.
* Because the Prisma generated artefacts may not exist in CI (no database),
* we mock the db/client module at the top level so Prisma is never loaded.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// Top-level mock — must appear before any import of the scheduler so Vitest
// can hoist it ahead of module evaluation.
vi.mock("../../db/client", () => ({
db: {},
}));

// Now safe to import the scheduler.
import { startRetentionScheduler } from "../scheduler";

describe("startRetentionScheduler", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it("returns undefined and never starts a cron job when RETENTION_ENABLED=false", () => {
vi.stubEnv("RETENTION_ENABLED", "false");

const task = startRetentionScheduler();

expect(task).toBeUndefined();
});

it("returns a scheduled task when RETENTION_ENABLED is not set (defaults to enabled)", () => {
vi.stubEnv("RETENTION_ENABLED", "true");
// Use a valid cron that won't actually fire during the test.
vi.stubEnv("RETENTION_CRON_SCHEDULE", "0 3 * * *");

const task = startRetentionScheduler();

// A ScheduledTask is returned and can be stopped.
expect(task).toBeDefined();
// Clean up to prevent the task from outliving the test.
task?.stop();
});
});
14 changes: 14 additions & 0 deletions lib/retention/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* lib/retention/index.ts
*
* Public surface of the retention module.
*
* - `startRetentionScheduler` — call once at server startup; starts the
* node-cron job that prunes old rows on a configurable schedule.
* - `pruneOldData` / `logPruneResult` — lower-level exports for the CLI
* script and tests.
*/

export { startRetentionScheduler } from "./scheduler";
export { pruneOldData, logPruneResult } from "./pruner";
export type { PruneOptions, PruneResult, TablePruneResult } from "./pruner";
Loading