From 8e0d92a418aea3a3bac54686e9aff4e01d66829a Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 19 Jul 2026 19:02:58 +0200 Subject: [PATCH] Phase 3 site side: per-bench + per-variant reads via CDN blobs (kills SRH from detail + filter render paths) --- src/lib/bench-blob.ts | 92 +++++++++++++++++++++++++++++++++++++++++++ src/lib/spec.ts | 24 ++++++----- 2 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 src/lib/bench-blob.ts diff --git a/src/lib/bench-blob.ts b/src/lib/bench-blob.ts new file mode 100644 index 00000000..9a10deb2 --- /dev/null +++ b/src/lib/bench-blob.ts @@ -0,0 +1,92 @@ +/** + * Per-bench + per-variant CDN reader (Phase 3 of the SRH-elimination). + * + * The materialize worker publishes: + * - `bench-aggregate/latest.json` (all benches, homepage) + * - `bench-aggregate/benches/.json` (one unfiltered bench) + * - `bench-aggregate/variants//.json` (one filtered variant) + * + * Aggregate is consumed by `src/lib/aggregate-blob.ts` (Phase 2). This + * module handles the two per-bench shapes — replaces the SRH GETs that + * `loadBenchmarkUnfilteredCached` and `loadBenchmarkFiltered` used to + * make in `src/lib/spec.ts`. + * + * Failure model: any error (fetch throw, 4xx/5xx, malformed JSON, schema + * mismatch) resolves to `null` so callers fall through to the Redis + * path. Never throws. + */ + +import type { Benchmark } from "@/types/benchmark"; + +const DEFAULT_BASE_URL = "https://kv.openchainbench.com/aggregate"; +const FETCH_TIMEOUT_MS = 5_000; + +type BenchEnvelope = { + v: number; + builtAt: number; + slug: string; + bench: Benchmark; +}; + +type VariantEnvelope = BenchEnvelope & { sig: string }; + +function baseUrl(): string { + return process.env.AGGREGATE_BLOB_BASE_URL || DEFAULT_BASE_URL; +} + +function isBenchEnvelope(x: unknown): x is BenchEnvelope { + if (typeof x !== "object" || x === null) return false; + const o = x as Record; + return ( + typeof o.v === "number" && + typeof o.builtAt === "number" && + typeof o.slug === "string" && + typeof o.bench === "object" && + o.bench !== null + ); +} + +async function fetchJson(url: string): Promise { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + cache: "no-store", + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +/** + * Fetch a single unfiltered bench from the CDN. Returns the raw + * `Benchmark` from the store (no editorial overlay applied — caller + * layers that on top so a stale blob doesn't outlive a spec edit). + */ +export async function loadBenchFromBlob( + slug: string, +): Promise { + const url = `${baseUrl()}/benches/${encodeURIComponent(slug)}.json`; + const raw = await fetchJson(url); + if (!isBenchEnvelope(raw) || raw.v !== 1 || raw.slug !== slug) return null; + return raw.bench; +} + +/** + * Fetch a single filtered variant from the CDN. `sig` is the filterSig + * string (empty for unfiltered — callers with sig="" should use + * `loadBenchFromBlob` instead). + */ +export async function loadVariantFromBlob( + slug: string, + sig: string, +): Promise { + if (!sig) return null; + const url = `${baseUrl()}/variants/${encodeURIComponent(slug)}/${encodeURIComponent(sig)}.json`; + const raw = await fetchJson(url); + if (!isBenchEnvelope(raw) || raw.v !== 1 || raw.slug !== slug) return null; + const env = raw as VariantEnvelope; + if (env.sig !== sig) return null; + return env.bench; +} diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 81f3dfd8..795b5df8 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -27,6 +27,7 @@ import { } from "@/lib/materialize/load"; import { readMaterialized } from "@/lib/materialize/store"; import { loadAggregateFromBlob } from "@/lib/aggregate-blob"; +import { loadBenchFromBlob, loadVariantFromBlob } from "@/lib/bench-blob"; export type { Spec } from "@/lib/spec-schema"; export type { BenchmarkFilters } from "@/lib/materialize/load"; @@ -200,11 +201,13 @@ const loadBenchmarkUnfilteredCached = unstable_cache( const specs = await loadSpecs(); const spec = specs.find((s) => s.slug === slug); if (!spec) return undefined; - // Blob-only: serve whatever the worker last published. Missing or - // unparseable blobs fall through to the aggregator's draft - // placeholder instead of fanning out a Prom build at render time — - // that fan-out is what saturated Prom's query queue under Vercel - // ISR re-render bursts. + // Fast path: per-bench blob written by the materialize worker + // after every tier A sweep. One CDN fetch (~20 ms edge, ~1 s cold) + // replaces the 2-3 SRH GETs this used to run. See bench-blob.ts + // and worker/publish-aggregate.ts. + const fromBlob = await loadBenchFromBlob(slug); + if (fromBlob) return slimBenchmarkForCache(overlayEditorial(fromBlob, spec)); + // Fallback: Redis-via-SRH. Kept until Phase 3 fully retires SRH. const stored = await benchFromStore(slug, ""); if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec)); if (spec.status === "live") { @@ -579,11 +582,12 @@ const loadBenchmarkFiltered = unstable_cache( const specs = await loadSpecs(); const spec = specs.find((s) => s.slug === slug); if (!spec) return undefined; - // Blob-only: variant blobs are published by the worker's tier-B - // sweep. A missing variant blob means the worker has not covered - // this filter combination yet — return undefined so the caller can - // fall back to the unfiltered "All" view rather than spinning up a - // render-time Prom build. + // Fast path: per-variant blob written by the worker's tier B + // sweep. Missing blob → this filter combo hasn't been covered yet + // (or the tier B has never run since worker start) — fall through + // to Redis-via-SRH. + const fromBlob = await loadVariantFromBlob(slug, sig); + if (fromBlob) return slimBenchmarkForCache(overlayEditorial(fromBlob, spec)); const stored = await benchFromStore(slug, sig); if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec)); return undefined;