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 { initDatabase, pool, cleanupStaleData } from "./services/db.js";
|
||||
import { startPeriodicCleanup, stopPeriodicCleanup } from "./utils/periodic-cleanup.js";
|
||||
|
||||
|
||||
const app = express();
|
||||
|
|
@ -244,12 +245,18 @@ async function start() {
|
|||
}
|
||||
}, 30_000);
|
||||
|
||||
// Run database cleanup every 6 hours (expired verifications, orphaned usage)
|
||||
startPeriodicCleanup();
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
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)
|
||||
await new Promise<void>((resolve) => {
|
||||
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