feat: add periodic database cleanup every 6 hours (TDD)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 18m15s

- Cleans expired verifications and orphaned usage rows
- Previously only ran once on startup (13d+ uptime = accumulation)
- Interval uses .unref() to not block graceful shutdown
- Stopped during shutdown before pool.end()
- Idempotent start (safe to call multiple times)
- 6 TDD tests added (periodic-cleanup.test.ts)
- 663 tests total, all passing
This commit is contained in:
Hoid 2026-03-11 11:06:09 +01:00
parent 75c6a6ce58
commit cc7de5ef49
3 changed files with 135 additions and 0 deletions

View file

@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the db module
vi.mock("../services/db.js", () => ({
cleanupStaleData: vi.fn().mockResolvedValue({ expiredVerifications: 0, orphanedUsage: 0 }),
}));
vi.mock("../services/logger.js", () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import {
startPeriodicCleanup,
stopPeriodicCleanup,
CLEANUP_INTERVAL_MS,
} from "../utils/periodic-cleanup.js";
import { cleanupStaleData } from "../services/db.js";
import logger from "../services/logger.js";
const mockCleanupStaleData = vi.mocked(cleanupStaleData);
describe("periodic-cleanup", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
stopPeriodicCleanup();
});
afterEach(() => {
stopPeriodicCleanup();
vi.useRealTimers();
});
it("should export a 6-hour cleanup interval constant", () => {
expect(CLEANUP_INTERVAL_MS).toBe(6 * 60 * 60 * 1000);
});
it("should call cleanupStaleData after one interval elapses", async () => {
startPeriodicCleanup();
expect(mockCleanupStaleData).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
expect(mockCleanupStaleData).toHaveBeenCalledTimes(1);
});
it("should call cleanupStaleData multiple times over multiple intervals", async () => {
startPeriodicCleanup();
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
expect(mockCleanupStaleData).toHaveBeenCalledTimes(2);
});
it("should log errors but not throw when cleanupStaleData fails", async () => {
mockCleanupStaleData.mockRejectedValueOnce(new Error("DB connection lost"));
startPeriodicCleanup();
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining("Periodic database cleanup failed")
);
// Next interval should still work
mockCleanupStaleData.mockResolvedValueOnce({ expiredVerifications: 1, orphanedUsage: 0 });
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
expect(mockCleanupStaleData).toHaveBeenCalledTimes(2);
});
it("should stop cleanup when stopPeriodicCleanup is called", async () => {
startPeriodicCleanup();
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
expect(mockCleanupStaleData).toHaveBeenCalledTimes(1);
stopPeriodicCleanup();
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
expect(mockCleanupStaleData).toHaveBeenCalledTimes(1);
});
it("should be idempotent — calling startPeriodicCleanup twice doesn't create two intervals", async () => {
startPeriodicCleanup();
startPeriodicCleanup();
await vi.advanceTimersByTimeAsync(CLEANUP_INTERVAL_MS);
expect(mockCleanupStaleData).toHaveBeenCalledTimes(1);
});
});

View file

@ -24,6 +24,7 @@ import { loadKeys, getAllKeys } from "./services/keys.js";
import { pagesRouter } from "./routes/pages.js"; import { pagesRouter } from "./routes/pages.js";
import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; import { initDatabase, pool, cleanupStaleData } from "./services/db.js";
import { startPeriodicCleanup, stopPeriodicCleanup } from "./utils/periodic-cleanup.js";
const app = express(); const app = express();
@ -244,12 +245,18 @@ async function start() {
} }
}, 30_000); }, 30_000);
// Run database cleanup every 6 hours (expired verifications, orphaned usage)
startPeriodicCleanup();
let shuttingDown = false; let shuttingDown = false;
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
logger.info(`Received ${signal}, starting graceful shutdown...`); logger.info(`Received ${signal}, starting graceful shutdown...`);
// 0. Stop periodic cleanup timer
stopPeriodicCleanup();
// 1. Stop accepting new connections, wait for in-flight requests (max 10s) // 1. Stop accepting new connections, wait for in-flight requests (max 10s)
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const forceTimeout = setTimeout(() => { const forceTimeout = setTimeout(() => {

View file

@ -0,0 +1,30 @@
import { cleanupStaleData } from "../services/db.js";
import logger from "../services/logger.js";
export const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
let intervalHandle: ReturnType<typeof setInterval> | null = null;
export function startPeriodicCleanup(): void {
// Idempotent — don't create duplicate intervals
if (intervalHandle !== null) return;
intervalHandle = setInterval(async () => {
try {
logger.info("Running periodic database cleanup...");
await cleanupStaleData();
} catch (err: unknown) {
logger.error({ err }, "Periodic database cleanup failed (non-fatal)");
}
}, CLEANUP_INTERVAL_MS);
// Don't prevent graceful shutdown
intervalHandle.unref();
}
export function stopPeriodicCleanup(): void {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
}