Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions src/app/api/series/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { NextResponse } from "next/server";
import { hasSeriesHistory, loadSeriesHistory } from "@/lib/series-history";
import { unstable_cache } from "next/cache";
import { getBenchmark } from "@/data/benchmarks";
import { filterSig, loadSpecsUncached, specToBenchmark } from "@/lib/materialize/load";
import {
dimensionLabels,
filterSig,
loadSpecsUncached,
panelMetricQuery,
specToBenchmark,
} from "@/lib/materialize/load";
import { readMaterialized } from "@/lib/materialize/store";
import { loadSnapshotFromBlob } from "@/lib/bench-blob";
import { buildProviderColors } from "@/lib/series-colors";
Expand Down Expand Up @@ -95,13 +101,17 @@ const getSeriesMapCached = unstable_cache(
const windowSec = range === "7d" ? 7 * 86_400 : 30 * 86_400;
const numPoints = range === "7d" ? 84 : 60;
const labelKey = panel.label_key ?? "builder";
// The spec here is the raw one off disk, never through
// applyDimensionsToSpec, so the reader's chain/region have to be
// injected explicitly. Omitting them pooled every chain into one
// line while the head-lag view beside it moved per chain.
const dims = dimensionLabels({ chain, region, kind, venue, tier });
const scoped = panelMetricQuery(panel, dims);
const result: Record<string, (number | null)[]> = {};
await Promise.all(
spec.providers.map(async (p) => {
const sel = `${labelKey}="${escapePromLabelValue(p.slug)}"`;
const q = panel.metric.includes("{")
? panel.metric.replace("{", `{${sel},`)
: `${panel.metric}{${sel}}`;
const q = scoped.replace("{", `{${sel},`);
const s = await prom.series(q, windowSec, numPoints);
if (s && s.length > 0) result[p.slug] = s;
}),
Expand Down Expand Up @@ -143,9 +153,9 @@ const getSeriesMapCached = unstable_cache(
if (!panel) return;
const labelKey = panel.label_key ?? "builder";
const sel = `${labelKey}="${escapePromLabelValue(p.slug)}"`;
q = panel.metric.includes("{")
? panel.metric.replace("{", `{${sel},`)
: `${panel.metric}{${sel}}`;
// Same raw spec, same reason as the 7d/30d path above.
const dims = dimensionLabels({ chain, region, kind, venue, tier });
q = panelMetricQuery(panel, dims).replace("{", `{${sel},`);
} else {
q = p.queries?.series;
}
Expand All @@ -158,7 +168,9 @@ const getSeriesMapCached = unstable_cache(
return Object.keys(result).length > 0 ? result : null;
},
// v8: denser 90d/1y resolution (6h / 18h step) so young benches render at full fidelity.
["series-by-range-v9"],
// v10: panel series carry the reader's chain/region. Entries written
// before this hold one cross-chain average under a per-chain key.
["series-by-range-v10"],
{ revalidate: 300, tags: ["benchmarks"] },
);

Expand Down
57 changes: 36 additions & 21 deletions src/components/time-series-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
RANGE_LABEL,
REGION_LABEL,
pickSeries,
panelSeriesCacheKey,
mean,
type Range,
type LineWithColor,
Expand Down Expand Up @@ -186,14 +187,28 @@ export function TimeSeriesChart({
const [lazySeries1y, setLazySeries1y] = useState<Record<string, (number | null)[]> | null>(null);
// Declared early so effects below can guard on it without TDZ issues.
const panelActive = !!seriesOverride;
// Per-panel lazy series, keyed by panel id. Cached across a panel
// switch so flipping back to a previously-viewed panel is instant.
// Per-panel lazy series, keyed by panel id AND by the filters the series
// was fetched under. Cached across a panel switch so flipping back to a
// previously-viewed panel is instant.
// 7d/30d: fetched on panel activation. 90d/1y: fetched on-demand when
// the user clicks those tabs (Prom-backed, not archive-backed).
//
// The filters have to be in the key. Keyed on the panel alone, the first
// chain a reader opened was served back on every other chain tab: the
// effects below DO re-run on a chain change (chainProp is in their deps)
// and then return early on their own cache hit, so the fetch never
// happened. 1H/6H/24H never showed it because those render from the
// embedded per-filter series, which is why the bug presented as "only 7D
// and 30D are wrong". Lucas reported it on bench 001, where the First to
// report shares are a per-chain race, so one chain's numbers appearing on
// four tabs named the wrong leader on three of them.
const [lazyPanel7d, setLazyPanel7d] = useState<Record<string, Record<string, (number | null)[]>>>({});
const [lazyPanel30d, setLazyPanel30d] = useState<Record<string, Record<string, (number | null)[]>>>({});
const [lazyPanel90d, setLazyPanel90d] = useState<Record<string, Record<string, (number | null)[]>>>({});
const [lazyPanel1y, setLazyPanel1y] = useState<Record<string, Record<string, (number | null)[]>>>({});
const panelCacheKey = activePanelId
? panelSeriesCacheKey(activePanelId, chainProp, regionProp, tierProp)
: null;

// Pre-fetch 7d AND 30d in the background as soon as the chart mounts,
// not just when the user clicks the tab. The fetches are non-blocking
Expand Down Expand Up @@ -318,14 +333,14 @@ export function TimeSeriesChart({
// case since slimBenchmarkForCache drops them). Cache-keyed per panel so
// switching between panels re-uses previously loaded data instantly.
useEffect(() => {
if (!activePanelId) return;
if (!activePanelId || !panelCacheKey) return;
// Inline overrides win — skip the fetch when the parent already
// supplied panel long-range series (e.g. an unshimmed test caller).
if (seriesOverride7d && seriesOverride30d) return;
if (lazyPanel7d[activePanelId] && lazyPanel30d[activePanelId]) return;
if (lazyPanel7d[panelCacheKey] && lazyPanel30d[panelCacheKey]) return;
let cancelled = false;
const need7d = !seriesOverride7d && !lazyPanel7d[activePanelId];
const need30d = !seriesOverride30d && !lazyPanel30d[activePanelId];
const need7d = !seriesOverride7d && !lazyPanel7d[panelCacheKey];
const need30d = !seriesOverride30d && !lazyPanel30d[panelCacheKey];
const done: Record<"7d" | "30d", boolean> = {
"7d": !need7d,
"30d": !need30d,
Expand All @@ -349,9 +364,9 @@ export function TimeSeriesChart({
const map: Record<string, (number | null)[]> = {};
for (const p of data.providers) map[p.slug] = p.values;
if (range === "7d") {
setLazyPanel7d((prev) => ({ ...prev, [activePanelId]: map }));
setLazyPanel7d((prev) => ({ ...prev, [panelCacheKey]: map }));
} else {
setLazyPanel30d((prev) => ({ ...prev, [activePanelId]: map }));
setLazyPanel30d((prev) => ({ ...prev, [panelCacheKey]: map }));
}
})
.catch(() => {
Expand All @@ -361,9 +376,9 @@ export function TimeSeriesChart({
} else {
done[range] = true;
if (range === "7d") {
setLazyPanel7d((prev) => ({ ...prev, [activePanelId]: {} }));
setLazyPanel7d((prev) => ({ ...prev, [panelCacheKey]: {} }));
} else {
setLazyPanel30d((prev) => ({ ...prev, [activePanelId]: {} }));
setLazyPanel30d((prev) => ({ ...prev, [panelCacheKey]: {} }));
}
}
});
Expand All @@ -384,9 +399,9 @@ export function TimeSeriesChart({
// Triggered the first time a panel becomes active — we prefetch both
// ranges so the user can switch between them without extra round-trips.
useEffect(() => {
if (!activePanelId) return;
const need90d = !lazyPanel90d[activePanelId];
const need1y = !lazyPanel1y[activePanelId];
if (!activePanelId || !panelCacheKey) return;
const need90d = !lazyPanel90d[panelCacheKey];
const need1y = !lazyPanel1y[panelCacheKey];
if (!need90d && !need1y) return;
let cancelled = false;
const buildQs = (r: "90d" | "1y") => {
Expand All @@ -406,13 +421,13 @@ export function TimeSeriesChart({
if (cancelled) return;
const map: Record<string, (number | null)[]> = {};
for (const p of data.providers) map[p.slug] = p.values;
if (r === "90d") setLazyPanel90d((prev) => ({ ...prev, [activePanelId]: map }));
else setLazyPanel1y((prev) => ({ ...prev, [activePanelId]: map }));
if (r === "90d") setLazyPanel90d((prev) => ({ ...prev, [panelCacheKey]: map }));
else setLazyPanel1y((prev) => ({ ...prev, [panelCacheKey]: map }));
})
.catch(() => {
if (cancelled) return;
if (r === "90d") setLazyPanel90d((prev) => ({ ...prev, [activePanelId]: {} }));
else setLazyPanel1y((prev) => ({ ...prev, [activePanelId]: {} }));
if (r === "90d") setLazyPanel90d((prev) => ({ ...prev, [panelCacheKey]: {} }));
else setLazyPanel1y((prev) => ({ ...prev, [panelCacheKey]: {} }));
});
};
if (need90d) fetchOne("90d");
Expand All @@ -421,10 +436,10 @@ export function TimeSeriesChart({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePanelId, regionProp, chainProp, tierProp, benchmark.slug]);

const panelLazy7d = activePanelId ? lazyPanel7d[activePanelId] : undefined;
const panelLazy30d = activePanelId ? lazyPanel30d[activePanelId] : undefined;
const panelLazy90d = activePanelId ? lazyPanel90d[activePanelId] : undefined;
const panelLazy1y = activePanelId ? lazyPanel1y[activePanelId] : undefined;
const panelLazy7d = panelCacheKey ? lazyPanel7d[panelCacheKey] : undefined;
const panelLazy30d = panelCacheKey ? lazyPanel30d[panelCacheKey] : undefined;
const panelLazy90d = panelCacheKey ? lazyPanel90d[panelCacheKey] : undefined;
const panelLazy1y = panelCacheKey ? lazyPanel1y[panelCacheKey] : undefined;

// null = still loading → show optimistically; {} = confirmed empty → hide
const seriesHasData = (m: Record<string, (number | null)[]> | null) =>
Expand Down
65 changes: 65 additions & 0 deletions src/components/time-series-chart/panel-cache-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { panelSeriesCacheKey } from "@/components/time-series-chart/scales";

/**
* The chart caches a panel's lazily fetched 7d/30d/90d/1y series, and the key
* decides whether switching a tab refetches.
*
* Keyed on the panel id alone, it did not. The effects re-ran on a chain
* change, hit their own "already cached?" guard, and returned before
* fetching, so the first chain a reader opened stayed on screen for every
* other chain. On bench 001 that put one chain's First to report shares on
* four tabs and named the wrong leader on three of them (Base is Serialized
* at 74.7 %, BNB is Mobula at 64.8 %, Robinhood is Serialized at 93.3 %;
* only Solana was Codex). 1H/6H/24H were always right because they render
* from the embedded per-filter series, which is exactly why the report was
* "only 7D and 30D".
*
* So the property under test is simply: two different filter sets must never
* share a key.
*/
describe("panelSeriesCacheKey", () => {
test("a chain change produces a different key", () => {
expect(panelSeriesCacheKey("first_share", "base")).not.toBe(
panelSeriesCacheKey("first_share", "bnb"),
);
});

test("a region change produces a different key", () => {
expect(panelSeriesCacheKey("first_share", "base", "eu-west")).not.toBe(
panelSeriesCacheKey("first_share", "base", "sgp"),
);
});

test("a tier change produces a different key", () => {
expect(panelSeriesCacheKey("p50", "ethereum", "eu-west", "keyed")).not.toBe(
panelSeriesCacheKey("p50", "ethereum", "eu-west", "public"),
);
});

test("a panel change still produces a different key", () => {
expect(panelSeriesCacheKey("first_share", "base")).not.toBe(
panelSeriesCacheKey("head_lag", "base"),
);
});

test("the same filters produce the same key, so the cache still works", () => {
expect(panelSeriesCacheKey("first_share", "base", "eu-west", "public")).toBe(
panelSeriesCacheKey("first_share", "base", "eu-west", "public"),
);
});

test("an absent filter is distinct from a present one", () => {
// The unfiltered view and a chain tab must not share an entry either.
expect(panelSeriesCacheKey("first_share")).not.toBe(
panelSeriesCacheKey("first_share", "base"),
);
});

test("filters cannot collide across positions", () => {
// A naive concatenation would make (chain=a, region=b) equal to
// (chain=ab) and serve one tab's series on another.
expect(panelSeriesCacheKey("p", "a", "b")).not.toBe(panelSeriesCacheKey("p", "ab"));
expect(panelSeriesCacheKey("p", "", "x")).not.toBe(panelSeriesCacheKey("p", "x", ""));
});
});
24 changes: 24 additions & 0 deletions src/components/time-series-chart/scales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,27 @@ export function fmtTick(v: number, unit: string) {
if (v >= 1000) return `${(v / 1000).toFixed(1)}s`;
return `${Math.round(v)}ms`;
}

/** Cache key for a metric panel's lazily fetched long-range series.
*
* The filters belong in the key, not just the panel id. Keyed on the panel
* alone, the chart's own cache-hit guard returned before fetching whenever a
* reader switched chain, so the first chain opened was served back on every
* other tab. The effects DO re-run on a chain change; it is the early return
* that swallowed it. 1H/6H/24H never showed the fault because those render
* from the embedded per-filter series, which is why it presented as "only 7D
* and 30D are wrong". Found on bench 001 on 2026-10-29, where the First to
* report shares are a per-chain race, so one chain's numbers on four tabs
* named the wrong leader on three of them.
*
* The same class of bug was fixed once for the main series (see the `chain`
* prop docs in index.tsx) and left standing for panels.
*/
export function panelSeriesCacheKey(
panelId: string,
chain?: string,
region?: string,
tier?: string,
): string {
return `${panelId}|${chain ?? ""}|${region ?? ""}|${tier ?? ""}`;
}
69 changes: 50 additions & 19 deletions src/lib/materialize/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,25 +1011,10 @@ function applyDimensionsToSpec(spec: Spec, labels: Record<string, string>): Spec
// braced form so dimension labels (chain=..., region=...) reach them
// like every provider query. Without this a panel on a chain-dimensioned
// bench silently mixes every chain's series.
metric_panels: spec.metric_panels?.map((panel) => {
// A panel may declare the dimensions its metric does not carry.
// Appending one of those matches nothing and empties the panel in
// silence, which is how bench 282 shipped a "Billing units per
// request" chart that was blank on 71 of 75 views.
const ignored = new Set(panel.ignore_dimensions ?? []);
const panelLabels = ignored.size
? Object.fromEntries(
Object.entries(labels).filter(([k]) => !ignored.has(k)),
)
: labels;
return {
...panel,
metric: injectLabels(
panel.metric.includes("{") ? panel.metric : `${panel.metric}{}`,
panelLabels,
),
};
}),
metric_panels: spec.metric_panels?.map((panel) => ({
...panel,
metric: panelMetricQuery(panel, labels),
})),
providers: spec.providers.map((p) => ({
...p,
queries: p.queries
Expand Down Expand Up @@ -1059,6 +1044,52 @@ function applyDimensionsToSpec(spec: Spec, labels: Record<string, string>): Spec
};
}

/** The dimension labels a reader's filters select, ready for injectLabels.
*
* `tier` is excluded because it picks the cohort rather than narrowing a
* series, and an `all` value is not a filter. Exported so a caller holding
* loose filter strings can build the same label set the loader uses.
*/
export function dimensionLabels(f: BenchmarkFilters): Record<string, string> {
return activeFilterLabels(labelFilters(f));
}

/** The PromQL a metric panel runs under the reader's active filters.
*
* A panel declares a bare metric name or `metric{sel}`; normalise to the
* braced form so the dimension labels reach it like every provider query.
* Without this, a panel on a chain-dimensioned bench silently pools every
* chain into one line.
*
* Exported because TWO paths need this exact string and had drifted. The
* loader builds panel values through applyDimensionsToSpec, but the 7d and
* 30d panel SERIES are stripped from both the cache form and the snapshot on
* purpose (slimBenchmarkForCache, toSnapshot) and refetched by
* /api/series/[slug]?panel=..., which rebuilds them from the raw spec. That
* route injected only the provider selector, so from the day panels got a
* chart, every chain tab of bench 001's "First to report" view published the
* same cross-chain average, while the head-lag view beside it moved per chain
* because it goes through specToBenchmark. Lucas spotted it on 2026-10-29.
*
* A panel may also declare dimensions its metric does not carry: appending one
* matches nothing and empties the panel in silence, which is how bench 282
* shipped a "Billing units per request" chart blank on 71 of 75 views. Hence
* ignore_dimensions, honoured here so both callers honour it.
*/
export function panelMetricQuery(
panel: { metric: string; ignore_dimensions?: string[] },
labels: Record<string, string>,
): string {
const ignored = new Set(panel.ignore_dimensions ?? []);
const panelLabels = ignored.size
? Object.fromEntries(Object.entries(labels).filter(([k]) => !ignored.has(k)))
: labels;
return injectLabels(
panel.metric.includes("{") ? panel.metric : `${panel.metric}{}`,
panelLabels,
);
}

export function injectLabels(query: string, labels: Record<string, string>): string {
return query.replace(/\{([^}]*)\}/g, (_, inside: string) => {
const additions: string[] = [];
Expand Down
Loading
Loading