diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index fd70bd5f..0d600162 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -72,6 +72,18 @@ jobs: | xargs -P 4 -I{} curl -s -o /dev/null -w "%{http_code} %{time_total}s {}\n" --max-time 120 {} \ || echo "warm-up failed (non-blocking)" + # Warm pages that can cold-render 500: products/polymarket triggers the + # degraded-read tripwire on empty store; alternatives/* for perp venues + # run a live Prom cohort fetch on first hit. Both self-heal after one + # successful warm render, so hitting them here pre-empts the smoke gate. + - name: Warm ISR-sensitive pages + run: | + base="https://openchainbench.com" + for path in /products/polymarket /alternatives/dydx /alternatives/gains /alternatives/hyperliquid /alternatives/polymarket; do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 120 "$base$path" || echo "000") + echo "$code $base$path" + done + # Warm /api/citable explicitly: the aggregator cache is independent # from per-bench caches, and the bench-page warm-up only populates # the latter. Without this step, /api/citable's first hit after diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index 734ddb2b..a7b35462 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -22,10 +22,9 @@ import { ProviderTypeBadge } from "@/components/provider-type-badge"; import { isRegion } from "@/lib/brand"; import { PERP_VENUE_META, benchRowsForVenue } from "@/lib/perp-venue-context"; import { fetchPerpCohort } from "@/lib/perp-stats"; -import { loadBenchFromBlob } from "@/lib/bench-blob"; import { PerpVenueBenchCards } from "@/components/perp-venue-bench-cards"; -export const revalidate = 60; +export const dynamic = "force-dynamic"; // Same budget as /products/[slug]: on-demand renders span the whole bench // catalog and the 60s default killed them mid-flight. @@ -69,17 +68,16 @@ export default async function AlternativePage({ const { slug } = await params; const isPerpVenue = slug in PERP_VENUE_META; - const [alt, cohort, feesAtSizeBench] = await Promise.all([ + const [alt, cohort] = await Promise.all([ loadAlternative(slug), isPerpVenue ? fetchPerpCohort() : Promise.resolve(null), - isPerpVenue ? loadBenchFromBlob("perp-fees-at-size") : Promise.resolve(null), ]); if (!alt) notFound(); const venueRow = cohort?.venues.find((v) => v.slug === slug) ?? null; const perpBenchRows = cohort && venueRow - ? benchRowsForVenue(cohort, venueRow, feesAtSizeBench) + ? benchRowsForVenue(cohort, venueRow, null) : []; const { bench } = alt; diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index b6be77c3..6e0dad0e 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -27,7 +27,6 @@ import { getPmVenueContext } from "@/lib/pm-venue-context"; import { fetchPmDataFeedKpis } from "@/lib/pm-venue-data"; import { fetchPerpVenueKpis } from "@/lib/perp-venue-data"; import { hasRpcProviderData } from "@/lib/rpc-hub-stats"; -import { loadBenchFromBlob } from "@/lib/bench-blob"; import { PmVenueSection } from "@/components/pm-venue-section"; import { getPerpVenueContext, @@ -204,14 +203,20 @@ export default async function ProviderPage({ a.result.availability === "live" && a.result.unresponsive !== true, ); - const anyMeasuredAppearance = p.appearances.some( - (a) => (a.result.ms?.p50 ?? 0) > 0, + // Exclude appearances with insufficient data confidence from the + // "any measured" check: a new provider whose every bench appearance is + // still warming up (insufficient sample count) legitimately has rank=0 + // everywhere — that is not a degraded store read, it is warm-up state. + const anyConfidentAppearance = p.appearances.some( + (a) => + (a.result.ms?.p50 ?? 0) > 0 && + a.result.dataConfidence !== "insufficient", ); if ( p.appearances.length >= 8 && p.appearances.every((a) => a.rank === 0) && allClaimLive && - anyMeasuredAppearance + anyConfidentAppearance ) { throw new Error(`degraded store read for /products/${slug}: ${p.appearances.length} appearances, all unranked`); } @@ -232,11 +237,10 @@ export default async function ProviderPage({ const pmContext = await getPmVenueContext(p.slug); // Perp DEX cohort dashboard. Same pattern as the PM context above. - const isPerpSlug = PERP_PRODUCT_PILL_SLUGS.has(p.slug); - const feesAtSizeBench = isPerpSlug - ? await loadBenchFromBlob("perp-fees-at-size") - : null; - const perpContext = await getPerpVenueContext(p.slug, feesAtSizeBench); + // perp-fees-at-size bench was removed; pass null so loadBenchFromBlob + // (cache: "no-store") is not called from an ISR page (it would trigger + // Next.js "Page changed from static to dynamic" for polymarket). + const perpContext = await getPerpVenueContext(p.slug, null); // KPI-domain availability, resolved upfront so the pill bar knows // every domain before rendering. A pill must never open onto an empty diff --git a/src/lib/sitemap-builder.ts b/src/lib/sitemap-builder.ts index ad98bf2d..a8613ed0 100644 --- a/src/lib/sitemap-builder.ts +++ b/src/lib/sitemap-builder.ts @@ -10,6 +10,7 @@ import { isHlBuilderSlug, isHlBuilderWithHistory, } from "@/lib/hl-builder-stats"; +import { PERP_PRODUCT_PILL_SLUGS } from "@/lib/perp-venue-context"; import { loadAllAlternatives } from "@/lib/alternatives"; import { loadAllAnswers } from "@/lib/answers"; import { CHAIN_BY_SLUG, CHAINS, getBenchmarksForChain } from "@/lib/chains"; @@ -240,6 +241,9 @@ async function buildFullSitemap(): Promise { // Emitting them in the sitemap advertises URLs the middleware will // then reject, failing the deploy-time sitemap-smoke gate. if (REMOVED_BENCH_SLUGS.has(b.slug)) return []; + // Benches that are editorially live but have no Prom data yet render + // with . Skip until data lands. + if (b.status === "draft" && b.editorialStatus === "live") return []; const last = b.lastRunAt ? new Date(b.lastRunAt) : BUILD_TIME; const entries: MetadataRoute.Sitemap = [ { @@ -307,6 +311,8 @@ async function buildFullSitemap(): Promise { providerSlugs.map(async (slug) => { if (CHAIN_BY_SLUG.has(slug)) return null; if (await isHlBuilderSlug(slug)) return null; + // Perp venue slugs (except polymarket) 308 to /perp/. + if (PERP_PRODUCT_PILL_SLUGS.has(slug) && slug !== "polymarket") return null; const p = await getProvider(slug); return p ? slug : null; }),