diff --git a/package.json b/package.json index 776f5474..4b6e7fdc 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@react-three/fiber": "^9.6.1", "@vercel/analytics": "^2.0.1", + "@vercel/blob": "^1.0.0", "cmdk": "^1.1.1", "fuse.js": "^7.4.2", "ioredis": "^5.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d7caede..b557ebc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@vercel/blob': + specifier: ^1.0.0 + version: 1.1.1 cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -374,6 +377,10 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -1219,6 +1226,10 @@ packages: vue-router: optional: true + '@vercel/blob@1.1.1': + resolution: {integrity: sha512-heiJGj2qt5qTv6yiShH9f6KRAoZGj+lz61GQ+lBRL4lhvUmKI9A51KYlQTnsUd9ymdFlKHBlvmPeG+yGz2Qsbg==} + engines: {node: '>=16.14'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1301,6 +1312,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1965,6 +1979,10 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -2008,6 +2026,9 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -2542,6 +2563,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2716,6 +2741,10 @@ packages: three@0.184.0: resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -2788,6 +2817,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -3151,6 +3184,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/busboy@2.1.1': {} + '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: hono: 4.12.18 @@ -3828,6 +3863,14 @@ snapshots: next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + '@vercel/blob@1.1.1': + dependencies: + async-retry: 1.3.3 + is-buffer: 2.0.5 + is-node-process: 1.2.0 + throttleit: 2.1.0 + undici: 5.29.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -3940,6 +3983,10 @@ snapshots: async-function@1.0.0: {} + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -4785,6 +4832,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@2.0.5: {} + is-bun-module@2.0.0: dependencies: semver: 7.7.4 @@ -4828,6 +4877,8 @@ snapshots: is-negative-zero@2.0.3: {} + is-node-process@1.2.0: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -5325,6 +5376,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.13.1: {} + reusify@1.1.0: {} router@2.2.0: @@ -5573,6 +5626,8 @@ snapshots: three@0.184.0: {} + throttleit@2.1.0: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -5669,6 +5724,10 @@ snapshots: undici-types@6.21.0: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + unpipe@1.0.0: {} unrs-resolver@1.11.1: diff --git a/src/app/api/internal/revalidate-aggregate/route.ts b/src/app/api/internal/revalidate-aggregate/route.ts new file mode 100644 index 00000000..1281c991 --- /dev/null +++ b/src/app/api/internal/revalidate-aggregate/route.ts @@ -0,0 +1,41 @@ +import { timingSafeEqual } from "node:crypto"; +import { revalidateTag } from "next/cache"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Worker-driven CDN cache purge for the aggregate bench snapshot. + * + * The materialize worker POSTs here after successfully uploading a fresh + * `bench-aggregate/latest.json` to Vercel Blob. This route invalidates + * the `bench-aggregate` cache tag so the next site request re-fetches + * the Blob URL, instead of waiting the ~60 s Blob overwrite propagation + * window or the ISR revalidate boundary. + * + * Auth: bearer token in `Authorization` header, `REVALIDATE_TOKEN` env, + * timing-safe compare. Fails closed when the env is missing. + */ +export async function POST(req: Request): Promise { + const secret = (process.env.REVALIDATE_TOKEN ?? "").trim(); + if (!secret) { + return NextResponse.json( + { error: "no_secret_configured" }, + { status: 503 }, + ); + } + const header = (req.headers.get("authorization") ?? "").trim(); + const expected = `Bearer ${secret}`; + const ok = + header.length === expected.length && + timingSafeEqual(Buffer.from(header), Buffer.from(expected)); + if (!ok) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + // Next 16's revalidateTag takes a required cache-profile argument. + // "default" applies the standard purge semantics for the CDN + ISR + // cache layers this route is meant to invalidate. + revalidateTag("bench-aggregate", "default"); + return NextResponse.json({ revalidated: true, tag: "bench-aggregate" }); +} diff --git a/worker/index.ts b/worker/index.ts index ddac2b5f..6311c311 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -58,6 +58,7 @@ import { CHAINS } from "@/lib/chains"; import { buildFeaturedLeadersFromStore } from "@/lib/search-featured"; import type { Benchmark, MetricPanel } from "@/types/benchmark"; import type { Spec } from "@/lib/spec-schema"; +import { publishAggregateBlob } from "./publish-blob"; const SWEEP_SEC = Number(process.env.SWEEP_SEC ?? 60); const VARIANT_EVERY = Number(process.env.VARIANT_EVERY ?? 5); @@ -352,6 +353,25 @@ async function sweep(iteration: number): Promise { (e) => noteHeartbeat(false, e), ); + // Broadcast the fresh aggregate to Vercel Blob so the site can read + // it from the CDN edge and stop fanning out ~150 concurrent Redis + // GETs per homepage render. See worker/publish-blob.ts for the full + // rationale. No-op when BLOB_READ_WRITE_TOKEN is unset (staged rollout). + if (process.env.BLOB_READ_WRITE_TOKEN) { + const blobStart = Date.now(); + const result = await publishAggregateBlob(specs); + const elapsedSec = ((Date.now() - blobStart) / 1000).toFixed(1); + if (result.ok) { + console.log( + `[worker] blob published in ${elapsedSec}s (${result.bytes} bytes, ${result.liveCount}/${result.total} live, revalidated=${result.revalidated})`, + ); + } else { + console.warn( + `[worker] blob publish failed in ${elapsedSec}s: ${result.error}`, + ); + } + } + // Cohort snapshots used by the hub pages and the search dialog. Each // builder hits Prom directly (via the in-network http://ocb-prom:9090 // URL), so they don't add load on the public reverse proxy. Failures diff --git a/worker/publish-blob.ts b/worker/publish-blob.ts new file mode 100644 index 00000000..936747b9 --- /dev/null +++ b/worker/publish-blob.ts @@ -0,0 +1,145 @@ +/** + * Aggregate-snapshot broadcaster. + * + * After each Tier A sweep the worker calls `publishAggregateBlob` to: + * 1. Read back every unfiltered bench snapshot it just wrote to Redis. + * 2. Materialize a lightweight envelope `{ v, builtAt, benches[] }`. + * 3. `put()` the JSON to a stable public URL on Vercel Blob so the site + * can fetch it CDN-cached, no per-bench Redis fan-out at read time. + * 4. POST the site's revalidate hook so the CDN cache tag purges + * immediately — no waiting on the ~60 s Blob overwrite propagation. + * + * Fail-soft: any step (blob read miss, network flap, missing token) + * degrades to a warning log. The worker's happy path is untouched, the + * site keeps reading via its existing Redis path via SRH. Once this + * proves stable, the site switches its aggregate reads to the Blob URL. + */ + +import { put } from "@vercel/blob"; +import type { Benchmark } from "@/types/benchmark"; +import type { Spec } from "@/lib/spec-schema"; +import { draftPlaceholderForSpec } from "@/lib/materialize/load"; +import { readMaterialized } from "@/lib/materialize/store"; + +export type PublishResult = { + ok: boolean; + url?: string; + bytes?: number; + total?: number; + liveCount?: number; + draftCount?: number; + revalidated?: boolean; + error?: string; +}; + +const BLOB_PATH = "bench-aggregate/latest.json"; +const CACHE_MAX_AGE_SEC = 60; + +export async function publishAggregateBlob( + specs: Spec[], +): Promise { + const blobToken = process.env.BLOB_READ_WRITE_TOKEN; + if (!blobToken) { + return { ok: false, error: "BLOB_READ_WRITE_TOKEN not set" }; + } + + const benches: Benchmark[] = []; + let liveCount = 0; + let draftCount = 0; + + // Read back what the tier-A sweep just wrote. Worker reads Redis via + // its own TCP client (via OCB_REDIS_URL), never SRH — so this is + // insulated from the SRH stuck-pool pathology that motivates the + // whole broadcast refactor. Serial read is fine (worker sweep is not + // latency-sensitive; user reads are what we're protecting). + for (const spec of specs) { + let bench: Benchmark | null = null; + try { + const snap = await readMaterialized(spec.slug, ""); + bench = snap?.bench ?? null; + } catch (err) { + console.warn( + `[blob-publish] read ${spec.slug} failed: ${err instanceof Error ? err.message : err}`, + ); + } + if (bench) { + benches.push(bench); + if (bench.status === "live") liveCount += 1; + else draftCount += 1; + } else { + benches.push(draftPlaceholderForSpec(spec)); + draftCount += 1; + } + } + + benches.sort((a, b) => (a.number ?? "").localeCompare(b.number ?? "")); + + const envelope = { + v: 1, + builtAt: Date.now(), + total: benches.length, + liveCount, + draftCount, + benches, + }; + const body = JSON.stringify(envelope); + const bytes = Buffer.byteLength(body); + + let url: string | undefined; + try { + const uploaded = await put(BLOB_PATH, body, { + access: "public", + contentType: "application/json", + cacheControlMaxAge: CACHE_MAX_AGE_SEC, + addRandomSuffix: false, + allowOverwrite: true, + token: blobToken, + }); + url = uploaded.url; + } catch (err) { + return { + ok: false, + error: `blob put failed: ${err instanceof Error ? err.message : String(err)}`, + bytes, + total: benches.length, + liveCount, + draftCount, + }; + } + + // Best-effort site notify. If the site is unreachable or rejects the + // hook, the Blob has still been overwritten and will propagate through + // the CDN within ~60 s. The hook only shortens that window. + let revalidated = false; + const siteUrl = (process.env.SITE_URL ?? "").replace(/\/+$/, ""); + const revalidateToken = process.env.REVALIDATE_TOKEN; + if (siteUrl && revalidateToken) { + try { + const res = await fetch(`${siteUrl}/api/internal/revalidate-aggregate`, { + method: "POST", + headers: { Authorization: `Bearer ${revalidateToken}` }, + signal: AbortSignal.timeout(5_000), + }); + revalidated = res.ok; + if (!res.ok) { + console.warn( + `[blob-publish] revalidate hook returned ${res.status} ${res.statusText}`, + ); + } + } catch (err) { + console.warn( + `[blob-publish] revalidate hook failed: ${err instanceof Error ? err.message : err}`, + ); + } + } + + return { + ok: true, + url, + bytes, + total: benches.length, + liveCount, + draftCount, + revalidated, + }; +}