mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
4 files changed
+83
-7
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user