Skip to content

Commit fb8eeb5

Browse files
committed
doc: design for durable object hydration history
Records completed compute-object hydration episodes in a durable table so that a hydration can be compared against previous ones after a restart. The document covers the collector's idempotence argument across concurrent environmentd processes, the aggregation conditions that each prevent a specific wrong row, the best-effort durability position, and the frontier-skew limitation the chosen write timestamp implies. Closes: SQL-632
1 parent 30e4b1d commit fb8eeb5

1 file changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
# Durable Object Hydration History
2+
3+
## Context
4+
5+
Materialize exposes current hydration state, but that state disappears when a
6+
dataflow or a replica restarts. A user can tell whether an object is hydrated
7+
now, but not how long the last hydration took, or whether today's is unusually
8+
slow.
9+
10+
This records completed compute-object hydration episodes in a durable table. It
11+
is the first stage of broader hydration history. Replica-wide episodes, resource
12+
peaks, failed episodes, and storage objects need signals that do not exist yet.
13+
14+
## Goals
15+
16+
- Record successful hydration of indexes and materialized views per replica.
17+
- Survive environmentd and replica restarts.
18+
- Stay idempotent across concurrent environmentd processes.
19+
- Leave the values of existing hydration relations untouched.
20+
- Bound storage with configurable retention.
21+
- Default off in production, on in CI.
22+
23+
## Non-Goals
24+
25+
Each of these is excluded because the signal to do it correctly is missing, not
26+
because it is unwanted:
27+
28+
- **Failed or canceled episodes.** A replica cannot report its own crash, and the
29+
current-state log retracts an object without recording why.
30+
- **Replica-wide episodes.** Correct boundaries need the object set present at the
31+
transition from hydrated to hydrating, and explicit transitions between those
32+
states.
33+
- **Resource peaks.** The metrics history holds samples, not high-water values.
34+
Presenting samples as peaks would mislead.
35+
- **Sources and sinks.** Storage publishes no equivalent lifecycle timestamps.
36+
- **Failed replicas.** A replica that never finishes hydrating an object records
37+
nothing for it, since only completions are stamped.
38+
39+
## Design
40+
41+
Compute stamps three replica-side timestamps per export and worker: `installed_at`
42+
when the dataflow is installed, `started_at` when it is unsuspended, and
43+
`hydrated_at` when the output frontier passes the as-of. They are added to the
44+
existing `mz_introspection.mz_compute_hydration_times_per_worker` log, which keeps
45+
its name, OID, and object kind, so its generated per-replica index and every
46+
relation built on it are unaffected. A consumer doing `SELECT *` sees three new
47+
columns.
48+
49+
Renaming the log was considered and not done, which is why the columns are appended
50+
rather than reorganized. A rename would move OID 16977 to a differently shaped
51+
relation and turn the old name into a view over the new one, and naming that
52+
relation is the compute team's call on their own change.
53+
54+
A coordinator task visits one user replica per interval, managed or unmanaged, in a
55+
rotation. The only replicas it skips are those with introspection disabled, whose
56+
logs are installed but never populated. It installs an internal subscribe on that
57+
replica which aggregates every worker's completed rows, anti-joins them against the
58+
history table, and writes the missing ones through the timestamped OCC
59+
read-then-write path.
60+
61+
An *episode* throughout this document is one such row: one dataflow's hydration on
62+
one replica, from installation to hydrated, recorded once.
63+
64+
## History Table
65+
66+
```text
67+
mz_internal.mz_object_hydration_history
68+
export_id text not null
69+
cluster_id text not null
70+
replica_id text not null
71+
installed_at timestamptz not null
72+
started_at timestamptz null
73+
hydrated_at timestamptz null
74+
status text not null
75+
```
76+
77+
An episode is identified by `(export_id, replica_id, installed_at)`. The
78+
installation time is replica-stamped and stable across an environmentd restart,
79+
where `started_at` cannot serve that role because it is null while an object waits
80+
to run.
81+
82+
The id is the dataflow export's, not the catalog item's, because that is the grain of the
83+
data we read: the log holds one row per export, and one item can own several
84+
exports at once. Resolving to an item id would force us to merge those into one
85+
row and invent its timestamps. The cost is that a superseded generation stops
86+
resolving through `mz_object_global_ids` once its mapping row is retracted, and
87+
consumers reach a catalog object through that view rather than joining `mz_objects`
88+
directly.
89+
90+
That identity is deliberately not declared as a key on the relation. A key is a
91+
promise to the optimizer, which may then elide a `DISTINCT` or assume a join
92+
cardinality, so a single duplicate from a best-effort sampler becomes a silently
93+
wrong answer rather than a duplicate row. The collector's anti-join is what keeps
94+
the identity unique, and none of the comparable history tables declare a key
95+
either. Adding one later is also expensive: it changes the relation's descriptor,
96+
which needs a migration, which for this table means giving up the exemptions that
97+
protect its contents.
98+
99+
Only terminal `hydrated` rows are written today, so every row has a finish time.
100+
The nullable columns and the `status` column reserve a compatible shape for
101+
episodes that are canceled or unfinished, once those become observable. Retention
102+
keys off `hydrated_at`, so an unfinished episode needs a second age basis before it
103+
can be recorded at all.
104+
105+
The table is in `mz_internal`, not `mz_catalog`, because its contents are best
106+
effort and `status` will gain values. `mz_internal` carries no stability
107+
commitment. Its closest sibling, `mz_internal.mz_object_arrangement_size_history`,
108+
sits there for the same reason.
109+
110+
No index. An arrangement on the catalog server would hold the whole table, which
111+
grows with objects times replicas times re-hydrations, and nothing queries this
112+
table by key yet. A user query instead scans a table bounded by retention. Note
113+
that an index would not help the collector anyway, since its anti-join runs on the
114+
targeted user replica, where a catalog-server arrangement does not exist.
115+
116+
For the same reason the table is not a retained-metrics object. That flag pins a
117+
30 day logical compaction window, which keeps 30 days of update history rather
118+
than current state. Our history is in the rows, which retention retracts on its
119+
own schedule, and nothing reads this table at an old timestamp.
120+
121+
## Why concurrent writers converge
122+
123+
The subscribe reads the history table it is about to write. That is what makes the
124+
write idempotent across processes, and it is the reason the read expression looks
125+
redundant.
126+
127+
Two environmentd processes computing the same missing row at frontier `T` both
128+
submit a timestamped write for `T`. One commits and advances the table upper. The
129+
other is told the timestamp passed, waits for its own subscribe to progress, sees
130+
the committed row appear, and its anti-join retracts the candidate. There is
131+
nothing left to write.
132+
133+
The losing writer never retries stale diffs at the next eligible timestamp. It
134+
only retries with state it has observed to be valid at the frontier it is writing
135+
at. That same property is what makes a trailing replica record nothing, in the limitation section below.
136+
137+
## Reading every worker
138+
139+
Collection aggregates a replica's workers for an export and records nothing until
140+
all of them have hydrated. The reason is narrower than it looks, and it is worth
141+
writing down because the obvious argument for reading one worker is almost right.
142+
143+
For an index it *is* right. A worker stamps `hydrated_at` when its reported output
144+
frontier passes the as_of, an index's output frontier is the arrangement upper, and
145+
that moves only as timely's dataflow-wide progress tracking allows. No worker sees
146+
it advance while another still holds capabilities below it, so any one worker's
147+
stamp already accounts for the slowest.
148+
149+
A materialized view breaks it. Its output frontier is the meet of the compute
150+
frontier and the *sink write* frontier, and the sink write frontier is not a timely
151+
progress quantity. The persist sink picks one active worker, `hash(sink_id) %
152+
workers`. Only that worker's frontier tracks the shard upper. Every other worker
153+
clears its own, so its output frontier is the compute frontier alone and it stamps
154+
at compute completion, before the snapshot is durable. The sink buffers the snapshot
155+
until the desired frontier passes, so that write follows the computation rather than
156+
overlapping it, which makes the gap the whole write rather than a flush.
157+
158+
Reading one worker would therefore give a finish that means "durable" for the
159+
`1/workers` of objects whose hash picks worker 0 and "computed" for the rest, with
160+
nothing in the data to say which. `max` over a complete set of workers gives one
161+
meaning for every object, and matches `mz_compute_hydration_times`, so the durable
162+
history and the live hydration signal agree.
163+
164+
Completeness needs no worker count. The log carries a row per
165+
`(export_id, worker_id)` from installation with a null `hydrated_at`, so
166+
`count(*) = count(hydrated_at)` says every worker that has the dataflow has
167+
finished. An object still hydrating is skipped rather than recorded with a finish
168+
that precedes its write, and a later sweep picks it up. The cutoff and the anti-join
169+
have to apply to the aggregate's output rather than its input, since as `WHERE`
170+
clauses either one drops the not-yet-hydrated rows and makes the check trivially
171+
true.
172+
173+
The cost is that an interval can span two process clocks. Each process anchors its
174+
logging clock at its own `SystemTime`, so a duration carries whatever skew there is
175+
between the ends. That inflates it. Nothing rejects a row for looking inconsistent,
176+
which is deliberate: a guard comparing cross-worker stamps rejects complete episodes
177+
permanently, because the log values never change. Skew is normally milliseconds
178+
against durations of seconds, so inflation is the right failure to accept and
179+
rejection is not.
180+
181+
`started_at` is the earliest across workers. Note that the compute log does not
182+
leave it unset when no start was observed, it reports the installation time instead,
183+
which keeps `installed_at <= started_at <= hydrated_at` total. So a queueing interval
184+
of exactly zero means "no start was observed" rather than "the dataflow started
185+
immediately", and the two are not distinguishable here. An import-free dataflow is
186+
never suspended, so it is the common case for the former.
187+
188+
The aggregate is per export, not per catalog item. A materialized view being
189+
replaced has two global ids live at once, and a replica running both dataflows logs
190+
a row for each. We record both, as two rows with different `export_id`s, which is
191+
what actually happened. Keying by item id instead would have collapsed them into one
192+
row per item and forced us to pick timestamps across two separate hydrations.
193+
194+
## What is and is not recorded
195+
196+
Collection samples current state. It is not an event log, and the log it reads
197+
retracts an object's row when the export goes away. An episode is recorded only if
198+
its row is still live when its replica's turn comes around, so an object dropped
199+
before then, or a replica process that restarts before then, leaves no trace.
200+
201+
A short-lived dataflow makes this concrete. A constant materialized view hydrates
202+
immediately, its log row briefly carries a complete episode, and then the
203+
controller drops the collection and the row is retracted for good. Whether a sweep
204+
lands inside that window is a race, so such an object is recorded on some runs and
205+
not others. Nothing wrong is recorded either way, and no test can pin the outcome,
206+
which is why `test/testdrive/hydration-status.td` asserts nothing about it.
207+
208+
Making these durable requires compute to emit hydration transitions into an
209+
append-only collection that survives until an observer acknowledges them. Until
210+
then, best effort is the honest description of a sampler.
211+
212+
## Retention
213+
214+
`hydration_history_retention_period` defaults to 30 days. Retention is another OCC
215+
mutation: it subscribes to rows older than the cutoff and writes their retractions
216+
at the observed frontier. Collection applies the same cutoff, so a still-live log
217+
row cannot resurrect an episode retention just retracted.
218+
219+
Retention deletes a bounded batch per sweep and converges over as many sweeps as it
220+
takes. The bound is not a nicety. The OCC path refuses a selection larger than
221+
`max_result_size` before submitting any write, so one unbounded delete over a large
222+
backlog would fail identically forever and never shrink the table. The bound has to
223+
sit inside a derived table, because a top-level `LIMIT` lands in the plan's
224+
`RowSetFinishing`, which the OCC path deliberately discards, and the delete would
225+
be silently unbounded again.
226+
227+
Retention runs on the catalog server, so it keeps working when there are no user
228+
replicas at all, and it runs even when that sweep's collection failed. A
229+
crash-looping replica must not be able to stop the table from shrinking. The
230+
dependency does not run the other way: a catalog server without a replica skips
231+
retention and leaves collection running.
232+
233+
Disabling collection also suspends retention. The alternative is an always-on
234+
subscribe in the default configuration, where the table is empty and there is
235+
nothing to retain. Rows already collected are therefore kept while collection is
236+
off.
237+
238+
## Durability is best effort
239+
240+
Builtin tables are reset at bootstrap and re-shard on a forced schema migration.
241+
The history table is exempt from both, because a sampled history cannot be rebuilt
242+
from anything else once it is gone. Those two exemptions are the whole promise.
243+
244+
We do not commit to carrying the contents across every future version. A schema
245+
change to a builtin table allocates a fresh shard, so changing these columns clears
246+
the history. That is an acceptable trade: the value here is the distribution the
247+
table accumulates, and starting it over costs one retention period, while freezing
248+
the schema to protect it costs every later improvement.
249+
250+
So we try not to break it and we do not promise not to. The exemption exists to
251+
stop an incidental migration from wiping the table as a side effect of unrelated
252+
work, not to make it untouchable. Giving it up is deliberate: an assert in
253+
`plan_migration` fires if a migration step ever names this table, and the comment
254+
there says to remove the assert and the exemption together and to note in the
255+
release notes that the history restarts. The user-facing documentation states the
256+
same limit, that a schema change in a future release may clear the contents.
257+
258+
## Scheduling and isolation
259+
260+
`hydration_history_collection_interval` sets the cadence and disables collection at
261+
zero. One sweep visits one replica, and sweeps never overlap, which bounds compute
262+
load and keeps the collector from contending with itself in the serialized
263+
timestamped-write path.
264+
265+
Fires align to interval boundaries, and each sleep is capped, so that lowering a
266+
long interval at runtime takes effect within the cap rather than after the old
267+
interval elapses. Tests depend on that. The cap is much coarser while collection is
268+
disabled, because a disabled collector has nothing to do but notice that it has been
269+
enabled, and disabled is what every environment runs by default.
270+
271+
Two isolation decisions are worth stating outright:
272+
273+
**Background mutations take no OCC write permit.** The permits are one semaphore
274+
shared by every read-then-write in the process, not one per table. A session's wait for a permit is
275+
bounded by its statement timeout. A sweep has no statement timeout, and its
276+
subscribe must first hydrate a dataflow on a user replica, so holding a permit
277+
would let a background sampler stall user DML for as long as that takes. The sweep
278+
is single-flight, so skipping the permit adds at most one concurrent
279+
read-then-write.
280+
281+
**The sweep is aborted when the coordinator is dropped.** Unlike a session it holds
282+
no `Client`, so nothing otherwise stops it from outliving the coordinator, and the
283+
runtime teardown that follows drops the timestamp oracle's worker task. A sweep
284+
still running then reads a timestamp from a dead oracle and panics. The coordinator
285+
owns the task handle so that dropping it cancels the sweep first.
286+
287+
Each mutation has its own deliberately generous timeout. Even a mutation with
288+
nothing to write waits for its read to linearize, which can take a full
289+
`default_timestamp_interval`, a parameter with no upper bound, so a tighter bound
290+
would let a large timestamp interval starve retention permanently.
291+
292+
Replica drop, cluster drop, dependency replacement, replica failure, and timeout all
293+
skip the attempt, and a later sweep recomputes from current state. Read-only
294+
generations do nothing, which is discussed under upgrades below. Replicas with introspection disabled are skipped, since
295+
their log arrangements exist but are never populated, so a subscribe would read a
296+
sealed, empty collection on every sweep forever.
297+
298+
## Upgrades record late, not never
299+
300+
A read-only generation writes nothing, and a 0dt upgrade hydrates the incoming
301+
replicas while it is still read-only. That looks like it drops exactly the window
302+
worth measuring, since that is when most objects hydrate at once.
303+
304+
It does not, because collection samples current state rather than events. The
305+
stamps live in the replica's own log, the replicas keep running across promotion,
306+
and the first sweep after promotion writes those episodes with the replica's
307+
original `installed_at` and `hydrated_at`. The history is written late, the
308+
timestamps are not.
309+
310+
What is genuinely lost is an episode whose dataflow goes away before that first
311+
post-promotion sweep, which is the general sampling limit rather than anything
312+
specific to upgrades.
313+
314+
## Known limitation: a trailing replica records nothing
315+
316+
The write timestamp comes from the timeline's oracle, and the subscribe's frontier
317+
certifies that the loop has a complete view below it. The write happens once that
318+
frontier reaches the target. One of the subscribe's inputs is a replica-local
319+
introspection log whose frontier the *replica* advances from its own clock, rounded
320+
up to its introspection interval.
321+
322+
So a replica whose clock trails environmentd by more than one introspection
323+
interval produces a frontier that keeps sitting below the target the oracle hands
324+
out. The mutation waits, and the sweep's own timeout ends it. The consequence is
325+
bounded. Nothing wrong is recorded, that replica records nothing at all, and the
326+
sweep stretches while it waits, which delays others in the rotation. It resolves
327+
itself when the clocks converge.
328+
329+
Accepted for now: skew between processes is normally milliseconds while the
330+
tolerance is a whole introspection interval, and the failure mode is waiting rather
331+
than wrong data. Because the symptom is otherwise hard to attribute, a step that
332+
times out logs that the replica's introspection frontier may be trailing.
333+
334+
## Notes on the catalog plumbing
335+
336+
The `test/sqllogictest/autogenerated/*.slt` goldens are generated from the
337+
user-facing docs markdown, but the sqllogictest run compares them against the live
338+
catalog. Editing a column comment in the markdown without editing it in the Rust
339+
builtin definition produces a golden that passes the docs lint locally and fails in
340+
CI.
341+
342+
## Rollout
343+
344+
The interval is zero, and so disabled, in production, and runtime configuration can
345+
enable it without restarting environmentd. The mzcompose configuration enables it at
346+
60 seconds so hydration, restart, retention, and catalog tests exercise the path.
347+
348+
It stays off in the sqllogictest runner defaults, against the usual preference for
349+
enabling new paths in tests. The collector installs subscribes and writes a builtin
350+
table, while those runs assert on catalog contents and plans, so enabling it risks
351+
churn and timing flakiness in files that have nothing to do with hydration.
352+
353+
## Future Work
354+
355+
- Record installation and start before completion, then finalize canceled and
356+
failed episodes by joining replica lifecycle events.
357+
- Define a replica episode state machine from the object set present at the
358+
hydrated-to-hydrating transition.
359+
- Publish resettable per-process high-water values for RAM, swap, and scratch disk,
360+
and define replica aggregation without pretending sampled maxima are simultaneous
361+
peaks.
362+
- Give storage objects equivalent lifecycle timestamps.
363+
- Build replica history and progress views on those signals.

0 commit comments

Comments
 (0)