Skip to main content

mz_adapter/coord/
hydration_history.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Durable history collection for completed object and replica hydration episodes.
11//!
12//! One sweep visits a single user replica, installs a replica-targeted
13//! subscribe that diffs that replica's live hydration timestamps against the
14//! durable history tables, and appends what is missing through the timestamped
15//! OCC write path. Including each history table in its read expression is what
16//! makes the write idempotent across concurrent `environmentd` processes: two
17//! collectors that compute the same row race for one write timestamp, and the
18//! loser observes the winner's append through its own subscribe and finds
19//! nothing left to write.
20//!
21//! One replica is sampled per interval, so an environment with `N` eligible
22//! replicas revisits each one approximately every `N * interval`. Lowering the
23//! interval improves freshness at the cost of more replica dataflow installs.
24//!
25//! Collection is sampling, not an event log. Replica history records only the
26//! latest completed episode visible in a sweep. Intermediate episodes and
27//! intervals retracted before collection leave no evidence and are not recorded.
28//! See the design doc for the resulting semantics.
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32use std::time::{Duration, Instant};
33
34use itertools::Itertools;
35use mz_adapter_types::dyncfgs::{
36    FRONTEND_READ_THEN_WRITE, HYDRATION_HISTORY_COLLECTION_INTERVAL,
37    HYDRATION_HISTORY_RETENTION_PERIOD,
38};
39use mz_catalog::builtin::{
40    MZ_CATALOG_SERVER_CLUSTER, MZ_OBJECT_HYDRATION_HISTORY, MZ_REPLICA_HYDRATION_HISTORY,
41};
42use mz_cluster_client::ReplicaId;
43use mz_controller::clusters::{ClusterStatus, ReplicaLocation};
44use mz_controller_types::ClusterId;
45use mz_ore::cast::CastFrom;
46use mz_ore::collections::CollectionExt;
47use mz_ore::now::EpochMillis;
48use mz_ore::task;
49use mz_repr::CatalogItemId;
50use mz_sql::plan::{MutationKind, Params, Plan, ReadThenWritePlan};
51use sha2::{Digest, Sha256};
52use tracing::warn;
53
54use crate::catalog::Catalog;
55use crate::command::ExecuteResponse;
56use crate::coord::{Coordinator, Message};
57use crate::metrics::Metrics;
58use crate::peek_client::CoordinatorClient;
59use crate::session::Session;
60use crate::{AdapterError, PeekClient};
61
62/// Longest a scheduler sleep may run before it rechecks the configuration.
63///
64/// Sleeping the whole interval would leave a dyncfg change ineffective until the
65/// old interval elapsed, so lowering the interval at runtime (which tests do)
66/// would not take effect for up to the previous interval.
67const SCHEDULE_RECHECK_CAP: Duration = Duration::from_secs(5);
68
69/// How often a disabled collector rechecks whether it was enabled.
70///
71/// This is the cadence of every environment in the default configuration, so it
72/// is much coarser than the enabled one: nothing is waiting on it.
73const DISABLED_RECHECK_INTERVAL: Duration = Duration::from_secs(60);
74
75/// Bound on one replica-targeted mutation.
76///
77/// Subscribe installation, replica-side progress, OCC conflict retries, and the
78/// external commit can all wait indefinitely. Exceeding the bound skips the
79/// step. The next sweep recomputes from current state.
80const MUTATION_TIMEOUT: Duration = Duration::from_secs(300);
81
82/// Rows retracted per retention step.
83///
84/// Retention has to be bounded: the OCC path refuses a selection larger than
85/// `max_result_size` before submitting any write, so an unbounded delete over a
86/// large backlog would fail identically on every sweep and never shrink the
87/// table. Retention repeats bounded batches across sweeps until it drains the
88/// fixed-cutoff backlog.
89const RETENTION_BATCH_SIZE: usize = 1000;
90
91/// Milliseconds until the next fire on this environment's own grid.
92///
93/// The grid has period `interval_ms` and is shifted by `offset`. When this
94/// environment's point in the current period has already passed, the next one is a
95/// full period later.
96fn next_fire_delay(now: EpochMillis, interval_ms: EpochMillis, offset: EpochMillis) -> Duration {
97    debug_assert!(interval_ms > 0);
98    let this_period = (now - (now % interval_ms)).saturating_add(offset);
99    let next = if this_period > now {
100        this_period
101    } else {
102        this_period.saturating_add(interval_ms)
103    };
104    Duration::from_millis(next.saturating_sub(now))
105}
106
107/// Stable offset within `interval_ms` for one environment id.
108fn environment_schedule_offset(environment_id: &str, interval_ms: EpochMillis) -> EpochMillis {
109    debug_assert!(interval_ms > 0);
110    let digest = Sha256::digest(environment_id);
111    let hash = u64::from_le_bytes(digest[..8].try_into().expect("SHA-256 digest has 32 bytes"));
112    hash % interval_ms
113}
114
115impl Coordinator {
116    /// Schedules the next hydration history sweep.
117    ///
118    /// Fires are aligned to interval boundaries so that they stay evenly spaced
119    /// across restarts, offset per environment so that a fleet-wide interval does
120    /// not make every environment sweep at the same instant, and each sleep is
121    /// capped so a configuration change is picked up promptly. Sweeps never
122    /// overlap: the next one is only scheduled once the previous one has finished
123    /// or failed.
124    ///
125    /// NOTE: Alignment reads the wall clock, so a test that freezes `NowFn` and
126    /// configures an interval longer than the recheck cap never reaches a
127    /// boundary and never fires.
128    pub(super) fn schedule_hydration_history_collection(&self) {
129        let interval =
130            HYDRATION_HISTORY_COLLECTION_INTERVAL.get(self.catalog().system_config().dyncfgs());
131
132        // A zero interval disables collection. Keep polling so that enabling it
133        // takes effect without an `environmentd` restart.
134        let (delay, fire) = if interval.is_zero() {
135            (DISABLED_RECHECK_INTERVAL, false)
136        } else {
137            // An absurd interval saturates rather than panicking. The setting is
138            // durable, so a panic here would recur on every restart.
139            let interval_ms = EpochMillis::try_from(interval.as_millis())
140                .unwrap_or(EpochMillis::MAX)
141                .max(1);
142            let remaining = next_fire_delay(
143                self.now(),
144                interval_ms,
145                self.hydration_history_schedule_offset(interval_ms),
146            );
147            if remaining <= SCHEDULE_RECHECK_CAP {
148                (remaining, true)
149            } else {
150                (SCHEDULE_RECHECK_CAP, false)
151            }
152        };
153
154        let internal_cmd_tx = self.internal_cmd_tx.clone();
155        task::spawn(|| "hydration_history_schedule", async move {
156            tokio::time::sleep(delay).await;
157            let message = if fire {
158                Message::HydrationHistoryRun
159            } else {
160                Message::HydrationHistorySchedule
161            };
162            // Best effort: the coordinator may be shutting down.
163            let _ = internal_cmd_tx.send(message);
164        });
165    }
166
167    /// A stable offset into the collection interval for this environment.
168    ///
169    /// Seeded from the full environment id, so it survives restarts but differs
170    /// between environments in the same organization. Without it every
171    /// environment would sweep on the same absolute grid, turning each boundary
172    /// into a fleet-wide burst of dataflow installs, oracle round trips and persist
173    /// writes.
174    fn hydration_history_schedule_offset(&self, interval_ms: EpochMillis) -> EpochMillis {
175        let environment_id = self.catalog().state().config().environment_id.to_string();
176        environment_schedule_offset(&environment_id, interval_ms)
177    }
178
179    /// Runs one sweep: collect from the next replica, then apply retention.
180    pub(super) fn run_hydration_history_collection(&mut self) {
181        let (collection_interval, retention) = {
182            let dyncfgs = self.catalog().system_config().dyncfgs();
183            (
184                HYDRATION_HISTORY_COLLECTION_INTERVAL.get(dyncfgs),
185                HYDRATION_HISTORY_RETENTION_PERIOD.get(dyncfgs),
186            )
187        };
188        // Builtin tables are not writable in read-only mode, and a disabled
189        // collector must do no background work at all. Retention is part of the
190        // sweep, so disabling collection also suspends it. That is deliberate:
191        // the table can only be non-empty if collection ran at some point, and
192        // the alternative is an always-on subscribe in the default (disabled)
193        // production configuration.
194        if collection_interval.is_zero() || self.controller.read_only() {
195            self.schedule_hydration_history_collection();
196            return;
197        }
198
199        let replicas = self
200            .catalog()
201            .user_cluster_replicas()
202            .filter(|replica| replica.config.compute.logging.enabled())
203            .filter(|replica| match &replica.config.location {
204                ReplicaLocation::Managed(_) => {
205                    self.cluster_replica_statuses
206                        .get_cluster_replica_status(replica.cluster_id, replica.replica_id)
207                        == ClusterStatus::Online
208                }
209                // Unmanaged replicas have no orchestrator status and are only
210                // used by tests. Their bounded mutation determines readiness.
211                ReplicaLocation::Unmanaged(_) => true,
212            })
213            .map(|replica| ReplicaTarget {
214                cluster_id: replica.cluster_id,
215                replica_id: replica.replica_id,
216                process_count: replica.config.location.num_processes(),
217            })
218            .sorted_by_key(|replica| replica.replica_id)
219            .collect_vec();
220
221        let catalog = self.owned_catalog();
222        // Retention runs on the catalog server, so that it keeps working when
223        // there are no user replicas to collect from at all. Without a replica
224        // there it is skipped, while collection still runs.
225        let catalog_server = catalog.resolve_builtin_cluster(&MZ_CATALOG_SERVER_CLUSTER);
226        let catalog_server_target = catalog_server
227            .replicas()
228            .next()
229            .map(|replica| (catalog_server.id, replica.replica_id));
230
231        let replica = next_replica(&replicas, self.hydration_history_replica_cursor);
232        if let Some(replica) = replica {
233            self.hydration_history_replica_cursor = Some(replica.replica_id);
234        }
235        let mut sweep = self.new_sweep(catalog, retention);
236        let internal_cmd_tx = self.internal_cmd_tx.clone();
237
238        let handle = task::spawn(|| "hydration_history_sweep", async move {
239            let started = Instant::now();
240            if let Some(replica) = replica {
241                sweep.collect(replica).await;
242            }
243
244            // Retention runs even when collection failed above. A replica that
245            // is crash-looping or slow must not be able to stop the table from
246            // shrinking back to its retention bound.
247            if let Some((cluster_id, replica_id)) = catalog_server_target {
248                sweep.retain(cluster_id, replica_id).await;
249            }
250
251            sweep
252                .metrics
253                .hydration_history_sweep_duration_seconds
254                .observe(started.elapsed().as_secs_f64());
255            let _ = internal_cmd_tx.send(Message::HydrationHistorySchedule);
256        });
257
258        // Keep the sweep coordinator-owned so dropping the coordinator requests
259        // its abort. Fallible coordinator calls make concurrent shutdown safe.
260        self.hydration_history_sweep = Some(handle.abort_on_drop());
261    }
262
263    /// Assembles the sweep context, including the client it writes through.
264    fn new_sweep(&self, catalog: Arc<Catalog>, retention: Duration) -> Sweep {
265        let retention_ms = u64::try_from(retention.as_millis()).unwrap_or(u64::MAX);
266        let build_version = catalog.state().config().build_info.human_version(None);
267        // Background read-then-write always uses the frontend OCC path. This
268        // shared constructor field only controls session fallback, so the flag
269        // does not gate history collection.
270        let client = PeekClient::new(
271            CoordinatorClient::Background {
272                tx: self.internal_cmd_tx.clone(),
273                metrics: self.metrics.clone(),
274            },
275            &catalog,
276            Arc::clone(&self.controller.storage_collections),
277            Arc::clone(&self.transient_id_gen),
278            self.optimizer_metrics.clone(),
279            self.persist_client.clone(),
280            self.statement_logging.create_frontend(build_version),
281            Arc::clone(&self.occ_write_semaphore),
282            FRONTEND_READ_THEN_WRITE.get(self.catalog().system_config().dyncfgs()),
283            self.group_commit_tx.clone(),
284            self.controller.read_only(),
285        );
286        Sweep {
287            client,
288            object_history_id: catalog.resolve_builtin_table(&MZ_OBJECT_HYDRATION_HISTORY),
289            replica_history_id: catalog.resolve_builtin_table(&MZ_REPLICA_HYDRATION_HISTORY),
290            catalog,
291            metrics: self.metrics.clone(),
292            wall_time: self.now_datetime(),
293            cutoff: mz_ore::now::to_datetime(self.now().saturating_sub(retention_ms)).to_rfc3339(),
294        }
295    }
296}
297
298/// A user replica eligible for one collection step.
299#[derive(Clone, Copy, Debug, Eq, PartialEq)]
300struct ReplicaTarget {
301    cluster_id: ClusterId,
302    replica_id: ReplicaId,
303    process_count: usize,
304}
305
306/// Picks the replica after `cursor`, wrapping around at the end.
307///
308/// `replicas` must be sorted ascending by replica id. Unsorted input still
309/// returns a replica but degenerates the rotation, revisiting some replicas and
310/// starving others.
311fn next_replica(replicas: &[ReplicaTarget], cursor: Option<ReplicaId>) -> Option<ReplicaTarget> {
312    replicas
313        .iter()
314        .find(|replica| cursor.is_none_or(|cursor| replica.replica_id > cursor))
315        .or_else(|| replicas.first())
316        .copied()
317}
318
319/// The rows this replica has completed that the history table is missing.
320///
321/// Aggregates every worker's row for an export, and records nothing until all of
322/// them have hydrated. One worker is not enough, because a materialized view's
323/// persist sink has a single active worker, `hash(sink_id) % workers`. Only that
324/// worker's reported output frontier is gated on the shard upper, so only its
325/// `hydrated_at` covers the initial snapshot write. Every other worker clears its
326/// sink write frontier and stamps at compute completion, which for a materialized
327/// view is before the data is durable. Taking `max` over a complete set of workers
328/// is therefore the only way to get a finish that means the same thing for every
329/// object, and it is the rule `mz_compute_hydration_times` already applies.
330///
331/// Completeness needs no configured worker count. The log carries a row per
332/// `(export_id, worker_id)` from installation with a null `hydrated_at`, so
333/// `count(*) = count(hydrated_at)` says every row visible at the OCC read
334/// timestamp has finished. Per-process logging clocks also determine Differential
335/// update timestamps, so a worker whose clock is ahead can be absent at that
336/// timestamp. A visible unfinished object is skipped and picked up by a later
337/// sweep.
338///
339/// A worker missing at the read timestamp cannot later change the episode key.
340/// Its logging clock stamps both the Differential update and `installed_at`, so
341/// late visibility means its installation stamp is later than the visible
342/// minimum. The anti-join therefore keeps matching the recorded row. A later
343/// `hydrated_at` can raise the aggregate's maximum, but history is not repaired
344/// after the episode key has been recorded.
345///
346/// The collector deliberately accepts this sampling race rather than depending on
347/// `ReplicaLocation::workers()`. A durable finish can therefore precede the latest
348/// worker's finish. A whole-replica restart resets the collection as a unit.
349///
350/// The interval spans workers, so it carries whatever skew there is between the
351/// process clocks that stamped its ends. Each process anchors its logging clock at
352/// its own `SystemTime`. That inflates a duration, and nothing here rejects a row
353/// for being inconsistent, which is deliberate: an ordering guard on cross-worker
354/// stamps rejects complete episodes permanently, since the log values never change.
355///
356/// Collection has no explicit batch bound. It returns at most one row per
357/// not-yet-recorded dataflow, and the OCC path rejects a result that exceeds
358/// `max_result_size` or `max_query_result_size`. At their 1 GiB defaults that
359/// ceiling only matters at millions of dataflows per replica.
360fn object_collection_sql(cluster_id: ClusterId, replica_id: ReplicaId, cutoff: &str) -> String {
361    // Interpolating into SQL is safe here: the ids are catalog-internal and the
362    // cutoff is an RFC 3339 timestamp we formatted ourselves. Nothing in this
363    // query comes from a user.
364    //
365    // NOTE: The cutoff and the anti-join sit outside the aggregate deliberately. As
366    // a `WHERE` clause either one drops not-yet-hydrated rows, which would make
367    // `count(*) = count(hydrated_at)` trivially true and hand back a compute-only
368    // finish for a materialized view whose active worker is still writing.
369    //
370    // NOTE: `hydrated_at` is the terminal stamp for a history episode. Nothing
371    // waits for the history row before proceeding. If the log gains a separate
372    // `written_at` stamp, only `hydrated_at` belongs in this completeness check.
373    // A materialized view being replaced can hydrate while it runs read-only, and
374    // may never write if the replacement is rolled back.
375    format!(
376        "SELECT
377            e.object_id,
378            '{cluster_id}'::text AS cluster_id,
379            '{replica_id}'::text AS replica_id,
380            e.installed_at,
381            e.started_at,
382            e.hydrated_at,
383            'hydrated'::text AS status
384        FROM (
385            SELECT
386                t.export_id AS object_id,
387                min(t.installed_at) AS installed_at,
388                min(t.started_at) AS started_at,
389                max(t.hydrated_at) AS hydrated_at
390            FROM mz_introspection.mz_compute_hydration_times_per_worker AS t
391            WHERE t.export_id NOT LIKE 'si%'
392              AND t.export_id NOT LIKE 't%'
393            GROUP BY t.export_id
394            HAVING count(*) = count(t.hydrated_at)
395        ) AS e
396        WHERE e.hydrated_at >= TIMESTAMPTZ '{cutoff}'
397          AND NOT EXISTS (
398              SELECT 1
399              FROM mz_internal.mz_object_hydration_history AS h
400              WHERE h.object_id = e.object_id
401                AND h.replica_id = '{replica_id}'::text
402                AND h.installed_at = e.installed_at
403          )"
404    )
405}
406
407/// Returns SQL for the latest completed compute hydration episode and its
408/// process resource peaks.
409///
410/// Episodes are connected components of export hydration intervals. An export
411/// that has not hydrated keeps its component open: whether it is slow or
412/// permanently stuck is unobservable, so an open component is simply an
413/// in-progress episode, recorded when (if) it completes. It blocks only its
414/// own component: an unhydrated export installed at or before a completed
415/// component's finish would extend that component, one installed later belongs
416/// to a later episode. The latest completed component disconnected from every
417/// open one is recorded. The monotonic history guard still admits an open
418/// episode once it completes, because its start lies after every recorded
419/// finish. Cross-process clock skew can break that ordering, in which case the
420/// guard suppresses the episode rather than misrecording it.
421///
422/// Collection also waits until every configured replica process has reported
423/// resource usage. The query itself narrates how each step works.
424fn replica_collection_sql(target: ReplicaTarget, cutoff: &str) -> String {
425    let ReplicaTarget {
426        cluster_id,
427        replica_id,
428        process_count,
429    } = target;
430    // Interpolating into SQL is safe here: the ids and process count are
431    // catalog-internal and the cutoff is an RFC 3339 timestamp we formatted.
432    format!(
433        "WITH
434        -- One hydration interval per compute export: earliest install and
435        -- latest finish across its workers. Hydrated only once every worker
436        -- visible at this timestamp has finished.
437        objects AS (
438            SELECT
439                t.export_id AS object_id,
440                min(t.installed_at) AS installed_at,
441                max(t.hydrated_at) AS hydrated_at,
442                count(*) = count(t.hydrated_at) AS hydrated
443            FROM mz_introspection.mz_compute_hydration_times_per_worker AS t
444            WHERE t.export_id NOT LIKE 't%'
445            GROUP BY t.export_id
446        ),
447        -- Completed intervals in install order, each with the coverage
448        -- horizon: the latest finish among this and all earlier intervals.
449        covered AS (
450            SELECT
451                object_id,
452                installed_at,
453                hydrated_at,
454                max(hydrated_at) OVER (
455                    ORDER BY installed_at, object_id
456                    ROWS UNBOUNDED PRECEDING
457                ) AS covered_through
458            FROM objects
459            WHERE hydrated
460        ),
461        -- An interval starts a new episode when the horizon just before it
462        -- does not reach its install: for a moment, nothing was hydrating.
463        flagged AS (
464            SELECT
465                object_id,
466                installed_at,
467                hydrated_at,
468                lag(covered_through) OVER (
469                    ORDER BY installed_at, object_id
470                ) IS NULL
471                    OR lag(covered_through) OVER (
472                        ORDER BY installed_at, object_id
473                    ) < installed_at AS starts_episode
474            FROM covered
475        ),
476        -- Each interval belongs to the latest episode start at or before it.
477        labeled AS (
478            SELECT
479                object_id,
480                installed_at,
481                hydrated_at,
482                max(CASE WHEN starts_episode THEN installed_at END) OVER (
483                    ORDER BY installed_at, object_id
484                    ROWS UNBOUNDED PRECEDING
485                ) AS episode_started_at
486            FROM flagged
487        ),
488        -- One row per completed episode.
489        episodes AS (
490            SELECT
491                episode_started_at AS started_at,
492                max(hydrated_at) AS finished_at,
493                count(*) FILTER (WHERE object_id NOT LIKE 'si%')::uint8 AS object_count
494            FROM labeled
495            GROUP BY episode_started_at
496        ),
497        -- The earliest install of an export that has not hydrated yet.
498        open_min AS (
499            SELECT min(installed_at) AS v FROM objects WHERE NOT hydrated
500        ),
501        -- The episode to record: the latest one that finished before any
502        -- unhydrated export was installed. An episode finishing at or after
503        -- open_min contains that open interval and is still in progress.
504        -- Comparing against this one scalar, instead of joining episodes
505        -- with open intervals, avoids a cross product that is quadratic when
506        -- many episodes coexist with many still-hydrating exports.
507        episode AS (
508            SELECT e.started_at, e.finished_at, e.object_count
509            FROM episodes AS e, open_min AS o
510            WHERE o.v IS NULL OR e.finished_at < o.v
511            ORDER BY e.started_at DESC
512            LIMIT 1
513        ),
514        -- Process-lifetime resource high-water marks, and how many processes
515        -- have reported them.
516        resources AS (
517            SELECT
518                count(DISTINCT process_id) AS process_count,
519                max(value) FILTER (
520                    WHERE source = 'cgroup' AND metric = 'memory_peak'
521                ) AS peak_memory_bytes,
522                coalesce(
523                    max(value) FILTER (
524                        WHERE source = 'statvfs' AND metric = 'fs_used_peak'
525                    ),
526                    max(value) FILTER (
527                        WHERE source = 'cgroup' AND metric = 'swap_peak'
528                    )
529                ) AS peak_disk_bytes
530            FROM mz_introspection.mz_cluster_replica_resource_usage
531        ),
532        -- The history row to write, held back until every configured process
533        -- has reported resource usage and dropped once the episode has aged
534        -- past the retention cutoff.
535        candidate AS (
536            SELECT
537                '{replica_id}'::text AS replica_id,
538                '{cluster_id}'::text AS cluster_id,
539                e.started_at,
540                e.finished_at,
541                e.object_count,
542                r.peak_memory_bytes,
543                r.peak_disk_bytes,
544                'hydrated'::text AS status
545            FROM episode AS e
546            CROSS JOIN resources AS r
547            WHERE r.process_count = {process_count}::uint8
548              AND e.finished_at >= TIMESTAMPTZ '{cutoff}'
549        )
550        -- Skip episodes the history already covers: a recorded row finishing
551        -- at or after this start is this episode, or overlaps it under
552        -- cross-process clock skew.
553        SELECT c.*
554        FROM candidate AS c
555        WHERE NOT EXISTS (
556            SELECT 1
557            FROM mz_internal.mz_replica_hydration_history AS h
558            WHERE h.replica_id = c.replica_id
559              AND h.finished_at >= c.started_at
560        )"
561    )
562}
563
564/// A bounded batch of history rows that have aged out.
565///
566/// Only rows with a `hydrated_at` age out. Every row written today has one, and
567/// a row without one would be immortal here, so an unfinished-episode
568/// representation needs a second age basis before it can be recorded.
569fn object_retention_sql(cutoff: &str) -> String {
570    // The LIMIT has to sit inside a subquery. A top-level LIMIT lands in the
571    // plan's `RowSetFinishing`, which this OCC stage cannot apply. Inside a
572    // derived table it lowers into the relation expression instead.
573    format!(
574        "SELECT * FROM (
575            SELECT
576                object_id, cluster_id, replica_id, installed_at, started_at,
577                hydrated_at, status
578            FROM mz_internal.mz_object_hydration_history
579            WHERE hydrated_at < TIMESTAMPTZ '{cutoff}'
580            ORDER BY hydrated_at
581            LIMIT {RETENTION_BATCH_SIZE}
582        )"
583    )
584}
585
586/// A bounded batch of replica history rows that have aged out.
587fn replica_retention_sql(cutoff: &str) -> String {
588    format!(
589        "SELECT * FROM (
590            SELECT
591                replica_id, cluster_id, started_at, finished_at, object_count,
592                peak_memory_bytes, peak_disk_bytes, status
593            FROM mz_internal.mz_replica_hydration_history
594            WHERE finished_at < TIMESTAMPTZ '{cutoff}'
595            ORDER BY finished_at
596            LIMIT {RETENTION_BATCH_SIZE}
597        )"
598    )
599}
600
601/// What one sweep needs to run its mutations against the history table.
602struct Sweep {
603    client: PeekClient,
604    catalog: Arc<Catalog>,
605    object_history_id: CatalogItemId,
606    replica_history_id: CatalogItemId,
607    metrics: Metrics,
608    wall_time: chrono::DateTime<chrono::Utc>,
609    /// Rows finishing before this have aged out. Both steps apply it, so this
610    /// sweep cannot resurrect an episode its own retention step retracts.
611    /// Concurrent sweeps can have different cutoffs, making retention eventual.
612    cutoff: String,
613}
614
615impl Sweep {
616    /// Appends completed object and replica episodes from one replica.
617    async fn collect(&mut self, target: ReplicaTarget) {
618        let ReplicaTarget {
619            cluster_id,
620            replica_id,
621            ..
622        } = target;
623        let sql = object_collection_sql(cluster_id, replica_id, &self.cutoff);
624        let _ = self
625            .run(
626                "collection",
627                self.object_history_id,
628                cluster_id,
629                replica_id,
630                MutationKind::Insert,
631                &sql,
632            )
633            .await;
634
635        let sql = replica_collection_sql(target, &self.cutoff);
636        let _ = self
637            .run(
638                "replica_collection",
639                self.replica_history_id,
640                cluster_id,
641                replica_id,
642                MutationKind::Insert,
643                &sql,
644            )
645            .await;
646    }
647
648    /// Retracts one bounded batch of aged-out rows.
649    async fn retain(&mut self, cluster_id: ClusterId, replica_id: ReplicaId) {
650        let sql = object_retention_sql(&self.cutoff);
651        if let Some(deleted) = self
652            .run(
653                "retention",
654                self.object_history_id,
655                cluster_id,
656                replica_id,
657                MutationKind::Delete,
658                &sql,
659            )
660            .await
661            && deleted == RETENTION_BATCH_SIZE
662        {
663            self.metrics.hydration_history_retention_batch_full.inc();
664        }
665
666        let sql = replica_retention_sql(&self.cutoff);
667        if let Some(deleted) = self
668            .run(
669                "replica_retention",
670                self.replica_history_id,
671                cluster_id,
672                replica_id,
673                MutationKind::Delete,
674                &sql,
675            )
676            .await
677            && deleted == RETENTION_BATCH_SIZE
678        {
679            self.metrics.hydration_history_retention_batch_full.inc();
680        }
681    }
682
683    /// Runs one mutation, leaving transient failures for the next sweep to retry.
684    ///
685    /// Replica loss, dependency replacement, and write races are logged rather
686    /// than propagated because the next sweep recomputes from current state.
687    ///
688    /// NOTE: A timed-out mutation can still commit. The write is submitted
689    /// before we wait for its answer, and a background write carries no
690    /// connection to cancel it with, so a `timeout` outcome says we stopped
691    /// waiting, not that nothing landed. Rows such a write commits afterwards
692    /// are never counted, which makes `rows_affected` a lower bound.
693    async fn run(
694        &mut self,
695        step: &'static str,
696        history_id: CatalogItemId,
697        cluster_id: ClusterId,
698        replica_id: ReplicaId,
699        kind: MutationKind,
700        sql: &str,
701    ) -> Option<usize> {
702        let mutation = async {
703            let plan = plan_mutation(&self.catalog, history_id, kind, sql)?;
704            let mut session = Session::dummy();
705            session.start_transaction_single_stmt(self.wall_time);
706            let response = self
707                .client
708                .background_read_then_write(
709                    &mut session,
710                    plan,
711                    cluster_id,
712                    replica_id,
713                    &self.catalog,
714                )
715                .await?;
716            Ok::<_, AdapterError>(response)
717        };
718        match tokio::time::timeout(MUTATION_TIMEOUT, mutation).await {
719            Ok(Ok(response)) => {
720                let (rows, action) = match (kind, response) {
721                    (MutationKind::Insert, ExecuteResponse::Inserted(rows)) => (rows, "appended"),
722                    (MutationKind::Update, ExecuteResponse::Updated(rows)) => (rows, "updated"),
723                    (MutationKind::Delete, ExecuteResponse::Deleted(rows)) => (rows, "deleted"),
724                    (_, response) => {
725                        self.observe_mutation(step, "error");
726                        mz_ore::soft_panic_or_log!(
727                            "hydration history {step} returned an unexpected response: {response:?}"
728                        );
729                        return None;
730                    }
731                };
732                self.metrics
733                    .hydration_history_rows_affected
734                    .with_label_values(&[action])
735                    .inc_by(u64::cast_from(rows));
736                let outcome = if rows == 0 { "noop" } else { "success" };
737                self.observe_mutation(step, outcome);
738                Some(rows)
739            }
740            Ok(Err(error)) => {
741                self.observe_mutation(step, "error");
742                if step.ends_with("collection")
743                    && matches!(&error, AdapterError::ReadThenWriteContention)
744                {
745                    warn!(
746                        %step, %cluster_id, %replica_id, %error,
747                        "hydration history step failed, the replica's introspection frontier \
748                         may be trailing the write frontier"
749                    );
750                } else {
751                    warn!(%step, %cluster_id, %replica_id, %error, "hydration history step failed");
752                }
753                None
754            }
755            // A trailing replica can repeatedly certify a target only after the
756            // oracle has advanced past it. Each refused write raises the target,
757            // and the conflict loop can continue until this timeout fires.
758            Err(_) if step.ends_with("collection") => {
759                self.observe_mutation(step, "timeout");
760                warn!(
761                    %step, %cluster_id, %replica_id,
762                    "hydration history step timed out, \
763                     the replica's introspection frontier may be trailing the write frontier"
764                );
765                None
766            }
767            Err(_) => {
768                self.observe_mutation(step, "timeout");
769                warn!(%step, %cluster_id, %replica_id, "hydration history step timed out");
770                None
771            }
772        }
773    }
774
775    fn observe_mutation(&self, operation: &str, outcome: &str) {
776        self.metrics
777            .hydration_history_mutations
778            .with_label_values(&[operation, outcome])
779            .inc();
780    }
781}
782
783/// Plans `sql` as the read side of a mutation against `target_id`.
784///
785/// The statement is planned as a `SELECT` whose columns are already in the
786/// target table's order, so the mutation needs no assignments or projection.
787///
788/// The selection's column types are checked against the target table here. A
789/// user `INSERT ... SELECT` gets that from the planner, but a hand-built plan
790/// bypasses it, and a wrong type would be written into the shard verbatim and
791/// break every later read of a table that is deliberately never truncated.
792fn plan_mutation(
793    catalog: &Arc<Catalog>,
794    target_id: CatalogItemId,
795    kind: MutationKind,
796    sql: &str,
797) -> Result<ReadThenWritePlan, AdapterError> {
798    let session_catalog = catalog.for_system_session();
799    let parsed = mz_sql::parse::parse(sql)
800        .map_err(AdapterError::from)?
801        .into_element();
802    let (stmt, resolved_ids) = mz_sql::names::resolve(&session_catalog, parsed.ast)?;
803    let (plan, _) = mz_sql::plan::plan(
804        None,
805        &session_catalog,
806        stmt,
807        &Params::empty(),
808        &resolved_ids,
809    )?;
810    let Plan::Select(select) = plan else {
811        return Err(AdapterError::Internal(
812            "hydration history query did not plan as SELECT".into(),
813        ));
814    };
815
816    let target_desc = catalog
817        .get_entry(&target_id)
818        .relation_desc_latest()
819        .expect("hydration history target is a table");
820    let selection_types = select.source.typ(&[], &BTreeMap::new()).column_types;
821    let target_types = &target_desc.typ().column_types;
822    let matches = selection_types.len() == target_types.len()
823        && selection_types
824            .iter()
825            .zip_eq(target_types)
826            // Nullability may be tighter than the column allows, only the
827            // scalar types have to agree.
828            .all(|(selected, target)| selected.scalar_type == target.scalar_type);
829    if !matches {
830        return Err(AdapterError::Internal(format!(
831            "hydration history query does not match the target table: \
832             selection {selection_types:?}, table {target_types:?}"
833        )));
834    }
835
836    Ok(ReadThenWritePlan {
837        id: target_id,
838        selection: select.source,
839        finishing: select.finishing,
840        assignments: BTreeMap::new(),
841        kind,
842        returning: Vec::new(),
843    })
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    #[mz_ore::test]
851    fn replica_sweep_advances_and_wraps() {
852        let cluster = ClusterId::user(1).expect("valid cluster ID");
853        let replicas = [
854            ReplicaTarget {
855                cluster_id: cluster,
856                replica_id: ReplicaId::User(1),
857                process_count: 1,
858            },
859            ReplicaTarget {
860                cluster_id: cluster,
861                replica_id: ReplicaId::User(3),
862                process_count: 1,
863            },
864        ];
865
866        assert_eq!(next_replica(&replicas, None), Some(replicas[0]));
867        assert_eq!(
868            next_replica(&replicas, Some(ReplicaId::User(1))),
869            Some(replicas[1])
870        );
871        assert_eq!(
872            next_replica(&replicas, Some(ReplicaId::User(3))),
873            Some(replicas[0])
874        );
875        assert_eq!(next_replica(&[], None), None);
876    }
877
878    /// Every environment shares one interval, so the grid has to be shifted per
879    /// environment or the whole fleet sweeps at the same instant.
880    #[mz_ore::test]
881    fn fire_delay_is_offset_within_the_interval() {
882        let interval = 60_000;
883
884        // Before this environment's point in the period, we wait for it.
885        assert_eq!(
886            next_fire_delay(1_000, interval, 5_000),
887            Duration::from_millis(4_000)
888        );
889        // On it, we take the next period rather than firing twice.
890        assert_eq!(
891            next_fire_delay(5_000, interval, 5_000),
892            Duration::from_millis(interval)
893        );
894        // After it, the next period's point.
895        assert_eq!(
896            next_fire_delay(6_000, interval, 5_000),
897            Duration::from_millis(59_000)
898        );
899        // A zero offset is plain alignment, and never returns a zero delay.
900        assert_eq!(
901            next_fire_delay(59_999, interval, 0),
902            Duration::from_millis(1)
903        );
904        assert_eq!(
905            next_fire_delay(60_000, interval, 0),
906            Duration::from_millis(interval)
907        );
908
909        // Region and ordinal are part of the seed, not just the organization.
910        let one = environment_schedule_offset(
911            "aws-us-east-1-00000000-0000-0000-0000-000000000000-0",
912            interval,
913        );
914        let two = environment_schedule_offset(
915            "aws-us-west-1-00000000-0000-0000-0000-000000000000-1",
916            interval,
917        );
918        assert_eq!(one, 30_189);
919        assert_eq!(two, 38_252);
920        assert_ne!(one, two);
921    }
922
923    /// A materialized view's finish is only durable on the sink's active worker, so
924    /// the query has to see every worker and take the latest stamp. Pinning a single
925    /// worker, or letting the cutoff or the anti-join filter rows before the
926    /// completeness check, silently reintroduces a finish that precedes the write.
927    #[mz_ore::test]
928    fn collect_requires_every_worker() {
929        let cutoff = "1970-01-01T00:00:00+00:00";
930        let sql = object_collection_sql(
931            ClusterId::user(1).expect("valid cluster ID"),
932            ReplicaId::User(2),
933            cutoff,
934        );
935        assert!(
936            sql.contains("HAVING count(*) = count(t.hydrated_at)"),
937            "{sql}"
938        );
939        assert!(sql.contains("max(t.hydrated_at)"), "{sql}");
940        assert!(!sql.contains("worker_id"), "{sql}");
941
942        // Both of these have to apply to the aggregate's output, not to the rows
943        // feeding it.
944        let aggregate_end = sql.find(") AS e").expect("aggregate subquery");
945        assert!(sql.find(cutoff).expect("cutoff") > aggregate_end, "{sql}");
946        assert!(
947            sql.find("NOT EXISTS").expect("anti-join") > aggregate_end,
948            "{sql}"
949        );
950    }
951
952    /// Replica episodes are connected components of object hydration intervals,
953    /// enumerated gaps-and-islands style. An episode still connected to an open
954    /// interval is skipped via a scalar comparison against the earliest open
955    /// install, and the latest remaining episode is recorded. The query must
956    /// also wait for every replica process before it snapshots process-local
957    /// high-water marks.
958    #[mz_ore::test]
959    fn replica_collection_uses_latest_completed_interval_island() {
960        let sql = replica_collection_sql(
961            ReplicaTarget {
962                cluster_id: ClusterId::user(1).expect("valid cluster ID"),
963                replica_id: ReplicaId::User(2),
964                process_count: 3,
965            },
966            "1970-01-01T00:00:00+00:00",
967        );
968        let normalized_sql = sql.split_whitespace().collect::<Vec<_>>().join(" ");
969
970        // The gaps-and-islands scaffolding: running coverage horizon, gap
971        // detection against the previous row's horizon, episode labels, and
972        // per-episode aggregation.
973        assert!(sql.contains("ROWS UNBOUNDED PRECEDING"), "{sql}");
974        assert!(sql.contains("lag(covered_through)"), "{sql}");
975        assert!(
976            sql.contains("CASE WHEN starts_episode THEN installed_at END"),
977            "{sql}"
978        );
979        assert!(sql.contains("GROUP BY episode_started_at"), "{sql}");
980        // The unfinished-export guard applies per episode. A replica-wide
981        // all-hydrated gate would lose a completed episode for good: once the
982        // in-progress one finishes, it is the latest and the earlier one is
983        // never recorded.
984        assert!(!sql.contains("bool_and(hydrated)"), "{sql}");
985        // The guard compares each episode against the earliest open install,
986        // one scalar row. A join against all open intervals is quadratic when
987        // many episodes coexist with many still-hydrating exports.
988        assert!(
989            normalized_sql.contains("WHERE o.v IS NULL OR e.finished_at < o.v"),
990            "{sql}"
991        );
992        assert!(!sql.contains("o.installed_at <= e.finished_at"), "{sql}");
993        assert!(
994            normalized_sql.contains("ORDER BY e.started_at DESC LIMIT 1"),
995            "{sql}"
996        );
997        assert!(sql.contains("r.process_count = 3::uint8"), "{sql}");
998        assert!(sql.contains("WHERE t.export_id NOT LIKE 't%'"), "{sql}");
999        assert!(!sql.contains("WHERE t.export_id LIKE 'u%'"), "{sql}");
1000        assert!(!sql.contains("mz_object_global_ids"), "{sql}");
1001        assert!(!sql.contains("mz_catalog.mz_objects"), "{sql}");
1002        assert!(
1003            normalized_sql
1004                .contains("max(value) FILTER ( WHERE source = 'cgroup' AND metric = 'memory_peak'"),
1005            "{sql}"
1006        );
1007        assert!(
1008            normalized_sql.contains(
1009                "max(value) FILTER ( WHERE source = 'statvfs' AND metric = 'fs_used_peak'"
1010            ),
1011            "{sql}"
1012        );
1013        assert!(
1014            normalized_sql
1015                .contains("max(value) FILTER ( WHERE source = 'cgroup' AND metric = 'swap_peak'"),
1016            "{sql}"
1017        );
1018        assert!(!normalized_sql.contains("sum(value)"), "{sql}");
1019        assert!(
1020            sql.contains("FROM mz_internal.mz_replica_hydration_history"),
1021            "{sql}"
1022        );
1023        assert!(sql.contains("h.finished_at >= c.started_at"), "{sql}");
1024    }
1025}