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
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:
parent
75c6a6ce58
commit
cc7de5ef49
3 changed files with 135 additions and 0 deletions
98
src/__tests__/periodic-cleanup.test.ts
Normal file
98
src/__tests__/periodic-cleanup.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
30
src/utils/periodic-cleanup.ts
Normal file
30
src/utils/periodic-cleanup.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue