mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
+56
-37
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user