Skip to main content

mz_adapter/coord/
message_handler.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//! Logic for processing [`Coordinator`] messages. The [`Coordinator`] receives
11//! messages from various sources (ex: controller, clients, background tasks, etc).
12
13use std::collections::{BTreeMap, BTreeSet, btree_map};
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16
17use futures::FutureExt;
18use maplit::btreemap;
19use mz_audit_log::VersionedStorageUsage;
20use mz_catalog::memory::objects::ClusterReplicaProcessStatus;
21use mz_controller::ControllerResponse;
22use mz_controller::clusters::{ClusterEvent, ClusterStatus};
23use mz_ore::cast::CastFrom;
24use mz_ore::instrument;
25use mz_ore::now::EpochMillis;
26use mz_ore::option::OptionExt;
27use mz_ore::tracing::OpenTelemetryContext;
28use mz_ore::{soft_assert_or_log, soft_panic_or_log, task};
29use mz_persist_client::usage::ShardsUsageReferenced;
30use mz_repr::{Datum, Diff, Row};
31use mz_sql::ast::Statement;
32use mz_sql::names::ResolvedIds;
33use mz_sql::pure::PurifiedStatement;
34use mz_storage_client::controller::IntrospectionType;
35use mz_storage_types::StorageDiff;
36use opentelemetry::trace::TraceContextExt;
37use rand::{RngExt, SeedableRng, rngs};
38use serde_json::json;
39use tracing::{Instrument, Level, event, info_span, warn};
40use tracing_opentelemetry::OpenTelemetrySpanExt;
41
42use crate::active_compute_sink::{ActiveComputeSink, ActiveComputeSinkRetireReason};
43use crate::catalog::BuiltinTableUpdate;
44use crate::command::Command;
45use crate::coord::{
46    AlterConnectionValidationReady, ArrangementSizeRecord, ClusterReplicaStatuses, Coordinator,
47    CreateConnectionValidationReady, Message, PurifiedStatementReady, WatchSetResponse,
48};
49use crate::telemetry::{EventDetails, SegmentClientExt};
50use crate::{AdapterNotice, TimestampContext};
51
52/// How long an introspection subscribe must have been delivering data before
53/// the arrangement sizes snapshot trusts its replica's rows.
54///
55/// See `Coordinator::fresh_introspection_replicas` for why a margin is needed.
56/// 10s comfortably covers the collection manager's ~1s write batching plus the
57/// oracle read timestamp trailing the wall clock.
58const ARRANGEMENT_SIZES_FRESHNESS_MARGIN: Duration = Duration::from_secs(10);
59
60impl Coordinator {
61    /// BOXED FUTURE: As of Nov 2023 the returned Future from this function was 74KB. This would
62    /// get stored on the stack which is bad for runtime performance, and blow up our stack usage.
63    /// Because of that we purposefully move Futures of inner function calls onto the heap
64    /// (i.e. Box it).
65    #[instrument]
66    pub(crate) async fn handle_message(&mut self, msg: Message) -> () {
67        match msg {
68            Message::Command(otel_ctx, cmd) => {
69                // TODO: We need a Span that is not none for the otel_ctx to attach the parent
70                // relationship to. If we swap the otel_ctx in `Command::Message` for a Span, we
71                // can downgrade this to a debug_span.
72                let span = tracing::info_span!("message_command").or_current();
73                span.in_scope(|| otel_ctx.attach_as_parent());
74                self.message_command(cmd).instrument(span).await
75            }
76            Message::ControllerReady { controller: _ } => {
77                let Coordinator {
78                    controller,
79                    catalog,
80                    ..
81                } = self;
82                let storage_metadata = catalog.state().storage_metadata();
83                if let Some(m) = controller
84                    .process(storage_metadata)
85                    .expect("`process` never returns an error")
86                {
87                    self.message_controller(m).boxed_local().await
88                }
89            }
90            Message::PurifiedStatementReady(ready) => {
91                self.message_purified_statement_ready(ready)
92                    .boxed_local()
93                    .await
94            }
95            Message::CreateConnectionValidationReady(ready) => {
96                self.message_create_connection_validation_ready(ready)
97                    .boxed_local()
98                    .await
99            }
100            Message::AlterConnectionValidationReady(ready) => {
101                self.message_alter_connection_validation_ready(ready)
102                    .boxed_local()
103                    .await
104            }
105            Message::DeferredPlanReady { conn_id } => self.sequence_deferred_plan(conn_id).await,
106            Message::GroupCommitInitiate(span, permit) => {
107                // Add an OpenTelemetry link to our current span.
108                tracing::Span::current().add_link(span.context().span().span_context().clone());
109                span.in_scope(|| self.stage_group_commit(permit));
110            }
111            Message::GroupCommitApplied {
112                responses,
113                statement_logging_ids,
114                internal_results,
115                write_ts,
116            } => {
117                // Record statement timestamps before retiring, since retiring ends the statement
118                // execution and drops its logging record.
119                for id in statement_logging_ids {
120                    self.set_statement_execution_timestamp(id, write_ts);
121                }
122                for response in responses {
123                    let (mut ctx, result) = response.finalize();
124                    ctx.session_mut().apply_write(write_ts);
125                    ctx.retire(result);
126                }
127                // The committer applied `write_ts` to the oracle, so the read ts is at least
128                // that and we can downgrade the local read holds without an oracle round trip.
129                self.downgrade_local_read_holds(write_ts);
130                self.advance_custom_timelines().boxed_local().await;
131                for result in internal_results {
132                    result.send(crate::coord::appends::WriteResult::Success {
133                        timestamp: write_ts,
134                    });
135                }
136            }
137            Message::AdvanceTimelines => {
138                // Only sent by the periodic tick in read-only mode, where group commits (which
139                // otherwise drive the local timeline) don't run. Fetch the oracle's read ts here,
140                // it is the freshest timestamp the read holds may downgrade to.
141                let read_ts = self.get_local_read_ts().await;
142                self.downgrade_local_read_holds(read_ts);
143                self.advance_custom_timelines().boxed_local().await;
144            }
145            Message::ClusterEvent(event) => self.message_cluster_event(event).boxed_local().await,
146            Message::LinearizeReads => {
147                self.message_linearize_reads().boxed_local().await;
148            }
149            Message::StagedBatches {
150                conn_id,
151                table_id,
152                batches,
153            } => {
154                self.commit_staged_batches(conn_id, table_id, batches);
155            }
156            Message::StorageUsageSchedule => {
157                self.schedule_storage_usage_collection().boxed_local().await;
158            }
159            Message::StorageUsageFetch => {
160                self.storage_usage_fetch().boxed_local().await;
161            }
162            Message::StorageUsageUpdate(sizes) => {
163                self.storage_usage_update(sizes).boxed_local().await;
164            }
165            Message::StorageUsagePrune(expired) => {
166                self.storage_usage_prune(expired).boxed_local().await;
167            }
168            Message::ArrangementSizesSchedule => {
169                self.schedule_arrangement_sizes_collection();
170            }
171            Message::ArrangementSizesSnapshot => {
172                self.arrangement_sizes_snapshot();
173            }
174            Message::ArrangementSizesWrite {
175                records,
176                collection_ts,
177            } => {
178                self.arrangement_sizes_write(records, collection_ts);
179            }
180            Message::ArrangementSizesPrune(expired) => {
181                self.arrangement_sizes_prune(expired);
182            }
183            Message::HydrationHistorySchedule => {
184                self.schedule_hydration_history_collection();
185            }
186            Message::HydrationHistoryRun => {
187                self.run_hydration_history_collection();
188            }
189            Message::CaughtUpCheck(request) => {
190                self.handle_caught_up_check_request(request);
191            }
192            Message::RetireExecute {
193                otel_ctx,
194                data,
195                reason,
196            } => {
197                otel_ctx.attach_as_parent();
198                self.retire_execution(reason, data);
199            }
200            Message::ExecuteSingleStatementTransaction {
201                ctx,
202                otel_ctx,
203                stmt,
204                params,
205            } => {
206                otel_ctx.attach_as_parent();
207                self.sequence_execute_single_statement_transaction(ctx, stmt, params)
208                    .boxed_local()
209                    .await;
210            }
211            Message::CreateIndexStageReady { ctx, span, stage } => {
212                self.sequence_staged(ctx, span, stage).boxed_local().await;
213            }
214            Message::CreateMetricSinkStageReady { ctx, span, stage } => {
215                self.sequence_staged(ctx, span, stage).boxed_local().await;
216            }
217            Message::CreateViewStageReady { ctx, span, stage } => {
218                self.sequence_staged(ctx, span, stage).boxed_local().await;
219            }
220            Message::CreateMaterializedViewStageReady { ctx, span, stage } => {
221                self.sequence_staged(ctx, span, stage).boxed_local().await;
222            }
223            Message::SubscribeStageReady { ctx, span, stage } => {
224                self.sequence_staged(ctx, span, stage).boxed_local().await;
225            }
226            Message::IntrospectionSubscribeStageReady { span, stage } => {
227                self.sequence_staged((), span, stage).boxed_local().await;
228            }
229            Message::MetricSinkStageReady { span, stage } => {
230                self.sequence_staged((), span, stage).boxed_local().await;
231            }
232            Message::ExplainTimestampStageReady { ctx, span, stage } => {
233                self.sequence_staged(ctx, span, stage).boxed_local().await;
234            }
235            Message::SecretStageReady { ctx, span, stage } => {
236                self.sequence_staged(ctx, span, stage).boxed_local().await;
237            }
238            Message::ClusterStageReady { ctx, span, stage } => {
239                self.sequence_staged(ctx, span, stage).boxed_local().await;
240            }
241            Message::DrainStatementLog => {
242                self.drain_statement_log();
243            }
244            Message::PrivateLinkVpcEndpointEvents(events) => {
245                if !self.controller.read_only() {
246                    self.controller.storage.append_introspection_updates(
247                        IntrospectionType::PrivatelinkConnectionStatusHistory,
248                        events
249                            .into_iter()
250                            .map(|e| (mz_repr::Row::from(e), Diff::ONE))
251                            .collect(),
252                    );
253                }
254            }
255            Message::ClusterControllerRequest(request) => {
256                self.handle_cluster_controller_request(request)
257                    .boxed_local()
258                    .await;
259            }
260            Message::DeferredStatementReady => {
261                self.handle_deferred_statement().boxed_local().await;
262            }
263        }
264    }
265
266    #[mz_ore::instrument(level = "debug")]
267    pub async fn storage_usage_fetch(&self) {
268        // In read-only mode (e.g. a standby coordinator during a zero-downtime
269        // deployment) we cannot durably write the per-batch allocator bump or
270        // append to `mz_storage_usage_by_shard`, and we also don't want to do
271        // the slow shard scan on a process that isn't going to record the
272        // results. Skip the whole cycle and reschedule so we resume
273        // automatically once the coordinator transitions out of read-only.
274        if self.controller.read_only() {
275            tracing::info!("skipping storage usage collection in read-only mode");
276            if let Err(e) = self.internal_cmd_tx.send(Message::StorageUsageSchedule) {
277                warn!("internal_cmd_rx dropped before we could send: {:?}", e);
278            }
279            return;
280        }
281
282        let internal_cmd_tx = self.internal_cmd_tx.clone();
283        let client = self.storage_usage_client.clone();
284
285        // Record the currently live shards.
286        let live_shards: BTreeSet<_> = self
287            .controller
288            .storage
289            .active_collection_metadatas()
290            .into_iter()
291            .map(|(_id, m)| m.data_shard)
292            .collect();
293
294        let collection_metric = self.metrics.storage_usage_collection_time_seconds.clone();
295
296        // Spawn an asynchronous task to compute the storage usage, which
297        // requires a slow scan of the underlying storage engine.
298        task::spawn(|| "storage_usage_fetch", async move {
299            let collection_metric_timer = collection_metric.start_timer();
300            let shard_sizes = client.shards_usage_referenced(live_shards).await;
301            collection_metric_timer.observe_duration();
302
303            // It is not an error for shard sizes to become ready after
304            // `internal_cmd_rx` is dropped.
305            if let Err(e) = internal_cmd_tx.send(Message::StorageUsageUpdate(shard_sizes)) {
306                warn!("internal_cmd_rx dropped before we could send: {:?}", e);
307            }
308        });
309    }
310
311    #[mz_ore::instrument(level = "debug")]
312    async fn storage_usage_update(&mut self, shards_usage: ShardsUsageReferenced) {
313        // Similar to audit events, use the oracle ts so this is guaranteed to
314        // increase. This is intentionally the timestamp of when collection
315        // finished, not when it started, so that we don't write data with a
316        // timestamp in the past.
317        //
318        // `storage_usage_fetch` skips this path in read-only mode, so we can
319        // unconditionally bump the oracle write ts here.
320        let write_ts = self.get_catalog_write_ts().await;
321        let collection_timestamp: EpochMillis = write_ts.into();
322
323        // All rows in this collection cycle share `batch_id` so consumers can
324        // identify rows that were collected together. We use one durable
325        // allocator bump per cycle (rather than per shard) so the id is
326        // monotonic across coordinator restarts while still keeping the
327        // coord-blocking cost proportional to one round-trip, not N.
328        let batch_id = match self.catalog().allocate_storage_usage_id(write_ts).await {
329            Ok(id) => id,
330            Err(err) => {
331                tracing::warn!("failed to allocate storage usage batch id: {:?}", err);
332                return;
333            }
334        };
335
336        let updates: Vec<_> = shards_usage
337            .by_shard
338            .into_iter()
339            .map(|(shard_id, shard_usage)| {
340                let event = VersionedStorageUsage::new(
341                    batch_id,
342                    Some(shard_id.to_string()),
343                    shard_usage.size_bytes(),
344                    collection_timestamp,
345                );
346                self.catalog().pack_storage_usage_update(event, Diff::ONE)
347            })
348            .collect();
349
350        let table_updates = self.builtin_table_update().execute(updates);
351
352        let internal_cmd_tx = self.internal_cmd_tx.clone();
353        let task_span = info_span!(parent: None, "coord::storage_usage_update::table_updates");
354        OpenTelemetryContext::obtain().attach_as_parent_to(&task_span);
355        task::spawn(|| "storage_usage_update_table_updates", async move {
356            table_updates.instrument(task_span).await;
357            // It is not an error for this task to be running after `internal_cmd_rx` is dropped.
358            if let Err(e) = internal_cmd_tx.send(Message::StorageUsageSchedule) {
359                warn!("internal_cmd_rx dropped before we could send: {e:?}");
360            }
361        });
362    }
363
364    #[mz_ore::instrument(level = "debug")]
365    async fn storage_usage_prune(&mut self, expired: Vec<BuiltinTableUpdate>) {
366        let fut = self.builtin_table_update().execute(expired);
367        task::spawn(|| "storage_usage_pruning_apply", async move {
368            fut.await;
369        });
370    }
371
372    pub async fn schedule_storage_usage_collection(&self) {
373        // Instead of using an `tokio::timer::Interval`, we calculate the time until the next
374        // usage collection and wait for that amount of time. This is so we can keep the intervals
375        // consistent even across restarts. If collection takes too long, it is possible that
376        // we miss an interval.
377
378        // 1) Deterministically pick some offset within the collection interval to prevent
379        // thundering herds across environments.
380        const SEED_LEN: usize = 32;
381        let mut seed = [0; SEED_LEN];
382        for (i, byte) in self
383            .catalog()
384            .state()
385            .config()
386            .environment_id
387            .organization_id()
388            .as_bytes()
389            .into_iter()
390            .take(SEED_LEN)
391            .enumerate()
392        {
393            seed[i] = *byte;
394        }
395        let storage_usage_collection_interval_ms: EpochMillis =
396            EpochMillis::try_from(self.storage_usage_collection_interval.as_millis())
397                .expect("storage usage collection interval must fit into u64");
398        let offset =
399            rngs::SmallRng::from_seed(seed).random_range(0..storage_usage_collection_interval_ms);
400        let now_ts: EpochMillis = self.peek_local_write_ts().await.into();
401
402        // 2) Determine the amount of ms between now and the next collection time.
403        let previous_collection_ts =
404            (now_ts - (now_ts % storage_usage_collection_interval_ms)) + offset;
405        let next_collection_ts = if previous_collection_ts > now_ts {
406            previous_collection_ts
407        } else {
408            previous_collection_ts + storage_usage_collection_interval_ms
409        };
410        let next_collection_interval = Duration::from_millis(next_collection_ts - now_ts);
411
412        // 3) Sleep for that amount of time, then initiate another storage usage collection.
413        let internal_cmd_tx = self.internal_cmd_tx.clone();
414        task::spawn(|| "storage_usage_collection", async move {
415            tokio::time::sleep(next_collection_interval).await;
416            if internal_cmd_tx.send(Message::StorageUsageFetch).is_err() {
417                // If sending fails, the main thread has shutdown.
418            }
419        });
420    }
421
422    /// Schedules the next per-object arrangement sizes snapshot.
423    ///
424    /// Aligns each fire to an `organization_id`-seeded offset within the
425    /// interval so collections stay consistent across restarts and don't
426    /// synchronize across environments. Sleeps are capped at `MAX_SLEEP`,
427    /// so dyncfg changes (interval edits or the `0s` disable sentinel) take
428    /// effect within one cap rather than after the full interval.
429    pub fn schedule_arrangement_sizes_collection(&self) {
430        const MAX_SLEEP: Duration = Duration::from_secs(60);
431
432        let interval_duration =
433            mz_adapter_types::dyncfgs::ARRANGEMENT_SIZE_HISTORY_COLLECTION_INTERVAL
434                .get(self.catalog().system_config().dyncfgs());
435
436        // `0s` disables collection. Keep polling so re-enabling takes effect
437        // within `MAX_SLEEP` rather than requiring an envd restart.
438        if interval_duration.is_zero() {
439            let internal_cmd_tx = self.internal_cmd_tx.clone();
440            task::spawn(|| "arrangement_sizes_collection_disabled", async move {
441                tokio::time::sleep(MAX_SLEEP).await;
442                let _ = internal_cmd_tx.send(Message::ArrangementSizesSchedule);
443            });
444            return;
445        }
446
447        const SEED_LEN: usize = 32;
448        let mut seed = [0; SEED_LEN];
449        for (i, byte) in self
450            .catalog()
451            .state()
452            .config()
453            .environment_id
454            .organization_id()
455            .as_bytes()
456            .into_iter()
457            .take(SEED_LEN)
458            .enumerate()
459        {
460            seed[i] = *byte;
461        }
462        let interval_ms: EpochMillis = EpochMillis::try_from(interval_duration.as_millis())
463            .expect("arrangement_size_history_collection_interval must fit into u64");
464        // `rand::random_range` panics on an empty range.
465        let interval_ms = interval_ms.max(1);
466        let offset = rngs::SmallRng::from_seed(seed).random_range(0..interval_ms);
467
468        // The oracle peek is a network round trip with unbounded tail latency,
469        // so it must not run on the coordinator loop.
470        let oracle = self.get_local_timestamp_oracle();
471        let internal_cmd_tx = self.internal_cmd_tx.clone();
472        task::spawn(|| "arrangement_sizes_collection", async move {
473            let now_ts: EpochMillis = oracle.peek_write_ts().await.into();
474
475            let previous_collection_ts = (now_ts - (now_ts % interval_ms)) + offset;
476            let next_collection_ts = if previous_collection_ts > now_ts {
477                previous_collection_ts
478            } else {
479                previous_collection_ts + interval_ms
480            };
481            let sleep_for = Duration::from_millis(next_collection_ts - now_ts);
482
483            // Within one cap of the next fire we sleep the remainder and
484            // snapshot; further out we sleep the cap and re-enter so a dyncfg
485            // change is picked up before committing to a long sleep.
486            let (capped_sleep, fire_snapshot) = if sleep_for <= MAX_SLEEP {
487                (sleep_for, true)
488            } else {
489                (MAX_SLEEP, false)
490            };
491
492            tokio::time::sleep(capped_sleep).await;
493            let msg = if fire_snapshot {
494                Message::ArrangementSizesSnapshot
495            } else {
496                Message::ArrangementSizesSchedule
497            };
498            // Send is best-effort: if the coordinator is shutting down, drop.
499            let _ = internal_cmd_tx.send(msg);
500        });
501    }
502
503    /// Kicks off a snapshot of `mz_object_arrangement_sizes` for appending to
504    /// `mz_object_arrangement_size_history`.
505    ///
506    /// The oracle read, persist reads, and row preparation are too slow for
507    /// the coordinator main loop, so they run on a spawned task. The prepared
508    /// records come back as [`Message::ArrangementSizesWrite`] and are
509    /// appended by [`Coordinator::arrangement_sizes_write`], which also
510    /// reschedules the next collection. An empty or failed snapshot
511    /// reschedules directly.
512    ///
513    /// Rows from replicas without fresh introspection data are excluded, so
514    /// sizes predating an environmentd or replica restart are not recorded.
515    /// See [`Coordinator::fresh_introspection_replicas`].
516    #[mz_ore::instrument(level = "debug")]
517    fn arrangement_sizes_snapshot(&self) {
518        // Builtin collections are not writable in read-only mode. Skip the
519        // cycle but keep rescheduling, mirroring `storage_usage_fetch`, so
520        // collection stays alive regardless of how the coordinator leaves
521        // read-only mode. The transition is one-way, so
522        // `arrangement_sizes_write` needs no check of its own.
523        if self.controller.read_only() {
524            self.schedule_arrangement_sizes_collection();
525            return;
526        }
527
528        let fresh_size_replicas = self.fresh_introspection_replicas(
529            IntrospectionType::ComputeObjectArrangementSizes,
530            ARRANGEMENT_SIZES_FRESHNESS_MARGIN,
531        );
532        let fresh_hydration_replicas = self.fresh_introspection_replicas(
533            IntrospectionType::ComputeHydrationTimes,
534            ARRANGEMENT_SIZES_FRESHNESS_MARGIN,
535        );
536        if fresh_size_replicas.is_empty() {
537            // No replica has reported sizes in this process yet, so the live
538            // collection contains only stale rows (or none). Skip the cycle.
539            self.schedule_arrangement_sizes_collection();
540            return;
541        }
542
543        let live_item_id = self.catalog().resolve_builtin_storage_collection(
544            &mz_catalog::builtin::MZ_OBJECT_ARRANGEMENT_SIZES_UNIFIED,
545        );
546        let live_global_id = self.catalog.get_entry(&live_item_id).latest_global_id();
547        let hydration_item_id = self
548            .catalog()
549            .resolve_builtin_storage_collection(&mz_catalog::builtin::MZ_COMPUTE_HYDRATION_TIMES);
550        let hydration_global_id = self
551            .catalog
552            .get_entry(&hydration_item_id)
553            .latest_global_id();
554
555        let oracle = self.get_local_timestamp_oracle();
556        let storage_collections = Arc::clone(&self.controller.storage_collections);
557        let collection_metric = self
558            .metrics
559            .arrangement_sizes_collection_time_seconds
560            .clone();
561        let internal_cmd_tx = self.internal_cmd_tx.clone();
562
563        task::spawn(|| "arrangement_sizes_snapshot", async move {
564            let collection_metric_timer = collection_metric.start_timer();
565
566            // No read hold is taken, so the reads rely on both collections
567            // being retained-metrics objects, whose since lags the upper by
568            // `metrics_retention` rather than tracking it closely. If the
569            // since still overtakes `read_ts`, the snapshot fails, and the
570            // cycle is skipped and retried at the next interval.
571            let read_ts = oracle.read_ts().await;
572            let live_snapshot = match storage_collections.snapshot(live_global_id, read_ts).await {
573                Ok(s) => s,
574                Err(e) => {
575                    // Unreachable short of a read-policy bug or catalog
576                    // corruption, so be loud, but degrade to a skipped cycle
577                    // in production.
578                    soft_panic_or_log!("arrangement sizes snapshot failed: {e:?}");
579                    let _ = internal_cmd_tx.send(Message::ArrangementSizesSchedule);
580                    return;
581                }
582            };
583            let hydration_snapshot = match storage_collections
584                .snapshot(hydration_global_id, read_ts)
585                .await
586            {
587                Ok(s) => s,
588                Err(e) => {
589                    soft_panic_or_log!("arrangement sizes hydration snapshot failed: {e:?}");
590                    let _ = internal_cmd_tx.send(Message::ArrangementSizesSchedule);
591                    return;
592                }
593            };
594
595            let records = arrangement_sizes_records(
596                live_snapshot,
597                hydration_snapshot,
598                &fresh_size_replicas,
599                &fresh_hydration_replicas,
600            );
601            collection_metric_timer.observe_duration();
602
603            let msg = if records.is_empty() {
604                Message::ArrangementSizesSchedule
605            } else {
606                // `collection_ts` is the snapshot's read timestamp, the state
607                // the rows describe. Stamps strictly increase within a process,
608                // because the next snapshot is only scheduled after this append
609                // applies, and across restarts via the oracle. This assumes a
610                // single writer, which fencing ensures.
611                Message::ArrangementSizesWrite {
612                    records,
613                    collection_ts: read_ts.into(),
614                }
615            };
616            // It is not an error for this task to outlive `internal_cmd_rx`.
617            let _ = internal_cmd_tx.send(msg);
618        });
619    }
620
621    /// Appends prepared snapshot records to
622    /// `mz_object_arrangement_size_history`, stamped with the snapshot's
623    /// `collection_ts`. Reschedules the next collection once the append
624    /// completes.
625    #[mz_ore::instrument(level = "debug")]
626    fn arrangement_sizes_write(
627        &mut self,
628        records: Vec<ArrangementSizeRecord>,
629        collection_ts: EpochMillis,
630    ) {
631        // Freshness may have been invalidated while the snapshot task ran,
632        // e.g. by a cluster event reporting a replica offline. Revalidate so
633        // records prepared from a now-untrusted replica's data are dropped.
634        let fresh_size_replicas = self.fresh_introspection_replicas(
635            IntrospectionType::ComputeObjectArrangementSizes,
636            ARRANGEMENT_SIZES_FRESHNESS_MARGIN,
637        );
638        let records: Vec<_> = records
639            .into_iter()
640            .filter(|record| fresh_size_replicas.contains(&record.replica_id))
641            .collect();
642        if records.is_empty() {
643            self.schedule_arrangement_sizes_collection();
644            return;
645        }
646
647        let collection_datum = Datum::TimestampTz(
648            mz_ore::now::to_datetime(collection_ts)
649                .try_into()
650                .expect("collection_timestamp must fit into TimestampTz"),
651        );
652
653        let history_item_id = self
654            .catalog()
655            .resolve_builtin_table(&mz_catalog::builtin::MZ_OBJECT_ARRANGEMENT_SIZE_HISTORY);
656
657        let updates: Vec<_> = records
658            .into_iter()
659            .map(|record| {
660                let row = Row::pack_slice(&[
661                    Datum::String(&record.replica_id),
662                    Datum::String(&record.object_id),
663                    Datum::Int64(record.size),
664                    collection_datum,
665                    Datum::from(record.hydration_complete),
666                ]);
667                BuiltinTableUpdate::row(history_item_id, row, Diff::ONE)
668            })
669            .collect();
670
671        let row_count = updates.len();
672        self.metrics
673            .arrangement_sizes_rows_written
674            .inc_by(u64::cast_from(row_count));
675
676        // TODO(arrangement-sizes): when the writeable-catalog-server plumbing
677        // in https://github.com/MaterializeInc/materialize/pull/35436 lands,
678        // append directly on `mz_catalog_server` instead of going through
679        // the environmentd builtin-table-update path.
680        let fut = self.builtin_table_update().execute(updates);
681        let internal_cmd_tx = self.internal_cmd_tx.clone();
682        let task_span = info_span!(parent: None, "coord::arrangement_sizes_write::table_updates");
683        OpenTelemetryContext::obtain().attach_as_parent_to(&task_span);
684        task::spawn(|| "arrangement_sizes_write_table_updates", async move {
685            fut.instrument(task_span).await;
686            if let Err(e) = internal_cmd_tx.send(Message::ArrangementSizesSchedule) {
687                warn!("internal_cmd_rx dropped before we could send: {e:?}");
688            }
689        });
690
691        tracing::debug!(
692            "appended {row_count} rows to mz_object_arrangement_size_history at ts {collection_ts}"
693        );
694    }
695
696    #[mz_ore::instrument(level = "debug")]
697    fn arrangement_sizes_prune(&mut self, expired: Vec<BuiltinTableUpdate>) {
698        let fut = self.builtin_table_update().execute(expired);
699        task::spawn(|| "arrangement_sizes_pruning_apply", async move {
700            fut.await;
701        });
702    }
703
704    #[mz_ore::instrument(level = "debug")]
705    async fn message_command(&mut self, cmd: Command) {
706        self.handle_command(cmd).await;
707    }
708
709    #[mz_ore::instrument(level = "debug")]
710    async fn message_controller(&mut self, message: ControllerResponse) {
711        event!(Level::TRACE, message = format!("{:?}", message));
712        match message {
713            ControllerResponse::PeekNotification(uuid, response, otel_ctx) => {
714                self.handle_peek_notification(uuid, response, otel_ctx);
715            }
716            ControllerResponse::SubscribeResponse(sink_id, response) => {
717                if let Some(ActiveComputeSink::Subscribe(active_subscribe)) =
718                    self.active_compute_sinks.get_mut(&sink_id)
719                {
720                    let finished = active_subscribe.process_response(response);
721                    // Read the backlog into a `Copy` local so the mutable borrow
722                    // of `active_subscribe` (and the accounting lock) ends before
723                    // we call `self.retire_compute_sinks`. The producer runs on
724                    // this loop, so it cannot block on a slow client. Instead we
725                    // bound the backlog here and retire the subscribe once it
726                    // exceeds the budget.
727                    //
728                    // The backlog excludes the message the client is currently
729                    // draining, so a client working through a single large batch
730                    // (e.g. the initial snapshot) is never retired for it.
731                    let buffered_bytes = active_subscribe
732                        .backlog_accounting
733                        .lock()
734                        .expect("subscribe backlog accounting poisoned")
735                        .backlog_size();
736                    let max_buffered_bytes = active_subscribe.max_buffered_bytes;
737
738                    let reason = if finished {
739                        Some(ActiveComputeSinkRetireReason::Finished)
740                    } else if buffered_bytes > max_buffered_bytes {
741                        Some(ActiveComputeSinkRetireReason::BufferExceeded {
742                            buffered_bytes,
743                            max_buffered_bytes,
744                        })
745                    } else {
746                        None
747                    };
748                    if let Some(reason) = reason {
749                        let retire_notify = self
750                            .retire_compute_sinks(btreemap! {
751                                sink_id => reason,
752                            })
753                            .await;
754                        // `retire_compute_sinks` waits before sending the terminal
755                        // SUBSCRIBE response. There is no separate statement response here.
756                        drop(retire_notify);
757                    }
758
759                    soft_assert_or_log!(
760                        !self.introspection_subscribes.contains_key(&sink_id),
761                        "`sink_id` {sink_id} unexpectedly found in both `active_subscribes` \
762                         and `introspection_subscribes`",
763                    );
764                } else if self.introspection_subscribes.contains_key(&sink_id) {
765                    self.handle_introspection_subscribe_batch(sink_id, response)
766                        .await;
767                } else {
768                    // Cancellation may cause us to receive responses for subscribes no longer
769                    // tracked, so we quietly ignore them.
770                }
771            }
772            ControllerResponse::CopyToResponse(sink_id, response) => {
773                match self.drop_compute_sink(sink_id).await {
774                    Some((ActiveComputeSink::CopyTo(active_copy_to), _write_notify)) => {
775                        active_copy_to.retire_with_response(response);
776                    }
777                    _ => {
778                        // Cancellation may cause us to receive responses for subscribes no longer
779                        // tracked, so we quietly ignore them.
780                    }
781                }
782            }
783            ControllerResponse::WatchSetFinished(ws_ids) => {
784                let now = self.now();
785                for ws_id in ws_ids {
786                    let Some((conn_id, rsp)) = self.installed_watch_sets.remove(&ws_id) else {
787                        continue;
788                    };
789                    self.connection_watch_sets
790                        .get_mut(&conn_id)
791                        .expect("corrupted coordinator state: unknown connection id")
792                        .remove(&ws_id);
793                    if self.connection_watch_sets[&conn_id].is_empty() {
794                        self.connection_watch_sets.remove(&conn_id);
795                    }
796
797                    match rsp {
798                        WatchSetResponse::StatementDependenciesReady(id, ev) => {
799                            self.record_statement_lifecycle_event(&id, &ev, now);
800                        }
801                        WatchSetResponse::AlterSinkReady(ctx) => {
802                            self.sequence_alter_sink_finish(ctx).await;
803                        }
804                        WatchSetResponse::AlterMaterializedViewReady(ctx) => {
805                            self.sequence_alter_materialized_view_apply_replacement_finish(ctx)
806                                .await;
807                        }
808                    }
809                }
810            }
811        }
812    }
813
814    #[mz_ore::instrument(level = "debug")]
815    async fn message_purified_statement_ready(
816        &mut self,
817        PurifiedStatementReady {
818            ctx,
819            result,
820            params,
821            mut plan_validity,
822            original_stmt,
823            otel_ctx,
824        }: PurifiedStatementReady,
825    ) {
826        otel_ctx.attach_as_parent();
827
828        // Ensure that all dependencies still exist after purification, as a
829        // `DROP CONNECTION` or other `DROP` may have sneaked in. If any have gone missing, we
830        // repurify the original statement. This will either produce a nice
831        // "unknown connector" error, or pick up a new connector that has
832        // replaced the dropped connector.
833        //
834        // n.b. an `ALTER CONNECTION` occurring during purification is OK
835        // because we always look up/populate a connection's state after
836        // committing to the catalog, so are guaranteed to see the connection's
837        // most recent version.
838        if plan_validity.check(self.catalog()).is_err() {
839            self.handle_execute_inner(original_stmt, params, ctx).await;
840            return;
841        }
842
843        let purified_statement = match result {
844            Ok(ok) => ok,
845            Err(e) => return ctx.retire(Err(e)),
846        };
847
848        let plan = match purified_statement {
849            PurifiedStatement::PurifiedCreateSource {
850                create_progress_subsource_stmt,
851                create_source_stmt,
852                subsources,
853                available_source_references,
854            } => self
855                .plan_purified_create_source(
856                    &ctx,
857                    params,
858                    create_progress_subsource_stmt,
859                    create_source_stmt,
860                    subsources,
861                    available_source_references,
862                )
863                .await
864                .map(|(plan, resolved_ids)| (plan, resolved_ids, ResolvedIds::empty())),
865            PurifiedStatement::PurifiedAlterSourceAddSubsources {
866                source_name,
867                options,
868                subsources,
869            } => self
870                .plan_purified_alter_source_add_subsource(
871                    ctx.session(),
872                    params,
873                    source_name,
874                    options,
875                    subsources,
876                )
877                .await
878                .map(|(plan, resolved_ids)| (plan, resolved_ids, ResolvedIds::empty())),
879            PurifiedStatement::PurifiedAlterSourceRefreshReferences {
880                source_name,
881                available_source_references,
882            } => self
883                .plan_purified_alter_source_refresh_references(
884                    ctx.session(),
885                    params,
886                    source_name,
887                    available_source_references,
888                )
889                .map(|(plan, resolved_ids)| (plan, resolved_ids, ResolvedIds::empty())),
890            o @ (PurifiedStatement::PurifiedAlterSource { .. }
891            | PurifiedStatement::PurifiedCreateSink(..)
892            | PurifiedStatement::PurifiedCreateTableFromSource { .. }) => {
893                // Unify these into a `Statement`.
894                let stmt = match o {
895                    PurifiedStatement::PurifiedAlterSource { alter_source_stmt } => {
896                        Statement::AlterSource(alter_source_stmt)
897                    }
898                    PurifiedStatement::PurifiedCreateTableFromSource { stmt } => {
899                        Statement::CreateTableFromSource(stmt)
900                    }
901                    PurifiedStatement::PurifiedCreateSink(stmt) => Statement::CreateSink(stmt),
902                    PurifiedStatement::PurifiedCreateSource { .. }
903                    | PurifiedStatement::PurifiedAlterSourceAddSubsources { .. }
904                    | PurifiedStatement::PurifiedAlterSourceRefreshReferences { .. } => {
905                        unreachable!("not part of exterior match stmt")
906                    }
907                };
908
909                // Determine all dependencies, not just those in the statement
910                // itself.
911                let catalog = self.catalog().for_session(ctx.session());
912                let resolved_ids = mz_sql::names::visit_dependencies(&catalog, &stmt);
913                self.plan_statement(ctx.session(), stmt, &params, &resolved_ids)
914                    .map(|(plan, sql_impl_ids)| (plan, resolved_ids, sql_impl_ids))
915            }
916        };
917
918        match plan {
919            Ok((plan, resolved_ids, sql_impl_ids)) => {
920                self.sequence_plan(ctx, plan, resolved_ids, sql_impl_ids)
921                    .await
922            }
923            Err(e) => ctx.retire(Err(e)),
924        }
925    }
926
927    #[mz_ore::instrument(level = "debug")]
928    async fn message_create_connection_validation_ready(
929        &mut self,
930        CreateConnectionValidationReady {
931            mut ctx,
932            result,
933            connection_id,
934            connection_gid,
935            mut plan_validity,
936            otel_ctx,
937            resolved_ids,
938        }: CreateConnectionValidationReady,
939    ) {
940        otel_ctx.attach_as_parent();
941
942        // Ensure that all dependencies still exist after validation, as a
943        // `DROP SECRET` may have sneaked in.
944        //
945        // WARNING: If we support `ALTER SECRET`, we'll need to also check
946        // for connectors that were altered while we were purifying.
947        if let Err(e) = plan_validity.check(self.catalog()) {
948            if self.secrets_controller.delete(connection_id).await.is_ok() {
949                self.caching_secrets_reader.invalidate(connection_id);
950            }
951            return ctx.retire(Err(e));
952        }
953
954        let plan = match result {
955            Ok(ok) => ok,
956            Err(e) => {
957                if self.secrets_controller.delete(connection_id).await.is_ok() {
958                    self.caching_secrets_reader.invalidate(connection_id);
959                }
960                return ctx.retire(Err(e));
961            }
962        };
963
964        let result = self
965            .sequence_create_connection_stage_finish(
966                &mut ctx,
967                connection_id,
968                connection_gid,
969                plan,
970                resolved_ids,
971            )
972            .await;
973        ctx.retire(result);
974    }
975
976    #[mz_ore::instrument(level = "debug")]
977    async fn message_alter_connection_validation_ready(
978        &mut self,
979        AlterConnectionValidationReady {
980            mut ctx,
981            result,
982            connection_id,
983            connection_gid: _,
984            mut plan_validity,
985            otel_ctx,
986            resolved_ids: _,
987        }: AlterConnectionValidationReady,
988    ) {
989        otel_ctx.attach_as_parent();
990
991        // Ensure that all dependencies still exist after validation, as a
992        // `DROP SECRET` may have sneaked in.
993        //
994        // WARNING: If we support `ALTER SECRET`, we'll need to also check
995        // for connectors that were altered while we were purifying.
996        if let Err(e) = plan_validity.check(self.catalog()) {
997            return ctx.retire(Err(e));
998        }
999
1000        let conn = match result {
1001            Ok(ok) => ok,
1002            Err(e) => {
1003                return ctx.retire(Err(e));
1004            }
1005        };
1006
1007        let result = self
1008            .sequence_alter_connection_stage_finish(ctx.session_mut(), connection_id, conn)
1009            .await;
1010        ctx.retire(result);
1011    }
1012
1013    #[mz_ore::instrument(level = "debug")]
1014    async fn message_cluster_event(&mut self, event: ClusterEvent) {
1015        event!(Level::TRACE, event = format!("{:?}", event));
1016
1017        if let Some(segment_client) = &self.segment_client {
1018            let env_id = &self.catalog().config().environment_id;
1019            let mut properties = json!({
1020                "cluster_id": event.cluster_id.to_string(),
1021                "replica_id": event.replica_id.to_string(),
1022                "process_id": event.process_id,
1023                "status": event.status.as_kebab_case_str(),
1024            });
1025            match event.status {
1026                ClusterStatus::Online => (),
1027                ClusterStatus::Offline(reason) => {
1028                    let properties = match &mut properties {
1029                        serde_json::Value::Object(map) => map,
1030                        _ => unreachable!(),
1031                    };
1032                    properties.insert(
1033                        "reason".into(),
1034                        json!(reason.display_or("unknown").to_string()),
1035                    );
1036                }
1037            };
1038            segment_client.environment_track(
1039                env_id,
1040                "Cluster Changed Status",
1041                properties,
1042                EventDetails {
1043                    timestamp: Some(event.time),
1044                    ..Default::default()
1045                },
1046            );
1047        }
1048
1049        // It is possible that we receive a status update for a replica that has
1050        // already been dropped from the catalog. Just ignore these events.
1051        let Some(replica_statuses) = self
1052            .cluster_replica_statuses
1053            .try_get_cluster_replica_statuses(event.cluster_id, event.replica_id)
1054        else {
1055            return;
1056        };
1057
1058        let old_process_status = &replica_statuses[&event.process_id];
1059        let status_changed = event.status != old_process_status.status;
1060        let restart_count_changed = event.restart_count != old_process_status.restart_count;
1061
1062        // We mirror the restart count in memory even when only it changes (and the
1063        // status stays the same), so the 0dt caught-up check can detect replica
1064        // restarts it would otherwise miss by only sampling the status. The status
1065        // history and the status-changed notice are keyed on the status itself, so
1066        // we only touch those when the status actually changes.
1067        //
1068        // NOTE: The 0dt stability gate detects flaps by watching a process's
1069        // status-change `time` advance between checks. That only works because we
1070        // freeze `time` on no-op events, i.e. we return early here instead of
1071        // rewriting the record when neither the status nor the restart count
1072        // changed.
1073        if !status_changed && !restart_count_changed {
1074            return;
1075        }
1076
1077        if status_changed && !self.controller.read_only() {
1078            let offline_reason = match event.status {
1079                ClusterStatus::Online => None,
1080                ClusterStatus::Offline(None) => None,
1081                ClusterStatus::Offline(Some(reason)) => Some(reason.to_string()),
1082            };
1083            let row = Row::pack_slice(&[
1084                Datum::String(&event.replica_id.to_string()),
1085                Datum::UInt64(event.process_id),
1086                Datum::String(event.status.as_kebab_case_str()),
1087                Datum::from(offline_reason.as_deref()),
1088                Datum::TimestampTz(event.time.try_into().expect("must fit")),
1089            ]);
1090            self.controller.storage.append_introspection_updates(
1091                IntrospectionType::ReplicaStatusHistory,
1092                vec![(row, Diff::ONE)],
1093            );
1094        }
1095
1096        // Capture the rolled-up replica status before the update so we can tell
1097        // whether the user-visible status changed. Only needed for the notice.
1098        let old_replica_status = status_changed
1099            .then(|| ClusterReplicaStatuses::cluster_replica_status(replica_statuses));
1100
1101        let new_process_status = ClusterReplicaProcessStatus {
1102            status: event.status,
1103            restart_count: event.restart_count,
1104            time: event.time,
1105        };
1106        self.cluster_replica_statuses.ensure_cluster_status(
1107            event.cluster_id,
1108            event.replica_id,
1109            event.process_id,
1110            new_process_status,
1111        );
1112
1113        // The replica's introspection subscribes may keep serving data written
1114        // for its previous incarnation until their failure responses are
1115        // processed. Invalidate freshness eagerly so consumers like the
1116        // arrangement sizes history don't record that data as current.
1117        if !matches!(event.status, ClusterStatus::Online) || restart_count_changed {
1118            self.invalidate_introspection_freshness(event.replica_id);
1119        }
1120
1121        if let Some(old_replica_status) = old_replica_status {
1122            let cluster = self.catalog().get_cluster(event.cluster_id);
1123            let replica = cluster.replica(event.replica_id).expect("Replica exists");
1124            let new_replica_status = self
1125                .cluster_replica_statuses
1126                .get_cluster_replica_status(event.cluster_id, event.replica_id);
1127
1128            if old_replica_status != new_replica_status {
1129                let notifier = self.broadcast_notice_tx();
1130                let notice = AdapterNotice::ClusterReplicaStatusChanged {
1131                    cluster: cluster.name.clone(),
1132                    replica: replica.name.clone(),
1133                    status: new_replica_status,
1134                    time: event.time,
1135                };
1136                notifier(notice);
1137            }
1138        }
1139    }
1140
1141    #[mz_ore::instrument(level = "debug")]
1142    /// Linearizes sending the results of a read transaction by,
1143    ///   1. Holding back any results that were executed at some point in the future, until the
1144    ///   containing timeline has advanced to that point in the future.
1145    ///   2. Confirming that we are still the current leader before sending results to the client.
1146    async fn message_linearize_reads(&mut self) {
1147        let mut shortest_wait = Duration::MAX;
1148        let mut ready_txns = Vec::new();
1149
1150        // Cache for `TimestampOracle::read_ts` calls. These are somewhat
1151        // expensive so we cache the value. This is correct since all we're
1152        // risking is being too conservative. We will not accidentally "release"
1153        // a result too early.
1154        let mut cached_oracle_ts = BTreeMap::new();
1155
1156        for (conn_id, mut read_txn) in std::mem::take(&mut self.pending_linearize_read_txns) {
1157            if let TimestampContext::TimelineTimestamp {
1158                timeline,
1159                chosen_ts,
1160                oracle_ts,
1161            } = read_txn.timestamp_context()
1162            {
1163                let oracle_ts = match oracle_ts {
1164                    Some(oracle_ts) => oracle_ts,
1165                    None => {
1166                        // There was no oracle timestamp, so no need to delay.
1167                        ready_txns.push(read_txn);
1168                        continue;
1169                    }
1170                };
1171
1172                if chosen_ts <= oracle_ts {
1173                    // Chosen ts was already <= the oracle ts, so we're good
1174                    // to go!
1175                    ready_txns.push(read_txn);
1176                    continue;
1177                }
1178
1179                // See what the oracle timestamp is now and delay when needed.
1180                let current_oracle_ts = cached_oracle_ts.entry(timeline.clone());
1181                let current_oracle_ts = match current_oracle_ts {
1182                    btree_map::Entry::Vacant(entry) => {
1183                        let timestamp_oracle = self.get_timestamp_oracle(timeline);
1184                        let read_ts = timestamp_oracle.read_ts().await;
1185                        entry.insert(read_ts.clone());
1186                        read_ts
1187                    }
1188                    btree_map::Entry::Occupied(entry) => entry.get().clone(),
1189                };
1190
1191                if *chosen_ts <= current_oracle_ts {
1192                    ready_txns.push(read_txn);
1193                } else {
1194                    let wait =
1195                        Duration::from_millis(chosen_ts.saturating_sub(current_oracle_ts).into());
1196                    if wait < shortest_wait {
1197                        shortest_wait = wait;
1198                    }
1199                    read_txn.num_requeues += 1;
1200                    self.pending_linearize_read_txns.insert(conn_id, read_txn);
1201                }
1202            } else {
1203                ready_txns.push(read_txn);
1204            }
1205        }
1206
1207        if !ready_txns.is_empty() {
1208            // Sniff out one ctx, this is where tracing breaks down because we
1209            // process all outstanding txns as a batch here.
1210            let otel_ctx = ready_txns.first().expect("known to exist").otel_ctx.clone();
1211            let span = tracing::debug_span!("message_linearize_reads");
1212            otel_ctx.attach_as_parent_to(&span);
1213
1214            let now = Instant::now();
1215            for ready_txn in ready_txns {
1216                let span = tracing::debug_span!("retire_read_results");
1217                ready_txn.otel_ctx.attach_as_parent_to(&span);
1218                let _entered = span.enter();
1219                self.metrics
1220                    .linearize_message_seconds
1221                    .with_label_values(&[
1222                        "read",
1223                        if ready_txn.num_requeues == 0 {
1224                            "true"
1225                        } else {
1226                            "false"
1227                        },
1228                    ])
1229                    .observe((now - ready_txn.created).as_secs_f64());
1230                ready_txn.finish();
1231            }
1232        }
1233
1234        if !self.pending_linearize_read_txns.is_empty() {
1235            // Cap wait time to 1s, then signal a re-check. `serve` awaits this
1236            // below group commit; see its linearize branch for why.
1237            let remaining_ms = std::cmp::min(shortest_wait, Duration::from_millis(1_000));
1238            let linearize_reads_notify = Arc::clone(&self.linearize_reads_notify);
1239            task::spawn(|| "deferred_read_txns", async move {
1240                tokio::time::sleep(remaining_ms).await;
1241                linearize_reads_notify.notify_one();
1242            });
1243        }
1244    }
1245}
1246
1247/// Builds history records from snapshots of `mz_object_arrangement_sizes` and
1248/// `mz_compute_hydration_times`.
1249///
1250/// Each `(replica_id, object_id)` pair is recorded with a
1251/// `hydration_complete` flag: `true` once the pair's initial hydration on that
1252/// replica is finished (`time_ns IS NOT NULL`), `false` while still building.
1253/// Consumers that want only stable sizes should filter
1254/// `WHERE hydration_complete`.
1255///
1256/// Rows from replicas outside `fresh_size_replicas` are dropped, and the
1257/// hydration flag is only trusted for replicas in `fresh_hydration_replicas`.
1258/// Rows for other replicas may predate an environmentd or replica restart.
1259///
1260/// Rows with a size of 0 (arrangements below the live collection's 5 MiB
1261/// quantization threshold) are not recorded.
1262fn arrangement_sizes_records(
1263    mut live_snapshot: Vec<(Row, StorageDiff)>,
1264    mut hydration_snapshot: Vec<(Row, StorageDiff)>,
1265    fresh_size_replicas: &BTreeSet<String>,
1266    fresh_hydration_replicas: &BTreeSet<String>,
1267) -> Vec<ArrangementSizeRecord> {
1268    differential_dataflow::consolidation::consolidate(&mut live_snapshot);
1269    differential_dataflow::consolidation::consolidate(&mut hydration_snapshot);
1270
1271    let mut datum_vec = mz_repr::DatumVec::new();
1272
1273    // Column positions in `mz_compute_hydration_times`.
1274    const HYDRATION_COL_REPLICA_ID: usize = 0;
1275    const HYDRATION_COL_OBJECT_ID: usize = 1;
1276    const HYDRATION_COL_TIME_NS: usize = 2;
1277    const HYDRATION_COL_COUNT: usize = 3;
1278
1279    let mut hydrated: BTreeSet<(String, String)> = BTreeSet::new();
1280    for (row, diff) in &hydration_snapshot {
1281        if *diff != 1 {
1282            continue;
1283        }
1284        let datums = datum_vec.borrow_with(row);
1285        if datums.len() < HYDRATION_COL_COUNT {
1286            continue;
1287        }
1288        if datums[HYDRATION_COL_TIME_NS].is_null() {
1289            continue;
1290        }
1291        let replica_id = datums[HYDRATION_COL_REPLICA_ID].unwrap_str();
1292        if !fresh_hydration_replicas.contains(replica_id) {
1293            continue;
1294        }
1295        hydrated.insert((
1296            replica_id.to_string(),
1297            datums[HYDRATION_COL_OBJECT_ID].unwrap_str().to_string(),
1298        ));
1299    }
1300
1301    // Column positions in `mz_object_arrangement_sizes`.
1302    const LIVE_COL_REPLICA_ID: usize = 0;
1303    const LIVE_COL_OBJECT_ID: usize = 1;
1304    const LIVE_COL_SIZE: usize = 2;
1305    const LIVE_COL_COUNT: usize = 3;
1306
1307    let mut skipped_malformed: u64 = 0;
1308    let mut skipped_null_size: u64 = 0;
1309    let mut skipped_zero_size: u64 = 0;
1310    let mut skipped_stale_replica: u64 = 0;
1311    let mut records = Vec::with_capacity(live_snapshot.len());
1312    for (row, diff) in &live_snapshot {
1313        if *diff != 1 {
1314            continue;
1315        }
1316        let datums = datum_vec.borrow_with(row);
1317        // Surface schema drift via a warn log below rather than silently
1318        // skipping entire snapshots.
1319        if datums.len() != LIVE_COL_COUNT {
1320            skipped_malformed += 1;
1321            continue;
1322        }
1323        let replica_id = datums[LIVE_COL_REPLICA_ID].unwrap_str();
1324        if !fresh_size_replicas.contains(replica_id) {
1325            skipped_stale_replica += 1;
1326            continue;
1327        }
1328        let object_id = datums[LIVE_COL_OBJECT_ID].unwrap_str();
1329        let size_datum = datums[LIVE_COL_SIZE];
1330        // The history table's `size` is non-null; fabricating zero would
1331        // be misleading, so drop.
1332        if size_datum.is_null() {
1333            skipped_null_size += 1;
1334            continue;
1335        }
1336        // A quantized size of 0 means "below 5 MiB". The live collection
1337        // keeps such rows so small objects stay visible, but recording them
1338        // every cycle would bloat the history with rows carrying no signal.
1339        if size_datum.unwrap_int64() == 0 {
1340            skipped_zero_size += 1;
1341            continue;
1342        }
1343        let hydration_complete =
1344            hydrated.contains(&(replica_id.to_string(), object_id.to_string()));
1345        records.push(ArrangementSizeRecord {
1346            replica_id: replica_id.to_string(),
1347            object_id: object_id.to_string(),
1348            size: size_datum.unwrap_int64(),
1349            hydration_complete,
1350        });
1351    }
1352    if skipped_malformed > 0 {
1353        warn!(
1354            "mz_object_arrangement_sizes schema drift: skipped {skipped_malformed} rows \
1355             with unexpected arity"
1356        );
1357    }
1358    if skipped_null_size > 0 {
1359        tracing::debug!("skipped {skipped_null_size} live rows with null size");
1360    }
1361    if skipped_zero_size > 0 {
1362        tracing::debug!("skipped {skipped_zero_size} live rows with zero size");
1363    }
1364    if skipped_stale_replica > 0 {
1365        tracing::debug!(
1366            "skipped {skipped_stale_replica} live rows from replicas without fresh \
1367             introspection data"
1368        );
1369    }
1370    records
1371}
1372
1373#[cfg(test)]
1374mod arrangement_sizes_records_tests {
1375    use std::collections::BTreeSet;
1376
1377    use mz_repr::{Datum, Row};
1378
1379    use super::arrangement_sizes_records;
1380
1381    fn live_row(replica_id: &str, object_id: &str, size: Option<i64>) -> Row {
1382        Row::pack_slice(&[
1383            Datum::String(replica_id),
1384            Datum::String(object_id),
1385            size.map_or(Datum::Null, Datum::Int64),
1386        ])
1387    }
1388
1389    fn hydration_row(replica_id: &str, object_id: &str, hydrated: bool) -> Row {
1390        Row::pack_slice(&[
1391            Datum::String(replica_id),
1392            Datum::String(object_id),
1393            if hydrated {
1394                Datum::UInt64(1)
1395            } else {
1396                Datum::Null
1397            },
1398        ])
1399    }
1400
1401    fn replicas(ids: &[&str]) -> BTreeSet<String> {
1402        ids.iter().map(|id| id.to_string()).collect()
1403    }
1404
1405    #[mz_ore::test]
1406    fn hydration_flag_per_pair() {
1407        let live = vec![
1408            (live_row("u1", "u100", Some(10)), 1),
1409            (live_row("u1", "u200", Some(20)), 1),
1410        ];
1411        let hydration = vec![
1412            (hydration_row("u1", "u100", true), 1),
1413            (hydration_row("u1", "u200", false), 1),
1414        ];
1415        let fresh = replicas(&["u1"]);
1416        let records = arrangement_sizes_records(live, hydration, &fresh, &fresh);
1417        assert_eq!(records.len(), 2);
1418        assert!(
1419            records
1420                .iter()
1421                .any(|r| r.object_id == "u100" && r.hydration_complete)
1422        );
1423        assert!(
1424            records
1425                .iter()
1426                .any(|r| r.object_id == "u200" && !r.hydration_complete)
1427        );
1428    }
1429
1430    #[mz_ore::test]
1431    fn skips_malformed_null_and_retracted() {
1432        let live = vec![
1433            // Wrong arity.
1434            (Row::pack_slice(&[Datum::String("u1")]), 1),
1435            // Null size.
1436            (live_row("u1", "u100", None), 1),
1437            // Retracted by consolidation.
1438            (live_row("u1", "u200", Some(20)), 1),
1439            (live_row("u1", "u200", Some(20)), -1),
1440            (live_row("u1", "u300", Some(30)), 1),
1441        ];
1442        let fresh = replicas(&["u1"]);
1443        let records = arrangement_sizes_records(live, Vec::new(), &fresh, &fresh);
1444        assert_eq!(records.len(), 1);
1445        assert_eq!(records[0].object_id, "u300");
1446        assert_eq!(records[0].size, 30);
1447        assert!(!records[0].hydration_complete);
1448    }
1449
1450    #[mz_ore::test]
1451    fn skips_zero_size_rows() {
1452        // Size 0 means "below the live collection's quantization threshold".
1453        // Such objects stay visible live but are not recorded in the history.
1454        let live = vec![
1455            (live_row("u1", "u100", Some(0)), 1),
1456            (live_row("u1", "u200", Some(10485760)), 1),
1457        ];
1458        let fresh = replicas(&["u1"]);
1459        let records = arrangement_sizes_records(live, Vec::new(), &fresh, &fresh);
1460        assert_eq!(records.len(), 1);
1461        assert_eq!(records[0].object_id, "u200");
1462    }
1463
1464    #[mz_ore::test]
1465    fn skips_rows_from_stale_replicas() {
1466        // u1 has fresh introspection data, u2's rows predate a restart.
1467        let live = vec![
1468            (live_row("u1", "u100", Some(10)), 1),
1469            (live_row("u2", "u100", Some(99)), 1),
1470        ];
1471        let hydration = vec![
1472            (hydration_row("u1", "u100", true), 1),
1473            (hydration_row("u2", "u100", true), 1),
1474        ];
1475        let fresh = replicas(&["u1"]);
1476        let records = arrangement_sizes_records(live, hydration, &fresh, &fresh);
1477        assert_eq!(records.len(), 1);
1478        assert_eq!(records[0].replica_id, "u1");
1479        assert!(records[0].hydration_complete);
1480    }
1481
1482    #[mz_ore::test]
1483    fn stale_hydration_data_is_not_trusted() {
1484        // u1's sizes subscribe is fresh but its hydration subscribe is not,
1485        // so its stale "hydrated" row must not mark the record complete.
1486        let live = vec![(live_row("u1", "u100", Some(10)), 1)];
1487        let hydration = vec![(hydration_row("u1", "u100", true), 1)];
1488        let records =
1489            arrangement_sizes_records(live, hydration, &replicas(&["u1"]), &replicas(&[]));
1490        assert_eq!(records.len(), 1);
1491        assert!(!records[0].hydration_complete);
1492    }
1493}