From 0d4c74dd77086d659e6c2a27c46f058f0168df2f Mon Sep 17 00:00:00 2001 From: AsperforMias Date: Fri, 3 Jul 2026 04:05:42 +0800 Subject: [PATCH 1/2] fix: refresh stale score cache safely --- src/app/[locale]/u/[username]/page.tsx | 85 +++++++++++--- src/app/api/score/[username]/route.ts | 5 + src/lib/__tests__/db.test.ts | 152 ++++++++++++++++++++++++- src/lib/db.ts | 68 +++++++---- src/lib/redis.ts | 35 ++++++ src/lib/score-persist.ts | 33 ++++++ src/messages/en.json | 6 +- src/messages/zh.json | 6 +- 8 files changed, 347 insertions(+), 43 deletions(-) create mode 100644 src/lib/score-persist.ts diff --git a/src/app/[locale]/u/[username]/page.tsx b/src/app/[locale]/u/[username]/page.tsx index 46877bd..a4a152b 100644 --- a/src/app/[locale]/u/[username]/page.tsx +++ b/src/app/[locale]/u/[username]/page.tsx @@ -15,7 +15,15 @@ import { getSimilarAccounts, getUserMatchups, } from "@/lib/db"; -import { getCachedScan } from "@/lib/redis"; +import { + coalesceScan, + deleteCachedScan, + getCachedScan, + hasStaleScoreRefreshCooldown, + markStaleScoreRefreshCooldown, +} from "@/lib/redis"; +import { buildScanResult } from "@/lib/scan-core"; +import { recordDeterministicScan } from "@/lib/score-persist"; import { aggregateLanguages, collectTopics } from "@/lib/profile-insights"; import { PendingProfile } from "./PendingProfile"; import { LiveRoast } from "@/components/LiveRoast"; @@ -57,8 +65,29 @@ export const dynamic = "force-dynamic"; // Dedupe the DB read between generateMetadata() and the page render. const getDetail = cache((username: string) => getAccountDetail(username)); +const getStaleDetail = cache((username: string) => + getAccountDetail(username, { includeStale: true }), +); // Dedupe the cached-scan read (pending-profile fallback) across the same pair. const getLiveScan = cache((username: string) => getCachedScan(username)); +const refreshStaleDetail = cache(async (username: string) => { + if (await hasStaleScoreRefreshCooldown(username)) return null; + + try { + const scan = await coalesceScan(username, () => buildScanResult(username)); + await recordDeterministicScan(scan); + await deleteCachedScan(scan.metrics.username); + const detail = await getAccountDetail(scan.metrics.username); + if (!detail) { + await markStaleScoreRefreshCooldown(scan.metrics.username); + } + return detail; + } catch (e) { + console.error("stale score refresh failed:", e); + await markStaleScoreRefreshCooldown(username); + return null; + } +}); export async function generateMetadata({ params, @@ -83,6 +112,13 @@ export async function generateMetadata({ robots: { index: false, follow: true }, }; } + const stale = await getStaleDetail(decoded); + if (stale) { + return { + title: t("staleTitle", { username: stale.username }), + robots: { index: false, follow: true }, + }; + } return { title: t("notFoundTitle") }; } @@ -133,7 +169,8 @@ export default async function AccountPage({ const { locale, username } = await params; setRequestLocale(locale); const decoded = decodeURIComponent(username); - const d = await getDetail(decoded); + let d = await getDetail(decoded); + let staleFallback = false; if (!d) { // First-time username being roasted right now: no `scores` row yet. Render // the live pending shell when we have a scan to show — either the server-side @@ -142,8 +179,17 @@ export default async function AccountPage({ // completion. Otherwise it's a genuine unknown handle → 404. const scan = await getLiveScan(decoded); const roasting = (await searchParams)?.roasting === "1"; - if (!scan && !roasting) notFound(); - return ; + if (!scan && !roasting) { + const stale = await getStaleDetail(decoded); + if (!stale) notFound(); + d = await refreshStaleDetail(stale.username); + if (!d) { + d = stale; + staleFallback = true; + } + } else { + return ; + } } const t = await getTranslations("detail"); @@ -162,17 +208,19 @@ export default async function AccountPage({ // Row exists but this language's roast is missing (e.g. an English visitor on a // zh-only roast). If the scan is still cached, stream a live roast in the // report slot instead of the "run a roast" empty state. - const liveScan = !roast && !roastLine ? await getLiveScan(d.username) : null; - const [similar, comments, snap, rank, session, battles, facetRank] = - await Promise.all([ - getSimilarAccounts(d.username, d.final_score, d.sub_scores), - getProfileComments(d.username), - getProfileSnapshot(d.username), - getRank(d.final_score), - authConfigured() ? auth() : Promise.resolve(null), - getUserMatchups(d.username), - getFacetRank(d.username, d.final_score), - ]); + const liveScan = + !staleFallback && !roast && !roastLine ? await getLiveScan(d.username) : null; + const [similar, comments, snap, rank, session, battles, facetRank] = await Promise.all([ + staleFallback + ? Promise.resolve([]) + : getSimilarAccounts(d.username, d.final_score, d.sub_scores), + getProfileComments(d.username), + getProfileSnapshot(d.username, { includeStale: staleFallback }), + staleFallback ? Promise.resolve(null) : getRank(d.final_score), + authConfigured() ? auth() : Promise.resolve(null), + getUserMatchups(d.username), + staleFallback ? Promise.resolve(null) : getFacetRank(d.username, d.final_score), + ]); // Inline re-detect is self-service: only the signed-in owner sees it on their // own profile. GitHub handles are case-insensitive, so compare normalized. const isOwner = @@ -257,6 +305,13 @@ export default async function AccountPage({ )} + {staleFallback && ( +
+
{t("staleBadge")}
+

{t("staleBody")}

+
+ )} +
{/* Left: sticky identity sidebar — score stays visible while reading */}