|
| 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. |
0 commit comments