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