refactor: optimize cron scheduling, DB maintenance, and TMDB reliability

- Skip recommendations refresh for titles fetched within the last 7 days via `getTitlesWithFreshRecommendations`; only stale titles are re-fetched
- Replace `Promise.allSettled` in `refreshTvChildren` with a `mapWithConcurrency` helper (concurrency = 5) to stay within TMDB's rate limit
- Add 30s request timeout middleware to TMDB client to prevent indefinitely hung fetches
- Expand `optimizeDb` weekly job to prune 30-day-old cron runs, integration events, expired sessions, and expired verifications
- Add `wal_checkpoint(TRUNCATE)` to `optimizeDatabase` so the WAL is flushed before `PRAGMA optimize`
- Add `recoverStaleImportJobs` called on server startup to mark any `pending`/`running` import jobs as errored after a crash/restart
- Return `false` from `triggerJob` immediately if the job is already running
This commit is contained in:
2026-03-23 13:59:31 -04:00
parent d9be566863
commit b289b7abed
17 changed files with 235 additions and 36 deletions
+2
View File
@@ -6,6 +6,7 @@ import {
getStaleNonLibraryTitles,
getTitleByIdForCron,
getTitleIdsWithStaleSeasons,
getTitlesWithFreshRecommendations,
getTitlesWithStaleOffers,
getTitlesWithStaleOffersFetchedBefore,
insertCronRunReturning,
@@ -53,4 +54,5 @@ export {
getReturningTvShows,
getTitleByIdForCron,
getTitleIdsWithStaleSeasons,
getTitlesWithFreshRecommendations,
};
+26 -3
View File
@@ -63,6 +63,27 @@ import {
const log = createLogger("metadata");
async function mapWithConcurrency<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency: number,
): Promise<PromiseSettledResult<R>[]> {
const results: PromiseSettledResult<R>[] = Array.from({ length: items.length });
let i = 0;
async function worker() {
while (i < items.length) {
const idx = i++;
try {
results[idx] = { status: "fulfilled", value: await fn(items[idx]) };
} catch (reason) {
results[idx] = { status: "rejected", reason };
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}
export function updateTitleWithArtInvalidation(
title: Pick<
typeof titles.$inferSelect,
@@ -340,10 +361,12 @@ export async function refreshTitle(titleId: string) {
}
export async function refreshTvChildren(titleId: string, tmdbId: number, numberOfSeasons: number) {
// Fetch all seasons from TMDB concurrently (~40 req/s rate limit)
// Fetch seasons with limited concurrency to stay within TMDB's 40 req/s limit
const seasonNumbers = Array.from({ length: numberOfSeasons }, (_, i) => i + 1);
const fetched = await Promise.allSettled(
seasonNumbers.map((sn) => getTvSeasonDetails(tmdbId, sn)),
const fetched = await mapWithConcurrency(
seasonNumbers,
(sn) => getTvSeasonDetails(tmdbId, sn),
5,
);
const now = new Date();
+2 -1
View File
@@ -86,8 +86,9 @@ export const db = new Proxy({} as ReturnType<typeof drizzle>, {
},
});
/** Run PRAGMA optimize to refresh query planner statistics. */
/** Checkpoint the WAL and refresh query planner statistics. */
export function optimizeDatabase() {
getClient().run("PRAGMA wal_checkpoint(TRUNCATE)");
getClient().run("PRAGMA optimize");
}
+28
View File
@@ -0,0 +1,28 @@
import { lt } from "drizzle-orm";
import { db } from "../client";
import { session, verification } from "../schema";
export function deleteExpiredSessions(): number {
const now = new Date();
const count = db
.select({ id: session.id })
.from(session)
.where(lt(session.expiresAt, now))
.all().length;
if (count === 0) return 0;
db.delete(session).where(lt(session.expiresAt, now)).run();
return count;
}
export function deleteExpiredVerifications(): number {
const now = new Date();
const count = db
.select({ id: verification.id })
.from(verification)
.where(lt(verification.expiresAt, now))
.all().length;
if (count === 0) return 0;
db.delete(verification).where(lt(verification.expiresAt, now)).run();
return count;
}
+5 -1
View File
@@ -121,7 +121,11 @@ export function purgeShellTitlesTransaction(): PurgeResult {
// After title cascade deletes, collect orphaned person images then delete persons
orphanedImages.push(...collectOrphanedPersonImages());
return { deletedTitles: toDelete.length, deletedPersons: purgeOrphanedPersons(), orphanedImages };
return {
deletedTitles: toDelete.length,
deletedPersons: purgeOrphanedPersons(),
orphanedImages,
};
});
}
+41 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, isNotNull, lt, or } from "drizzle-orm";
import { and, eq, gte, inArray, isNotNull, lt, or } from "drizzle-orm";
import { db } from "../client";
import {
@@ -6,6 +6,7 @@ import {
cronRuns,
seasons,
titleCast,
titleRecommendations,
titles,
userTitleStatus,
} from "../schema";
@@ -125,3 +126,42 @@ export function getTitleByIdForCron(titleId: string) {
export function getCastEntryForTitle(titleId: string) {
return db.select().from(titleCast).where(eq(titleCast.titleId, titleId)).limit(1).get();
}
export function deleteOldCronRuns(beforeDate: Date): number {
const old = db
.select({ id: cronRuns.id })
.from(cronRuns)
.where(lt(cronRuns.startedAt, beforeDate))
.all();
if (old.length === 0) return 0;
const ids = old.map((r) => r.id);
for (let i = 0; i < ids.length; i += 500) {
db.delete(cronRuns)
.where(inArray(cronRuns.id, ids.slice(i, i + 500)))
.run();
}
return old.length;
}
export function getTitlesWithFreshRecommendations(
titleIds: string[],
sinceDate: Date,
): Set<string> {
if (titleIds.length === 0) return new Set();
return new Set(
db
.select({ titleId: titleRecommendations.titleId })
.from(titleRecommendations)
.where(
and(
inArray(titleRecommendations.titleId, titleIds),
// All recs for a title share the same lastFetchedAt, so any row suffices
gte(titleRecommendations.lastFetchedAt, sinceDate),
),
)
.groupBy(titleRecommendations.titleId)
.all()
.map((r) => r.titleId),
);
}
+22
View File
@@ -86,3 +86,25 @@ export function getActiveImportJobForUser(userId: string) {
.where(and(eq(importJobs.userId, userId), inArray(importJobs.status, ["pending", "running"])))
.get();
}
/** Mark any running/pending import jobs as errored — called on server startup to recover from crashes. */
export function recoverStaleImportJobs(): number {
const stale = db
.select({ id: importJobs.id })
.from(importJobs)
.where(inArray(importJobs.status, ["pending", "running"]))
.all();
if (stale.length === 0) return 0;
const now = new Date();
for (const { id } of stale) {
db.update(importJobs)
.set({
status: "error",
finishedAt: now,
errors: JSON.stringify(["Import interrupted by server restart"]),
})
.where(eq(importJobs.id, id))
.run();
}
return stale.length;
}
+17 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, gte } from "drizzle-orm";
import { and, eq, gte, inArray, lt } from "drizzle-orm";
import { db } from "../client";
import { integrationEvents, integrations, userEpisodeWatches, userMovieWatches } from "../schema";
@@ -41,3 +41,19 @@ export function updateIntegrationLastEvent(connectionId: string): void {
.where(eq(integrations.id, connectionId))
.run();
}
export function deleteOldIntegrationEvents(beforeDate: Date): number {
const old = db
.select({ id: integrationEvents.id })
.from(integrationEvents)
.where(lt(integrationEvents.receivedAt, beforeDate))
.all();
if (old.length === 0) return 0;
const ids = old.map((r) => r.id);
for (let i = 0; i < ids.length; i += 500) {
db.delete(integrationEvents)
.where(inArray(integrationEvents.id, ids.slice(i, i + 500)))
.run();
}
return old.length;
}
+20 -1
View File
@@ -126,6 +126,25 @@ const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
}
: null;
const TMDB_REQUEST_TIMEOUT_MS = 30_000;
const requestTimeouts = new WeakMap<Request, Timer>();
const timeoutMiddleware: Middleware = {
async onRequest({ request }) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TMDB_REQUEST_TIMEOUT_MS);
const newRequest = new Request(request, { signal: controller.signal });
requestTimeouts.set(newRequest, timeout);
return newRequest;
},
async onResponse({ request }) {
const timeout = requestTimeouts.get(request);
if (timeout) clearTimeout(timeout);
return undefined;
},
};
const authMiddleware: Middleware = {
async onRequest({ request }) {
request.headers.set("Authorization", `Bearer ${getApiKey()}`);
@@ -155,7 +174,7 @@ const loggingMiddleware: Middleware = {
const client = createClient<paths>({ baseUrl });
if (baseUrlRewriteMiddleware) client.use(baseUrlRewriteMiddleware);
client.use(authMiddleware, loggingMiddleware);
client.use(timeoutMiddleware, authMiddleware, loggingMiddleware);
// ─── Search ─────────────────────────────────────────────────────────