Repository navigation
console: show how a replica's workers share its CPU on the redesigned cluster page - #39889
Conversation
49d56ce to
340052e
Compare
QA LLM Review1. MEDIUM -- Skew verdicts can't fire on 2-worker replicas, which get "spread evenly" instead
DetailsWith two workers, This is the case the PR description motivates the drawer with: one worker pegged while A fix is to compare the busiest worker against the mean of the other workers. That gives 5× for |
340052e to
3c07311
Compare
QA LLM Review1. MEDIUM -- High-CPU verdict headline says "spread evenly" while its own body reports uneven work
This push turns uneven work under high replica CPU into a note on the DetailsThis branch is easy to reach. Take a 2-worker replica at one worker per core, with workers Before this push the headline was accurate, because an One fix is to drop "spread evenly across its workers" from the label when |
3c07311 to
e57a587
Compare
e57a587 to
5c304f9
Compare
QA LLM Review1. MEDIUM -- Finished ad-hoc queries keep resetting the verdict window, so replicas loaded by ad-hoc queries never get a verdict
Any reading where a worker's counter fell is marked DetailsA worker's counter sums only the dataflows that are still running. A slow-path peek runs in a transient dataflow, which I ran synthetic counters through the PR's
|
1e047f7 to
87f9e42
Compare
|
Both QA reviews are addressed in 87f9e42:
|
QA LLM Review1. MEDIUM -- The saturated-worker verdict names a busiest process even when the processes are even
The new process sentence names whichever process has the highest mean CPU, with no evenness threshold. A process with many workers barely moves when one of them saturates, so on large multi-process replicas the sentence often credits another process with "the most CPU" by about a point. It then links to the processes drawer, which applies Details
Take 2 processes of 62 workers, with w3 at 100% and every other worker at 20%. w3 adds (1 − 0.2)/62 ≈ 1.3 points to process 0, so process 0 reads about 21% and process 1 about 20%, a ratio of 1.06. Any larger difference between the processes, for example in work outside dataflows, decides which one comes out ahead. If process 1 wins, the verdict for w3 says "Process 1 used the most CPU, 21% against 20% for the other 1." That points away from the worker the verdict is about. "CPU and memory by process" then opens a drawer that says "CPU and memory are spread evenly across the processes." If process 0 wins, "Its process, process 0, used the most CPU" claims a lead the processes drawer doesn't count. The tests only use 61 vs 34 and 70 vs 40, both well past 1.25. One fix is to share |
87f9e42 to
80fef06
Compare
|
Busiest process named when the processes are even is addressed in 80fef06. |
80fef06 to
2fcf71c
Compare
QA LLM Review1. MEDIUM -- The busiest worker's breakdown says "about as much as the other workers" when it spent far less
DetailsThis needs one object hot on the busiest worker and another hot on a different worker, for example two keyed objects whose hot keys hash to different workers, or a single-key reduce. Take 4 workers,
The other workers averaged 7s on One fix is to treat the band from 1/ |
2fcf71c to
bbbd2b7
Compare
|
Breakdown calling a much smaller share even is addressed in bbbd2b7. Even is now the band from 1/1.25 to 1.25. Below it, the line says "less than the other workers" and gives the multiple. When the object is skewed onto another worker, it also names that worker, so it agrees with the table's hottest-worker cell. Your |
6d6d137 to
782792a
Compare
QA LLM Review1. MEDIUM -- Measure again is lost when clicked in the last minute of a reading session, and the report keeps the minute before the click
Details
I reproduced this in the sampler's own test harness ( Measure again is the action the description recommends for checking a fix that was just deployed. Someone who clicks late in a session is shown the finding from before the fix. One fix: when less than |
QA LLM Review1. MEDIUM -- A report restored after 20 minutes loses its replica CPU, and its verdict changes
A restored measurement now stays until Measure again, and it is restored for up to 30 minutes after it ended ( DetailsThe process-samples subscribe starts at mount time minus 20 minutes (
The skew verdicts also lose Before this push, a restored report was replaced within about a minute. Now it persists, which breaks the sampler's comment that it "shows the last report as it was". |
782792a to
580dafd
Compare
QA LLM Review1. MEDIUM -- The queries row's Query History link leaves out statements that began before the measured span, such as a long-running SUBSCRIBE
The "Queries and subscribes" row counts every session dataflow that ran during the span. Its link ( DetailsThe list query bounds Take an app that holds a SUBSCRIBE open on the cluster, like the one running in the PR's screenshots. Opened 10 minutes before the measurement, it takes most of the queries row's time. Clicking the row lists only the statements that began in the last few minutes, sorted by duration, so the short SELECTs at the top look like the cause. Statement logging writes a statement's begin record while it runs ( |
580dafd to
2d1e942
Compare
|
The four QA reviews since Oct 22 are addressed:
|
| const continues = | ||
| continuation && | ||
| lastPrior && | ||
| continuation.atMs === lastPrior.atMs && | ||
| continuation.rowTotals.every(([, ns]) => ns.length === workerCount); |
There was a problem hiding this comment.
🟡 Resized replicas retain phantom workers
When a replica changes worker count with no attributed rows, continues accepts the old worker totals. The empty rowTotals passes the length check, leaving removed workers in subsequent readings and producing incorrect CPU findings.
Learn more
The sampler saves its last worker totals in a reading and its per-dataflow state in a continuation. When no running dataflow has an attributed export, rowTotals is empty. Its every check accepts any worker count, even when the saved worker map came from a different size. accumulateWorkerElapsedNs copies that map, so removed workers remain present in later readings. workerBusyBetween includes every worker in the map, including the removed ones.
Example: A two-worker replica runs only unexported dataflows. After its sampler stops, resize it to one worker and reopen the tab. The empty saved row totals pass the check. The resulting measurement still includes w1, although the replica now runs only w0.
Recommended fix: Check the saved reading's worker IDs against workerCount before restoring previous, independently of whether rowTotals contains rows. Also invalidate a stored measurement when its worker count no longer matches the current replica.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 6d0af16. A new sampler now restores readings, object reads and the measurement only when the saved readings hold exactly this size's workers. Every dataflow runs on every worker, so that holds whether or not any rows were saved. The new sampler test is a 2-worker sampler followed by a 4-worker one, with no exported dataflow, and nothing carries over.
| if ( | ||
| !startReading || | ||
| !isComparable(startReading, endReading) || | ||
| hasDataflowCounterReset(start.rows, end.rows) | ||
| ) { | ||
| continue; |
There was a problem hiding this comment.
🟡 Intermediate counter resets distort CPU reports
If a dataflow counter falls then exceeds its starting value within a minute, measureLatest accepts that span. It compares only endpoint rows, so a restart can yield a misleading CPU report.
Learn more
A measurement compares snapshots at its start and end, but the sampler also keeps intermediate object reads. A restarted dataflow can reuse an id, report a lower counter on an intermediate read, then surpass its original value before the end read. growthSince treats the decreased counter as a new dataflow and adds its new lifetime to the carried totals. The endpoint-only reset check misses this, and the measurement combines time from both lifetimes.
Example: A dataflow reads 5 seconds at the start, restarts and reads 1 second halfway through, then reads 30 seconds at the end. The end exceeds the start, so the span is accepted even though the intermediate read shows the reset.
Recommended fix: Check consecutive object reads across each candidate span for counter resets, or retain a reset marker on each read and reject candidates crossing one. Keep the existing endpoint check for a reset not captured by an intermediate read.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
A measurement no longer differences its two end reads. Each 5 s reading carries every row's total on by each dataflow's growth, and a counter below its last value counts as a new dataflow, so a restart inside the span is counted at the reading after it. The endpoint check is an extra guard. Your example is now a sampler test in 6d0af16: the dataflow starts at 5 s, restarts after 25 s and ends at 35 s, and its object gets all 60 s it ran. The two end reads alone would give 30.
494d643 to
6d0af16
Compare
… memory by object (#40102) ### Motivation Follow-up to #39508, #39510 and #39607, which merged with review findings still open: a QA review on #39510, and a self-review of the three PRs with the Console review skill. It sits at the bottom of the cluster details stack, under #39889. ### Description **Memory by object** - **A load error no longer sticks across replicas.** `LargestReplicaBoundary`'s error boundaries never reset when the Replica select changed, so after one replica's objects failed, every replica picked next showed the same "might mean it's busy" alert under its own name, and stopped polling. `useResetOnChange` re-keys a boundary once the replica differs from the one it caught an error for. A boundary that hasn't caught keeps its key: a plain `key={replica}` would swap the card for its loading spinner on every pick, because the router's navigation runs inside `startTransition` and a new boundary has no content to keep. Coming back to a replica that failed from a healthy one shows its cached error without a new request, until react-query evicts it. - **Deep link, then All replicas, no longer blanks the card.** Opening `?replica=r1` and then picking All replicas showed the spinner until the largest-replica query first returned. The loader now keeps the last replica on screen, dimmed with a refreshing spinner, while that query is pending. `useLastDefined` moves to `hooks/` for both uses. - **Multi-process replicas.** The bar is replica-wide (objects and heap use summed over processes, against the size's heap limit times its processes), while the resource usage card shows the fullest process, so a replica at 95% and 20% read 57.5% here. The subtitle now says so: "% of heap limit across 2 processes on r1". The Other memory tooltip also says it counts the objects past the top ten and memory that arrangement sizes don't count, not only undercounting. **Resource usage card** - **The tooltip's last memory row says what it measures.** Memory and Disk read against their own limits, summed over processes, as the Clusters list does. The row below them was still the fullest process against one process's heap limit, the bar's value, so on a multi-process replica it read less than its parts. It's now "Heap … of heap limit" on one process and "Fullest process … of process heap limit" on more. - **The header wraps.** The toggles' `flex="1"` gave them a zero basis, so `flexWrap` never applied, and at narrow widths a long replica name overlapped the title. Checked in Chromium at 360 and 900px. - **Small CPU readings** read "< 0.01 cores" instead of "0.00 cores". - **The past-replicas swatch** stops at three stripes. - **The test fixture's heap limit** is memory plus disk, as the controller sets it. ### Verification - `MemoryByObject.test.tsx`: a replica's load error is replaced by the next replica's objects; picking a replica inside `startTransition` keeps the old card with no spinner; a deep link then All replicas keeps the last replica dimmed; the subtitle names the process count; the Other memory tooltip copy. - `ResourceUsage.test.tsx`: the heap row's label on one process and on a two-process replica, with readings consistent with the fixture; "< 0.01 cores"; the swatch's stripe cap. - Each new test fails with its fix reverted. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jdonelson
left a comment
There was a problem hiding this comment.
The one-minute report is much easier to read and act on than the live charts, and thanks for following up on both of my earlier comments. Unit tests pass (758) and typecheck is clean. I didn't run the SQL tests. The main thing is the comment on workerVerdict: several findings contradict the bars or each other at the ladder's edges, and I wonder if the summary needs a different shape. The countdown and the CPU fill are smaller bugs, and the rest are nits.
| export function workerVerdict(workers: WorkerBusy[]): WorkerVerdict { | ||
| const busiest = workers[0]; | ||
| const averageBusyFraction = averageBusy(workers); | ||
| const othersBusyFraction = averageBusy(workers.slice(1)); | ||
| if ( | ||
| !busiest || | ||
| (averageBusyFraction < IDLE_AVERAGE_FRACTION && | ||
| busiest.busyFraction < IDLE_BUSIEST_FRACTION) | ||
| ) { | ||
| return { kind: "idle" }; | ||
| } | ||
| const result = { busiest, averageBusyFraction, othersBusyFraction }; | ||
| if (averageBusyFraction >= ALL_BUSY_FRACTION) { | ||
| return { kind: "allBusy", ...result }; | ||
| } | ||
| const isSkewed = | ||
| workers.length > 1 && | ||
| busiest.busyFraction >= SKEW_RATIO * othersBusyFraction; | ||
| if (isSkewed && busiest.busyFraction >= SATURATED_FRACTION) { | ||
| return { kind: "saturated", ...result }; | ||
| } | ||
| if (isSkewed && busiest.busyFraction >= UNEVEN_FRACTION) { | ||
| return { kind: "uneven", ...result }; | ||
| } | ||
| if (isSkewed) return { kind: "light", ...result }; | ||
| return { kind: "balanced", ...result }; |
There was a problem hiding this comment.
I wonder if one ladder is the right shape for the summary. It answers several separate questions at once: is a worker at its limit, is the work uneven, is the replica out of headroom, and what's to blame. On a real replica two or three of these can be true together, but the ladder reports one, so the others get dropped or contradicted:
- w0 at 100% with the others at 55% is only 1.8× the others, so it reads "Work is spread evenly… Skew isn't slowing r1 down".
- 15 workers at 96% and one at 30% reads "every worker is busy… the work is already spread evenly".
- Idle says "No object did measurable work" while the bars below show an object working, and it runs before the one-worker wording.
- "Queries and subscribes" is the culprit in a skew finding, with cross join and hot key advice, but is skipped in the busy and high-CPU findings, which then name a smaller object as "the most".
- The chart calls the busiest worker "Overloaded" whenever work is uneven, while the summary saves that word for 90%+.
The 2× cutoff also flips the story between 1.9× ("spread evenly") and 2.1× (overloaded). Could each question be answered on its own, with the headline from the most urgent and the body still stating the rest? Then a pegged worker always gets mentioned, and the chart and summary could share the same answers.
There was a problem hiding this comment.
Agreed, done in 25e3503. Each verdict now carries the workers at their core's limit, the headline comes from the most urgent finding, and the body still states the rest:
- A worker at its limit that leads the others by 1.25× to 2× is its own finding: "w0 is at its core's limit… short of the 2× that counts as skew, but work routed to w0 waits on it." So 1.8× no longer reads as even, and 1.9× and 2.1× both name the pegged worker.
- When workers average 90%+ but one lags, the summary counts the ones at their limit: "15 of 16 workers are at their core's limit, and the least busy, w15, is at 30%." The high CPU path does the same.
- Idle says how busy the busiest worker was rather than that no object worked, and a one-worker replica gets its wording before the idle check.
largestSharecounts the queries row liketopObjectOnWorkerdoes, so every finding blames the same row. A skew finding on the queries row drops the cross join and hot key advice and links to the queries that ran.- The bars take their label from the same verdict (
markedWorker): "Overloaded worker" only when saturated, "Worker at its core's limit" when pegged, "Busiest worker" when uneven.
Balanced work between 1.25× and 2× now reads "Work leans a little on w0" rather than "spread evenly". All five of your cases are tests in WorkerCpuVerdict.test.tsx.
There was a problem hiding this comment.
Thanks, this reads much better. One gap left: the skew and pegged findings don't use atLimit. With w0 and w1 both at 100% on different objects, it names only w0 and orders_idx, averages w1 into "the other 3 workers average 53%", and marks only w0's bar. The QA bot's latest review has the cases. Could these findings state the other workers at their limit too, and mark them on the bars?
| const isMeasuringAgain = state.isMeasuring && state.status === "reading"; | ||
| const secondsLeft = | ||
| state.measuringFromMs !== undefined && last | ||
| ? Math.max( | ||
| 0, | ||
| Math.ceil( | ||
| (MEASUREMENT_MS - (last.atMs - state.measuringFromMs)) / 1000, | ||
| ), | ||
| ) | ||
| : undefined; |
There was a problem hiding this comment.
If the browser tab is hidden (or the user pauses) during Measure again, the countdown reaches "0s left" and stays there about 30s before the report lands. The measurement restarts its minute after the pause, but the countdown still counts from the original click. Could the pause reset the countdown too?
There was a problem hiding this comment.
Fixed in 25e3503. A pause or a failed read now clears the asked-for measurement's start, and the first read after it starts the countdown again, so it counts the minute that will actually be measured. There's a sampler test that pauses partway through and resumes.
| y0={(point) => yScale(point.dataflowPercent)} | ||
| y1={(point) => | ||
| yScale( | ||
| Math.max(point.dataflowPercent, point.replicaPercent ?? 0), | ||
| ) | ||
| } | ||
| fill={outsideFill} | ||
| pointerEvents="none" | ||
| /> | ||
| ))} | ||
| <LinePath | ||
| data={replicaCpu} | ||
| x={x} | ||
| y={(point) => yScale(point.percent)} | ||
| stroke={color} | ||
| strokeWidth={2} | ||
| pointerEvents="none" | ||
| /> |
There was a problem hiding this comment.
The Other CPU fill and the Replica CPU line read the same per-minute samples differently. The fill steps across each whole minute, while the line joins each minute's start. In the first screenshot, the fill sits at ~25% from 17:26:30 to 17:27 with no Replica CPU line above it. Maybe draw the line as steps too (curveStepAfter), so the fill always sits between the two lines?
There was a problem hiding this comment.
Done in 25e3503. The Replica CPU line uses curveStepAfter and holds its last sample to the end of that minute, the same minute replicaCpuAt gives the fill. A chart test checks the line's vertices step.
| } | ||
|
|
||
| export const TimeAxis = ({ width, xScale }: TimeAxisProps) => { | ||
| export const TimeAxis = ({ width, xScale, tickFormat }: TimeAxisProps) => { |
There was a problem hiding this comment.
With tickFormat's clock times, the evenly split ticks land on odd seconds (17:24:13, 17:24:50, …), and once the window slides they change on every 5s reading. When a tickFormat is passed, could the ticks come from xScale.ticks(TIME_TICK_COUNT), so they land on round times and stay put?
There was a problem hiding this comment.
Done in 25e3503. With a tickFormat, TimeAxis takes xScale.ticks(TIME_TICK_COUNT), so the clock labels land on round times and stay put as the window slides. The relative-time axes keep their even split. There's a test that slides the window 5s.
| * The subscribe's upper: `getSnapshot().data` holds every update before it. | ||
| * Undefined until the snapshot completes. It applies the updates waiting for | ||
| * the flush interval, so read the snapshot after calling it. | ||
| */ | ||
| getUpper = () => { | ||
| this.flushSocketBuffer(); | ||
| return this.currentState.snapshotComplete | ||
| ? this.currentTimestamp | ||
| : undefined; | ||
| }; |
There was a problem hiding this comment.
Nit: when the snapshot completes, setState({ snapshotComplete: true }) notifies listeners before currentTimestamp moves to the new progress timestamp, so a listener calling getUpper() then gets the previous upper. The sampler avoids it by deferring, but since this is new on a shared class, maybe set currentTimestamp before that setState?
There was a problem hiding this comment.
Done in 25e3503. currentTimestamp now moves to the progress timestamp before the setState that announces the snapshot, with a test that a listener reads the new upper.
| averageNs: number; | ||
| hottestWorkerId: number; | ||
| /** | ||
| * The hottest worker's time over the other workers' average, infinite when | ||
| * they spent none. An average that includes the hottest worker would cap | ||
| * this at the worker count. | ||
| */ | ||
| ratio: number; | ||
| /** Time the hottest worker spent beyond the other workers' average. */ | ||
| extraNs: number; |
There was a problem hiding this comment.
Nit: averageNs and extraNs aren't read outside a test. Can we drop them?
|
|
||
| expect(url.pathname).toBe("/regions/aws-us-east-1/query-history"); | ||
| expect( | ||
| queryHistoryListSchema.parse( |
There was a problem hiding this comment.
Nit: the route parses with queryHistoryListUrlSchema, which also requires REQUIRED_COLUMNS, and strips the whole search when that fails. Parsing with it here (and at line 152) would make the test catch a link that opens the unfiltered list.
There was a problem hiding this comment.
Done in 25e3503. Both tests parse with queryHistoryListUrlSchema, and they pass, so the route keeps the links' filters.
6d0af16 to
3901bbb
Compare
QA LLM Review1. MEDIUM -- When several workers are at their core's limit, the saturated and pegged findings name only w0
The skew branch, which now also handles DetailsI rendered
Only the |
| export function workerVerdict(workers: WorkerBusy[]): WorkerVerdict { | ||
| const busiest = workers[0]; | ||
| const averageBusyFraction = averageBusy(workers); | ||
| const othersBusyFraction = averageBusy(workers.slice(1)); | ||
| if ( | ||
| !busiest || | ||
| (averageBusyFraction < IDLE_AVERAGE_FRACTION && | ||
| busiest.busyFraction < IDLE_BUSIEST_FRACTION) | ||
| ) { | ||
| return { kind: "idle" }; | ||
| } | ||
| const result = { busiest, averageBusyFraction, othersBusyFraction }; | ||
| if (averageBusyFraction >= ALL_BUSY_FRACTION) { | ||
| return { kind: "allBusy", ...result }; | ||
| } | ||
| const isSkewed = | ||
| workers.length > 1 && | ||
| busiest.busyFraction >= SKEW_RATIO * othersBusyFraction; | ||
| if (isSkewed && busiest.busyFraction >= SATURATED_FRACTION) { | ||
| return { kind: "saturated", ...result }; | ||
| } | ||
| if (isSkewed && busiest.busyFraction >= UNEVEN_FRACTION) { | ||
| return { kind: "uneven", ...result }; | ||
| } | ||
| if (isSkewed) return { kind: "light", ...result }; | ||
| return { kind: "balanced", ...result }; |
There was a problem hiding this comment.
Thanks, this reads much better. One gap left: the skew and pegged findings don't use atLimit. With w0 and w1 both at 100% on different objects, it names only w0 and orders_idx, averages w1 into "the other 3 workers average 53%", and marks only w0's bar. The QA bot's latest review has the cases. Could these findings state the other workers at their limit too, and mark them on the bars?
25e3503 to
4972719
Compare
|
Re the QA review: fixed in d39f686.
|
d39f686 to
f911f96
Compare
… cluster page Adds a Workers tab to the redesigned cluster page. It reads the replica's per-worker scheduling counters over one WebSocket session while open, and reports one measured minute until Measure again: a finding that names the overloaded worker and the object behind it, or says why it isn't skew, one bar per worker, the objects ranked by their time on the worker picked from the bars with their spread and freshness, and the replica's CPU against the workers' time.
…own, CPU steps, clock ticks
The Workers tab no longer subscribes on its own. Its first look explains what measuring costs the replica and starts on a "Start measuring" click, so opening the tab, or picking another replica, adds no load until asked. The sampler gains an `idle` status that a hidden tab can't resume. Also: - Name the CPU outside the workers' dataflows "Overhead" in the chart legend, its tooltip, and the verdicts. - Clamp long object names to one line with an ellipsis, keeping the link only as wide as its text so the rest of the cell still picks the row. - Reword the Spread column's tooltip around its formula. - Keep chart tooltips from taking the pointer. One anchored to its mark could open under the pointer, hide on the mark's leave, and flicker.
A skew or pegged finding named only the busiest worker, so a second worker at its limit, and the object busying it, went unmentioned, and the bars marked one worker. - When several workers are at their limit short of skew, the finding is about all of them: "3 of 4 workers are at their core's limit", the least busy worker, and the objects taking the most of their time, instead of the busiest worker's lead by a tie-break. - An overloaded worker's finding names the other workers at their limit, with any object busying them other than the finding's. - The bars mark every worker at its limit. - A pegged worker's object gets the skew EXPLAIN, like the skew cause, only when the object leans on it.
f911f96 to
299748d
Compare
QA LLM Review1. MEDIUM -- The multi-worker finding says no worker leads by 2×, then gives the hot-key cause for "that pattern"
When DetailsI rendered
With One fix is to state the object's lean in |
Motivation
Part of the cluster details redesign stack, on top of main. The CPU chart shows a replica's CPU as a share of its allocation, averaged over its workers. The CPU troubleshooting guide points out that a replica whose work sits on one worker can peg a core while reporting a modest
cpu_percent, and that high CPU with evenly spread work points elsewhere: an expensive object, ad-hoc queries, or an undersized cluster. The Console had no way to tell these apart.This adds a Workers tab next to Metrics. It answers two questions in one sentence: is a worker overloaded, and which object is behind it. The tab shares the page's
?replica=choice with Metrics, offers every replica whose size it knows, and opens with a short explanation of workers and skew. The CPU chart's Workers button and the Processes drawer link to it on the same replica.An earlier version of this PR showed live charts of the busiest and the average worker, a busiest-workers card, and per-worker bars on every object row. In review they were hard to read and to act on: the numbers moved every few seconds, the worker count wasn't shown, and terms mixed workers, threads, dataflows and objects. This version reports one fixed minute and keeps the vocabulary to workers and objects.
Description
What it shows. A report of one measured minute that holds still until Measure again. The replica's CPU and the objects' freshness are recorded with the measurement, so a late metrics sample, a refetch on window focus, or coming back to the tab doesn't change a report that's already shown:
EXPLAIN ANALYZE CPU WITH SKEW, pinned to the replica, finds the step in the plan and its key. It's offered only for an object that leans on the worker. When no object takes most of the worker's time, it says so. On a multi-process replica it places the worker among the processes, as before.introspection-subscribe-andmetric-sink-dataflows the coordinator runs on every replica. A finding never names this row.What it reads. The tab reads only after Start measuring, while it's open and its browser tab is visible, through one SUBSCRIBE on one session pinned to the replica with
clusterandcluster_replica:mz_scheduling_elapsed_rawjoined tomz_dataflow_addresses_per_workeron(id, worker_id), the key of both indexes, and filtered to root operators (list_length(address) = 1) beforecount(*). A root's time includes its children's, so summing roots counts each nanosecond once.mz_compute_exports, to name each dataflow. A subscribe's export is tagged with its session's connection id throughmz_subscriptionsandmz_sessions. A transient (t) export carries its dataflow's name, read per worker on the operators index's key. The coordinator names its own dataflowsintrospection-subscribe-andmetric-sink-(src/adapter/src/coord). Every other transient dataflow runs a session's statement, namedsubscribe-,oneshot-select-,copy-to-orfrontend-read-then-write-subscribe-.Then:
UP TObefore that minute lands, it opens a new session first.process_peeks, insrc/compute/src/server.rs). Both show only as overhead.mz_wallclock_global_lag_recent_historyby object id, onmz_catalog_server), read once when a measurement lands and recorded on it.BackendKeyData). It remembers those ids across its sessions, because a closed session's tag leaves about a second before its dataflow does.UP TO10 minutes past opening or resuming, so it ends on the server even when the browser stops reading; reaching it reads as the 10-minute pause. Reading also pauses while the browser tab is hidden, and stops when leaving the tab.useReplicaProcessSamples), an index lookup onreplica_idover the last 20 minutes. The finding's CPU, and the busiest process, are recorded once the samples covering the measurement have loaded. If the metrics can't be read, the tab says the finding leaves the replica's CPU out.The tab ships with the redesign, so it is behind the same UI preview flag and off in self-managed. A classic-page user following a
/workerslink lands on the classic overview.Counter semantics. A dataflow's time leaves the counters when it shuts down (
handle_shutdowninsrc/compute/src/logging/timely.rs). Readings carry totals on by each dataflow's growth, so they don't fall when one finishes. A dataflow new since the last reading counts in full, and one whose counter fell counts as new, as for an id reused after a restart. A measurement never spans a per-dataflow counter fall or a pause.Supporting changes.
useObjectLinkis shared with the memory by object card.useBuildWorkflowGraphPathreturns a stable callback, so the memos built on it hold.queryHistoryPathinqueryHistoryUtils.tsbuilds a Query History link from the list's own URL encoding.ThresholdLineandTIME_TICK_COUNTare exported for the worker bars and the Workers CPU chart.TimeAxistakes atickFormat, and the Workers chart adds seconds when ticks are under a minute apart.SubscribeCollectiongainsgetRows.workers.GraphTooltipno longer takes pointer events. A tooltip anchored to its mark, as the worker bars' is, could open under the pointer, take it, fire the mark's leave and flicker. No chart puts anything clickable in it.SubscribeManagergainsgetUpper,getConnectionIdandisComplete, and its flush takes the buffer before notifying, so a listener can read the upper.buildSubscribeQuerytakesupToMs.mockSubscribeSockethands each connection to the test, with its id, options,advanceandcomplete.ReplicaSelectis shared by the Metrics and Workers tabs, and the tab links carry?replica=. With no replica in the URL, the Workers tab shows the first and leaves the URL alone, so the other tabs keep showing every replica; a replica the cluster doesn't have, or one whose size is unknown, gets a note instead of a different replica's readings.currentReplicaColoris shared with the Metrics tab's colors.processReadingsModel.tsexports its evenness rule (EVEN_RATIO) andotherProcessesLabel.Cost. Measured on a local replica, the earlier three-subscribe version against this subscribe, one viewer; each row is one replica:
mz_catalog_serverCPU. The freshness query adds one lookup per measurement there.src/environmentd/src/http/sql.rs~1175).Screenshots
From a local Materialize running a demo cluster of 2 processes × 2 workers. A cross-join materialized view keeps one worker busy while orders stream in, and a SUBSCRIBE and keyed SELECTs run alongside it. Local sizes have no CPU limit, so for these screenshots the browser gave each process the host's 16 cores as its allocation. Everything else is the tab's real readings.
Work leaning on w1, with replica CPU reading only 23%. The finding names the materialized view, the table pools the queries and Materialize's own dataflows, and the CPU chart shades the measured span:
Hovering w1:
After clicking w2, the line under the bars and the objects table follow w2:
A single-worker replica:
Verification
workerCpuModel.test.ts:workerCpuSampler.test.ts, through realSubscribeManagers overmockSubscribeSocket:UP TObound, pauses for the limit, hidden tabs and the user, waiting for counters, cadence under lag, own-subscribe exclusion and continuation across samplers.WorkerCpuVerdict.test.tsx:WorkerBars.test.tsx:ObjectCpuTable.test.tsx:WorkerCpuCharts.test.tsx: the legend, the measured span's position and clipping, the tooltip rows including the overhead, and reading the replica CPU minute that holds the cursor.ClusterWorkers.test.tsx, with fake timers overmockSubscribeSocket:queryHistoryUtils.test.ts: the Workers tab's Query History links read back, through the list's own URL parsing, as the cluster's statements from every user in the range, and as its running statements, oldest first.ClusterDetail.test.tsx: the Workers tab route, the tab strip carrying the replica, and the classic page's redirect.ResourceUsage.test.tsx: the CPU chart's Workers link and the Processes drawer's link go to the tab on the selected replica, and the tab's link back opens the Processes drawer.workerCpu.test.sql.ts, against a real Materialize:UP TO, and rounding cuts updates.Follow-ups
UP TOand the counters' lag both come from the browser clock. A browser clock running 10 or more minutes behind environmentd would fail every open.sexports aren't read, so their dataflows show as other dataflows.🤖 Generated with Claude Code