fix(cron): keep failing titles from blocking the non-library refresh

Exclude library titles and order by lastFetchedAt in SQL, and stamp
lastFetchedAt when a non-library refresh fails so it moves to the back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-06 12:30:17 -04:00
1 parent c4a322948b
commit 9140891dd5
4 files changed
+83 -7

No files matched your search

+6 -4
View File
@@ -15,6 +15,7 @@ import {
getTitleByIdForCron,
getTitleIdsCheckedBefore,
getTitleIdsWithStaleSeasons,
markTitleRefreshAttempted,
runIsolated,
startCronRun,
} from "@sofa/core/cron";
@@ -118,9 +119,7 @@ async function nightlyRefreshLibrary() {
);
// Non-library titles: 30 days
const nonLibraryIds = getStaleNonLibraryTitlesForRefresh(nonLibraryStale, 50)
.map((t) => t.id)
.filter((id) => !libraryIds.includes(id));
const nonLibraryIds = getStaleNonLibraryTitlesForRefresh(nonLibraryStale, 50).map((t) => t.id);
await runIsolated(
nonLibraryIds,
@@ -128,7 +127,10 @@ async function nightlyRefreshLibrary() {
await refreshTitle(id);
await Bun.sleep(RATE_LIMIT_MS);
},
(id, err) => log.warn(`Failed to refresh title ${id}:`, err),
(id, err) => {
log.warn(`Failed to refresh title ${id}:`, err);
markTitleRefreshAttempted(id);
},
);
}
+5
View File
@@ -9,6 +9,7 @@ import {
getTitleIdsWithStaleSeasons,
insertCronRunReturning,
markInterruptedCronRuns,
markTitleRefreshAttempted as queryMarkTitleRefreshAttempted,
updateCronRunError,
updateCronRunSuccess,
} from "@sofa/db/queries/cron";
@@ -111,6 +112,10 @@ export function getStaleNonLibraryTitlesForRefresh(staleDate: Date, limit: numbe
return getStaleNonLibraryTitles(staleDate, limit);
}
export function markTitleRefreshAttempted(titleId: string, at: Date = new Date()): void {
queryMarkTitleRefreshAttempted(titleId, at);
}
export {
getReturningTvShows,
getTitleByIdForCron,
+53 -1
View File
@@ -1,15 +1,17 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { cronRuns, seasons, titles } from "@sofa/db/schema";
import { clearAllTables, eq, insertTitle, testDb } from "@sofa/test/db";
import { clearAllTables, eq, insertStatus, insertTitle, insertUser, testDb } from "@sofa/test/db";
import {
completeCronRun,
failCronRun,
getLibraryTitlesDueForRefresh,
getStaleLibraryTitles,
getStaleNonLibraryTitlesForRefresh,
getTitleIdsWithStaleSeasons,
libraryRefreshIntervalMs,
markTitleRefreshAttempted,
recoverInterruptedCronRuns,
runIsolated,
startCronRun,
@@ -247,3 +249,53 @@ describe("getTitleIdsWithStaleSeasons", () => {
expect(getTitleIdsWithStaleSeasons(["tv-old"], cutoff).has("tv-old")).toBe(true);
});
});
describe("getStaleNonLibraryTitlesForRefresh", () => {
const staleDate = new Date("2026-01-08T00:00:00Z");
function setFetched(id: string, date: Date) {
testDb.update(titles).set({ lastFetchedAt: date }).where(eq(titles.id, id)).run();
}
test("excludes titles in a user's library", () => {
insertUser();
insertTitle({ id: "t-lib", tmdbId: 1 });
insertTitle({ id: "t-free", tmdbId: 2 });
setFetched("t-lib", new Date("2025-12-01T00:00:00Z"));
setFetched("t-free", new Date("2025-12-02T00:00:00Z"));
insertStatus("user-1", "t-lib", "watchlist");
expect(getStaleNonLibraryTitlesForRefresh(staleDate, 10).map((t) => t.id)).toEqual(["t-free"]);
});
test("returns the oldest titles first within the limit", () => {
insertTitle({ id: "t-a", tmdbId: 1 });
insertTitle({ id: "t-b", tmdbId: 2 });
insertTitle({ id: "t-c", tmdbId: 3 });
setFetched("t-a", new Date("2025-12-03T00:00:00Z"));
setFetched("t-b", new Date("2025-12-01T00:00:00Z"));
setFetched("t-c", new Date("2025-12-02T00:00:00Z"));
expect(getStaleNonLibraryTitlesForRefresh(staleDate, 2).map((t) => t.id)).toEqual([
"t-b",
"t-c",
]);
});
test("does not return shell titles with NULL lastFetchedAt", () => {
insertTitle({ id: "t-shell", tmdbId: 1 });
expect(getStaleNonLibraryTitlesForRefresh(staleDate, 10)).toEqual([]);
});
test("markTitleRefreshAttempted moves a title out of the stale window", () => {
insertTitle({ id: "t-old", tmdbId: 1 });
insertTitle({ id: "t-other", tmdbId: 2 });
setFetched("t-old", new Date("2025-12-01T00:00:00Z"));
setFetched("t-other", new Date("2025-12-02T00:00:00Z"));
markTitleRefreshAttempted("t-old");
expect(getStaleNonLibraryTitlesForRefresh(staleDate, 10).map((t) => t.id)).toEqual(["t-other"]);
});
});
+19 -2
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, isNotNull, isNull, lt, or, sql } from "drizzle-orm";
import { and, asc, eq, inArray, isNotNull, isNull, lt, notExists, or, sql } from "drizzle-orm";
import { db } from "../client";
import { cronRuns, seasons, titles, userTitleStatus } from "../schema";
@@ -62,11 +62,28 @@ export function getStaleNonLibraryTitles(staleDate: Date, limit: number) {
return db
.select({ id: titles.id })
.from(titles)
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, staleDate)))
.where(
and(
isNotNull(titles.lastFetchedAt),
lt(titles.lastFetchedAt, staleDate),
notExists(
db
.select({ one: sql`1` })
.from(userTitleStatus)
.where(eq(userTitleStatus.titleId, titles.id)),
),
),
)
.orderBy(asc(titles.lastFetchedAt))
.limit(limit)
.all();
}
/** Record a refresh attempt so a title that keeps failing doesn't stay at the front of the queue. */
export function markTitleRefreshAttempted(titleId: string, at: Date = new Date()): void {
db.update(titles).set({ lastFetchedAt: at }).where(eq(titles.id, titleId)).run();
}
export function getReturningTvShows() {
const returningStatuses = ["Returning Series", "In Production"];
return db