Replace custom setInterval scheduler with croner

Swap the hand-rolled Scheduler class (setInterval-based) for croner,
a battle-tested cron library with overlap protection and proper cron
expressions. Consolidate lib/jobs/ into a single lib/cron.ts and
remove the unused admin jobs API route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 18:52:05 -05:00
co-authored by Claude Opus 4.6
parent 2ee20a6aff
commit 0f345c8cd6
8 changed files with 120 additions and 173 deletions
+2 -2
View File
@@ -39,7 +39,7 @@ bun db:studio # Open Drizzle Studio (visual DB browser)
- `lib/services/` — Business logic layer (metadata, tracking, discovery, availability)
- `lib/tmdb/` — TMDB API client and TypeScript types
- `lib/auth/` — Better Auth server config (`server.ts`) and client hooks (`client.ts`)
- `lib/jobs/` — In-process background job scheduler (setInterval-based, `globalThis` singleton)
- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton)
- `app/api/` — Next.js route handlers
- `app/(pages)/` — User-facing pages (dashboard, search, title detail, login, register)
- `components/` — App components + `components/ui/` for shadcn primitives
@@ -86,7 +86,7 @@ Core files: `lib/services/image-cache.ts` (caching logic), `lib/tmdb/image.ts` (
### Background jobs
Registered in `lib/jobs/registry.ts`, started via Next.js instrumentation hook (`instrumentation.ts`) in production. Jobs: nightly library refresh (24h), availability refresh (6h), recommendations refresh (12h), TV episodes refresh (12h), image caching (12h). All batch jobs use 300ms delay between TMDB calls.
Defined in `lib/cron.ts` using croner cron expressions, started via Next.js instrumentation hook (`instrumentation.ts`) in production. Jobs: nightly library refresh (`0 3 * * *`), availability refresh (`0 */6 * * *`), recommendations refresh (`0 */12 * * *`), TV episodes refresh (`30 */12 * * *`), image caching (`0 1,13 * * *`). All jobs use `protect: true` to prevent overlapping runs. All batch jobs use 300ms delay between TMDB calls.
### Environment variables
-25
View File
@@ -1,25 +0,0 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { registerJobs } from "@/lib/jobs/registry";
import { scheduler } from "@/lib/jobs/scheduler";
// Ensure jobs are registered for manual triggering
registerJobs();
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
const jobNames = scheduler.getJobNames();
if (!jobNames.includes(name)) {
return NextResponse.json(
{ error: `Unknown job: ${name}. Available: ${jobNames.join(", ")}` },
{ status: 400 },
);
}
await scheduler.runNow(name);
return NextResponse.json({ ok: true, job: name });
}
+38 -33
View File
@@ -5,44 +5,45 @@
"": {
"name": "sofa",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@tabler/icons-react": "^3.38.0",
"@tanstack/react-hotkeys": "^0.3.1",
"better-auth": "^1.5.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"@base-ui/react": "1.2.0",
"@tabler/icons-react": "3.38.0",
"@tanstack/react-hotkeys": "0.3.1",
"better-auth": "1.5.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"croner": "10.0.2-dev.2",
"date-fns": "4.1.0",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"embla-carousel-react": "^8.6.0",
"jotai": "^2.18.0",
"motion": "^12.34.5",
"embla-carousel-react": "8.6.0",
"jotai": "2.18.0",
"motion": "12.34.5",
"next": "16.1.6",
"node-vibrant": "^4.0.4",
"node-vibrant": "4.0.4",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.7.0",
"react-resizable-panels": "4.7.0",
"recharts": "2.15.4",
"shadcn": "^3.8.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"uuid": "^13.0.0",
"vaul": "^1.1.2",
"zod": "^4.3.6",
"shadcn": "3.8.5",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"uuid": "13.0.0",
"vaul": "1.1.2",
"zod": "4.3.6",
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@tailwindcss/postcss": "^4.2.1",
"@types/bun": "^1.3.10",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@tailwindcss/postcss": "4.2.1",
"@types/bun": "1.3.10",
"@types/node": "25.3.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
},
},
},
@@ -705,6 +706,8 @@
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
"croner": ["croner@10.0.2-dev.2", "", {}, "sha512-/xijJtHje2b9SeU1T6ysrs1IGASOH/5YQ54oTEO/48sveZp1ig+MU9bO0N4bo2uXjJETuaDBRN7bJs+4kEEN1A=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -939,7 +942,7 @@
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1407,7 +1410,7 @@
"tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
"tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="],
"tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="],
@@ -1569,6 +1572,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
@@ -1597,7 +1602,7 @@
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"mssql/tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"mysql2/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -1615,6 +1620,8 @@
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
@@ -1649,8 +1656,6 @@
"isomorphic-fetch/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"node-vibrant/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+3 -4
View File
@@ -17,16 +17,15 @@ export async function register() {
runMigrations();
// Initialize job scheduler
const { initJobs } = await import("@/lib/jobs/init");
initJobs();
const { startJobs, stopJobs } = await import("@/lib/cron");
startJobs();
// Graceful shutdown — stop jobs and close DB on container stop
const { scheduler } = await import("@/lib/jobs/scheduler");
const { closeDatabase } = await import("@/lib/db/client");
const shutdown = () => {
console.log("[shutdown] Stopping scheduler...");
scheduler.stop();
stopJobs();
console.log("[shutdown] Closing database...");
closeDatabase();
console.log("[shutdown] Clean shutdown complete");
+48 -13
View File
@@ -1,3 +1,4 @@
import { Cron } from "croner";
import { and, eq, isNotNull, lt, or } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
@@ -19,12 +20,40 @@ import {
refreshTvChildren,
} from "@/lib/services/metadata";
import { getTvDetails } from "@/lib/tmdb/client";
import { scheduler } from "./scheduler";
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const DAY = 24 * 60 * 60 * 1000;
const RATE_LIMIT_MS = 300;
const globalForJobs = globalThis as unknown as {
_jobs: Map<string, Cron> | undefined;
};
if (!globalForJobs._jobs) {
globalForJobs._jobs = new Map<string, Cron>();
}
const jobs = globalForJobs._jobs;
function schedule(name: string, cron: string, handler: () => Promise<void>) {
jobs.set(
name,
new Cron(
cron,
{
name,
protect: true,
catch: (err: unknown) => {
console.error(`[scheduler] Job ${name} failed:`, err);
},
},
async () => {
console.log(`[scheduler] Running job: ${name}`);
await handler();
console.log(`[scheduler] Completed job: ${name}`);
},
),
);
}
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
@@ -175,14 +204,20 @@ async function cacheImagesJob() {
}
}
export function registerJobs() {
scheduler.register("nightlyRefreshLibrary", nightlyRefreshLibrary, 24 * HOUR);
scheduler.register("refreshAvailability", refreshAvailabilityJob, 6 * HOUR);
scheduler.register(
"refreshRecommendations",
refreshRecommendationsJob,
12 * HOUR,
);
scheduler.register("refreshTvChildren", refreshTvChildrenJob, 12 * HOUR);
scheduler.register("cacheImages", cacheImagesJob, 12 * HOUR);
export function startJobs() {
if (jobs.size > 0) return;
schedule("nightlyRefreshLibrary", "0 3 * * *", nightlyRefreshLibrary);
schedule("refreshAvailability", "0 */6 * * *", refreshAvailabilityJob);
schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob);
schedule("refreshTvChildren", "30 */12 * * *", refreshTvChildrenJob);
schedule("cacheImages", "0 1,13 * * *", cacheImagesJob);
console.log(`[scheduler] Started ${jobs.size} jobs`);
}
export function stopJobs() {
for (const job of jobs.values()) {
job.stop();
}
}
-7
View File
@@ -1,7 +0,0 @@
import { registerJobs } from "./registry";
import { scheduler } from "./scheduler";
export function initJobs() {
registerJobs();
scheduler.start();
}
-61
View File
@@ -1,61 +0,0 @@
interface Job {
name: string;
handler: () => Promise<void>;
intervalMs: number;
timer?: ReturnType<typeof setInterval>;
}
const globalForScheduler = globalThis as unknown as {
_scheduler: Scheduler | undefined;
};
class Scheduler {
private jobs = new Map<string, Job>();
private running = false;
register(name: string, handler: () => Promise<void>, intervalMs: number) {
this.jobs.set(name, { name, handler, intervalMs });
}
start() {
if (this.running) return;
this.running = true;
for (const job of this.jobs.values()) {
job.timer = setInterval(async () => {
try {
console.log(`[scheduler] Running job: ${job.name}`);
await job.handler();
console.log(`[scheduler] Completed job: ${job.name}`);
} catch (err) {
console.error(`[scheduler] Job ${job.name} failed:`, err);
}
}, job.intervalMs);
}
console.log(`[scheduler] Started ${this.jobs.size} jobs`);
}
stop() {
for (const job of this.jobs.values()) {
if (job.timer) clearInterval(job.timer);
}
this.running = false;
}
async runNow(name: string) {
const job = this.jobs.get(name);
if (!job) throw new Error(`Job not found: ${name}`);
await job.handler();
}
getJobNames() {
return [...this.jobs.keys()];
}
}
if (!globalForScheduler._scheduler) {
globalForScheduler._scheduler = new Scheduler();
}
export const scheduler = globalForScheduler._scheduler;
+29 -28
View File
@@ -15,44 +15,45 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@tabler/icons-react": "^3.38.0",
"@tanstack/react-hotkeys": "^0.3.1",
"better-auth": "^1.5.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"@base-ui/react": "1.2.0",
"@tabler/icons-react": "3.38.0",
"@tanstack/react-hotkeys": "0.3.1",
"better-auth": "1.5.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"croner": "10.0.2-dev.2",
"date-fns": "4.1.0",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"embla-carousel-react": "^8.6.0",
"jotai": "^2.18.0",
"motion": "^12.34.5",
"embla-carousel-react": "8.6.0",
"jotai": "2.18.0",
"motion": "12.34.5",
"next": "16.1.6",
"node-vibrant": "^4.0.4",
"node-vibrant": "4.0.4",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.7.0",
"react-resizable-panels": "4.7.0",
"recharts": "2.15.4",
"shadcn": "^3.8.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"uuid": "^13.0.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
"shadcn": "3.8.5",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"uuid": "13.0.0",
"vaul": "1.1.2",
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@types/bun": "^1.3.10",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^25.3.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/bun": "1.3.10",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "25.3.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
"tailwindcss": "4.2.1",
"typescript": "5.9.3"
},
"packageManager": "bun@1.3.10"
}