Repository navigation
console: Add a Freshness page under Monitoring - #39340
Conversation
A multi-line time series with a threshold the reader drags. Lines above it are colored against the rest in grey, so moving the handle re-sorts the picture into "over" and "under". Colors are generated to fit the highlighted set, so they neither run out nor repeat. The y axis scales from the data alone, so lines hold still as the threshold moves. Part of: CNS-164 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A line's color now comes from its breachValue rank alone, so neither moving the threshold nor changing how many lines there are recolors anything. Hues come from a bit-reversal sequence: the first four are 90 degrees apart, narrowing as it grows. It is ordered furthest-apart-first by construction, so it replaces the golden-ratio stride that reordered an evenly spaced palette after the fact. Part of: CNS-164 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Nothing in the graph reads the label, so requiring one made every caller supply a string that went unused. Part of: CNS-164 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A line's color is its breachValue rank, not its identity, so it holds only while that ordering does. The docs claimed more: that adding or removing lines never recolors the others. Stability against the threshold is real and is what the graph needs, since the threshold is what a drag varies continuously. Part of: CNS-164 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…jdonelson/CNS-164_freshness-page
…shness-page # Conflicts: # console/src/components/ThresholdLineGraph/ThresholdLineGraph.stories.tsx # console/src/components/ThresholdLineGraph/ThresholdLineGraph.tsx # console/src/components/ThresholdLineGraph/thresholdLineGraphHelpers.test.ts # console/src/components/ThresholdLineGraph/thresholdLineGraphHelpers.ts # console/src/components/ThresholdLineGraph/types.ts
Shows how far behind wall clock time each object on a cluster is, judged against a threshold the user drags on the graph. Objects exceeding it get their own table above the full list. Threshold, window, statistic, object-type filter and cluster all live in the URL, so a view is a link. The drag drives the graph live but settles before rebuilding the tables. The statistic offered is p90 rather than p99. The query returns 60 readings at every window, so p99 would land on the maximum and the two predicates would select identically. Freshness values on Environment Overview now link here with the cluster preselected. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Gates the route, the nav item, and the Environment Overview link on `freshness-page-CNS164`. The link falls back to plain text when the flag is off, so the table never points at a route that is not mounted. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
QA LLM Review1. MEDIUM -- Not-queryable objects score as 0 lag and never exceed the threshold
The page computes current/peak/p90 from the graph's DetailsIn The tests miss this because they model a non-reporting object as all- Suggested fix: compute stats from each 2. LOW -- URL parsers accept prototype keys such as
|
leedqin
left a comment
There was a problem hiding this comment.
To check the query changes locally, follow Phase 1 of .agents/skills/mz-query-perf/SKILL.md:
docker run -d -p 6875:6875 materialize/materialized:unstable
psql postgres://materialize@localhost:6875/materialize
SET search_path = mz_catalog, mz_internal, mz_catalog_unstable, mz_introspection;
SET cluster = mz_catalog_server;
SET transaction_isolation = serializable;
EXPLAIN OPTIMIZED PLAN AS TEXT FOR <builder .compile() output, params inlined>;
The Used Indexes footer should list only mz_wallclock_global_lag_recent_history_ind as (lookup) or (differential join), with no *** full scan *** on mz_objects, mz_schemas or mz_databases. Create a cluster with a few materialized views first so lag history has rows.
| // Aggregated before the join on purpose. `mz_hydration_statuses` has a | ||
| // row per (object, replica), so joining it directly would multiply every | ||
| // bucket of the time series by the replica count. | ||
| .leftJoin( |
There was a problem hiding this comment.
Since buildLagHistoryQuery is shared with Environment Overview, Cluster Overview and the object panel's 60s poll, this adds a full scan and reduce of mz_hydration_statuses to all of them. Could we move it to a page-only query scoped by the same object IDs?
SELECT object_id, count(*) AS total_replicas,
count(*) FILTER (WHERE hydrated) AS hydrated_replicas
FROM mz_internal.mz_hydration_statuses
JOIN unnest(ARRAY['u1', 'u2']::text[]) AS ids(id) ON ids.id = object_id
GROUP BY object_idThere was a problem hiding this comment.
Moved it to a page-only query in d69a1ea — you're right that three callers were paying for a column none of them read.
One correction though: scoping by object ID doesn't make this read cheap. mz_hydration_statuses_ind is keyed on (object_id, replica_id), and a lookup needs every key column, so constraining object_id alone still plans as a full scan. Measured on v26.45.0-dev with both forms:
| IDs | literal IN |
unnest join |
|---|---|---|
| 2 | full scan | full scan |
| 1000 | full scan | full scan |
So the win here is narrower than the suggestion implies: the scan still happens, it just happens once, for the one page that shows hydration, instead of on Environment Overview, Cluster Overview and the object panel's 60s poll as well. Kept the IN list since unnest bought nothing and the rest of the Console already reads that way.
There was a problem hiding this comment.
Agreed both plan as a full scan, but they don't cost the same: with the GROUP BY, 2000 IDs took 517 ms with IN vs 57 ms with unnest (80 IDs: 13.7 vs 6.8), I think because the literal list is checked per row.
There was a problem hiding this comment.
Thanks for timing it. Since you filed CNS-186 for the unnest switch, I've left it out of this PR.
There was a problem hiding this comment.
Could we take the object IDs, names and types from useAllObjects() filtered by clusterId, with system and subsource/progress objects filtered client-side (isSystemId, sourceType), and pass only the IDs to the lag query? That drops the mz_objects, mz_clusters and names joins, and unnest keeps big clusters on the index, where a literal IN list stops using it past ~300 IDs:
SELECT occurred_at, lag, object_id
FROM mz_internal.mz_wallclock_global_lag_recent_history
JOIN unnest(ARRAY['u1', 'u2']::text[]) AS ids(id) ON ids.id = object_id
WHERE occurred_at + INTERVAL '3600000 MILLISECONDS' >= mz_now()I think this would make the cluster freshness query much faster. It also cuts each row from 11 columns to 3.
There was a problem hiding this comment.
Done, but with a literal IN list rather than unnest. Measured on v26.45.0-dev (Docker, mz_catalog_server):
| IDs | literal IN |
unnest join |
IN (SELECT ...) (what we had) |
|---|---|---|---|
| ≤ 330 | lookup |
differential join | differential join |
| ≥ 331 | full scan | differential join | differential join |
Your ~300 was close: the cliff is between 330 and 331. But unnest never reaches a lookup at any size, so switching to it would have given up the win for normal-sized clusters.
Dropping the name joins is the bigger part regardless — five indexes and three full scans down to one lookup, and eleven columns to three.
Past 330 objects we do lose the lookup and read the whole index. No worse than today, but not fixed. I considered picking the form by ID count and decided against putting that threshold in Console code, since it describes optimizer behaviour at one version and would fail silently if it moved. Happy to revisit if you know it to be stable.
There was a problem hiding this comment.
Thanks for measuring. In my run unnest was as fast or faster than the IN lookup at every size (80 IDs: 9.8 vs 11.7 ms, 300: 14.9 vs 31.6, 2000: 62 vs 718), on a small emulator history. Could you time both on your bench? If it holds, unnest removes the 330 cliff without a threshold in code.
There was a problem hiding this comment.
I didn't get to timing both on my bench. CNS-186 covers it, so I've left this PR on the IN list.
| export type Predicate = "current" | "peak" | "p90"; | ||
|
|
||
| export const PREDICATE_LABELS: Record<Predicate, string> = { | ||
| current: "right now", |
There was a problem hiding this comment.
At 24h each point is a 24-minute max, so "right now" is the worst lag in the last bin (2s vs a latest reading of 1s locally). Could we take Now from the latest reading instead? At 1h the last point already is one; above that a small query covers it:
SELECT DISTINCT ON (object_id) object_id, occurred_at, lag
FROM mz_internal.mz_wallclock_global_lag_recent_history
JOIN unnest(ARRAY['u1', 'u2']::text[]) AS ids(id) ON ids.id = object_id
WHERE occurred_at + INTERVAL '2 MINUTES' >= mz_now()
ORDER BY object_id, occurred_at DESC
There was a problem hiding this comment.
Fixed in 852be00. Confirmed your reading: at 24h the last bin is the worst reading of 24 minutes, so an object that spiked twenty minutes ago and recovered still read high, and under the "right now" predicate it was counted as exceeding and coloured on the graph.
Took the second query as you suggested, batched into the same request via executeSqlV2's array form, so no extra round trip. The two minute filter keeps it to one row per object whatever range is being graphed: 100ms on top of a 499ms request, measured on a 300 object fleet.
The graph still plots bin maxima, since that is all 60 points can show, so a point can now sit above its row's "Now" value. The footer says so rather than leaving a reader to notice it.
| * NOTE: p99 would not survive that. ceil(0.99 * 60) is the 60th, so over these | ||
| * readings p99 *is* the peak and the two predicates would select identically. | ||
| * Each reading is also already a maximum over its bin, so these are | ||
| * percentiles of maxima rather than of the underlying lag. | ||
| */ |
There was a problem hiding this comment.
Maybe compute the percentiles from the unbinned readings instead of the 60 bins, so p99 stops collapsing onto the peak from 3h up? Materialize has no percentile_cont, so this would be client-side, with the trade that 24h is 1440 readings per object.
There was a problem hiding this comment.
Measured this before deciding, since it would have retired the p90 compromise. Bench cluster, shadow table with the production index, 300 objects x 1440 readings (a 24h range on a cluster larger than 99.7% of production), 10 runs, first dropped:
| Query | p50 |
|---|---|
| Binned series, 300 objects (what ships today) | 499 ms |
| peak + p90 + current from unbinned readings, 300 objects | 2,594 ms |
peak only, plain max, 300 objects |
549 ms |
latest reading only, DISTINCT ON, 300 objects |
100 ms |
| p90 from unbinned readings, 50 objects | 177 ms |
p90 is the whole cost. Nearest rank needs row_number() ordered within each object's partition, so it sorts 432,000 rows in 300 groups. Everything else is cheap.
So the honest version of your suggestion is 5x slower than the entire current query, and it also adds a second dataflow on mz_catalog_server rather than replacing one. Declining it for this PR on those grounds.
Took 852be00 instead: a second query for each object's latest reading, batched into the same request, which fixes "Now" for 100ms. p90 stays a percentile of the 60 bin maxima and the footer says so.
Worth noting for later: that last row is the interesting one. Scoped to 50 objects the same p90 costs 177ms, because it sorts 72,000 rows instead of 432,000. If the graph grows a line cap, ranking by max(lag) server-side (549ms) and fetching unbinned readings only for the top N would give honest percentiles and cut the payload, since the fetch would be bounded by the cap rather than by cluster size. That is a bigger change than this PR should carry, but it is the shape I would build toward.
Separately, re your other comment: a raw read plans as Explained Query (fast path) and builds no dataflow, while our date_bin + DISTINCT ON does. Client-side binning would move that work off the shared cluster. Confirmed up to 1000 IDs.
There was a problem hiding this comment.
Makes sense for this PR, thanks for measuring. For the record, I meant the client-side version: a plain raw read (fast path, no dataflow, as you confirmed below) with the sort done in the browser. I sized it for 300 objects in Node:
| Range | Readings per object | Rows | Payload (uncompressed) | Parse + sort + rank | p99 lands on |
|---|---|---|---|---|---|
| 1h | 60 | 18k | 0.8 MB | ~4 ms | the peak |
| 3h | 180 | 54k | 2.5 MB | ~10 ms | the 2nd largest |
| 6h | 360 | 108k | 5.1 MB | ~20 ms | the 4th largest |
| 24h | 1440 | 432k | 20 MB | ~95 ms | the 15th largest |
Today's binned response is 18k rows (0.8 MB) at every range. So the browser work is small and the payload is what grows: fine up to about 6h, too heavy at 24h, which is where your top-N idea fits. Could we capture it in a follow-up issue, since it's what gets us to p99?
| } | ||
| > | ||
| <FreshnessContent | ||
| key={selected.id} |
There was a problem hiding this comment.
Could we move key={selected.id} (plus the window) onto the AppErrorBoundary? Once a query fails, switching cluster or window keeps showing the error until a reload.
There was a problem hiding this comment.
Fixed in 429b785. The key is now cluster:range on the AppErrorBoundary itself.
Worth stating why the old placement could never have worked: a boundary showing its fallback is not rendering its children, so re-keying a child it has stopped rendering has no effect. The key has to be on the thing holding the error state.
One deliberate consequence: hand-picked rows now clear on a range change too, not just a cluster change, since the boundary remounts its subtree. Accepted that, on the grounds that trying a narrower range is exactly when a stuck error needs clearing. Lifting selectedKeys above the boundary would preserve them if you think it is worth the extra state.
| Freshness is wallclock lag: how far behind wall clock time an | ||
| object’s results are. Series from{" "} | ||
| <Text as="span" fontFamily="mono"> | ||
| mz_wallclock_global_lag_recent_history | ||
| </Text> | ||
| , hydration from{" "} | ||
| <Text as="span" fontFamily="mono"> | ||
| mz_hydration_statuses | ||
| </Text> | ||
| . Readings are binned to 60 points per range and each point is the | ||
| worst lag in its span, so p90 is a percentile of those maxima. |
There was a problem hiding this comment.
Nit: maybe drop the relation names from the page copy? mz_internal isn't part of the stable interface, so they can go stale, and the binning note changes if the percentiles move to unbinned readings.
There was a problem hiding this comment.
Dropped the relation names in 852be00, agreed on both counts.
The binning sentence stayed, since the percentiles did not move to unbinned readings in the end (measured it at 5x the cost of the whole current query, see the thread on freshnessRows.ts). It gained a sentence naming the "Now" column's disagreement with the graph, which the second query in that commit introduced.
| React.useEffect(() => { | ||
| setLive(committed); | ||
| }, [committed]); |
There was a problem hiding this comment.
Nit: we usually avoid an effect that copies a prop into state. Maybe keep only the in-flight drag value, with live = dragValue ?? committed, and clear it when the debounce commits?
There was a problem hiding this comment.
Done in ce789d2. It now holds only the in-flight drag value and returns dragValue ?? committed, so there is no effect and no render that shows the stale value first.
One detail worth flagging: the drag value is cleared inside the debounced callback, alongside the commit, so React batches both into a single render. Clearing it in a later render would briefly fall back to committed before that caught up, which reads as the handle springing back.
| onChange={(e) => { | ||
| const seconds = Number(e.target.value); | ||
| if (Number.isFinite(seconds) && seconds >= 0) { | ||
| onThresholdChange(seconds * 1000); | ||
| } | ||
| }} |
There was a problem hiding this comment.
Nit: clearing the field to type a new value sets the threshold to 0, so every object flips to exceeding until the new number is in. Maybe keep the text in local state and only commit a parsed, non-empty value?
There was a problem hiding this comment.
Fixed in 1cc4651, though not quite as suggested.
Committing a parsed, non-empty value still fires per keystroke, so clearing the field and typing 25 would commit 2 on the way. Debounced the whole thing instead: the field shows what is typed immediately, and commits 400ms after typing stops. Whatever has settled is then taken at face value, so a field left empty commits 0 rather than being ignored. Someone who clears it and stops has asked for no threshold, and the page reads fine in that state.
400ms is its own constant rather than reusing THRESHOLD_SETTLE_MS. 120ms paces a drag, where a pointer emits continuously and that much stillness means the reader has stopped. Typing has gaps that long between digits.
Tests assert the commit lands once with the final value, not three times on the way to it.
|
|
||
| /** A time period the selector actually offers, or null. */ | ||
| export function parseTimePeriod(raw: string | null): number | null { | ||
| if (raw === null || !(raw in TIME_PERIOD_OPTIONS)) return null; |
There was a problem hiding this comment.
+1 to the QA review here: in also matches inherited keys like constructor. Object.hasOwn would close it, here and in parsePredicate below.
| if (raw === null || !(raw in TIME_PERIOD_OPTIONS)) return null; | |
| if (raw === null || !Object.hasOwn(TIME_PERIOD_OPTIONS, raw)) return null; |
There was a problem hiding this comment.
Done in 8f44898, both parsers.
Confirmed the two failure modes you and the QA review described:
?timePeriod=constructorpassed theincheck,Numberof it is NaN, and that reached the query asINTERVAL 'NaN MILLISECONDS'.?exceeded=toStringpassed and fell throughstatForto p90, so the page judged by one statistic while the menu named another. That one is the worse of the two, since nothing on screen says so.
Added regression tests covering constructor, toString, valueOf and __proto__ against both parsers. parseObjectTypes tests membership of an array, so it was never affected.
There was a problem hiding this comment.
Optional, not blocking: at the default 1h window each bin is one reading, so the page could fetch the raw readings and bin them client-side at this group, like rebucketUtilizationSamples does for utilization. For clusters under ~300 objects, a plain read with the IDs inline runs as a fast-path peek, so the default view builds no dataflow on catalog_server.
There was a problem hiding this comment.
Confirmed the fast-path claim, and it is a sharper difference than it looks: a raw read plans as Explained Query (fast path) and builds no dataflow, while our date_bin + DISTINCT ON plans as Explained Query and does. That holds up to 1000 IDs. So client-side binning would move that work off mz_catalog_server entirely, not just make it cheaper.
Not taking it in this PR. The cost is on the other side: binned is 60 rows per object whatever the range, raw is one per minute per object, so at 24h a 300 object cluster goes from ~760KB to ~20MB at the 42 bytes/row I measured. Against production, 99.7% of clusters are at or under 330 objects and 92% at or under 60, so the common case is ~3MB, but the tail is not something I want to ship untested.
The version that resolves it is the one in the freshnessRows.ts thread: cap the graph's lines, rank server-side with max(lag) (549ms), and fetch unbinned readings only for the top N. Then the payload is bounded by the cap rather than by cluster size, the binning moves client-side as you suggest, and the percentiles become honest. That wants the line cap first, which this PR does not have.
The graph draws a NULL lag at 0 so the line sits at the bottom, and the statistics read through that same accessor. An object that could not be queried therefore scored 0 for current, peak and p90, cleared every threshold, and was reported as within target. computeStats now reads the readings directly, which separates a reading whose lag is NULL from no reading at all. Such an object judges as Infinity so it breaches at any threshold, and the table prints "Not queryable" instead of that number. The fixture built DataPoints with an empty lag map, so it could not produce the shape that causes this. It now builds readings the way the query does. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
buildLagHistoryQuery is shared with Environment Overview, Cluster Overview and the object panel's poll. None of them read a hydration column, but the join added a full scan and reduce of mz_hydration_statuses to all of them. The Freshness page now fetches hydration separately, keyed by the objects it is already showing. Scoping by object ID does not make that read a lookup: mz_hydration_statuses_ind is keyed on (object_id, replica_id), and a lookup needs every key column. Verified on v26.45.0-dev with an IN list and with an unnested array, at 2 and at 1000 IDs. The win is that only this page pays the scan. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The cluster freshness query joined mz_objects, mz_clusters and mz_object_fully_qualified_names to name its lag readings. That last one is a view over three more relations, so one request planned as three full scans and no lookup. The useAllObjects subscribe already holds every name, schema, database and type, so useClusterFreshness now takes the objects to report on and sends only their IDs. On v26.45.0-dev that goes from five indexes and three full scans to a single index lookup. A literal IN list is the only form that reaches a lookup. It degrades to a full scan past 330 IDs, where an unnested array would hold a differential join. Taking the common case rather than encoding that threshold in our code. buildLagHistoryQuery keeps its joins for Environment Overview and the object panel, which do read names from SQL. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
QA LLM Review1. HIGH -- New lag query returns newest bucket first, so every "current" value is the oldest reading
DetailsThe query it replaces ends with
Probe: I fed Fix: sort the result ascending. Materialize requires the leading 2. MEDIUM -- One unreadable reading anywhere in the window marks the object "Not queryable" right now
DetailsGlobal lag is Probe: readings A consistent approach is to score each unreadable bin as |
Readings are binned to 60 points per range, and each point is the worst reading in its span. "Now" read the last point, so at a 24 hour range it answered "the worst of the last 24 minutes" to the question "is this bad right now". An object that spiked twenty minutes ago and recovered still read high, and under the "right now" predicate it was counted as exceeding the threshold and coloured on the graph. A second query returns each object's newest reading, batched into the same request. Its two minute filter keeps it cheap whatever range is being graphed: measured on a 300 object, 24 hour fleet it adds 100ms to a 499ms request, against 2,594ms for computing every statistic from unbinned readings. The graph still plots bin maxima, since that is all 60 points can show, so a point can sit above its row's "Now" value. The footer says so, and drops the mz_internal relation names, which are not a stable interface. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
parseTimePeriod and parsePredicate used `raw in OPTIONS` as a whitelist, but `in` also answers true for inherited keys, and both read user-editable search params. ?timePeriod=constructor reached the query as INTERVAL 'NaN MILLISECONDS'. ?exceeded=toString fell through statFor to p90, so the page judged by one statistic while the menu named another, with nothing on screen saying so. Object.hasOwn asks only about a key the object has. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
useSettledThreshold debounces the expensive work so that only the handle follows the cursor. It had no effect: parseObjectTypes returned a new array each render, useMemo caches on reference equality, and so every memo behind it rebuilt anyway. objectTypes is now memoized on the raw param, which is a string. breaching and the empty message are memoized too, and the table is wrapped in React.memo, since one rebuilt prop would undo the others. useSettledThreshold now holds only the value of a drag in flight and derives the rest from the committed value, rather than copying a prop into state through an effect. A test asserts objectTypes keeps its identity across renders. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The key sat on FreshnessContent, inside the boundary. A boundary that has caught an error renders its fallback instead of its children, so re-keying a child it is no longer rendering does nothing, and a failed query survived every change of cluster and range until a reload. The key is now on the boundary itself. Hand-picked rows now also clear when the range changes, not just the cluster. Accepted: trying a narrower range is exactly when a stuck error needs clearing. Both freshness queries gain a 60s staleTime and refetchInterval. A reading lands once a minute, so a refetch inside that window re-asks a question whose answer cannot have changed, and an open page should not sit frozen at whatever it loaded with. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
| as={Link} | ||
| to={absoluteFreshnessPath(regionSlug, clusterId)} | ||
| title={`Freshness details for ${clusterName}`} | ||
| // The link text is a bare duration, which does not say |
There was a problem hiding this comment.
nit: comment is probably unnecessary
leedqin
left a comment
There was a problem hiding this comment.
Thanks for the thorough follow-ups, and for measuring before deciding. Most of my comments are addressed. The new ones are mostly on the latest commits: the settle debounce can strand the handle, the not-queryable flag reaches "right now", and the hydration request waits on the lag request. I also replied on the two IN vs unnest threads with my timings. Tests pass locally (91/91).
| const debouncedCommit = React.useMemo( | ||
| () => | ||
| debounce((value: number) => { | ||
| // Both updates come from the same callback, so React batches them into | ||
| // one render. Clearing the drag value in a later render would briefly | ||
| // show `committed` before it caught up, which reads as the handle | ||
| // springing back. | ||
| commit(value); | ||
| setDragValue(undefined); | ||
| }, THRESHOLD_SETTLE_MS), | ||
| [commit], |
There was a problem hiding this comment.
If another param changes inside the settle window, commit gets a new identity (react-router 7's setSearchParams depends on searchParams), so the debounce is rebuilt and the pending call is cancelled. Since setDragValue(undefined) lives in that call, the handle stays on the drag value, and after that a pasted link moves the URL but not the handle. Maybe hold commit in useLatestRef and build the debounce once? The "Stable identities" comment in useFreshnessParams would need updating too.
There was a problem hiding this comment.
Tried this locally: with commit in useLatestRef (like useAutomaticallyConnectSocket) and the debounce built once, a drag followed by a param change commits both, and a pasted link moves the handle. All seven useCallbacks in useFreshnessParams can go with it, and the existing tests still pass.
There was a problem hiding this comment.
Fixed in b09ad06: commit is held in a ref, with a regression test. The field and drag now share one timer, in useThresholdControl next to ThresholdLineGraph. The Stable identities comment is corrected. I left the useCallbacks in place.
There was a problem hiding this comment.
Correction: the useCallbacks are gone too, in 8f532c3.
| // `Infinity` exceeds every threshold and sorts ahead of every measured | ||
| // lag, which is what an unreadable object deserves. It never reaches the | ||
| // screen: `FreshnessRow.notQueryable` is what the table renders from. | ||
| breachValue: stats.notQueryable ? Infinity : statFor(stats, predicate), |
There was a problem hiding this comment.
notQueryable is set by any unreadable bin in the window, so an object that was unhydrated once 20 hours ago breaches under "right now" and shows "Not queryable" in Now. Could "right now" judge only the latest reading?
There was a problem hiding this comment.
Fixed in 61cd62e. Unreadable is now a value (Infinity), not a flag. It counts in the denominator, so seven bad readings of either kind breach p90. The graph breaks its line there.
| () => Array.from(objectsById.keys()), | ||
| [objectsById], | ||
| ); | ||
| const { data: hydrationByObjectId } = useFreshnessHydration(objectIds); |
There was a problem hiding this comment.
Since useClusterFreshness suspends first, the hydration request only starts after the lag request returns. Could hydration join the same executeSqlV2 batch as the lag and latest queries?
There was a problem hiding this comment.
Fixed in 003db7b. Hydration takes its IDs from objects.
| } | ||
|
|
||
| export const freshnessQueryKeys = { | ||
| all: () => ["freshness"] as const, |
There was a problem hiding this comment.
Could this start with buildRegionQueryKey("freshness")? Object IDs repeat across regions, so after a region switch this can serve the other region's hydration counts.
| }: FreshnessTableProps) => { | ||
| const { colors } = useTheme<MaterializeTheme>(); | ||
| const [sort, setSort] = React.useState<{ key: SortKey; direction: 1 | -1 }>({ | ||
| key: "peak", |
There was a problem hiding this comment.
The table opens sorted by Peak, so with "right now" or p90 selected it isn't ordered by what it's judged on, and unreadable rows sort to the bottom of Exceeding threshold. Could the initial sort follow breachValue?
There was a problem hiding this comment.
Fixed in 8be686f: the table now opens in the order buildFreshnessRows gives it, which is worst first by breachValue, so it follows the active predicate.
| <AccordionItem> | ||
| <SectionHeader | ||
| title="Exceeding threshold" | ||
| count={`(${breaching.length}/${rows.length})`} |
There was a problem hiding this comment.
Nit: the three section headers show counts as N objects, (x/y) and (N). Maybe one format?
| const HydrationPill = ({ row }: { row: FreshnessRow }) => { | ||
| const bucket = bucketForHydration(row.hydratedReplicas, row.totalReplicas); | ||
| // No rows in `mz_hydration_statuses` for the object yet. | ||
| if (!bucket) return <>-</>; |
There was a problem hiding this comment.
Nit: - here and — in the stat cells. Maybe the same placeholder?
There was a problem hiding this comment.
Both use NO_VALUE now. Note the app is split three and three between em dash and hyphen. I kept the em dash.
| export const PREDICATE_LABELS: Record<Predicate, string> = { | ||
| current: "right now", | ||
| peak: "at any moment", | ||
| p90: ">10% of readings (p90)", |
There was a problem hiding this comment.
Nit: in the headline this reads "exceeded 2s >10% of readings (p90)". Maybe "for more than 10% of readings (p90)"?
There was a problem hiding this comment.
Thanks for measuring. In my run unnest was as fast or faster than the IN lookup at every size (80 IDs: 9.8 vs 11.7 ms, 300: 14.9 vs 31.6, 2000: 62 vs 718), on a small emulator history. Could you time both on your bench? If it holds, unnest removes the 330 cliff without a threshold in code.
| // Aggregated before the join on purpose. `mz_hydration_statuses` has a | ||
| // row per (object, replica), so joining it directly would multiply every | ||
| // bucket of the time series by the replica count. | ||
| .leftJoin( |
There was a problem hiding this comment.
Agreed both plan as a full scan, but they don't cost the same: with the GROUP BY, 2000 IDs took 517 ms with IN vs 57 ms with unnest (80 IDs: 13.7 vs 6.8), I think because the literal list is checked per row.
QA LLM Review1. MEDIUM -- Releasing a threshold drag briefly snaps the handle back to the old value
The new hook assumes Details
Probe (vitest,
The stale commit stays on screen while the transition renders. Time-slicing yields to the browser, so the stale frame gets painted, and how long it lasts grows with the number of objects. While the seconds input shows the old value, a keystroke typed in that window edits the old number. Fix: clear the drag value in the same lane as the navigation, 2. LOW -- The new "Now" test passes when
|
leedqin
left a comment
There was a problem hiding this comment.
A few more, mostly hooks and refresh cost, plus nits. None of the memo comments are blocking.
The bugs from my last review are the ones I'd fix before merge:
- The handle can stick after a param change mid-drag. I tested the
useLatestReffix in that thread. - "Not queryable" shows under "right now"
- The hydration key isn't region-scoped
- Hydration waits on the lag request
- An empty cluster reads as success, and "No clusters" shows while clusters load
Tests pass locally (91/91).
| staleTime: 60_000, | ||
| refetchInterval: 60_000, |
There was a problem hiding this comment.
At 24h a bin is 24 minutes, so refetching every 60s re-reads the whole window to update the last bin, about 500 ms per open tab per minute by your bench numbers. Could the series refetch once per bin (calculateBucketSizeFromLookback), with only the latest reading on the 60s clock?
There was a problem hiding this comment.
Fixed in 003db7b. Series and latest readings are separate queries. The series refetches at bin size.
There was a problem hiding this comment.
The difficulty with changing to query once per bin size (e.g. 24m for 24h) is that the "Now" column in the table still queries once per minute. Seems like our options are:
- change "Now" to query once per bin size
- remove "Now" entirely
- factor the "Now" time into the graph and threshold, which is a little weird because of different time scales
- explain the discrepancy in the fine print at the bottom
- go back to once per minute queries for everything
I'm inclined to go with 1 query per minute but I'd like to get your opinion.
There was a problem hiding this comment.
I'd keep the per-bin series and fold the latest reading into pMAX, like the QA review suggested (suggestion on computeStats). pMAX is a max over raw readings and the latest is one of them, so no time scales get mixed. I'd leave p90 on the bins.
There was a problem hiding this comment.
Agreed. I'd assumed the Cluster Overview card refreshed every minute on main, but it only refetched on mount and focus, so the per-bin refresh is no worse for it. Applied your computeStats suggestion in 55438e1 and kept the per-bin series. I also added a sentence to the fine print, since the Freshness page's graph can now trail its own pMAX by up to a bin. The series also refetches on load and focus once it's a minute old, in a13cdbf, since people tend to open this page when they suspect a problem.
|
|
||
| // Hydration is its own query: `buildLagHistoryQuery` is shared with pages | ||
| // that never show it, and joined there it cost all of them a scan. | ||
| const objectIds = React.useMemo( |
There was a problem hiding this comment.
Nit: this useMemo can go, since the hydration key is built from a sorted string. Taking the IDs from objects would also stop hydration depending on the lag result.
| } = useSettledThreshold(threshold, setThreshold); | ||
|
|
||
| // Filter out system clusters | ||
| const selectable = React.useMemo( |
There was a problem hiding this comment.
Nit: this useMemo doesn't buy anything: nothing below it is memoized (options is rebuilt every render), and it's a few dozen clusters.
| direction: -1, | ||
| }); | ||
|
|
||
| const sorted = React.useMemo( |
There was a problem hiding this comment.
Nit: with React.memo on the table, a render already means rows or sort changed, so this memo never hits.
There was a problem hiding this comment.
Gone with the move to useUniversalTable in 8be686f.
| const objectIds = useMemo( | ||
| () => objects.map((object) => object.objectId), | ||
| [objects], | ||
| ); |
There was a problem hiding this comment.
Nit: this useMemo can go too. The key is a sorted string, so a new array doesn't refetch.
| {breaching.length} of {rows.length} | ||
| </b>{" "} | ||
| {rows.length === 1 ? "object" : "objects"} exceeded{" "} | ||
| {formatDurationForAxis(settledThreshold)} {predicateLabel} |
There was a problem hiding this comment.
Nit: the headline and the empty message both build "{threshold} {predicate}{window}". One helper would keep that copy in one place.
There was a problem hiding this comment.
Fixed in 10c1330 with describeThreshold.
| <Accordion allowMultiple defaultIndex={[0, 1]}> | ||
| <AccordionItem> | ||
| <SectionHeader | ||
| title="Freshness" |
There was a problem hiding this comment.
Nit: the first section is titled "Freshness", same as the page heading. Maybe "Lag over time"?
There was a problem hiding this comment.
Fixed in d40a130. All section titles are title case.
| background={ok ? colors.accent.green : colors.accent.red} | ||
| /> | ||
| <Text textStyle="text-base"> | ||
| <b> |
There was a problem hiding this comment.
Nit: we only use <b> in one other place. Maybe <Text as="span" fontWeight="500">, matching the breaching cells?
There was a problem hiding this comment.
Fixed in 23bb301 with textStyle="text-ui-med".
| const debouncedCommit = React.useMemo( | ||
| () => | ||
| debounce((value: number) => { | ||
| // Both updates come from the same callback, so React batches them into | ||
| // one render. Clearing the drag value in a later render would briefly | ||
| // show `committed` before it caught up, which reads as the handle | ||
| // springing back. | ||
| commit(value); | ||
| setDragValue(undefined); | ||
| }, THRESHOLD_SETTLE_MS), | ||
| [commit], |
There was a problem hiding this comment.
Tried this locally: with commit in useLatestRef (like useAutomaticallyConnectSocket) and the debounce built once, a drag followed by a param change commits both, and a pasted link moves the handle. All seven useCallbacks in useFreshnessParams can go with it, and the existing tests still pass.
`e.target.value` is a string, and the cast told the compiler it was one of the three predicates without anything checking. parsePredicate already checks, and already backs the URL param. No behaviour change: the value comes from our own option list. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
It read "exceeded 2s >10% of readings (p90) in the 1 hour". The predicate labels were written for the menu, where naming the statistic is the point, and dropped unchanged into prose. They are two strings now, one for each job. "in the 1 hour" is not English either. The range labels are bare nouns, so the sentence supplies "over the last", matching the control above it. Both the headline and the empty message end with the same clause, built by one helper rather than assembled twice. The variable holding it no longer shadows the global window. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The three section headers read "12 objects", "(3/12)" and "(12)". They are all parenthesised now. Exceeding threshold keeps its ratio, which is what that section is about. The headline pluralises with the shared helper rather than a ternary. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The first section was called "Freshness", the same as the page heading, so the page read "Freshness > Freshness". It is "Lag Over Time", which says what is in it the way the other two titles do. All three are title case now. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Cluster names run long and are often distinguished only by their last few characters, so a truncated one cannot be chosen between. The closed control sizes to its content, and the menu floors at 240px and grows to its longest option. max-content rather than the base fit-content, which resolves against the select's own container and so cannot grow past the closed control. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The object's name links to /regions/:region/maintained-objects/:objectId. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
In order to minimize truncation of object names, all other columns should use minimum necessary width. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
QA LLM Review1. MEDIUM -- At 3h, 6h and 24h, an object lagging right now can read as within target for up to one bin
The series now refetches once per bin, which is 24 minutes at 24h, but pMAX and p90 still come only from the series, while Now refreshes every minute. An object that starts lagging after a series fetch shows the lag in Now but keeps its old pMAX. Under the default "at any moment" predicate it stays out of Exceeding Threshold, and the headline says all objects are within target until the next series fetch. DetailsThe comment says a bin cannot change faster than its own width. But the trailing bin is partial ( Probe: Because One fix is to fold the latest reading into 2. MEDIUM -- Series, latest and hydration requests now run one after another
Two DetailsProbe: I rendered The doc comment on Cluster Overview, which is not behind the flag, goes from one request to two in sequence. It also polls the latest-reading query every 60s, although it never reads Issuing all three through one 3. LOW -- Object names can contain the separators in the
|
|
|
||
| return acc; | ||
| }, new Map<string, GraphLineSeries>()); | ||
| const latest = useSuspenseQuery({ |
There was a problem hiding this comment.
I'd prefer useQuery with explicit loading and error states over useSuspenseQuery here, and ideally we migrate most of our suspense queries this way. These three are new in this PR: on main this hook makes one request, and now the page makes three in series (series, then latest, then hydration), because each suspense query stops the component before the next one starts. Cluster Overview's card makes two.
Following useReplicaUtilizationHistory, the hook becomes:
const series = useQuery({
// queryKey and queryFn unchanged
staleTime: binSizeMs,
refetchInterval: binSizeMs,
placeholderData: keepPreviousData,
});
const latest = useQuery({
// queryKey and queryFn unchanged
staleTime: LATEST_READING_INTERVAL_MS,
refetchInterval: LATEST_READING_INTERVAL_MS,
placeholderData: keepPreviousData,
});
const data = useMemo(
() =>
series.data && latest.data
? buildFreshnessData({ readings: series.data, latest: latest.data }, objects)
: undefined,
[series.data, latest.data, objects],
);
return {
data,
isLoading: series.isLoading || latest.isLoading,
isError: series.isError || latest.isError,
};useFreshnessHydration changes the same way. Each call site then branches after its hooks:
const freshness = useClusterFreshness({ lookbackMs, objects });
const hydration = useFreshnessHydration(objectIds);
if (freshness.isError) {
return <ErrorBox message="An error occurred fetching freshness data." />;
}
if (!freshness.data) {
return (
<Box height="320px" width="100%">
<LoadingContainer />
</Box>
);
}On this page the rendering moves into a child that takes freshness.data, so useFreshnessRows always has data, and the hydration pills fill in when hydration.data gets loaded. Cluster Overview's card keeps the load spinner and error message. We won't need React.Suspense wrappers and so can the key on AppErrorBoundary: query errors are state now.
I tried this locally on this head: types check, 124/124 tests pass, and the three requests start together.
There was a problem hiding this comment.
Fixed in ef42dfb, without keepPreviousData. Switching cluster or range would show the previous data under the new label until the new data loaded. Two changes from your sketch: an error only replaces the page when there is no data, so a failed refetch keeps the last readings on screen. A hydration error leaves that column at "—" rather than failing the page. A new test checks that all three requests start together.
|
Thanks @jdonelson for working through all of these, and for measuring before deciding on the harder ones. For the record, here's roughly what the review changes bought. These are local emulator and jsdom numbers, so read them as ratios. The catalog scans that were removed grow with environment size, so larger environments should see more.
The same rounds fixed correctness too: the threshold handle no longer sticks, unreadable objects no longer score as fresh or reach "right now", "Now" is the latest reading, empty and loading states are distinct, and the query keys are region-scoped. I filed CNS-186 for the large-cluster |
Co-Authored-By: Claude Opus 5.5 <[email protected]>
leedqin
left a comment
There was a problem hiding this comment.
Following up on the QA reviews: four findings are still open at ef42dfb, with fixes inline, plus my answer on the refetch question. The ordering one is the one I'd fix before merge, since Cluster Overview isn't behind the flag. With all of them applied, the freshness, clusters and graph tests pass locally (381/381).
| // `buildLagHistoryQuery` reports. | ||
| .distinctOn(["bucket_start", "object_id"]) | ||
| .select(["bucket_start as bucketStart", "object_id as objectId", "lag"]) | ||
| .orderBy(["bucket_start desc", "object_id", "lag desc"]) |
There was a problem hiding this comment.
The QA review's ordering finding is still open: this returns the newest bin first, so both graphs draw time backwards and Cluster Overview's Freshness column, which isn't behind the flag, shows the oldest reading in the window. DISTINCT ON only needs the leading columns to match, not their direction:
| .orderBy(["bucket_start desc", "object_id", "lag desc"]) | |
| .orderBy(["bucket_start", "object_id", "lag desc"]) |
There was a problem hiding this comment.
Applied in c7d58f4. The graph-data builder also sorts by time, so the graph and the Freshness column no longer depend on the query's order. The new tests feed rows newest first.
| // Both updates come from one callback, so React batches them into a | ||
| // single render. Clearing `pending` later would show `committed` | ||
| // before it caught up, which reads as the control springing back. | ||
| commitRef.current(value); | ||
| setPending(undefined); |
There was a problem hiding this comment.
Releasing a drag still snaps the handle back for a frame, as the QA review found. BrowserRouter runs commit's navigation in a transition, so setPending(undefined) renders first against the old URL. Let's clear it in a transition too:
| // Both updates come from one callback, so React batches them into a | |
| // single render. Clearing `pending` later would show `committed` | |
| // before it caught up, which reads as the control springing back. | |
| commitRef.current(value); | |
| setPending(undefined); | |
| // `commit` usually navigates, and `BrowserRouter` navigates inside a | |
| // transition. Clearing `pending` outside one renders first and shows | |
| // the old `committed` for a frame, which reads as the control | |
| // springing back. | |
| commitRef.current(value); | |
| React.startTransition(() => setPending(undefined)); |
There was a problem hiding this comment.
Applied in e834f9e, with a test that records each value shown and fails without the transition.
| return { | ||
| // The newest reading, not the newest bin. A bin reports the worst reading | ||
| // in its span, so at a 24 hour range the last one answers "the worst of | ||
| // the last 24 minutes" when the question asked was "right now". | ||
| current: currentFrom(latest), | ||
| peak: sorted[sorted.length - 1], |
There was a problem hiding this comment.
For the refetch question: folding the latest reading into pMAX keeps the per-bin series and stops pMAX reading below Now. p90 stays on the bins.
| return { | |
| // The newest reading, not the newest bin. A bin reports the worst reading | |
| // in its span, so at a 24 hour range the last one answers "the worst of | |
| // the last 24 minutes" when the question asked was "right now". | |
| current: currentFrom(latest), | |
| peak: sorted[sorted.length - 1], | |
| const current = currentFrom(latest); | |
| return { | |
| // The newest reading, not the newest bin. A bin reports the worst reading | |
| // in its span, so at a 24 hour range the last one answers "the worst of | |
| // the last 24 minutes" when the question asked was "right now". | |
| current, | |
| // The series refetches once per bin and the latest reading once a minute, | |
| // so a lag that starts between series fetches is only in `current`. | |
| peak: Math.max(sorted[sorted.length - 1], current ?? -Infinity), |
With a test:
it("counts a lag that started after the series was fetched in pMAX", () => {
const stats = computeStats("spiky", data, 600_000);
expect(stats.peak).toBe(600_000);
expect(stats.p90).toBe(9_000);
});| it('takes "Now" from the latest reading, not the worst in the last bin', () => { | ||
| // At a wide range the final bin is a maximum over many minutes, so it | ||
| // reads high for an object that has already recovered. | ||
| const lastBinMax = Math.max( | ||
| ...(SERIES.spiky as number[]).map((r) => r as number), | ||
| ); | ||
| expect(lastBinMax).toBe(9_000); | ||
|
|
||
| const stats = computeStats("spiky", data, 430); | ||
| expect(stats.current).toBe(430); | ||
| expect(stats.peak).toBe(9_000); | ||
| }); |
There was a problem hiding this comment.
This still passes if current goes back to the last bin, since spiky's last bin equals latest (QA review). A last bin that differs would catch it:
| it('takes "Now" from the latest reading, not the worst in the last bin', () => { | |
| // At a wide range the final bin is a maximum over many minutes, so it | |
| // reads high for an object that has already recovered. | |
| const lastBinMax = Math.max( | |
| ...(SERIES.spiky as number[]).map((r) => r as number), | |
| ); | |
| expect(lastBinMax).toBe(9_000); | |
| const stats = computeStats("spiky", data, 430); | |
| expect(stats.current).toBe(430); | |
| expect(stats.peak).toBe(9_000); | |
| }); | |
| it('takes "Now" from the latest reading, not the worst in the last bin', () => { | |
| // At a wide range the final bin is a maximum over many minutes, so it | |
| // reads high for an object that has already recovered. | |
| const recovered = buildData({ recovered: [400, 410, 9_000] }, 3); | |
| const stats = computeStats("recovered", recovered, 430); | |
| expect(stats.current).toBe(430); | |
| expect(stats.peak).toBe(9_000); | |
| }); |
There was a problem hiding this comment.
Applied in 8157092. Checked that it fails with current read from the last bin.
| // Separators for the key below. Control characters, which an identifier | ||
| // cannot contain, and a marker because a name can be null and null has to stay | ||
| // distinguishable from the empty string. | ||
| const FIELD_SEP = "\u0000"; | ||
| const RECORD_SEP = "\u0001"; | ||
| const NULL_MARKER = "\u0002"; | ||
|
|
||
| const encode = (value: string | null) => value ?? NULL_MARKER; | ||
| const decode = (value: string) => (value === NULL_MARKER ? null : value); | ||
|
|
There was a problem hiding this comment.
Nit: a quoted identifier can contain anything but NUL (lex_quoted_ident in src/sql-lexer/src/lexer.rs), so a name with \u0001 splits into two records and a name equal to \u0002 reads as null (QA review). Maybe key on JSON instead, which drops these (suggestion below):
| // Separators for the key below. Control characters, which an identifier | |
| // cannot contain, and a marker because a name can be null and null has to stay | |
| // distinguishable from the empty string. | |
| const FIELD_SEP = "\u0000"; | |
| const RECORD_SEP = "\u0001"; | |
| const NULL_MARKER = "\u0002"; | |
| const encode = (value: string | null) => value ?? NULL_MARKER; | |
| const decode = (value: string) => (value === NULL_MARKER ? null : value); |
| const objectsKey = allObjects | ||
| .filter( | ||
| (object) => | ||
| object.clusterId === clusterId && | ||
| (isSystemCluster(clusterId) || !isSystemId(object.id)) && | ||
| object.sourceType !== "subsource" && | ||
| object.sourceType !== "progress", | ||
| ) | ||
| .map((object) => | ||
| [ | ||
| object.id, | ||
| encode(object.name), | ||
| encode(object.schemaName), | ||
| encode(object.databaseName), | ||
| object.objectType, | ||
| ].join(FIELD_SEP), | ||
| ) | ||
| .join(RECORD_SEP); | ||
|
|
||
| // Rebuilt from the key, so the dependency is the whole truth: nothing else | ||
| // is read in here. | ||
| return useMemo(() => { | ||
| if (objectsKey === "") return []; | ||
|
|
||
| return objectsKey.split(RECORD_SEP).map((record) => { | ||
| const [objectId, objectName, schemaName, databaseName, objectType] = | ||
| record.split(FIELD_SEP); | ||
| return { | ||
| objectId, | ||
| objectName: decode(objectName), | ||
| schemaName: decode(schemaName), | ||
| databaseName: decode(databaseName), | ||
| objectType, | ||
| }; | ||
| }); | ||
| }, [objectsKey]); |
There was a problem hiding this comment.
The JSON key:
| const objectsKey = allObjects | |
| .filter( | |
| (object) => | |
| object.clusterId === clusterId && | |
| (isSystemCluster(clusterId) || !isSystemId(object.id)) && | |
| object.sourceType !== "subsource" && | |
| object.sourceType !== "progress", | |
| ) | |
| .map((object) => | |
| [ | |
| object.id, | |
| encode(object.name), | |
| encode(object.schemaName), | |
| encode(object.databaseName), | |
| object.objectType, | |
| ].join(FIELD_SEP), | |
| ) | |
| .join(RECORD_SEP); | |
| // Rebuilt from the key, so the dependency is the whole truth: nothing else | |
| // is read in here. | |
| return useMemo(() => { | |
| if (objectsKey === "") return []; | |
| return objectsKey.split(RECORD_SEP).map((record) => { | |
| const [objectId, objectName, schemaName, databaseName, objectType] = | |
| record.split(FIELD_SEP); | |
| return { | |
| objectId, | |
| objectName: decode(objectName), | |
| schemaName: decode(schemaName), | |
| databaseName: decode(databaseName), | |
| objectType, | |
| }; | |
| }); | |
| }, [objectsKey]); | |
| const objects = allObjects | |
| .filter( | |
| (object) => | |
| object.clusterId === clusterId && | |
| (isSystemCluster(clusterId) || !isSystemId(object.id)) && | |
| object.sourceType !== "subsource" && | |
| object.sourceType !== "progress", | |
| ) | |
| .map((object): FreshnessObject => ({ | |
| objectId: object.id, | |
| objectName: object.name, | |
| schemaName: object.schemaName, | |
| databaseName: object.databaseName, | |
| objectType: object.objectType, | |
| })); | |
| // JSON keeps a null name apart from an empty one, and no name can break it. | |
| const objectsKey = JSON.stringify(objects); | |
| // Rebuilt from the key, so the dependency is the whole truth: nothing else | |
| // is read in here. | |
| return useMemo( | |
| () => JSON.parse(objectsKey) as FreshnessObject[], | |
| [objectsKey], | |
| ); |
And a test for queries.test.ts:
it("keeps a name that contains control characters intact", async () => {
const { result } = await renderFreshnessObjects([
buildObject({ id: "u10", name: "orders\u0001u2" }),
buildObject({ id: "u11", name: "\u0002" }),
]);
expect(result.current.map((o) => [o.objectId, o.objectName])).toEqual([
["u10", "orders\u0001u2"],
["u11", "\u0002"],
]);
});There was a problem hiding this comment.
Applied in 39fb494 with your test, which fails on the old separators. JSON costs about 1µs more per render than the joined key at 1000 objects with 50 on the cluster.
| staleTime: 60_000, | ||
| refetchInterval: 60_000, |
There was a problem hiding this comment.
I'd keep the per-bin series and fold the latest reading into pMAX, like the QA review suggested (suggestion on computeStats). pMAX is a max over raw readings and the latest is one of them, so no time scales get mixed. I'd leave p90 on the bins.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
All QA LLM Review findings are addressed:
|
Co-Authored-By: Claude Opus 5.5 <[email protected]>
leedqin
left a comment
There was a problem hiding this comment.
Thank you for addressing all the comments and feedback! Can we make sure the flag works before we roll this out
Release notes for the **v26.47.0** train. One commit was added per snapshot; the `final` snapshot sets the real dates. **All six snapshots (`rc.1`–`rc.5`, `final`) are now applied — this branch is complete and ready for review.** - **Content**: 2 Feature sections (`TRY_CAST`, `EXCLUDE ALL NOT NULL CONSTRAINTS`), 7 Improvements, 13 Bug Fixes — `rc.1` contributed all of it except the top four Bug Fixes: `rc.2` added two, and `rc.3` and `rc.4` one each. `rc.5` added no new entries; it amended `rc.4`'s probe-port Bug Fix instead (see below). `final` adds nothing at all — its tracking issue holds only the unassociated `release: bump to version v26.47.0` commit ([`bed23494d`](bed2349)), and the `v26.47.0-rc.5` and `v26.47.0` tags are 14h28m apart with that bump the only thing between them. Its commit is empty and exists to record on the branch that the snapshot was applied. - **Dates (final, no longer provisional)**: Cloud **2026-10-22**, Self-Managed **2026-10-23**. Every RC estimated this same pair from the `v26.47.0-rc.1` tag, and the real dates confirmed it — the five RCs compressed into one week rather than pushing the train out — so no published date text changed in the `final` commit.⚠️ One thing worth a last look before merge: **2026-10-22 is a Wednesday**, one day off the usual Thursday Cloud date (2026-10-23 is the normal Friday Self-Managed day). That is the supplied value and it stands, but if the release owner expected Thursday 2026-10-23 / Friday 2026-10-24, this branch is where to correct it. - **Operator compatibility**: row added for `v26.47`, release date `2026-10-23`. - **Branch base**: `main`, whose newest section is `## v26.45.1` — v26.46.0's section is still on its own unmerged branch (#39690). Expect a top-of-list conflict when both merge; `v26.47.0` goes above `v26.46.0`. ✅ **Corrections already on this branch** — a `final` verification pass over the whole assembled section confirmed these and deliberately left them alone: - **`rc.1` named a syntax that does not exist.** It published "`CREATE TABLE ... FROM SOURCE` now accepts an `ALL COLUMNS NULLABLE` option", taking the name from [#39680](#39680 title and body. At the `v26.47.0` tag the option is `EXCLUDE ALL NOT NULL CONSTRAINTS` (`TableFromSourceOptionName::ExcludeAllNotNullConstraints`), and `NULLABLE` is not a lexer keyword at all, so the published syntax would have been a parse error for anyone who copied it. A human editor caught this and replaced the bullet with the `{#v26.47-exclude-not-null-constraints}` feature section now on the branch. The `rc.1` snapshot directory in mz-skills still carries the wrong wording — snapshot directories are the record of what was reviewed at the time and are left untouched. - **`rc.5`'s cross-snapshot rewrite is applied once**, including the "Deployments using password, SASL, or OIDC authentication have no separate internal SQL port and continue to be probed on the external one" clause. - **PR citations are stripped from all entries** as a deliberate house-style edit, matching how published sections read. PR links remain available in the mz-skills snapshot drafts linked below.⚠️ **Open questions for the release owner** — none of these were resolved by `final`; this branch is the last place to act on them - **Was any `rc.4` artifact handed to a customer or used for an upgrade rehearsal?** This decides a published entry. [#40036](#40036) (published as a Bug Fix by `rc.4`) made the operator probe `environmentd`'s internal SQL port unconditionally, which crashloops deployments using Password, SASL, or OIDC authentication — they have no internal SQL port. [#40046](#40046) fixed that roughly seven hours later, inside this same unreleased train, so no shipped build (`v26.46.1` probes the external port unconditionally) could have hit it. It is therefore recorded as **Borderline** rather than published as a Bug Fix of its own, and its substance folded into `rc.4`'s bullet as a scoping clause. If an `rc.4` build did reach anyone, the crashloop *was* reachable and [#40046](#40046) should be published in its own right. Note the author wrote an explicit positive release-note line for it, which is why this is flagged rather than quietly dropped. - **GA announcements — the largest judgment call in this train.** Three docs PRs remove preview markers: [#39831](#39831) (source versioning, `CREATE TABLE ... FROM SOURCE`), [#39836](#39836) (replacement materialized views), [#39830](#39830) (Kafka sink headers, noted as "GA in v26.47"). None changes code, so all three were held; drafted wording for each is in `omitted.md`. Which are actually GA in v26.47.0 and should be announced? Note v26.46.0 published Kafka sink message attribution as *private preview* — if the header goes GA here, this release should say so rather than repeat the preview framing. - **GCS blob read throughput** ([#39906](#39906), held in `rc.2`): restores the read throughput GCS deployments lost when [#39351](#39351) — already shipped — forced HTTP/2 and collapsed per-request connections into one. It is selected by `persist_blob_http_pool`, verified to default to `off` at the `v26.47.0-rc.2` tag, and the author notes "the default should eventually be 'GCS-only'". **Is `persist_blob_http_pool` being set to `gcs` for v26.47.0?** If so this is a clear win to publish for GCP users, who carry the regression today; drafted wording is in rc.2's `omitted.md`. - **`SUBSCRIBE` soft assertions** ([#37611](#37611), [#39764](#39764), both held): drafted as Bug Fixes, then pulled back. Both failures come from `soft_assert_or_log!`, which panics only where soft assertions are enabled and otherwise logs; neither PR says what a release build does. Are soft assertions on in the environments customers run? If yes, both are publishable as drafted in `omitted.md`. - **Console Freshness page** ([#39340](#39340), held): a complete new Monitoring > Freshness page, gated behind `freshness-page-CNS164`, off in both Cloud and self-managed, and the LaunchDarkly flag is not yet created. If it is turned on for v26.47.0, this likely deserves a Feature heading. - **PostgreSQL sources ingesting during a snapshot** ([#39419](#39419), [#39479](#39479), held): adding a large table to an existing source would no longer stall the tables already hydrated — but `storage_persist_sink_description_lookahead` is `Duration::ZERO` at the tag. Publish if the lookahead is being enabled. - **Coalesced materialized-view sink batches** ([#37591](#37591), held): measures ~67% fewer blob writes and ~54% smaller state diffs, at ~10 ms added p50 latency on small writes, but `compute_mv_sink_shared_batch_max_bytes` is `0` at the tag. Publish (with the latency cost stated) if the staged rollout reaches customers in this release. - **MCP 2026-07-28 conformance checks** ([#39694](#39694), [#39695](#39695), held): `enable_mcp_protocol_2026_07_28` is `false` at the tag. If the revision is being served, these belong together as one Improvement. - **Refresh strategy columns** ([#39597](#39597), published as an Improvement): this renames `aligned_to` → `aligned_to_expression` and `at` → `at_expression` and changes their type. It is a breaking change for anyone selecting them, in a schema documented as unstable — it may warrant more prominence than a bullet. - **Retained input after `ALTER SINK ... SET FROM`** (from `rc.3`'s [#37653](#37653)): the PR records a pre-existing gap it does not fix — after `SET FROM`, the *old* upstream relation stays held, and therefore un-compactable, until the sink is dropped or `environmentd` restarts. It is not something this release changed, so it is out of these notes, but it is a real footgun for the blue/green pattern that `sql/alter-sink.md` recommends `SET FROM` for. Should the `ALTER SINK` page or the blue/green guide call it out? - **Operator upgrade shows pending changes** (from `rc.4`'s [#40036](#40036)): the probe-port change alters the generated `environmentd` StatefulSet, so after upgrading the operator, existing environments report pending changes and pick up the new port on their next rollout — they are **not** restarted on their own. That is an upgrade consequence rather than something the release changed, so it is out of the notes, but a Self-Managed operator who cannot account for the pending-changes state may read it as a problem. The v26.47 upgrade guide looks like the right venue. Snapshot drafts (with PR links) in mz-skills: - rc.1: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.1/release_notes.md) / [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.1/omitted.md) - rc.2: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.2/release_notes.md) / [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.2/omitted.md) - rc.3: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.3/release_notes.md) / [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.3/omitted.md) - rc.4: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.4/release_notes.md) / [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.4/omitted.md) - rc.5: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.5/release_notes.md) / [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.5/omitted.md) - final: [release_notes.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/final/release_notes.md) / [omitted.md](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/final/omitted.md) **Borderline PRs omitted:** rc.1: 14 — [omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.1/omitted.md#borderline), rc.2: 2 — [omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.2/omitted.md#borderline), rc.3: 0 — [omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.3/omitted.md#borderline), rc.4: 0 — [omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.4/omitted.md#borderline), rc.5: 1 — [omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/rc.5/omitted.md#borderline), final: 0 — [omitted.md#borderline](https://github.com/MaterializeInc/mz-skills/blob/main/data/mz-release-notes/v26.47.0/final/omitted.md#borderline) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Valerie Chiang <[email protected]> Co-authored-by: Pranshu Maheshwari <[email protected]>
Adds Monitoring > Freshness: how far behind wall clock time each object on a
cluster is, judged against a threshold you drag on the graph. Objects over the
threshold get their own table above the full list.
Threshold, window, statistic, object-type filter and cluster all live in the
URL, so a view can be shared as a link. The drag updates the graph live but
settles before rebuilding the tables.
The statistic offered is p90, not p99. The query returns 60 readings at every
window, so p99 lands on the maximum and the two predicates would select
identically.
Freshness values on Environment Overview now link here with the cluster
preselected.
Feature flag
Gated behind
freshness-page-CNS164, off by default in both cloud andself-managed. The route, the nav item and the Environment Overview link are all
gated; with the flag off the link falls back to plain text rather than pointing
at a route that is not mounted. The LaunchDarkly flag still needs creating
before the page is reachable in cloud.
Known gap
The graph draws one line per object with no cap, so a cluster with thousands of
objects is slow to render and slow to drag.
LINE_MAX_COUNTalready exists inclusters/queries.tsbut caps onlycurrentData. Left as a TODO at that seamrather than fixed here, since Cluster Overview has the same exposure today and
the fix should cover both.
Tests
New unit tests for the row and statistics helpers (
freshnessRows), thesearch-param parsers (
useFreshnessParams), and the table's sorting andhydration pills (
FreshnessTable).Manually tested locally, impersonating customers with high object counts.
Part of: CNS-164
🤖 Generated with Claude Code