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