Skip to content

Commit 67183be

Browse files
committed
compute: split hydration into dataflow and durable stages
The hydration lifecycle has four stages, installed, started, hydrated and written, where written is the moment the output is durable in persist. Indexes do not write, so for them the last two coincide. Materialized views do, and the gap is the initial snapshot write. hydrated_at was stamped off the reported output frontier, which is the meet of the write and compute frontiers. That made it a measure of durability rather than of computation, and worse, not uniform across workers: the sink's mint operator maintains the shared sink frontier on one elected worker and clears it on all the others, so the same dataflow reported hydration at two different stages depending on which worker's log a consumer read. Both stages are wanted. A replacement materialized view runs in read-only mode and does not write until a cutover, so folding durability into hydrated_at would measure how long a human took to promote, where the dataflow stage is the earliest moment the replacement could have been promoted. Durability is what readiness needs, and readiness is served by time_ns and the relations built on it. So the signal is split rather than moved. The Hydration event still fires on the output frontier crossing and still owns time_ns, which is what keeps mz_compute_hydration_times, mz_compute_hydration_statuses, mz_hydration_statuses and the blue-green readiness query reporting exactly what they reported before. A new DataflowHydrated event fires on the dataflow's own progress frontier and owns hydrated_at, along with the started_at backfill, since that is now the handler responsible for the installed_at <= started_at <= hydrated_at invariant. report_frontiers derives the progress frontier as the compute probe where one exists and the write frontier otherwise, so an index keeps stamping from its trace upper and the index degeneracy falls out rather than being special-cased. Subscribes report from their own frontier through the same observer, unchanged. Also drops the doc's claim that a REFRESH materialized view reports a refresh interval rather than hydration work. REFRESH EVERY takes an implicit refresh at creation, so its first write is prompt, and the claim was wrong for that variant. What is verified, and kept, is that the compute probe sits before apply_refresh deliberately, so hydration reads the pre-rounding frontier. Testdrive coverage asserts both workers report hydrated_at for a multi-worker materialized view, that they agree closely, and that hydration never postdates the durable moment time_ns measures. A small view writes in milliseconds, so these lock the invariants rather than proving the stages are separate. Part of CPU-226 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZVCMBSLdxzGus78ZKWhZz
1 parent f9be4ed commit 67183be

4 files changed

Lines changed: 223 additions & 36 deletions

File tree

doc/developer/design/20260817_compute_hydration_timestamps.md

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,26 @@ them.
103103
| --- | --- | --- |
104104
| `installed_at` | the `Export` event, from `CreateDataflow` | the dataflow exists on this worker, suspended, so this is also the start of queueing |
105105
| `started_at` | the dataflow is unsuspended | hydration is actually running |
106-
| `hydrated_at` | the output frontier passes the as-of | hydration is complete |
106+
| `hydrated_at` | the dataflow's own progress frontier passes the as-of | the dataflow has computed its output |
107107

108108
`hydrated_at - started_at` is hydration time as users mean it, and
109109
`started_at - installed_at` is the queueing interval. Today's `time_ns` conflates
110110
the two.
111111

112+
The full lifecycle has a fourth stage, `installed` to `started` to `hydrated` to
113+
`written`, where `written` is the moment the output is durable in persist. Indexes
114+
do not write, so for them the last two coincide. Materialized views do, and the
115+
gap is the initial snapshot write.
116+
117+
Only the first three are stamped here. `written` is tracked separately, and the
118+
reason it is a distinct stage rather than the same one is that a replacement
119+
materialized view runs in read-only mode and does not write until a cutover, so
120+
folding durability into `hydrated_at` would measure how long a human took to
121+
promote. `hydrated_at` is the earliest moment a replacement could have been
122+
promoted, which is the actionable number. Readiness is the question durability
123+
answers, and that is served by `time_ns` and the relations built on it, which this
124+
design leaves alone.
125+
112126
Two choices shape everything else, each argued in its own section below. The
113127
timestamps are stamped by the replica rather than by the compute controller, for
114128
the reasons in "Why the replica and not the compute controller". And `time_ns` is
@@ -163,6 +177,27 @@ compute logging and has not been observed to be severe, but it is a real risk an
163177
this design is the first to invite direct comparison of absolute times, so it is
164178
acknowledged rather than designed around.
165179

180+
### Which frontier hydration reads
181+
182+
Hydration is driven by the dataflow's own progress frontier, not by the reported
183+
output frontier. The output frontier is the meet of write and compute frontier,
184+
which makes it a measure of durability rather than of computation, and for a
185+
sink-backed collection it is not even uniform across workers: the sink's `mint`
186+
operator maintains the shared sink frontier on one elected worker and clears it on
187+
all the others, so the same dataflow would report hydration at two different times
188+
depending on which worker's log a consumer read.
189+
190+
So a collection with a compute frontier hydrates on that frontier. A collection
191+
without one, an index into its own trace, produces its output by writing it, so
192+
there the write frontier is the progress and hydration coincides with durability.
193+
That is the index degeneracy of the lifecycle, and it falls out rather than being
194+
special-cased.
195+
196+
The reported output frontier is unchanged and still the meet. The controller needs
197+
durability for its caught-up and autoscaling checks, and `time_ns` still fires on
198+
the meet crossing, which is what keeps every relation built on it reporting
199+
exactly what it reported before.
200+
166201
### A new hydration start event
167202

168203
There is no event for hydration start today. Add
@@ -274,9 +309,12 @@ change nor the rename would have touched that relation.
274309
**`time_ns` is kept rather than replaced.** It is the reason the existing columns
275310
keep their exact values: retained rather than derived, so nothing is recomputed,
276311
no precision is lost, and no cross-worker arithmetic is introduced.
277-
Deriving `time_ns` as `hydrated_at - installed_at` would have moved it to
278-
microsecond precision, since `timestamptz` caps there, where today it is true
279-
nanoseconds. Deriving it after aggregation would additionally have absorbed
312+
It could not be derived from the timestamps in any case. `time_ns` runs to the
313+
output frontier crossing and `hydrated_at` to the dataflow's own, which for a
314+
materialized view are different stages separated by the snapshot write, so
315+
`hydrated_at - installed_at` is not the same interval. Even for an index, where the
316+
two coincide, deriving it would have moved it to microsecond precision, since
317+
`timestamptz` caps there, where today it is true nanoseconds. Deriving it after aggregation would additionally have absorbed
280318
cross-worker install skew and the per-worker anchor skew described above. So
281319
`time_ns` remains the authoritative per-worker duration, measured from a single
282320
`Instant` inside one worker, and the timestamps carry episode identity, which
@@ -312,7 +350,7 @@ in one controller turn and one replica turn.
312350
| 6 | replica | inserts the suspension token and renders the dataflow, whose operators park on the `StartSignal` | |
313351
| 7 | replica | `handle_schedule` drops the token and the operators start | **`started_at`** |
314352
| 8 | replica | the dataflow reads its inputs from the as-of forward and builds arrangements. Nothing is stamped here, this interval is the hydration | |
315-
| 9 | replica | the output frontier passes the as-of and `set_reported_output_frontier` calls `set_hydrated` | **`hydrated_at`** |
353+
| 9 | replica | the dataflow's progress frontier passes the as-of and `observe_hydration_frontier` calls `set_hydrated` | **`hydrated_at`** |
316354
| 10 | replica | the demux writes the retract and insert pair, so the per-worker relation carries all three | |
317355
| 11 | controller | separately, a `Frontiers` response arrives and `update_output_frontier` flips the controller's own hydration view, which is what the 0dt caught-up check and the autoscaling signal read. One round trip later, and it stamps nothing | |
318356

@@ -391,21 +429,18 @@ restarting. Consumers must gate on introspection freshness, as
391429
`mz_object_arrangement_size_history` already does via
392430
`fresh_introspection_replicas`.
393431

394-
**`REFRESH` materialized views report a refresh interval, not hydration work.**
395-
The reported output frontier is the meet of write and compute frontier, and a
396-
REFRESH MV's write frontier sits at the as-of until the first refresh lands, so
397-
hydration is not considered complete until then. For `REFRESH EVERY '1 day'`,
398-
`hydrated_at - started_at` can be most of a day, nearly all of it idle. The
399-
per-object stamps are still internally consistent, so this is not a defect in the
400-
relation, but any rollup must exclude these objects or it will never close an
401-
episode. The controller already receives `refresh_schedule` in `add_collection`,
402-
so it can mark them.
403-
404-
**Read-only mode changes what the output frontier means.** In read-only mode the
405-
write frontier is deliberately excluded from the reported output frontier, because
406-
a read-only dataflow cannot push it forward. So `hydrated_at` during a 0dt
407-
read-only window reflects compute progress only, which is the intended reading but
408-
differs from the steady-state one.
432+
**`REFRESH` materialized views hydrate on their computation.** The compute probe
433+
is attached before the `apply_refresh` operator, deliberately, with the comment in
434+
`src/compute/src/sink/materialized_view.rs` explaining that rounding frontiers up
435+
"makes it impossible to accurately track the progress of the computation". So the
436+
frontier hydration reads is the pre-rounding one, and a REFRESH MV reports when
437+
its computation caught up rather than anything derived from its schedule.
438+
439+
How a refresh schedule affects the *written* stage is deliberately not claimed
440+
here. It varies by variant, `REFRESH EVERY` takes an implicit refresh at creation
441+
while a `REFRESH AT` in the future does not, and getting it wrong in either
442+
direction is easy. Whoever adds the `written` stamp should establish it against
443+
the refresh tests rather than inherit a claim from this document.
409444

410445
### Why the replica and not the compute controller
411446

src/compute/src/compute_state.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,8 @@ impl<'a> ActiveComputeState<'a> {
921921

922922
// Maintain a single allocation for `new_frontier` to avoid allocating on every iteration.
923923
let mut new_frontier = Antichain::new();
924+
// Same, for the frontier that measures dataflow progress.
925+
let mut hydration_frontier = Antichain::new();
924926

925927
for (&id, collection) in self.compute_state.collections.iter_mut() {
926928
// The compute protocol does not allow `Frontiers` responses for subscribe and copy-to
@@ -950,6 +952,30 @@ impl<'a> ActiveComputeState<'a> {
950952
.allows_reporting(&new_frontier)
951953
.then(|| new_frontier.clone());
952954

955+
// Collect the frontier that measures the dataflow's own progress, which is what
956+
// hydration is about.
957+
//
958+
// This is deliberately not the output frontier collected below. That folds in the
959+
// write frontier, which makes it a measure of durability rather than of dataflow
960+
// progress, and for a collection that sinks to persist it is not even uniform across
961+
// workers: the sink's `mint` operator maintains the shared sink frontier on one
962+
// elected worker and clears it on all the others, so the same dataflow would report
963+
// hydration at two different times depending on which worker's log you read.
964+
//
965+
// A collection with a compute frontier produces its output before writing it, so that
966+
// frontier is its progress. A collection without one produces its output *by* writing
967+
// it, an index into its own trace, so there the write frontier is the progress and
968+
// hydration coincides with durability.
969+
hydration_frontier.clear();
970+
match &collection.compute_probe {
971+
Some(probe) => {
972+
probe.with_frontier(|frontier| {
973+
hydration_frontier.extend(frontier.iter().copied())
974+
});
975+
}
976+
None => hydration_frontier.clone_from(&new_frontier),
977+
}
978+
953979
// Collect the output frontier and check for progress.
954980
//
955981
// By default, the output frontier equals the write frontier (which is still stored in
@@ -996,6 +1022,8 @@ impl<'a> ActiveComputeState<'a> {
9961022
.set_reported_output_frontier(ReportedFrontier::Reported(frontier.clone()));
9971023
}
9981024

1025+
collection.observe_hydration_frontier(&hydration_frontier);
1026+
9991027
let response = FrontiersResponse {
10001028
write_frontier: new_write_frontier,
10011029
input_frontier: new_input_frontier,
@@ -1209,6 +1237,7 @@ impl<'a> ActiveComputeState<'a> {
12091237
.set_reported_write_frontier(ReportedFrontier::Reported(new_frontier.clone()));
12101238
collection
12111239
.set_reported_input_frontier(ReportedFrontier::Reported(new_frontier.clone()));
1240+
collection.observe_hydration_frontier(&new_frontier);
12121241
collection.set_reported_output_frontier(ReportedFrontier::Reported(new_frontier));
12131242
} else {
12141243
// Presumably tracking state for this subscribe was already dropped by
@@ -1998,6 +2027,13 @@ pub struct CollectionState {
19982027
logging: Option<CollectionLogging>,
19992028
/// Metrics tracked for this collection.
20002029
metrics: CollectionMetrics,
2030+
/// Whether the dataflow-progress frontier has been observed to pass the as-of, and logged.
2031+
///
2032+
/// Only ever set, never cleared. Reconciliation resets the reported frontiers of a retained
2033+
/// dataflow, so without this the collection would look unhydrated again and re-log a stage it
2034+
/// already reported. The `time_ns` path does not need the same guard because the demux drops
2035+
/// repeats itself.
2036+
dataflow_hydrated: bool,
20012037
/// Send-side to transition a dataflow from read-only mode to read-write mode.
20022038
///
20032039
/// All dataflows start in read-only mode. Only after receiving a
@@ -2036,6 +2072,7 @@ impl CollectionState {
20362072
compute_probe: None,
20372073
logging: None,
20382074
metrics,
2075+
dataflow_hydrated: false,
20392076
read_only_tx,
20402077
read_only_rx,
20412078
}
@@ -2094,13 +2131,35 @@ impl CollectionState {
20942131
}
20952132

20962133
/// Return whether this collection is hydrated.
2134+
///
2135+
/// This is the output-frontier reading, which folds in the write frontier and so reports
2136+
/// durability for a collection that sinks to persist. `observe_hydration_frontier` reports the
2137+
/// dataflow-progress reading instead. Both are wanted, and they differ for a materialized view
2138+
/// by the time its snapshot takes to reach persist.
20972139
fn hydrated(&self) -> bool {
20982140
match &self.reported_frontiers.output_frontier {
20992141
ReportedFrontier::Reported(frontier) => PartialOrder::less_than(&self.as_of, frontier),
21002142
ReportedFrontier::NotReported { .. } => false,
21012143
}
21022144
}
21032145

2146+
/// Observe the frontier measuring this collection's dataflow progress, and log the dataflow
2147+
/// having hydrated if it has passed the as-of for the first time.
2148+
///
2149+
/// The caller decides which frontier measures progress; see the comment at the call site in
2150+
/// `report_frontiers`. An empty as-of never hydrates, which is consistent with no dataflow
2151+
/// being created for one.
2152+
fn observe_hydration_frontier(&mut self, frontier: &Antichain<Timestamp>) {
2153+
if self.dataflow_hydrated || !PartialOrder::less_than(&self.as_of, frontier) {
2154+
return;
2155+
}
2156+
2157+
self.dataflow_hydrated = true;
2158+
if let Some(logging) = &mut self.logging {
2159+
logging.set_dataflow_hydrated();
2160+
}
2161+
}
2162+
21042163
/// Allow writes for this collection.
21052164
fn allow_writes(&self) {
21062165
info!(

src/compute/src/logging/compute.rs

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,16 @@ pub struct ErrorCount {
158158
pub diff: Diff,
159159
}
160160

161+
/// A dataflow's own progress frontier passed its as-of.
162+
///
163+
/// Distinct from [`Hydration`], which fires on the reported output frontier and therefore reports
164+
/// durability for a collection that sinks to persist.
165+
#[derive(Debug, Clone, PartialOrd, PartialEq, Columnar)]
166+
pub struct DataflowHydrated {
167+
/// The ID of the export whose dataflow hydrated.
168+
pub export_id: GlobalId,
169+
}
170+
161171
/// An export started hydrating.
162172
#[derive(Debug, Clone, PartialOrd, PartialEq, Columnar)]
163173
pub struct HydrationStart {
@@ -236,6 +246,8 @@ pub enum ComputeEvent {
236246
ErrorCount(ErrorCount),
237247
/// A dataflow export started hydrating, i.e. its dataflow was unsuspended.
238248
HydrationStart(HydrationStart),
249+
/// A dataflow's progress frontier passed its as-of.
250+
DataflowHydrated(DataflowHydrated),
239251
/// A dataflow export was hydrated.
240252
Hydration(Hydration),
241253
/// A dataflow operator's hydration status changed.
@@ -887,6 +899,7 @@ impl DemuxHandler<'_, '_, '_> {
887899
DataflowShutdown(shutdown) => self.handle_dataflow_shutdown(shutdown),
888900
ErrorCount(error_count) => self.handle_error_count(error_count),
889901
HydrationStart(hydration) => self.handle_hydration_start(hydration),
902+
DataflowHydrated(hydration) => self.handle_dataflow_hydrated(hydration),
890903
Hydration(hydration) => self.handle_hydration(hydration),
891904
OperatorHydration(hydration) => self.handle_operator_hydration(hydration),
892905
LirMapping(mapping) => self.handle_lir_mapping(mapping),
@@ -1082,8 +1095,6 @@ impl DemuxHandler<'_, '_, '_> {
10821095

10831096
fn handle_hydration(&mut self, HydrationReference { export_id }: Ref<'_, Hydration>) {
10841097
let ts = self.ts();
1085-
// Stamp the event time rather than `ts`, as in `handle_export`.
1086-
let hydrated_at = self.time;
10871098
let export_id = Columnar::into_owned(export_id);
10881099

10891100
let Some(export) = self.state.exports.get_mut(&export_id) else {
@@ -1100,12 +1111,46 @@ impl DemuxHandler<'_, '_, '_> {
11001111
let nanos = u64::try_from(duration.as_nanos()).expect("must fit");
11011112
export.hydration_time_ns = Some(nanos);
11021113

1114+
let timestamps = export.hydration_timestamps;
1115+
1116+
let retraction = self
1117+
.state
1118+
.pack_hydration_time_update(export_id, None, &timestamps);
1119+
self.output
1120+
.hydration_time
1121+
.give((retraction, ts, Diff::MINUS_ONE));
1122+
let insertion = self
1123+
.state
1124+
.pack_hydration_time_update(export_id, Some(nanos), &timestamps);
1125+
self.output.hydration_time.give((insertion, ts, Diff::ONE));
1126+
}
1127+
1128+
fn handle_dataflow_hydrated(
1129+
&mut self,
1130+
DataflowHydratedReference { export_id }: Ref<'_, DataflowHydrated>,
1131+
) {
1132+
let ts = self.ts();
1133+
// Stamp the event time rather than `ts`, as in `handle_export`.
1134+
let hydrated_at = self.time;
1135+
let export_id = Columnar::into_owned(export_id);
1136+
1137+
let Some(export) = self.state.exports.get_mut(&export_id) else {
1138+
error!(%export_id, "dataflow hydrated event for unknown export");
1139+
return;
1140+
};
1141+
if export.hydration_timestamps.hydrated_at.is_some() {
1142+
// Reconciliation resets a retained dataflow's reported frontiers, so it can report
1143+
// progress past its as-of a second time. Ignore the repeats, as `handle_hydration`
1144+
// does.
1145+
return;
1146+
}
1147+
11031148
let old_timestamps = export.hydration_timestamps;
11041149
export.hydration_timestamps.hydrated_at = Some(hydrated_at);
1105-
// A dataflow can reach hydration before its `Schedule` arrives, and not only when it has
1106-
// no imports to suspend: an index over an already-hydrated arrangement reports hydration
1107-
// while still suspended, which happens for a handful of `mz_catalog_server` indexes on
1108-
// every bootstrap. So this is a normal path, not a repair for an exotic one.
1150+
// A dataflow can hydrate before its `Schedule` arrives, and not only when it has no
1151+
// imports to suspend: an index over an already-hydrated arrangement reports progress while
1152+
// still suspended, which happens for a handful of `mz_catalog_server` indexes on every
1153+
// bootstrap. So this is a normal path, not a repair for an exotic one.
11091154
//
11101155
// Stamp `started_at` from `installed_at`, which keeps `installed_at <= started_at <=
11111156
// hydrated_at` total and reports the queueing interval as zero. Stamping `hydrated_at`
@@ -1115,16 +1160,17 @@ impl DemuxHandler<'_, '_, '_> {
11151160
export.hydration_timestamps.started_at = Some(export.hydration_timestamps.installed_at);
11161161
}
11171162
let new_timestamps = export.hydration_timestamps;
1163+
let time_ns = export.hydration_time_ns;
11181164

11191165
let retraction = self
11201166
.state
1121-
.pack_hydration_time_update(export_id, None, &old_timestamps);
1167+
.pack_hydration_time_update(export_id, time_ns, &old_timestamps);
11221168
self.output
11231169
.hydration_time
11241170
.give((retraction, ts, Diff::MINUS_ONE));
1125-
let insertion =
1126-
self.state
1127-
.pack_hydration_time_update(export_id, Some(nanos), &new_timestamps);
1171+
let insertion = self
1172+
.state
1173+
.pack_hydration_time_update(export_id, time_ns, &new_timestamps);
11281174
self.output.hydration_time.give((insertion, ts, Diff::ONE));
11291175
}
11301176

@@ -1544,6 +1590,18 @@ impl CollectionLogging {
15441590
}));
15451591
}
15461592

1593+
/// Record that the collection's dataflow has hydrated, meaning its own progress frontier
1594+
/// passed the as-of.
1595+
///
1596+
/// Distinct from [`Self::set_hydrated`], which reports the *output* frontier doing so, and
1597+
/// therefore reports durability for a collection that sinks to persist.
1598+
pub fn set_dataflow_hydrated(&self) {
1599+
self.logger
1600+
.log(&ComputeEvent::DataflowHydrated(DataflowHydrated {
1601+
export_id: self.export_id,
1602+
}));
1603+
}
1604+
15471605
/// Set the collection as hydrated.
15481606
pub fn set_hydrated(&self) {
15491607
self.logger.log(&ComputeEvent::Hydration(Hydration {

0 commit comments

Comments
 (0)