Skip to content

Commit 767b1a7

Browse files
committed
compute: expose replica peak memory and disk usage
Replica resource usage is only visible as periodic orchestrator samples, roughly one per minute, so a spike between two samples is invisible. A hydration episode that starts and finishes inside one sampling gap cannot be recovered at all. Track a high-water mark instead. `mz_metrics::usage` samples memory, heap and disk on the existing periodic metrics task and folds monotonic peaks into process-global state, which a new compute logging dataflow reports as `mz_introspection.mz_cluster_peak_usage`, one row per replica process. The peaks are also registered as Prometheus gauges. Peaks are monotonic for the lifetime of the process and are never reset. A peak that resets is not composable: whoever reads it first consumes it, a retried read loses it, and two consumers reading at different times disagree about the same episode. Monotone peaks still answer the hydration question, because a replica starts fresh, so at the moment it finishes hydrating its since-start peak is its hydration peak. `memory_bytes` is exact, taken from the kernel's own high-water mark (`getrusage`'s `ru_maxrss`). `heap_bytes` and `disk_bytes` have no kernel-side equivalent, so they are maxima over samples and are therefore lower bounds. Sampling runs on the metrics task rather than in the logging operator, because a timely worker saturated by hydration stops scheduling its logging operators during exactly the episode whose peak we want. Sampling cadence is a new dyncfg, `mz_metrics_peak_usage_refresh_interval`, kept separate from `memory_limiter_interval`, which governs OOM-kill behavior and must not be retuned for introspection's sake. Tests: unit tests in `mz_metrics::usage` cover monotonicity, the `heap_bytes >= memory_bytes` ordering and the unmeasured-value case; a new `test-peak-usage` workflow in test/cluster asserts one row per process and that peaks never go backwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016V6ARZQJJ5RpXRci6oY6y9
1 parent 39dcae2 commit 767b1a7

34 files changed

Lines changed: 926 additions & 144 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Design: Replica Peak Resource Usage
2+
3+
## Summary
4+
5+
Replica resource usage is visible only as periodic samples taken by the orchestrator, roughly one
6+
per minute, landing in `mz_cluster_replica_metrics_history`. A memory spike between two samples is
7+
invisible, so the usage of a hydration episode that starts and finishes inside one sampling gap
8+
cannot be recovered at all.
9+
10+
This adds a measured high-water mark instead: each replica process tracks the peak memory, heap
11+
and disk usage it has reached, and compute introspection exposes those peaks as
12+
`mz_introspection.mz_cluster_peak_usage`.
13+
14+
## Semantics
15+
16+
Peaks are monotonic for the lifetime of the process, and are never reset.
17+
18+
The alternative, max-since-last-flush, makes a window's peak recoverable by taking the maximum of
19+
the rows in that window. It also makes every reader destructive: whoever reads first consumes the
20+
value, a retried read loses a peak, and two consumers cannot both see the same episode. In a
21+
system where the peak is read by ad-hoc SQL, that is not workable.
22+
23+
Monotone peaks still answer the question hydration visibility asks. A replica starts fresh, so at
24+
the moment it finishes hydrating, its since-start peak *is* its hydration peak. More generally,
25+
for a monotone series `M`, the peak over a window `(t1, t2]` is `M(t2)` whenever `M(t2) > M(t1)`,
26+
and is otherwise bounded above by `M(t1)`.
27+
28+
The cost is that peaks live and die with the process. A restarted replica reports the peaks of its
29+
new process, and the old ones are gone. Persisting peaks across replica lifetimes is deliberately
30+
not part of this work.
31+
32+
## Precision
33+
34+
`memory_bytes` is exact. `getrusage`'s `ru_maxrss` is a high-water mark the kernel maintains
35+
itself, so no spike can pass between two of our observations unseen.
36+
37+
`heap_bytes` and `disk_bytes` have no such kernel-side counter, so they are maxima over samples
38+
and are therefore lower bounds: a spike shorter than the sampling interval can be missed. The
39+
sampling interval is `mz_metrics_peak_usage_refresh_interval` (5s by default), separate from
40+
`memory_limiter_interval`, which governs OOM-kill behavior and must not be retuned for
41+
introspection's sake.
42+
43+
`heap_bytes` folds in the `memory_bytes` peak, since peak heap is at least peak memory. That keeps
44+
`heap_bytes >= memory_bytes` true even when `ru_maxrss` catches a spike that sampling missed.
45+
46+
## Where the peaks are measured
47+
48+
In `mz_metrics::usage`, on the periodic task that already samples `rusage` and lgalloc stats. That
49+
task runs on the tokio runtime, independent of the timely workers.
50+
51+
The tempting alternative, folding the maximum inside the compute logging operator, samples exactly
52+
where sampling is least reliable: a logging operator only runs when its worker schedules it, and a
53+
worker saturated by hydration is precisely the case whose peak we want. Keeping the fold in the
54+
sampler also means a slow reader cannot lose a peak, because it reads an already-monotone value
55+
rather than a series of instantaneous ones.
56+
57+
## How the peaks reach SQL
58+
59+
A new `ComputeLog::PeakUsage` logging dataflow reads the process-global peaks and emits one row
60+
per process, following `ComputeLog::PrometheusMetrics`: the usage is per-process, not per-worker,
61+
so one worker per process reports and the rest drop their capability.
62+
63+
The peaks are also registered as Prometheus gauges (`mz_metrics_peak_*_bytes`), which costs
64+
nothing extra since the sampler already holds the values, and which makes them scrapable without
65+
going through SQL.
66+
67+
## Alternatives considered
68+
69+
- **Extend `/api/usage-metrics` and `mz_cluster_replica_metrics_history`.** Gets 30-day retention
70+
and after-the-fact queryability for free. Rejected as the primary surface because the transport
71+
is a poll of an HTTP endpoint by the orchestrator, so reporting a peak means either making the
72+
endpoint destructive or duplicating peaks across polls, and because the peaks then inherit the
73+
orchestrator's poll cadence. Worth revisiting as the persistence story.
74+
- **Reuse `mz_cluster_prometheus_metrics`.** The gauges appear there automatically, so this needs
75+
no catalog change at all. Rejected as the primary surface: values are untyped `double`, the
76+
relation is a debugging escape hatch rather than a documented contract, and it is gated by its
77+
own scrape-interval dyncfg, so a config change would silently remove the peaks.

doc/user/content/reference/system-catalog/mz_introspection.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,26 @@ The `mz_compute_operator_durations_histogram` view describes a histogram of the
153153
<!-- RELATION_SPEC_UNDOCUMENTED mz_introspection.mz_compute_operator_durations_histogram_per_worker -->
154154
<!-- RELATION_SPEC_UNDOCUMENTED mz_introspection.mz_compute_operator_durations_histogram_raw -->
155155

156+
## `mz_cluster_peak_usage`
157+
158+
The `mz_cluster_peak_usage` source describes the peak resource usage of each process of a cluster
159+
replica, as a high-water mark measured since the process started. Peaks never decrease and are
160+
never reset, so a replica that has just finished hydrating reports the peak usage of its
161+
hydration.
162+
163+
Peaks are measured by sampling, so a spike shorter than the sampling interval can be missed and
164+
the reported values are lower bounds. The exception is `memory_bytes`, which the operating system
165+
tracks itself and which is therefore exact. A `NULL` means the value could not be measured, for
166+
example because the replica has no disk.
167+
168+
<!-- RELATION_SPEC mz_introspection.mz_cluster_peak_usage NO_COMMENTS -->
169+
| Field | Type | Meaning |
170+
|----------------|-----------|-------------------------------------------------------------------------------|
171+
| `process_id` | [`uint8`] | The ID of the process within the replica. |
172+
| `memory_bytes` | [`uint8`] | Peak memory (RAM) usage, in bytes. |
173+
| `heap_bytes` | [`uint8`] | Peak heap (RAM + swap) usage, in bytes. Always at least `memory_bytes`. |
174+
| `disk_bytes` | [`uint8`] | Peak disk usage, in bytes. |
175+
156176
## `mz_cluster_prometheus_metrics`
157177

158178
The `mz_cluster_prometheus_metrics` source exposes Prometheus metrics collected from each cluster replica process's internal metrics registry.

doc/user/data/metrics.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,18 @@ metrics:
13711371
help: user CPU time used
13721372
source: src/metrics/src/rusage.rs
13731373
visibility: internal
1374+
- name: mz_metrics_peak_disk_bytes
1375+
help: Peak disk usage since process start.
1376+
source: src/metrics/src/usage.rs
1377+
visibility: internal
1378+
- name: mz_metrics_peak_heap_bytes
1379+
help: Peak heap (RAM + swap) usage since process start.
1380+
source: src/metrics/src/usage.rs
1381+
visibility: internal
1382+
- name: mz_metrics_peak_memory_bytes
1383+
help: Peak memory (RAM) usage since process start.
1384+
source: src/metrics/src/usage.rs
1385+
visibility: internal
13741386
- name: mz_metrics_update_duration_bucket
13751387
help: The time it took to update lgalloc stats
13761388
labels:

misc/python/materialize/mzcompose/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,7 @@ def get_default_system_parameters(
751751
"mz_metrics_lgalloc_map_refresh_interval",
752752
"mz_metrics_lgalloc_refresh_interval",
753753
"mz_metrics_rusage_refresh_interval",
754+
"mz_metrics_peak_usage_refresh_interval",
754755
"compute_peek_response_stash_batch_max_runs",
755756
"compute_peek_response_stash_read_batch_size_bytes",
756757
"compute_peek_response_stash_read_memory_budget_bytes",

misc/python/materialize/parallel_workload/action.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3284,6 +3284,7 @@ def __init__(
32843284
"mz_metrics_lgalloc_map_refresh_interval",
32853285
"mz_metrics_lgalloc_refresh_interval",
32863286
"mz_metrics_rusage_refresh_interval",
3287+
"mz_metrics_peak_usage_refresh_interval",
32873288
"compute_peek_stash_num_batches",
32883289
"compute_peek_stash_batch_size",
32893290
"compute_peek_response_stash_batch_max_runs",

src/adapter/src/catalog/open/builtin_schema_migration.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,23 @@ static MIGRATIONS: LazyLock<Vec<MigrationStep>> = LazyLock::new(|| {
392392
MZ_CATALOG_SCHEMA,
393393
"mz_audit_events",
394394
),
395+
// Required because we added the `mz_cluster_peak_usage` builtin log.
396+
// make_mz_indexes and make_mz_sources inline the builtin-log set as
397+
// VALUES, so adding one changes both MVs' SQL fingerprints. See the
398+
// NOTE above: this version must stay at the workspace's current dev
399+
// version until the change ships.
400+
MigrationStep::replacement(
401+
"26.39.0-dev.0",
402+
CatalogItemType::MaterializedView,
403+
MZ_CATALOG_SCHEMA,
404+
"mz_indexes",
405+
),
406+
MigrationStep::replacement(
407+
"26.39.0-dev.0",
408+
CatalogItemType::MaterializedView,
409+
MZ_CATALOG_SCHEMA,
410+
"mz_sources",
411+
),
395412
]
396413
});
397414

src/catalog/src/builtin.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,7 @@ pub static BUILTINS_STATIC: LazyLock<Vec<Builtin<NameReference>>> = LazyLock::ne
10901090
Builtin::Log(&MZ_COMPUTE_EXPORTS_PER_WORKER),
10911091
Builtin::Log(&MZ_COMPUTE_DATAFLOW_GLOBAL_IDS_PER_WORKER),
10921092
Builtin::Log(&MZ_CLUSTER_PROMETHEUS_METRICS),
1093+
Builtin::Log(&MZ_CLUSTER_PEAK_USAGE),
10931094
Builtin::Log(&MZ_MESSAGE_COUNTS_RECEIVED_RAW),
10941095
Builtin::Log(&MZ_MESSAGE_COUNTS_SENT_RAW),
10951096
Builtin::Log(&MZ_MESSAGE_BATCH_COUNTS_RECEIVED_RAW),

src/catalog/src/builtin/mz_introspection.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,21 @@ pub static MZ_CLUSTER_PROMETHEUS_METRICS: LazyLock<BuiltinLog> = LazyLock::new(|
248248
}),
249249
});
250250

251+
pub static MZ_CLUSTER_PEAK_USAGE: LazyLock<BuiltinLog> = LazyLock::new(|| BuiltinLog {
252+
name: "mz_cluster_peak_usage",
253+
schema: MZ_INTROSPECTION_SCHEMA,
254+
oid: oid::LOG_MZ_CLUSTER_PEAK_USAGE_OID,
255+
variant: LogVariant::Compute(ComputeLog::PeakUsage),
256+
access: vec![PUBLIC_SELECT],
257+
ontology: Some(Ontology {
258+
entity_name: "cluster_peak_usage",
259+
description: "Peak resource usage of each process of the cluster replica, \
260+
measured since the process started.",
261+
links: &const { [] },
262+
column_semantic_types: &[],
263+
}),
264+
});
265+
251266
pub static MZ_COMPUTE_FRONTIERS_PER_WORKER: LazyLock<BuiltinLog> = LazyLock::new(|| BuiltinLog {
252267
name: "mz_compute_frontiers_per_worker",
253268
schema: MZ_INTROSPECTION_SCHEMA,

src/catalog/src/durable/transaction.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,7 @@ impl<'a> Transaction<'a> {
949949
LogVariant::Compute(ComputeLog::DataflowGlobal) => 31,
950950
LogVariant::Compute(ComputeLog::OperatorHydrationStatus) => 32,
951951
LogVariant::Compute(ComputeLog::PrometheusMetrics) => 33,
952+
LogVariant::Compute(ComputeLog::PeakUsage) => 34,
952953
};
953954

954955
let mut id: u64 = u64::from(cluster_variant) << 56;

0 commit comments

Comments
 (0)