diff --git a/changelog.d/fixes/9624-telemetry-cleanup-wiring.md b/changelog.d/fixes/9624-telemetry-cleanup-wiring.md new file mode 100644 index 0000000000..01e389606e --- /dev/null +++ b/changelog.d/fixes/9624-telemetry-cleanup-wiring.md @@ -0,0 +1 @@ +- fix(db): wire telemetry cleanup scheduler in Next.js startup path (#9624) diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index d0407e437a..7be56fb919 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -306,6 +306,7 @@ export async function registerNodejs(): Promise { { applyRuntimeSettings }, { startRuntimeConfigHotReload }, { startSpendBatchWriter }, + { startCleanupScheduler }, { registerDefaultGuardrails }, { ensurePersistentManagementPasswordHash }, { skillExecutor }, @@ -320,6 +321,7 @@ export async function registerNodejs(): Promise { import("@/lib/config/runtimeSettings"), import("@/lib/config/hotReload"), import("@/lib/spend/batchWriter"), + import("@/lib/db/cleanup"), import("@/lib/guardrails"), import("@/lib/auth/managementPassword"), import("@/lib/skills/executor"), @@ -489,6 +491,17 @@ export async function registerNodejs(): Promise { console.warn("[STARTUP] Could not initialize vacuum scheduler (non-fatal):", msg); } + // Retention cleanup scheduler (#4691/#6988, #9624): runs the general retention + // cleanup once after startup and then every 6 hours. Previously this was only + // wired into the unused src/server-init.ts, so telemetry tables grew unboundedly + // even with retention.autoCleanupEnabled=true. Idempotent (guarded internally). + try { + startCleanupScheduler(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[STARTUP] Could not start cleanup scheduler (non-fatal):", msg); + } + // Warm the model catalog's durable, apiKey-independent sub-caches at // startup — see warmModelCatalogCache() for why the top-level Response // cache alone doesn't deliver this. Fire-and-forget, non-fatal. diff --git a/tests/unit/repro-9624.test.ts b/tests/unit/repro-9624.test.ts new file mode 100644 index 0000000000..a73e83eddf --- /dev/null +++ b/tests/unit/repro-9624.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const INSTRUMENTATION_NODE_PATH = resolve( + __dirname, + "../../src/instrumentation-node.ts" +); + +describe("repro-9624: startCleanupScheduler wired in Next.js startup path", () => { + it("should import startCleanupScheduler from cleanup", () => { + const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8"); + + // instrumentation-node.ts loads all startup modules via dynamic imports in a + // Promise.all destructure, e.g.: + // const [{ startCleanupScheduler }, ...] = await Promise.all([ + // import("@/lib/db/cleanup"), ... + // ]); + // So the binding and the module import appear separately in the file. + const cleanupModuleImported = /import\(\s*["']@\/lib\/db\/cleanup["']\s*\)/.test( + source + ); + const schedulerBound = /\bstartCleanupScheduler\b/.test(source); + + assert.ok( + cleanupModuleImported, + "@/lib/db/cleanup should be imported (dynamic import) in instrumentation-node.ts" + ); + assert.ok( + schedulerBound, + "startCleanupScheduler should be bound in instrumentation-node.ts" + ); + }); + + it("should call startCleanupScheduler() during startup", () => { + const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8"); + + // Check that startCleanupScheduler is called (as a function call). + // It can be called directly or as part of a conditional. + const hasCall = /\bstartCleanupScheduler\s*\(/.test(source); + + assert.ok( + hasCall, + "startCleanupScheduler() should be called in instrumentation-node.ts" + ); + }); +}); \ No newline at end of file