|
| 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