From b4638c17786558a9f4b71fa738b0bf077f6dc92a Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Thu, 25 Jun 2026 15:10:27 +0200 Subject: [PATCH] search dialog: cron-warmed KV blob for Live Leaders + Trending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the slow Live Leaders skeleton: dialog used to fetch /api/citable on every open (~50 KB, full benchmark assembly server-side, frequently visible 1-2 s skeleton). Now a Vercel cron pre-builds a 12-card subset every minute, persists it as a single Upstash KV blob, and the dialog opens with data already prefetched at page load. Architecture: - src/lib/search-featured.ts: builds the slim blob (featured + trending cards: slug, title, category, unit, value, leader). Single source of truth for the dialog's hardcoded slug lists. - src/app/api/cron/warm-search-featured/route.ts: every minute, calls buildFeaturedLeaders + writeCohortSnapshot('search-featured'). Same CRON_SECRET gate + soft-no-op-on-missing-creds pattern as the existing cohort cron jobs. - src/app/api/search/featured/route.ts: KV-first reader with live-build fallback (cold-start safety). Aggressive edge cache (s-maxage=60, swr=300) — most requests served by Vercel edge with zero origin hit. - SearchProvider: prefetches /api/search/featured at mount (idempotent) and exposes featured + prefetchFeatured via context. Re-fetch hook on trigger hover/focus. - SearchTrigger: onMouseEnter + onFocus call prefetchFeatured. Dialog almost always opens with the blob in memory; the brief 'skeleton' state is now invisible to the user. - SearchDialog: consumes featured/trending arrays from context (zipped against the search index), drops the per-mount /api/citable fetch. Search-results rows still get leader logos via the same warmed blob when the bench appears in featured/trending; otherwise fall back to the kind-icon (no extra API calls). - vercel.json: cron schedule * * * * * for warm-search-featured. Graceful degradation: - No CRON_SECRET / no Upstash creds → cron 200s with configured:false, endpoint falls through to live build path, dialog still works. - Upstash unreachable → endpoint falls through to live build, same UX as the old /api/citable path (slow but functional). - Worker dies → endpoint serves last-good KV until 24h safety TTL expires, then falls through to live build. Payload size: ~50 KB (/api/citable, full index) → ~2 KB (/api/search/featured, 12 cards). TTFB: ~1-2 s server-side assembly → ~5-10 ms KV GET (edge-hit: ~0 ms). --- .../api/cron/warm-search-featured/route.ts | 90 +++++++++++++ src/app/api/search/featured/route.ts | 65 +++++++++ src/components/search/search-dialog.tsx | 124 +++++++----------- src/components/search/search-provider.tsx | 65 ++++++--- src/components/search/search-trigger.tsx | 19 ++- src/lib/search-featured.ts | 89 +++++++++++++ vercel.json | 4 + 7 files changed, 360 insertions(+), 96 deletions(-) create mode 100644 src/app/api/cron/warm-search-featured/route.ts create mode 100644 src/app/api/search/featured/route.ts create mode 100644 src/lib/search-featured.ts diff --git a/src/app/api/cron/warm-search-featured/route.ts b/src/app/api/cron/warm-search-featured/route.ts new file mode 100644 index 00000000..2c3309c2 --- /dev/null +++ b/src/app/api/cron/warm-search-featured/route.ts @@ -0,0 +1,90 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse, type NextRequest } from "next/server"; +import { + cohortSnapshotConfigured, + writeCohortSnapshot, +} from "@/lib/cohort-snapshot"; +import { buildFeaturedLeaders } from "@/lib/search-featured"; + +export const runtime = "nodejs"; +// No ISR — the cron's whole job is to refresh the blob. A cached 200 from +// a previous run would silently skip the rebuild. +export const dynamic = "force-dynamic"; + +/** + * Vercel cron: refresh the search dialog's "Live leaders" + "Trending" + * blob in Upstash. Runs every minute (vercel.json crons block). + * + * Why a dedicated blob: the search dialog used to fetch /api/citable on + * every open (the full ~30-bench citable index, ~50 KB, plus all the + * assembly cost server-side). Now the cron pre-computes the 12-card + * subset the dialog actually needs (~2 KB), the public endpoint becomes + * one KV GET, and the dialog opens with data already prefetched at page + * load. + * + * Same token gate + soft-no-op pattern as snapshot-perp-cohort. + */ + +function isAuthorized(req: NextRequest): boolean { + const secret = (process.env.CRON_SECRET ?? "").trim(); + const header = (req.headers.get("authorization") ?? "").trim(); + if (!secret) { + return process.env.NODE_ENV !== "production"; + } + const expected = Buffer.from(`Bearer ${secret}`); + const provided = Buffer.from(header); + if (provided.length !== expected.length) return false; + return timingSafeEqual(provided, expected); +} + +export async function GET(req: NextRequest) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + if (!cohortSnapshotConfigured()) { + return NextResponse.json( + { + ok: true, + configured: false, + message: + "cohort snapshot store not configured (KV_REST_API_URL / UPSTASH_REDIS_REST_URL absent)", + }, + { status: 200 }, + ); + } + + const startedAt = Date.now(); + let blob; + try { + blob = await buildFeaturedLeaders(); + } catch (err) { + return NextResponse.json( + { + ok: false, + stage: "build", + error: err instanceof Error ? err.message : String(err), + }, + { status: 502 }, + ); + } + + try { + await writeCohortSnapshot("search-featured", blob); + } catch (err) { + return NextResponse.json( + { + ok: false, + stage: "write", + error: err instanceof Error ? err.message : String(err), + }, + { status: 502 }, + ); + } + + return NextResponse.json({ + ok: true, + featuredCount: blob.featured.length, + trendingCount: blob.trending.length, + durationMs: Date.now() - startedAt, + }); +} diff --git a/src/app/api/search/featured/route.ts b/src/app/api/search/featured/route.ts new file mode 100644 index 00000000..eba33e04 --- /dev/null +++ b/src/app/api/search/featured/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { readCohortSnapshot } from "@/lib/cohort-snapshot"; +import { + buildFeaturedLeaders, + type FeaturedLeadersBlob, +} from "@/lib/search-featured"; + +export const runtime = "nodejs"; +// 60 s revalidate so even a cold edge cache only needs one round-trip +// per minute per region. The cron rewrites the underlying KV blob on the +// same cadence; stale-while-revalidate keeps every other request hot. +export const revalidate = 60; + +/** + * Slim payload feeding the header search dialog's "Live leaders" + + * "Trending" sections. Reads through Upstash KV first (cron-warmed, + * ~5 ms response) and falls through to a live build only on cold KV + * (deploy minute zero, missing creds, or worker outage). + * + * The previous implementation called /api/citable which assembled the + * full ~30-bench citable index (~50 KB) on every dialog open. This + * route returns the 12-card subset (~2 KB) the dialog actually needs. + */ +export async function GET() { + const snap = await readCohortSnapshot("search-featured"); + if (snap?.data) { + return NextResponse.json( + { ok: true, source: "kv", ageMs: snap.ageMs, ...snap.data }, + { + headers: { + "cache-control": + "public, s-maxage=60, stale-while-revalidate=300", + "access-control-allow-origin": "*", + }, + }, + ); + } + + // Cold-fall-through: build live. Slower (one full benchmark assembly) + // but never blocks the dialog if the cron hasn't run yet or Upstash is + // unreachable. The cron will heal this on its next tick. + try { + const blob = await buildFeaturedLeaders(); + return NextResponse.json( + { ok: true, source: "live", ageMs: 0, ...blob }, + { + headers: { + "cache-control": + "public, s-maxage=60, stale-while-revalidate=300", + "access-control-allow-origin": "*", + }, + }, + ); + } catch (err) { + return NextResponse.json( + { + ok: false, + error: err instanceof Error ? err.message : String(err), + featured: [], + trending: [], + }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } +} diff --git a/src/components/search/search-dialog.tsx b/src/components/search/search-dialog.tsx index d64fa850..c7f6921c 100644 --- a/src/components/search/search-dialog.tsx +++ b/src/components/search/search-dialog.tsx @@ -62,38 +62,13 @@ const KIND_ICON: Record = { }; /** - * Hand-picked editorial leaders. Shown as horizontal "Live leaders" cards - * when the query is empty. Live values (#1 provider + p50) come from - * `/api/citable`, which is already edge-cached for 300s. All slugs must - * appear in the citable response (editorialStatus === "live" + leader() - * non-null) — verified against prod before shipping. + * The featured + trending lists themselves now live in + * `src/lib/search-featured.ts` (single source of truth used by the cron + * that warms the KV blob and by the public endpoint that serves it). + * The dialog consumes the pre-resolved blob from SearchProvider, which + * fetches `/api/search/featured` at mount + on trigger hover. */ -const FEATURED_BENCH_SLUGS = [ - "pm-data-freshness", - "aggregator-head-lag", - "l1-finality", - "rpc-capabilities", - "perp-fees", - "bridge-quote-latency", -]; - -const TRENDING_BENCH_SLUGS = [ - "stablecoin-peg-usdt-anchored", - "metadata-coverage", - "validator-yield", - "network-fees", - "perp-funding", - "solana-tx-landing", -]; - -type CitableLeader = { - slug: string; - title: string; - category: string; - value: number | null; - unit: string; - leader: { name: string; slug: string; value: number } | null; -}; +import type { FeaturedCardData } from "@/lib/search-featured"; function Kbd({ children }: { children: React.ReactNode }) { return ( @@ -139,7 +114,7 @@ function fmtUnit(value: number | null | undefined, unit: string): string { } export default function SearchDialog() { - const { items, close: onClose } = useSearch(); + const { items, close: onClose, featured: featuredBlob } = useSearch(); const router = useRouter(); const [query, setQuery] = useState(""); const [isClosing, setIsClosing] = useState(false); @@ -147,25 +122,6 @@ export default function SearchDialog() { const { recent, push: pushRecent, remove: removeRecent, clear: clearRecent } = useRecentSearches(); - // Featured / trending live data. One fetch on mount, cached by browser - // since /api/citable ships s-maxage=300. - const [citable, setCitable] = useState | null>(null); - useEffect(() => { - let cancelled = false; - fetch("/api/citable") - .then((r) => (r.ok ? r.json() : null)) - .then((j) => { - if (cancelled || !j?.benchmarks) return; - const map = new Map(); - for (const b of j.benchmarks as CitableLeader[]) map.set(b.slug, b); - setCitable(map); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); - // One Fuse instance per dialog mount. The full corpus is ~400 docs // so build cost is sub-millisecond, no need to memoise across mounts. const fuse = useMemo( @@ -222,27 +178,44 @@ export default function SearchDialog() { return map; }, [items]); - const featured = useMemo(() => { - return FEATURED_BENCH_SLUGS - .map((slug) => { - const item = benchItemBySlug.get(slug); + // Featured + trending lists arrive pre-resolved from SearchProvider + // (warmed by a cron + Upstash KV blob, served via /api/search/featured). + // We zip each card against the search index entry to inherit the URL + // and any extra item-level metadata. Missing search-index hits drop + // out silently so a stale slug never breaks the dialog. + const zip = (cards: FeaturedCardData[] | undefined) => + (cards ?? []) + .map((card) => { + const item = benchItemBySlug.get(card.slug); if (!item) return null; - const live = citable?.get(slug); - return { item, live: live ?? null }; + return { item, live: card }; }) - .filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x)); - }, [benchItemBySlug, citable]); + .filter((x): x is { item: SearchItem; live: FeaturedCardData } => Boolean(x)); + + const featured = useMemo( + () => zip(featuredBlob?.featured), + // benchItemBySlug is stable for a given items array; zipping inside + // the memo keeps the array reference stable across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + [benchItemBySlug, featuredBlob?.featured], + ); - const trending = useMemo(() => { - return TRENDING_BENCH_SLUGS - .map((slug) => { - const item = benchItemBySlug.get(slug); - if (!item) return null; - const live = citable?.get(slug); - return { item, live: live ?? null }; - }) - .filter((x): x is { item: SearchItem; live: CitableLeader | null } => Boolean(x)); - }, [benchItemBySlug, citable]); + const trending = useMemo( + () => zip(featuredBlob?.trending), + // eslint-disable-next-line react-hooks/exhaustive-deps + [benchItemBySlug, featuredBlob?.trending], + ); + + // Flat slug → card lookup for the search-results render. Any bench + // appearing in featured OR trending gets its leader logo on the row; + // others fall back to the trophy / kind icon. Avoids an extra fetch + // and keeps the dialog snappy. + const featuredBySlug = useMemo(() => { + const map = new Map(); + for (const c of featuredBlob?.featured ?? []) map.set(c.slug, c); + for (const c of featuredBlob?.trending ?? []) map.set(c.slug, c); + return map; + }, [featuredBlob]); const results = useMemo(() => { const q = query.trim(); @@ -400,7 +373,7 @@ export default function SearchDialog() { live @@ -412,7 +385,7 @@ export default function SearchDialog() {
{featured.map(({ item, live }) => { const category = item.tags?.[0] ?? "Benchmark"; - const isLoading = citable === null; + const isLoading = featuredBlob === null; return (