Optimize database queries: add indexes, batch cron scans, transactional writes

Add covering indexes for recommendation rank ordering, title staleness scans,
and type+status filtering. Replace N+1 per-row staleness checks in cron jobs
with set-based batch queries. Wrap availability refresh in a transaction for
atomicity. Batch episode stills query and use existence check instead of
loading all rows for count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 15:47:51 -05:00
co-authored by Claude Opus 4.6
parent 49cea568a9
commit 29449fd02a
7 changed files with 2777 additions and 84 deletions
+56 -37
View File
@@ -1,5 +1,5 @@
import { Cron } from "croner";
import { and, eq, isNotNull, lt, or } from "drizzle-orm";
import { and, eq, inArray, isNotNull, lt, or } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
availabilityOffers,
@@ -123,18 +123,20 @@ async function nightlyRefreshLibrary() {
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
// Library titles: 7 days
for (const titleId of libraryIds) {
const t = db
.select()
.from(titles)
.where(
and(eq(titles.id, titleId), lt(titles.lastFetchedAt, libraryStale)),
)
.get();
if (t) {
await refreshTitle(titleId);
await Bun.sleep(RATE_LIMIT_MS);
}
const staleLibrary = db
.select({ id: titles.id })
.from(titles)
.where(
and(
inArray(titles.id, libraryIds),
lt(titles.lastFetchedAt, libraryStale),
),
)
.all();
for (const { id } of staleLibrary) {
await refreshTitle(id);
await Bun.sleep(RATE_LIMIT_MS);
}
// Non-library titles: 30 days
@@ -164,27 +166,34 @@ async function refreshAvailabilityJob() {
log.debug(`Checking availability for ${libraryIds.length} library titles`);
const stale = new Date(Date.now() - DAY);
for (const titleId of libraryIds) {
// Check if any offer is stale
const offer = db
.select()
// Batch: find titles with any offers, and titles with stale offers
const titlesWithOffers = new Set(
db
.select({ titleId: availabilityOffers.titleId })
.from(availabilityOffers)
.where(inArray(availabilityOffers.titleId, libraryIds))
.groupBy(availabilityOffers.titleId)
.all()
.map((r) => r.titleId),
);
const titlesWithStaleOffers = new Set(
db
.select({ titleId: availabilityOffers.titleId })
.from(availabilityOffers)
.where(
and(
eq(availabilityOffers.titleId, titleId),
inArray(availabilityOffers.titleId, libraryIds),
lt(availabilityOffers.lastFetchedAt, stale),
),
)
.get();
.groupBy(availabilityOffers.titleId)
.all()
.map((r) => r.titleId),
);
// Also handle titles with no offers yet
const anyOffer = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.get();
if (offer || !anyOffer) {
for (const titleId of libraryIds) {
if (titlesWithStaleOffers.has(titleId) || !titlesWithOffers.has(titleId)) {
await refreshAvailability(titleId);
await Bun.sleep(RATE_LIMIT_MS);
}
@@ -223,17 +232,27 @@ async function refreshTvChildrenJob() {
log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`);
for (const show of tvShows) {
// Check if seasons are stale
const staleSeason = db
.select()
.from(seasons)
.where(
and(eq(seasons.titleId, show.id), lt(seasons.lastFetchedAt, stale)),
)
.get();
// Batch: find shows with at least one stale season
const tvIds = tvShows.map((s) => s.id);
const titlesWithStaleSeasons = new Set(
tvIds.length > 0
? db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(
and(
inArray(seasons.titleId, tvIds),
lt(seasons.lastFetchedAt, stale),
),
)
.groupBy(seasons.titleId)
.all()
.map((r) => r.titleId)
: [],
);
if (staleSeason) {
for (const show of tvShows) {
if (titlesWithStaleSeasons.has(show.id)) {
const details = await getTvDetails(show.tmdbId);
await refreshTvChildren(show.id, show.tmdbId, details.number_of_seasons);
await Bun.sleep(RATE_LIMIT_MS);
+7
View File
@@ -124,6 +124,12 @@ export const titles = sqliteTable(
uniqueIndex("titles_tmdbId_unique").on(table.tmdbId),
index("titles_type_releaseDate").on(table.type, table.releaseDate),
index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
index("titles_lastFetchedAt").on(table.lastFetchedAt),
index("titles_type_status_lastFetchedAt").on(
table.type,
table.status,
table.lastFetchedAt,
),
],
);
@@ -316,6 +322,7 @@ export const titleRecommendations = sqliteTable(
table.recommendedTitleId,
table.source,
),
index("titleRecommendations_titleId_rank").on(table.titleId, table.rank),
],
);
+29 -27
View File
@@ -20,36 +20,38 @@ export async function refreshAvailability(titleId: string) {
const now = new Date();
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
// Delete existing offers for this title+region
db.delete(availabilityOffers)
.where(
and(
eq(availabilityOffers.titleId, titleId),
eq(availabilityOffers.region, "US"),
),
)
.run();
db.transaction((tx) => {
// Delete existing offers for this title+region
tx.delete(availabilityOffers)
.where(
and(
eq(availabilityOffers.titleId, titleId),
eq(availabilityOffers.region, "US"),
),
)
.run();
for (const offerType of offerTypes) {
const providers = us[offerType];
if (!providers) continue;
for (const offerType of offerTypes) {
const providers = us[offerType];
if (!providers) continue;
for (const p of providers) {
db.insert(availabilityOffers)
.values({
titleId,
region: "US",
providerId: p.provider_id,
providerName: p.provider_name,
logoPath: p.logo_path,
offerType,
link: us.link ?? null,
lastFetchedAt: now,
})
.onConflictDoNothing()
.run();
for (const p of providers) {
tx.insert(availabilityOffers)
.values({
titleId,
region: "US",
providerId: p.provider_id,
providerName: p.provider_name,
logoPath: p.logo_path,
offerType,
link: us.link ?? null,
lastFetchedAt: now,
})
.onConflictDoNothing()
.run();
}
}
}
});
const total = offerTypes.reduce((n, t) => n + (us[t]?.length ?? 0), 0);
log.debug(`Refreshed availability for title ${titleId}: ${total} offers`);
+17 -16
View File
@@ -1,6 +1,6 @@
import { mkdir, rename } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
availabilityOffers,
@@ -191,24 +191,25 @@ export async function cacheEpisodeStills(titleId: string) {
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
const seasonIds = allSeasons.map((s) => s.id);
if (seasonIds.length === 0) return;
const tasks: Promise<unknown>[] = [];
for (const ep of eps) {
if (
ep.stillPath &&
!(await isImageCached("stills", path.basename(ep.stillPath)))
) {
tasks.push(downloadAndCacheImage(ep.stillPath, "stills"));
}
const allEps = db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.all();
const tasks: Promise<unknown>[] = [];
for (const ep of allEps) {
if (
ep.stillPath &&
!(await isImageCached("stills", path.basename(ep.stillPath)))
) {
tasks.push(downloadAndCacheImage(ep.stillPath, "stills"));
}
await Promise.allSettled(tasks);
}
await Promise.allSettled(tasks);
}
export async function cacheProviderLogos(titleId: string) {
+5 -4
View File
@@ -134,12 +134,13 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
// They may be missing if a prior fetch failed or the title was created
// as a shell by the recommendations system (lastFetchedAt: null).
if (existing.type === "tv") {
const seasonCount = db
.select()
const hasSeason = db
.select({ id: seasons.id })
.from(seasons)
.where(eq(seasons.titleId, existing.id))
.all().length;
if (seasonCount === 0) {
.limit(1)
.get();
if (!hasSeason) {
const show = await getTvDetails(tmdbId);
if (!existing.lastFetchedAt) {
db.update(titles)