Skip to content

console: Add a Freshness page under Monitoring - #39340

Merged
jdonelson merged 53 commits into
mainfrom
jdonelson/CNS-164_freshness-page
Oct 14, 2026
Merged

jdonelson merged 53 commits into
mainfrom
jdonelson/CNS-164_freshness-page

Conversation

@jdonelson

@jdonelson jdonelson commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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.

Screenshot 2026-09-29 at 2 12 04 PM

Feature flag

Gated behind freshness-page-CNS164, off by default in both cloud and
self-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_COUNT already exists in
clusters/queries.ts but caps only currentData. Left as a TODO at that seam
rather 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), the
search-param parsers (useFreshnessParams), and the table's sorting and
hydration pills (FreshnessTable).

Manually tested locally, impersonating customers with high object counts.

Part of: CNS-164

🤖 Generated with Claude Code

jdonelson and others added 8 commits September 28, 2026 10:21
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]>
…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]>
@jdonelson
jdonelson requested a review from a team as a code owner September 29, 2026 17:14
@linear-code

linear-code Bot commented Sep 29, 2026

Copy link
Copy Markdown

CNS-164

@jdonelson
jdonelson marked this pull request as draft September 29, 2026 17:19
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]>
@jdonelson
jdonelson marked this pull request as ready for review September 29, 2026 18:47
@leedqin
leedqin requested review from leedqin and removed request for Alphadelta14 September 29, 2026 18:48
@def-

def- commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Not-queryable objects score as 0 lag and never exceed the threshold

console/src/platform/freshness/freshnessRows.ts:68

The page computes current/peak/p90 from the graph's yAccessor, and that accessor maps a NULL lag reading (the object is unreadable) to 0 so the line draws at the bottom. So an object that is not queryable scores as perfectly fresh, is left out of "Exceeding threshold", and the header reads "0 of N objects exceeded ... All N objects are within target".

Details

In useClusterFreshness, a lag IS NULL row becomes { queryable: false } and the line's accessor returns 0 for it (console/src/platform/clusters/queries.ts:996). computeStats only drops null, which only happens when a bucket has no row at all. It pushes the 0, so an index that never hydrates on any replica gets current = peak = p90 = 0. Then 0 > threshold is false at freshnessRows.ts:156, and the table shows 0ms instead of a dash. NULL is the worst state, not the best. The controller treats WallclockLag::Undefined as greater than any measured lag (src/storage-client/src/controller.rs:961), and lagHistory.ts:18 documents NULL as "Not queryable". An object that becomes unreadable partway through the window also loses those buckets from its peak and p90.

The tests miss this because they model a non-reporting object as all-null values (SERIES.absent, and the legacy_sink row). Production data never produces that shape for an object that has rows.

Suggested fix: compute stats from each DataPoint's queryable flag, not from the drawing accessor. Treat a not-queryable reading as breaching, for example by carrying a notQueryable flag or scoring it Infinity, and render it with NULL_LAG_TEXT. Add a test that builds its series through the same transform.

2. LOW -- URL parsers accept prototype keys such as constructor

console/src/platform/freshness/useFreshnessParams.ts:43

parseTimePeriod and parsePredicate check raw in OPTIONS, and in also matches inherited keys. So a link like ?timePeriod=constructor gets through, even though the docstring says these parsers reject untrusted param text.

Details

With timePeriod=constructor, Number(raw) is NaN. lookbackMs then becomes NaN and reaches sql.raw as INTERVAL 'NaN MILLISECONDS', the query fails, and the page shows only the error boundary. With exceeded=toString, the predicate is a function name: statFor falls through to p90 while the menu and header show something else. Use Object.hasOwn(TIME_PERIOD_OPTIONS, raw) and Object.hasOwn(PREDICATE_LABELS, raw) instead.

@leedqin leedqin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_id

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for timing it. Since you filed CNS-186 for the unnest switch, I've left it out of this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to +60
* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as CNS-182.

}
>
<FreshnessContent
key={selected.id}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +413 to +423
Freshness is wallclock lag: how far behind wall clock time an
object&rsquo;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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +48 to +50
React.useEffect(() => {
setLive(committed);
}, [committed]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +313 to +318
onChange={(e) => {
const seconds = Number(e.target.value);
if (Number.isFinite(seconds) && seconds >= 0) {
onThresholdChange(seconds * 1000);
}
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to the QA review here: in also matches inherited keys like constructor. Object.hasOwn would close it, here and in parsePredicate below.

Suggested change
if (raw === null || !(raw in TIME_PERIOD_OPTIONS)) return null;
if (raw === null || !Object.hasOwn(TIME_PERIOD_OPTIONS, raw)) return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8f44898, both parsers.

Confirmed the two failure modes you and the QA review described:

  • ?timePeriod=constructor passed the in check, Number of it is NaN, and that reached the query as INTERVAL 'NaN MILLISECONDS'.
  • ?exceeded=toString passed and fell through statFor to 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

jdonelson and others added 3 commits September 30, 2026 15:53
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]>
@def-

def- commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. HIGH -- New lag query returns newest bucket first, so every "current" value is the oldest reading

console/src/api/materialize/freshness/lagHistory.ts:103

buildObjectLagHistoryQuery ends with ORDER BY bucket_start DESC, but buildFreshnessData and computeStats still treat .at(-1) as the latest reading. So the Cluster Overview freshness table, which is not behind the flag, shows each object's oldest reading in the window (up to 24 hours old) as its freshness. The same happens to the Freshness page's Now column and its current predicate, and both graphs draw with the time axis reversed.

Details

The query it replaces ends with orderBy(["bucketStart asc", "lag desc"]) (lagHistory.ts:281). The new builder has only the ORDER BY that pairs with its DISTINCT ON. That one is descending, so the rows come back newest first. Downstream:

  • console/src/platform/clusters/queries.ts:979 takes rowsByObjectId.at(-1) for currentData, which ClusterFreshnessTable renders under "Freshness".
  • console/src/platform/clusters/queries.ts:1049 sets startTime to the newest bucket and endTime to the oldest. That reverses the scaleTime domain, so "now" is on the left. findNearestDatum (console/src/utils/graph.ts) bisects on the assumption that timestamps ascend, so FreshnessGraph tooltips land on the wrong bucket.
  • console/src/platform/freshness/freshnessRows.ts:94 reads data.at(-1) as current.

Probe: I fed buildFreshnessData rows in the order the query returns them (0.5s, then 5s, then an oldest reading of 10min). The result had startTime 3000000, endTime 1000000, a currentData lag of 10 minutes, and computeStats(...).current = 600000. The unit tests build DataPoint[] in ascending order by hand and never go through buildFreshnessData, so they still pass.

Fix: sort the result ascending. Materialize requires the leading ORDER BY columns to match the DISTINCT ON columns but does not constrain their direction (src/sql/src/plan/query.rs:2799). So .orderBy(["bucket_start", "object_id", "lag desc"]) still keeps the worst reading per bin.

2. MEDIUM -- One unreadable reading anywhere in the window marks the object "Not queryable" right now

console/src/platform/freshness/freshnessRows.ts:138

notQueryable becomes true if any bin in the window is NULL. When it is, judgeLines sets breachValue to Infinity whatever the predicate, and nowCell shows "Not queryable" in place of the current value. So an object that was briefly unreadable earlier in the window but is readable and fresh now shows up in two wrong places for the rest of the window. Under the current ("right now") predicate it counts as exceeding the threshold, and its Now cell reads "Not queryable".

Details

Global lag is min(lag) across replicas (src/catalog/src/builtin/mz_internal.rs:2987). So it is NULL whenever no replica is hydrated. Two ordinary cases are an index that has not hydrated yet after creation and any object on a single-replica cluster rehydrating after a restart.

Probe: readings [unreadable, 400, 400, 400, 400] with the current predicate and a 60s threshold gave current: 400, breachValue: Infinity, breaching: true, notQueryable: true. The same flag also makes p90 infinite from a single unreadable bin out of 60. That defeats p90's purpose, which is to tolerate short spikes. The new unreadable test series ends unreadable, so no test covers an object that recovered.

A consistent approach is to score each unreadable bin as Infinity in the value list. Then current breaches only if the latest bin is unreadable, peak if any bin is, and p90 only if more than 10% are. The Now cell would show "Not queryable" only when the latest bin is unreadable.

jdonelson and others added 4 commits October 1, 2026 10:01
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: comment is probably unnecessary

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in d77bf6a.

@leedqin leedqin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +50 to +60
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],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 003db7b. Hydration takes its IDs from objects.

}

export const freshnessQueryKeys = {
all: () => ["freshness"] as const,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 003db7b.

}: FreshnessTableProps) => {
const { colors } = useTheme<MaterializeTheme>();
const [sort, setSort] = React.useState<{ key: SortKey; direction: 1 | -1 }>({
key: "peak",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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})`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the three section headers show counts as N objects, (x/y) and (N). Maybe one format?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 48eef83.

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 <>-</>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: - here and — in the stat cells. Maybe the same placeholder?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: in the headline this reads "exceeded 2s >10% of readings (p90)". Maybe "for more than 10% of readings (p90)"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10c1330.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@def-

def- commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Releasing a threshold drag briefly snaps the handle back to the old value

console/src/platform/freshness/useSettledThreshold.ts:58

The new hook assumes commit(value) and setDragValue(undefined) render together, but React Router 7's BrowserRouter applies location updates inside React.startTransition. So the cleared drag value renders first, against the old URL. Each time a drag settles, the handle, the threshold line, the line colors and the seconds input show the pre-drag threshold until the transition render, which rebuilds both tables, commits. That is the "handle springing back" that the comment at lines 53-56 says cannot happen.

Details

BrowserRouter wraps setStateImpl in React.startTransition unless it gets useTransitions={false} (node_modules/react-router/dist/development/chunk-OB3PAWPO.mjs:10509), and App.tsx doesn't pass that. setDragValue(undefined) runs from lodash's setTimeout, so it lands in the default lane, which renders ahead of transition lanes.

Probe (vitest, BrowserRouter, useFreshnessParams + useSettledThreshold, recording live in a layout effect; drag from the 2000 default to 5000, then wait out the settle):

  • At this head: 5000 → 2000 (url already ?threshold=5000) → 5000
  • At dfe1420 (state plus effect): 5000 → 5000

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, React.startTransition(() => setDragValue(undefined)). With that change the same probe records a single 5000 → 5000 commit.

2. LOW -- The new "Now" test passes when current goes back to reading the last bin

console/src/platform/freshness/freshnessRows.test.ts:164

takes "Now" from the latest reading, not the worst in the last bin passes latest = 430, but spiky's last bin is also 430. The latestByObjectId fixture is built from each series' last bin, so latest and the last bin are equal in every test. I reverted computeStats to data.at(-1)?.lag[key] and all 15 tests in the file still passed. To guard the 852be00 fix, make the last bin differ from latest, for example a last bin of 9000 with latest 430.

@leedqin leedqin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Tests pass locally (91/91).

Comment on lines +1104 to +1105
staleTime: 60_000,
refetchInterval: 60_000,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 003db7b. Series and latest readings are separate queries. The series refetches at bin size.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone in 003db7b.

} = useSettledThreshold(threshold, setThreshold);

// Filter out system clusters
const selectable = React.useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this useMemo doesn't buy anything: nothing below it is memoized (options is rebuilt every render), and it's a few dozen clusters.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 8e6ca79.

direction: -1,
});

const sorted = React.useMemo(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: with React.memo on the table, a render already means rows or sort changed, so this memo never hits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone with the move to useUniversalTable in 8be686f.

Comment on lines +1083 to +1086
const objectIds = useMemo(
() => objects.map((object) => object.objectId),
[objects],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this useMemo can go too. The key is a sorted string, so a new array doesn't refetch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 8e6ca79.

{breaching.length} of {rows.length}
</b>{" "}
{rows.length === 1 ? "object" : "objects"} exceeded{" "}
{formatDurationForAxis(settledThreshold)} {predicateLabel}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the headline and the empty message both build "{threshold} {predicate}{window}". One helper would keep that copy in one place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 10c1330 with describeThreshold.

<Accordion allowMultiple defaultIndex={[0, 1]}>
<AccordionItem>
<SectionHeader
title="Freshness"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the first section is titled "Freshness", same as the page heading. Maybe "Lag over time"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d40a130. All section titles are title case.

background={ok ? colors.accent.green : colors.accent.red}
/>
<Text textStyle="text-base">
<b>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we only use <b> in one other place. Maybe <Text as="span" fontWeight="500">, matching the breaching cells?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 23bb301 with textStyle="text-ui-med".

Comment on lines +50 to +60
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],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

jdonelson and others added 8 commits October 6, 2026 12:52
`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]>
@def-

def- commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- At 3h, 6h and 24h, an object lagging right now can read as within target for up to one bin

console/src/platform/clusters/queries.ts:1156

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.

Details

The comment says a bin cannot change faster than its own width. But the trailing bin is partial (date_bin from the epoch, lagHistory.ts:82) and gains a reading every minute, and that bin is the one that holds an ongoing incident.

Probe: buildFreshnessData → buildStats → buildFreshnessRows with 60 bins at 1s, a latest reading of 10 min, a 24h range, peak and a 2s threshold gave current: 600000, peak: 1000, breachValue: 1000, breaching: false. At 8be686f both were fetched together every 60s, so the trailing bin always contained the latest reading and pMAX could not fall below Now.

Because staleTime equals the interval, neither window focus nor remount refreshes it. Cluster Overview's graph and freshness table read only the series, so at its wider ranges they also go stale for up to a bin. That page is not behind the flag, and on main it refetched on focus and on mount.

One fix is to fold the latest reading into values in computeStats, so pMAX is never below Now.

2. MEDIUM -- Series, latest and hydration requests now run one after another

console/src/platform/clusters/queries.ts:1159

Two useSuspenseQuery calls in one hook run in sequence, because the first one suspends before the second hook runs. useFreshnessHydration is still called after useClusterFreshness (FreshnessPage.tsx:124), so it waits for both. The page now needs three round trips in sequence before it renders, where 8be686f needed two, and the hydration change made in 003db7b does not take effect.

Details

Probe: I rendered FreshnessPage with an MSW handler that delays each SQL request by 300ms. Series ran 118→419ms, latest 429→730ms, and hydration 736→1037ms.

The doc comment on useFreshnessHydration says that taking IDs from objects keeps it from waiting on the lag query. But the component suspends at line 124 before line 126 runs, wherever the IDs come from.

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 latestByObjectId.

Issuing all three through one useSuspenseQueries would make them parallel again.

3. LOW -- Object names can contain the separators in the useFreshnessObjects key

console/src/platform/clusters/queries.ts:924

The comment says an identifier cannot contain these control characters. In fact the lexer rejects only NUL in a quoted identifier (src/sql-lexer/src/lexer.rs:249). A name containing \u0001 splits into two records, and a name equal to \u0002 decodes as null.

Details

Probe: object u10 named "orders\u0001u2" came back as u10 "orders", with no schema, database or type, so the type filter hides it. It also produced a phantom u2 named "public" with no type, and u2 is then included in the lag and hydration queries. Anyone who can create an object on the cluster can do this. Using JSON.stringify of the tuple list as the key avoids it.


return acc;
}, new Map<string, GraphLineSeries>());
const latest = useSuspenseQuery({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@leedqin

leedqin commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

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.

Optimization Before After Roughly Suggested in
Names and types from useAllObjects, lean lag query 17.7 ms; five index reads, including three full scans 11.7 ms; one index lookup ~1.5x faster, ~1/5 the catalog reads comment
Hydration out of the shared lag query +6-9 ms per call on Environment Overview, Cluster Overview and the object panel's 60s poll removed ~30-40% cheaper per call on those pages comment
Per-object columns out of the per-bin rows 11 columns per row 3 ~3.5x fewer columns comment
Stable memo inputs and memoized tables during a drag ~700 ms of work per pointer move (2000 objects) ~80 ms ~8-9x less work comment, comment
Series refetched once per bin 60s refetch of a ~500 ms query at 24h, ~8 mCPU per open tab ~2 mCPU ~4x less mz_catalog_server CPU at 24h (estimate from your bench) comment
useQuery instead of suspense queries (proposed) three requests in series three in parallel back to ~1 round trip per load comment
unnest for clusters over 330 objects (follow-up) 718 ms at 2000 IDs 62 ms ~10x comment, CNS-186

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 unnest measurements, since that's outside this PR.

@leedqin leedqin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
.orderBy(["bucket_start desc", "object_id", "lag desc"])
.orderBy(["bucket_start", "object_id", "lag desc"])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +79 to +83
// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
// 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));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in e834f9e, with a test that records each value shown and fails without the transition.

Comment on lines +120 to +125
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],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
  });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied with your test in 55438e1.

Comment on lines +163 to +174
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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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);
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8157092. Checked that it fails with current read from the last bin.

Comment on lines +924 to +933
// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Suggested change
// 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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 39fb494.

Comment on lines +953 to +988
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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON key:

Suggested change
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"],
    ]);
  });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1104 to +1105
staleTime: 60_000,
refetchInterval: 60_000,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jdonelson

Copy link
Copy Markdown
Contributor Author

All QA LLM Review findings are addressed:

  • Not-queryable objects scored as 0 lag: 61cd62e
  • URL parsers accepted prototype keys: 8f44898
  • Lag query returned newest bucket first: c7d58f4
  • One unreadable reading marked the object "Not queryable" right now: 61cd62e
  • Releasing a drag snapped the handle back: e834f9e
  • "Now" test could not fail: 8157092
  • A digit typed after the field committed was dropped: b09ad06
  • pMAX could read below "Now" between series fetches: 55438e1, a13cdbf
  • Series, latest and hydration requests ran in series: ef42dfb. Cluster Overview no longer fetches latest readings it never reads: 319b940
  • Object names could break the objects key: 39fb494

@leedqin
leedqin self-requested a review October 13, 2026 15:56

@leedqin leedqin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing all the comments and feedback! Can we make sure the flag works before we roll this out

@jdonelson
jdonelson merged commit 7cc38fb into main Oct 14, 2026
82 checks passed
@jdonelson
jdonelson deleted the jdonelson/CNS-164_freshness-page branch October 14, 2026 13:38
@bosconi bosconi mentioned this pull request Oct 16, 2026
maheshwarip added a commit that referenced this pull request Oct 23, 2026
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants