Skip to content

Commit 8c98a88

Browse files
committed
adapter: record durable object hydration history
### Motivation Materialize exposes current hydration state, but it disappears when a dataflow or replica restarts. A user can see whether an object is hydrated now, but not how long the last hydration took, which is what they need in order to size a cluster for the next one. Ref: SQL-632 ### Description Compute stamps three replica-local wallclock times per export and worker: dataflow installation, hydration start, and hydration completion. They are added to the existing `mz_compute_hydration_times_per_worker` log next to `time_ns`, which keeps that relation's name, OID, and object kind, so its generated per-replica index and every relation derived from it are untouched. A new `mz_catalog.mz_object_hydration_history` table records completed episodes for indexes and materialized views, keyed by object, replica, and installation time. A background sweep visits one managed user replica per interval, installs a replica-targeted internal subscribe that diffs that replica's live timestamps against the history table, and appends what is missing through the timestamped OCC write path. Including the target table in the read expression is what makes the write idempotent across concurrent `environmentd` processes: two collectors racing for one write timestamp leave the loser observing the winner's append and finding nothing to write. Retention retracts aged-out rows in bounded batches through the same path. Rows survive `environmentd` and replica restarts: the table is excluded from the bootstrap reset and from replacement schema migration. Collection is off by default in production and on in CI. Recording is best effort, and the limits are documented rather than implied away: it samples current state, so an episode whose object is dropped or whose replica restarts before that replica's next cycle is not recorded, unmanaged replicas are not sampled, and failed or canceled hydration is not recorded at all. `started_at` is NULL when an object was never suspended, because no start was observed. Replica-wide episodes and resource peaks need signals that do not exist yet and are scoped out in the design doc, not approximated from sampled metrics. ### Verification `test/testdrive/hydration-status.td` covers lifecycle ordering, several replicas, deduplication across sweeps, survival of a replica drop, the deliberate absence of an episode that cleans itself up, and retention. A `test/restart` workflow covers restart durability without duplication. Unit tests cover the sweep cursor and the two query invariants that are easy to regress silently: the retention bound must sit inside a derived table, because the OCC path discards the plan's `RowSetFinishing`, and a completed row requires consistent reports from every worker.
1 parent 5cfc385 commit 8c98a88

41 files changed

Lines changed: 1740 additions & 106 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# Durable Object Hydration History
2+
3+
## Context
4+
5+
Materialize exposes current hydration state, but that state disappears when a
6+
dataflow or replica restarts. A user can tell whether an object is hydrated now,
7+
but cannot tell how long the previous hydration took.
8+
9+
This design records completed compute-object hydration episodes in a durable
10+
catalog table. It is the first stage of broader hydration history. Replica-wide
11+
episodes, resource peaks, failed episodes, and storage objects require signals
12+
that do not exist yet and are described under Future Work.
13+
14+
## Goals
15+
16+
- Record successful hydration of indexes and materialized views per replica.
17+
- Preserve history across environmentd and replica restarts.
18+
- Make writes idempotent across concurrent environmentd processes.
19+
- Preserve the shape and values of existing hydration relations.
20+
- Bound storage with configurable retention.
21+
- Keep collection disabled by default in production while exercising it in CI.
22+
23+
## Non-Goals
24+
25+
- Failed or canceled hydration. A replica cannot report its own crash, and the
26+
current-state compute log retracts an object without recording why.
27+
- Replica-wide episodes. Correct episode boundaries require the initial object
28+
set and explicit transitions between fully hydrated and hydrating states.
29+
- Resource peaks. The existing metrics history contains samples, not true
30+
high-water values. Reporting those samples as peaks would be misleading.
31+
- Source and sink hydration. Storage does not publish equivalent lifecycle
32+
timestamps.
33+
- Unmanaged replicas. Their Timely worker count is unknown, so the collector
34+
cannot prove that a cross-worker aggregate is complete.
35+
36+
## Overview
37+
38+
Compute publishes three replica-stamped timestamps for every export and worker:
39+
40+
- `installed_at`, when the dataflow is installed
41+
- `started_at`, when the dataflow is unsuspended
42+
- `hydrated_at`, when the output frontier passes the as-of
43+
44+
The three columns are added to the existing
45+
`mz_introspection.mz_compute_hydration_times_per_worker` log, next to the
46+
existing `time_ns`. The relation keeps its name, OID, and object kind, so its
47+
auto-generated per-replica index is unchanged and every aggregate relation built
48+
on it keeps reading the same columns with the same values. The change is additive
49+
rather than byte-identical: a consumer doing `SELECT *` sees three new columns.
50+
51+
A rename plus a compatibility view was considered, since that is what the compute
52+
half of this project proposes. It is not done here. It would move OID 16977 to a
53+
differently shaped relation, flip the old name from a log to a view, and rename
54+
the generated per-replica index. Naming that relation is the compute team's
55+
decision on their own change, and this one does not need it.
56+
57+
A background task visits one managed user replica per interval. It installs an
58+
internal subscribe on that replica. The subscribe aggregates complete worker
59+
rows, maps runtime export IDs to catalog object IDs, and anti-joins the result
60+
against the history table. The task writes the resulting rows at the subscribe
61+
frontier using the timestamped OCC write path.
62+
63+
## What is and is not recorded
64+
65+
Collection samples current state. It is not an event log, and the compute log it
66+
reads retracts an object's row when the export goes away. So an episode is
67+
recorded only if its row is still live when its replica's turn comes around:
68+
69+
- An object dropped before the next sweep of its replica is not recorded.
70+
- A replica process that restarts before the next sweep loses the episode that
71+
preceded the restart.
72+
- An object that hydrates and immediately advances to the empty frontier, such as
73+
a constant materialized view, is never recorded, because its replica-side state
74+
is cleaned up as soon as it completes. `test/testdrive/hydration-status.td`
75+
asserts this absence explicitly so the limit is visible rather than surprising.
76+
77+
Making these cases durable requires compute to emit hydration transitions into an
78+
append-only collection that survives until an observer acknowledges them. That is
79+
compute-side work and is listed under Future Work. Until then the table is
80+
documented as best effort, which is the honest description of a sampler.
81+
82+
## History Table
83+
84+
`mz_catalog.mz_object_hydration_history` has this shape:
85+
86+
```text
87+
object_id text not null
88+
cluster_id text not null
89+
replica_id text not null
90+
installed_at timestamptz not null
91+
started_at timestamptz null
92+
finished_at timestamptz null
93+
status text not null
94+
key (object_id, replica_id, installed_at)
95+
```
96+
97+
The initial implementation writes only terminal `hydrated` rows, so its rows
98+
have non-null start and finish timestamps. Nullable columns reserve a compatible
99+
representation for an episode that is canceled before it starts or never
100+
finishes once those events become observable.
101+
102+
`installed_at` is the episode identity. It is stamped when the replica creates
103+
the export and remains stable across an environmentd restart. `started_at` is
104+
not suitable as the identity because it is null while the object waits to run.
105+
106+
## Compute Timestamps
107+
108+
Compute logging event times use a Unix epoch anchor advanced by a monotonic
109+
clock. Timestamp values come from the event time, not the rounded differential
110+
timestamp used to publish the update. Logging cadence can delay visibility but
111+
does not reduce timestamp accuracy.
112+
113+
The row state moves through these forms:
114+
115+
```text
116+
(installed_at, null, null)
117+
(installed_at, started_at, null)
118+
(installed_at, started_at, hydrated_at)
119+
```
120+
121+
An import-free dataflow is never suspended, so its `Schedule` can arrive after it
122+
has already hydrated. Recording that late arrival as the start of hydration would
123+
invent an interval nobody observed, so `started_at` stays NULL instead. A NULL
124+
start means "not observed", and `installed_at <= started_at <= hydrated_at` holds
125+
for every non-NULL pair.
126+
127+
Each worker has its own wall-clock anchor. A completed object row uses the
128+
minimum installation and start times and the maximum completion time across
129+
workers. The collector requires exactly the configured worker count and requires
130+
every worker to have reported completion. It additionally requires
131+
`max(installed_at) <= min(hydrated_at)`, which rejects worker rows that cannot
132+
belong to one episode: a replica whose processes restarted at different times can
133+
otherwise present one worker from before the restart and one from after, satisfy
134+
the count check, and produce a row whose duration includes the downtime between
135+
two episodes. The resulting interval can still include clock skew between worker
136+
processes.
137+
138+
Multi-export dataflows would need one more step. All exports of a dataflow share
139+
a suspension token, so the dataflow only starts once every export is scheduled,
140+
while the stamp is per export. Every compute dataflow has exactly one export
141+
today and `sequential_hydration.rs` asserts it, so the two coincide. The code
142+
records what has to change if that stops holding.
143+
144+
## OCC Collector
145+
146+
The subscribe reads both the replica-local timestamp log and the physical
147+
history table. Including the target table in the read expression is required for
148+
distributed idempotence.
149+
150+
Assume two environmentd processes compute the same missing row at frontier `T`:
151+
152+
1. Both submit a timestamped write for `T`.
153+
2. One write commits and advances the table upper and timestamp oracle.
154+
3. The other write receives `TimestampPassed`.
155+
4. Its subscribe observes the committed history row.
156+
5. The anti-join retracts the candidate, leaving no row to write.
157+
158+
The losing writer never retries stale diffs at the next eligible timestamp. It
159+
waits for subscribe progress and retries only with state known to be valid at
160+
the observed frontier.
161+
162+
Background subscribes use the ordinary active-compute-sink lifecycle but have a
163+
background owner rather than a synthetic SQL session. They do not write an
164+
`mz_subscriptions` row. Dropping the handle retires the compute dataflow on
165+
success, error, timeout, or replica failure.
166+
167+
## Scheduling and Failure Handling
168+
169+
`hydration_history_collection_interval` controls cadence. A zero duration
170+
disables collection. The task waits for each replica attempt to finish before
171+
scheduling the next one, which bounds compute load and avoids self-contention in
172+
the globally serialized timestamped-write path.
173+
174+
Fires are aligned to interval boundaries, and each scheduler sleep is capped so
175+
that a configuration change takes effect within the cap rather than after the
176+
previous interval has elapsed. Without the cap, lowering a long interval at
177+
runtime, which tests do, would appear to do nothing.
178+
179+
Each mutation is bounded by its own timeout. The bound has to be generous: a
180+
mutation that finds nothing to write still waits for its read to linearize, which
181+
can take a full `default_timestamp_interval`, and that parameter has no upper
182+
bound. A shared, tighter bound would let a large timestamp interval starve
183+
retention permanently.
184+
185+
The replica list is refreshed before every attempt. Replica drop, cluster drop,
186+
dependency replacement, replica failure, and timeout skip the attempt. A later
187+
cycle recomputes from current state. Read-only environmentd generations do not
188+
install subscribes or buffer writes.
189+
190+
Managed replicas expose their total worker count. Unmanaged replicas are skipped,
191+
because without that count the collector cannot distinguish a complete
192+
cross-worker aggregate from a partial one, and the public documentation says so.
193+
194+
## Retention and Restart
195+
196+
`hydration_history_retention_period` defaults to 30 days. Retention is another
197+
OCC mutation. It subscribes to rows with `finished_at` before the cutoff and
198+
writes their retractions at the observed frontier. The insertion query applies
199+
the same cutoff so a current-state log cannot resurrect a row just removed by
200+
retention.
201+
202+
Retention deletes a bounded batch per sweep, and converges over as many sweeps as
203+
it takes. It has to be bounded: the OCC path refuses a selection larger than
204+
`max_result_size` before submitting any write, so one unbounded delete over a
205+
large backlog would fail identically on every sweep and never shrink the table.
206+
The bound lives inside a derived table, because a top-level `LIMIT` lands in the
207+
plan's `RowSetFinishing`, which the OCC path deliberately discards.
208+
209+
Retention runs on the catalog server cluster, so it keeps working when there are
210+
no user replicas at all, and it runs even when that sweep's replica collection
211+
failed. A crash-looping replica must not be able to stop the table from shrinking
212+
back to its bound.
213+
214+
Disabling collection also suspends retention. The alternative is an always-on
215+
background subscribe in the default configuration, where the table is empty and
216+
there is nothing to retain. The consequence is that rows already collected are
217+
kept while collection is off, which the user-facing documentation states.
218+
219+
Builtin tables are normally reset during environmentd bootstrap. The history
220+
table is explicitly retained and is protected from replacement schema
221+
migrations, which would otherwise allocate a fresh shard and discard its data.
222+
223+
## Rollout
224+
225+
The collection interval defaults to zero in production. Test and CI defaults
226+
enable collection so hydration, restart, retention, and catalog tests exercise
227+
the path. Runtime configuration can enable the collector without restarting
228+
environmentd.
229+
230+
## Open Questions
231+
232+
Two decisions were made narrowly and are worth confirming.
233+
234+
Whether best-effort sampling is enough to ship. The sampler cannot see an episode
235+
whose evidence is already gone, so a user watching a churning cluster sees gaps.
236+
The alternative is to wait for compute to publish durable hydration transitions
237+
and build this on those instead. Shipping the sampler first gives the common case
238+
(an object that hydrates and stays hydrated) with no compute-side protocol work,
239+
at the cost of a table that is honest but incomplete.
240+
241+
Where collection should be enabled in tests. It is on in the mzcompose
242+
configuration at a 60 second interval, and off in the sqllogictest runner's
243+
defaults. Turning it on for sqllogictest as well would exercise the path more
244+
broadly, but the collector installs subscribes and writes to a builtin table, and
245+
those runs assert on catalog contents and plans, so it risks plan churn and
246+
timing flakiness in files that have nothing to do with hydration.
247+
248+
## Future Work
249+
250+
The full hydration visibility surface needs additional durable events:
251+
252+
- Record installation and start observations before completion, then finalize
253+
canceled and failed episodes by joining replica lifecycle events.
254+
- Define a replica episode state machine using the object set present when the
255+
replica transitions from fully hydrated to hydrating.
256+
- Publish resettable per-process high-water values for RAM, swap, and scratch
257+
disk. Define replica aggregation without pretending that sampled maxima are
258+
simultaneous peaks.
259+
- Give storage objects equivalent lifecycle timestamps.
260+
- Build replica history and progress views from those authoritative signals.

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,33 @@ Field | Type | Meaning
370370
`create_sql` | [`text`] | The `CREATE` SQL statement for the materialized view.
371371
`redacted_create_sql` | [`text`] | The redacted `CREATE` SQL statement for the materialized view.
372372

373+
### `mz_object_hydration_history`
374+
375+
The `mz_object_hydration_history` table contains completed hydration episodes
376+
for indexes and materialized views. Rows survive environmentd and replica
377+
restarts.
378+
379+
Collection is off by default. It is enabled by setting the
380+
`hydration_history_collection_interval` configuration parameter to a non-zero
381+
interval, and rows are retained for 30 days by default while it stays enabled.
382+
383+
Recording is best effort. Each cycle samples one managed replica's current
384+
hydration state, so an episode whose object is dropped, or whose replica process
385+
restarts, before that replica's next cycle is not recorded. Replicas of
386+
unmanaged clusters are not sampled at all. Only successful hydration is
387+
recorded, so there are no rows for failed or canceled hydration.
388+
389+
<!-- RELATION_SPEC mz_catalog.mz_object_hydration_history -->
390+
Field | Type | Meaning
391+
---------------|-------------------------------|--------
392+
`object_id` | [`text`] | The ID of the index or materialized view.
393+
`cluster_id` | [`text`] | The ID of the object's cluster.
394+
`replica_id` | [`text`] | The ID of the cluster replica.
395+
`installed_at` | [`timestamp with time zone`] | When the object's dataflow was installed on the replica.
396+
`started_at` | [`timestamp with time zone`] | When hydration work began, or `NULL` if the object was never suspended and so no start was observed.
397+
`finished_at` | [`timestamp with time zone`] | When hydration finished.
398+
`status` | [`text`] | The terminal status. Currently always `hydrated`.
399+
373400
### `mz_objects`
374401

375402
The `mz_objects` view contains a row for each table, source, view, materialized

doc/user/data/metrics.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ metrics:
9797
source: src/adapter/src/metrics.rs
9898
visibility: internal
9999
- name: mz_active_internal_subscribes
100-
help: The number of active internal subscribes, which serve frontend-sequenced read-then-write.
100+
help: The number of active internal subscribes used by read-then-write operations and background maintenance.
101101
labels:
102102
- session_type
103103
source: src/adapter/src/metrics.rs

misc/python/materialize/mzcompose/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ def get_minimal_system_parameters(
137137
if version < MzVersion.parse_mz("v26.25.0-dev"):
138138
config["enable_multi_replica_sources"] = "true"
139139

140+
if version >= MzVersion.parse_mz("v26.39.0-dev"):
141+
config["hydration_history_collection_interval"] = "60s"
142+
140143
if sanitizer_enabled():
141144
config["with_0dt_deployment_max_wait"] = "18000s"
142145

@@ -457,6 +460,9 @@ def get_variable_system_parameters(
457460
VariableSystemParameter(
458461
"arrangement_size_history_retention_period", "7d", ["1min", "1h", "7d"]
459462
),
463+
VariableSystemParameter(
464+
"hydration_history_retention_period", "30d", ["1min", "1h", "30d"]
465+
),
460466
VariableSystemParameter(
461467
"persist_validate_part_bounds_on_read", "false", ["true", "false"]
462468
),

misc/python/materialize/parallel_workload/action.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2949,6 +2949,16 @@ def __init__(
29492949
"'1h'",
29502950
"'7d'",
29512951
]
2952+
self.flags_with_values["hydration_history_collection_interval"] = [
2953+
"'0s'",
2954+
"'1s'",
2955+
"'1min'",
2956+
]
2957+
self.flags_with_values["hydration_history_retention_period"] = [
2958+
"'1min'",
2959+
"'1h'",
2960+
"'30d'",
2961+
]
29522962
# Keep these generous: a tight timeout would abort the oracle's own
29532963
# queries (they are retried, but it adds noise). "0s" leaves it unset.
29542964
self.flags_with_values["pg_timestamp_oracle_statement_timeout"] = [

src/adapter-types/src/dyncfgs.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,20 @@ pub const ARRANGEMENT_SIZE_HISTORY_RETENTION_PERIOD: Config<Duration> = Config::
343343
"How long to retain rows in mz_internal.mz_object_arrangement_size_history.",
344344
);
345345

346+
/// How often to sweep replicas for completed object hydration episodes.
347+
pub const HYDRATION_HISTORY_COLLECTION_INTERVAL: Config<Duration> = Config::new(
348+
"hydration_history_collection_interval",
349+
Duration::ZERO,
350+
"How often to record completed object hydration episodes. A zero duration disables collection.",
351+
);
352+
353+
/// How long to retain completed object hydration episodes.
354+
pub const HYDRATION_HISTORY_RETENTION_PERIOD: Config<Duration> = Config::new(
355+
"hydration_history_retention_period",
356+
Duration::from_hours(30 * 24),
357+
"How long to retain rows in mz_catalog.mz_object_hydration_history.",
358+
);
359+
346360
/// How frequently the catalog `*_info` metrics (`mz_object_info`,
347361
/// `mz_cluster_info`, …) are reconciled with the catalog. A zero duration
348362
/// disables reconciliation.
@@ -468,6 +482,8 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
468482
.add(&CONSOLE_OIDC_SCOPES)
469483
.add(&ARRANGEMENT_SIZE_HISTORY_COLLECTION_INTERVAL)
470484
.add(&ARRANGEMENT_SIZE_HISTORY_RETENTION_PERIOD)
485+
.add(&HYDRATION_HISTORY_COLLECTION_INTERVAL)
486+
.add(&HYDRATION_HISTORY_RETENTION_PERIOD)
471487
.add(&CATALOG_INFO_METRICS_RECONCILE_INTERVAL)
472488
.add(&PG_TIMESTAMP_ORACLE_STATEMENT_TIMEOUT)
473489
.add(&FRONTEND_READ_THEN_WRITE)

0 commit comments

Comments
 (0)