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