Skip to main content

mz_storage_controller/
instance.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//! A controller for a storage instance.
11
12use crate::CollectionMetadata;
13use std::collections::{BTreeMap, BTreeSet};
14use std::sync::atomic::AtomicBool;
15use std::sync::{Arc, atomic};
16use std::time::{Duration, Instant};
17
18use anyhow::bail;
19use itertools::Itertools;
20use mz_build_info::BuildInfo;
21use mz_cluster_client::ReplicaId;
22use mz_cluster_client::client::ClusterReplicaLocation;
23use mz_ore::cast::CastFrom;
24use mz_ore::now::NowFn;
25use mz_ore::retry::{Retry, RetryState};
26use mz_ore::task::AbortOnDropHandle;
27use mz_repr::{GlobalId, Timestamp};
28use mz_service::client::{GenericClient, Partitioned};
29use mz_service::params::GrpcClientParameters;
30use mz_service::transport;
31use mz_storage_client::client::{
32    RunIngestionCommand, RunSinkCommand, Status, StatusUpdate, StorageCommand, StorageResponse,
33};
34use mz_storage_client::metrics::{InstanceMetrics, ReplicaMetrics};
35use mz_storage_types::sinks::StorageSinkDesc;
36use mz_storage_types::sources::{IngestionDescription, SourceConnection};
37use timely::progress::Antichain;
38use tokio::select;
39use tokio::sync::mpsc;
40use tracing::{debug, info, warn};
41use uuid::Uuid;
42
43use crate::history::CommandHistory;
44
45/// A controller for a storage instance.
46///
47/// Encapsulates communication with replicas in this instance, and their rehydration.
48///
49/// Note that storage objects (sources and sinks) don't currently support replication (database-issues#5051).
50/// An instance can have multiple replicas connected, but only if it has no storage objects
51/// installed. Attempting to install storage objects on multi-replica instances, or attempting to
52/// add more than one replica to instances that have storage objects installed, is illegal and will
53/// lead to panics.
54#[derive(Debug)]
55pub(crate) struct Instance {
56    /// The workload class of this instance.
57    ///
58    /// This is currently only used to annotate metrics.
59    pub workload_class: Option<String>,
60    /// The replicas connected to this storage instance.
61    replicas: BTreeMap<ReplicaId, Replica>,
62    /// The ingestions currently running on this instance.
63    ///
64    /// While this is derivable from `history` on demand, keeping a denormalized
65    /// list of running ingestions is quite a bit more convenient in the
66    /// implementation of `StorageController::active_ingestions`.
67    active_ingestions: BTreeMap<GlobalId, ActiveIngestion>,
68    /// A map from ingestion export ID to the ingestion that is producing it.
69    ingestion_exports: BTreeMap<GlobalId, GlobalId>,
70    /// The exports currently running on this instance.
71    ///
72    /// While this is derivable from `history` on demand, keeping a denormalized
73    /// list of running exports is quite a bit more convenient for the
74    /// controller.
75    active_exports: BTreeMap<GlobalId, ActiveExport>,
76    /// The command history, used to replay past commands when introducing new replicas or
77    /// reconnecting to existing replicas.
78    history: CommandHistory,
79    /// Metrics tracked for this storage instance.
80    metrics: InstanceMetrics,
81    /// A function that returns the current time.
82    now: NowFn,
83    /// A sender for responses from replicas.
84    ///
85    /// Responses are tagged with the [`ReplicaId`] of the replica that sent the
86    /// response. Responses that don't originate from a replica (e.g. a "paused"
87    /// status update, when no replicas are connected) are tagged with `None`.
88    response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
89}
90
91#[derive(Debug)]
92struct ActiveIngestion {
93    /// The set of replicas that this ingestion is currently running on.
94    active_replicas: BTreeSet<ReplicaId>,
95}
96
97#[derive(Debug)]
98struct ActiveExport {
99    /// The set of replicas that this export is currently running on.
100    active_replicas: BTreeSet<ReplicaId>,
101}
102
103/// Which replicas actively run a given object, as resolved by
104/// [`Instance::active_replica_ids`].
105enum ActiveReplicas<'a> {
106    /// The object has per-replica scheduling and runs on exactly these
107    /// replicas. The set is empty when it currently runs nowhere, e.g. it has
108    /// been compacted away or is not yet scheduled onto a replica.
109    Scheduled(&'a BTreeSet<ReplicaId>),
110    /// The object has no per-replica scheduling, so every replica runs it.
111    All,
112}
113
114impl Instance {
115    /// Creates a new [`Instance`].
116    pub fn new(
117        workload_class: Option<String>,
118        metrics: InstanceMetrics,
119        now: NowFn,
120        instance_response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
121    ) -> Self {
122        let history = CommandHistory::new(metrics.for_history());
123
124        let mut instance = Self {
125            workload_class,
126            replicas: Default::default(),
127            active_ingestions: Default::default(),
128            ingestion_exports: Default::default(),
129            active_exports: BTreeMap::new(),
130            history,
131            metrics,
132            now,
133            response_tx: instance_response_tx,
134        };
135
136        instance.send(StorageCommand::Hello {
137            // The nonce is protocol iteration-specific and will be set in
138            // `ReplicaTask::specialize_command`.
139            nonce: Default::default(),
140        });
141
142        instance
143    }
144
145    /// Returns the IDs of all replicas connected to this storage instance.
146    pub fn replica_ids(&self) -> impl Iterator<Item = ReplicaId> + '_ {
147        self.replicas.keys().copied()
148    }
149
150    /// Adds a new replica to this storage instance.
151    pub fn add_replica(&mut self, id: ReplicaId, config: ReplicaConfig) {
152        // Reduce the history to limit the amount of commands sent to the new replica, and to
153        // enable the `objects_installed` assert below.
154        self.history.reduce();
155
156        let metrics = self.metrics.for_replica(id);
157        let replica = Replica::new(id, config, metrics, self.response_tx.clone());
158
159        self.replicas.insert(id, replica);
160
161        self.update_scheduling(false);
162
163        self.replay_commands(id);
164    }
165
166    /// Replays commands to the specified replica.
167    pub fn replay_commands(&mut self, replica_id: ReplicaId) {
168        let commands = self.history.iter().cloned();
169
170        let filtered_commands = commands
171            .filter_map(|command| match command {
172                StorageCommand::RunIngestion(ingestion) => {
173                    if self.is_active_replica(&ingestion.id, &replica_id) {
174                        Some(StorageCommand::RunIngestion(ingestion))
175                    } else {
176                        None
177                    }
178                }
179                StorageCommand::RunSink(sink) => {
180                    if self.is_active_replica(&sink.id, &replica_id) {
181                        Some(StorageCommand::RunSink(sink))
182                    } else {
183                        None
184                    }
185                }
186                StorageCommand::AllowCompaction(id, upper) => {
187                    if self.is_active_replica(&id, &replica_id) {
188                        Some(StorageCommand::AllowCompaction(id, upper))
189                    } else {
190                        None
191                    }
192                }
193                command => Some(command),
194            })
195            .collect::<Vec<_>>();
196
197        let replica = self
198            .replicas
199            .get_mut(&replica_id)
200            .expect("replica must exist");
201
202        // Replay the commands at the new replica.
203        for command in filtered_commands {
204            replica.send(command);
205        }
206    }
207
208    /// Removes the identified replica from this storage instance.
209    pub fn drop_replica(&mut self, id: ReplicaId) {
210        let replica = self.replicas.remove(&id);
211
212        let mut needs_rescheduling = false;
213        for (ingestion_id, ingestion) in self.active_ingestions.iter_mut() {
214            let was_running = ingestion.active_replicas.remove(&id);
215            if was_running {
216                tracing::debug!(
217                    %ingestion_id,
218                    replica_id = %id,
219                    "ingestion was running on dropped replica, updating scheduling decisions"
220                );
221                needs_rescheduling = true;
222            }
223        }
224        for (export_id, export) in self.active_exports.iter_mut() {
225            let was_running = export.active_replicas.remove(&id);
226            if was_running {
227                tracing::debug!(
228                    %export_id,
229                    replica_id = %id,
230                    "export was running on dropped replica, updating scheduling decisions"
231                );
232                needs_rescheduling = true;
233            }
234        }
235
236        tracing::info!(%id, %needs_rescheduling, "dropped replica");
237
238        if needs_rescheduling {
239            self.update_scheduling(true);
240        }
241
242        if replica.is_some() && self.replicas.is_empty() {
243            self.update_paused_statuses();
244        }
245    }
246
247    /// Rehydrates any failed replicas of this storage instance.
248    pub fn rehydrate_failed_replicas(&mut self) {
249        let replicas = self.replicas.iter();
250        let failed_replicas: Vec<_> = replicas
251            .filter_map(|(id, replica)| replica.failed().then_some(*id))
252            .collect();
253
254        for id in failed_replicas {
255            let replica = self.replicas.remove(&id).expect("must exist");
256            self.add_replica(id, replica.config);
257        }
258    }
259
260    /// Returns ingestions running on this instance. This _only_ includes the
261    /// "toplevel" ingestions, not any of their source tables (aka. subsources).
262    pub fn active_ingestions(&self) -> impl Iterator<Item = &GlobalId> {
263        self.active_ingestions.keys()
264    }
265
266    /// Returns ingestion exports running on this instance. This includes the
267    /// ingestion itself, if any, and running source tables (aka. subsources).
268    ///
269    /// This does _not_ filter out exports whose write frontier is the empty
270    /// frontier, which some might consider not active anymore. But for the
271    /// purposes of the instance controller, these are still considered active
272    /// because we don't know about frontiers.
273    pub fn active_ingestion_exports(&self) -> impl Iterator<Item = &GlobalId> {
274        let ingestion_exports = self.ingestion_exports.keys();
275        self.active_ingestions.keys().chain(ingestion_exports)
276    }
277
278    /// Returns the exports running on this instance.
279    pub fn active_exports(&self) -> impl Iterator<Item = &GlobalId> {
280        self.active_exports.keys()
281    }
282
283    /// Sets the status to paused for all sources/sinks in the history.
284    fn update_paused_statuses(&mut self) {
285        let now = mz_ore::now::to_datetime((self.now)());
286        let make_update = |id, object_type| StatusUpdate {
287            id,
288            status: Status::Paused,
289            timestamp: now,
290            error: None,
291            hints: BTreeSet::from([format!(
292                "There is currently no replica running this {object_type}"
293            )]),
294            namespaced_errors: Default::default(),
295            replica_id: None,
296        };
297
298        self.history.reduce();
299
300        let mut status_updates = Vec::new();
301        for command in self.history.iter() {
302            match command {
303                StorageCommand::RunIngestion(ingestion) => {
304                    let old_style_ingestion =
305                        ingestion.id != ingestion.description.remap_collection_id;
306                    let subsource_ids = ingestion.description.collection_ids().filter(|id| {
307                        // NOTE(aljoscha): We filter out the remap collection for old style
308                        // ingestions because it doesn't get any status updates about it from the
309                        // replica side. So we don't want to synthesize a 'paused' status here.
310                        // New style ingestion do, since the source itself contains the remap data.
311                        let should_discard =
312                            old_style_ingestion && id == &ingestion.description.remap_collection_id;
313                        !should_discard
314                    });
315                    for id in subsource_ids {
316                        status_updates.push(make_update(id, "source"));
317                    }
318                }
319                StorageCommand::RunSink(sink) => {
320                    status_updates.push(make_update(sink.id, "sink"));
321                }
322                _ => (),
323            }
324        }
325
326        for update in status_updates {
327            // NOTE: If we lift this "inject paused status" logic to the
328            // controller, we could instead return ReplicaId instead of an
329            // Option<ReplicaId>.
330            let _ = self
331                .response_tx
332                .send((None, StorageResponse::StatusUpdate(update)));
333        }
334    }
335
336    /// Sends a command to this storage instance.
337    pub fn send(&mut self, command: StorageCommand) {
338        // Record the command so that new replicas can be brought up to speed.
339        self.history.push(command.clone());
340
341        match command.clone() {
342            StorageCommand::RunIngestion(ingestion) => {
343                // First absorb into our state, because this might change
344                // scheduling decisions, which need to be respected just below
345                // when sending commands.
346                self.absorb_ingestion(*ingestion.clone());
347
348                for replica in self.active_replicas(&ingestion.id) {
349                    replica.send(StorageCommand::RunIngestion(ingestion.clone()));
350                }
351            }
352            StorageCommand::RunSink(sink) => {
353                // First absorb into our state, because this might change
354                // scheduling decisions, which need to be respected just below
355                // when sending commands.
356                self.absorb_export(*sink.clone());
357
358                for replica in self.active_replicas(&sink.id) {
359                    replica.send(StorageCommand::RunSink(sink.clone()));
360                }
361            }
362            StorageCommand::AllowCompaction(id, frontier) => {
363                // First send out commands and then absorb into our state since
364                // absorbing them might remove entries from active_ingestions.
365                for replica in self.active_replicas(&id) {
366                    replica.send(StorageCommand::AllowCompaction(
367                        id.clone(),
368                        frontier.clone(),
369                    ));
370                }
371
372                self.absorb_compaction(id, frontier);
373            }
374            command => {
375                for replica in self.replicas.values_mut() {
376                    replica.send(command.clone());
377                }
378            }
379        }
380
381        if command.installs_objects() && self.replicas.is_empty() {
382            self.update_paused_statuses();
383        }
384    }
385
386    /// Updates internal state based on incoming ingestion commands.
387    ///
388    /// This does _not_ send commands to replicas, we only record the ingestion
389    /// in state and potentially update scheduling decisions.
390    fn absorb_ingestion(&mut self, ingestion: RunIngestionCommand) {
391        let existing_ingestion_state = self.active_ingestions.get_mut(&ingestion.id);
392
393        // Always update our mapping from export to their ingestion.
394        for id in ingestion.description.source_exports.keys() {
395            self.ingestion_exports.insert(id.clone(), ingestion.id);
396        }
397
398        if let Some(ingestion_state) = existing_ingestion_state {
399            // It's an update for an existing ingestion. We don't need to
400            // change anything about our scheduling decisions, no need to
401            // update active_ingestions.
402
403            tracing::debug!(
404                ingestion_id = %ingestion.id,
405                active_replicas = %ingestion_state.active_replicas.iter().map(|id| id.to_string()).join(", "),
406                "updating ingestion"
407            );
408        } else {
409            // We create a new ingestion state for this ingestion.
410            let ingestion_state = ActiveIngestion {
411                active_replicas: BTreeSet::new(),
412            };
413            self.active_ingestions.insert(ingestion.id, ingestion_state);
414
415            // Maybe update scheduling decisions.
416            self.update_scheduling(false);
417        }
418    }
419
420    /// Updates internal state based on incoming export commands.
421    ///
422    /// This does _not_ send commands to replicas, we only record the export
423    /// in state and potentially update scheduling decisions.
424    fn absorb_export(&mut self, export: RunSinkCommand) {
425        let existing_export_state = self.active_exports.get_mut(&export.id);
426
427        if let Some(export_state) = existing_export_state {
428            // It's an update for an existing export. We don't need to
429            // change anything about our scheduling decisions, no need to
430            // update active_exports.
431
432            tracing::debug!(
433                export_id = %export.id,
434                active_replicas = %export_state.active_replicas.iter().map(|id| id.to_string()).join(", "),
435                "updating export"
436            );
437        } else {
438            // We create a new export state for this export.
439            let export_state = ActiveExport {
440                active_replicas: BTreeSet::new(),
441            };
442            self.active_exports.insert(export.id, export_state);
443
444            // Maybe update scheduling decisions.
445            self.update_scheduling(false);
446        }
447    }
448
449    /// Update scheduling decisions, that is what replicas should be running a
450    /// given object, if needed.
451    ///
452    /// An important property of this scheduling algorithm is that we never
453    /// change the scheduling decision for single-replica objects unless we
454    /// have to, that is unless the replica that they are running on goes away.
455    /// We do this, so that we don't send a mix of "run"/"allow
456    /// compaction"/"run" messages to replicas, which wouldn't deal well with
457    /// this. When we _do_ have to make a scheduling decision we schedule a
458    /// single-replica ingestion on the first replica, according to the sort
459    /// order of `ReplicaId`. We do this latter so that the scheduling decision
460    /// is stable across restarts of `environmentd`/the controller.
461    ///
462    /// For multi-replica objects (e.g. Kafka ingestions), each active object is
463    /// scheduled on all replicas.
464    ///
465    /// If `send_commands` is true, will send commands for newly-scheduled
466    /// single-replica objects.
467    fn update_scheduling(&mut self, send_commands: bool) {
468        #[derive(Debug)]
469        enum ObjectId {
470            Ingestion(GlobalId),
471            Export(GlobalId),
472        }
473        // We first collect scheduling preferences and then schedule below.
474        // Applying the decision needs a mutable borrow but we also need to
475        // borrow for determining `prefers_single_replica`, so we split this
476        // into two loops.
477        let mut scheduling_preferences: Vec<(ObjectId, bool)> = Vec::new();
478
479        for ingestion_id in self.active_ingestions.keys() {
480            let ingestion_description = self
481                .get_ingestion_description(ingestion_id)
482                .expect("missing ingestion description");
483
484            let prefers_single_replica = ingestion_description
485                .desc
486                .connection
487                .prefers_single_replica();
488
489            scheduling_preferences
490                .push((ObjectId::Ingestion(*ingestion_id), prefers_single_replica));
491        }
492
493        for export_id in self.active_exports.keys() {
494            // All sinks prefer single replica
495            scheduling_preferences.push((ObjectId::Export(*export_id), true));
496        }
497
498        // Collect all commands per replica and send them in one go.
499        let mut commands_by_replica: BTreeMap<ReplicaId, Vec<ObjectId>> = BTreeMap::new();
500
501        for (object_id, prefers_single_replica) in scheduling_preferences {
502            let active_replicas = match object_id {
503                ObjectId::Ingestion(ingestion_id) => {
504                    &mut self
505                        .active_ingestions
506                        .get_mut(&ingestion_id)
507                        .expect("missing ingestion state")
508                        .active_replicas
509                }
510                ObjectId::Export(export_id) => {
511                    &mut self
512                        .active_exports
513                        .get_mut(&export_id)
514                        .expect("missing ingestion state")
515                        .active_replicas
516                }
517            };
518
519            if prefers_single_replica {
520                // For single-replica ingestion, schedule only if it's not already running.
521                if active_replicas.is_empty() {
522                    let target_replica = self.replicas.keys().min().copied();
523                    if let Some(first_replica_id) = target_replica {
524                        tracing::info!(
525                            object_id = ?object_id,
526                            replica_id = %first_replica_id,
527                            "scheduling single-replica object");
528                        active_replicas.insert(first_replica_id);
529
530                        commands_by_replica
531                            .entry(first_replica_id)
532                            .or_default()
533                            .push(object_id);
534                    }
535                } else {
536                    tracing::info!(
537                        ?object_id,
538                        active_replicas = %active_replicas.iter().map(|id| id.to_string()).join(", "),
539                        "single-replica object already running, not scheduling again",
540                    );
541                }
542            } else {
543                let current_replica_ids: BTreeSet<_> = self.replicas.keys().copied().collect();
544                let unscheduled_replicas: Vec<_> = current_replica_ids
545                    .difference(active_replicas)
546                    .copied()
547                    .collect();
548                for replica_id in unscheduled_replicas {
549                    tracing::info!(
550                        ?object_id,
551                        %replica_id,
552                        "scheduling multi-replica object"
553                    );
554                    active_replicas.insert(replica_id);
555                }
556            }
557        }
558
559        if send_commands {
560            for (replica_id, object_ids) in commands_by_replica {
561                let mut ingestion_commands = vec![];
562                let mut export_commands = vec![];
563                for object_id in object_ids {
564                    match object_id {
565                        ObjectId::Ingestion(id) => {
566                            ingestion_commands.push(RunIngestionCommand {
567                                id,
568                                description: self
569                                    .get_ingestion_description(&id)
570                                    .expect("missing ingestion description")
571                                    .clone(),
572                            });
573                        }
574                        ObjectId::Export(id) => {
575                            export_commands.push(RunSinkCommand {
576                                id,
577                                description: self
578                                    .get_export_description(&id)
579                                    .expect("missing export description")
580                                    .clone(),
581                            });
582                        }
583                    }
584                }
585                for ingestion in ingestion_commands {
586                    let replica = self.replicas.get_mut(&replica_id).expect("missing replica");
587                    let ingestion = Box::new(ingestion);
588                    replica.send(StorageCommand::RunIngestion(ingestion));
589                }
590                for export in export_commands {
591                    let replica = self.replicas.get_mut(&replica_id).expect("missing replica");
592                    let export = Box::new(export);
593                    replica.send(StorageCommand::RunSink(export));
594                }
595            }
596        }
597    }
598
599    /// Returns the ingestion description for the given ingestion ID, if it
600    /// exists.
601    ///
602    /// This function searches through the command history to find the most
603    /// recent RunIngestionCommand for the specified ingestion ID and returns
604    /// its description.  Returns None if no ingestion with the given ID is
605    /// found.
606    pub fn get_ingestion_description(
607        &self,
608        id: &GlobalId,
609    ) -> Option<IngestionDescription<CollectionMetadata>> {
610        if !self.active_ingestions.contains_key(id) {
611            return None;
612        }
613
614        self.history.iter().rev().find_map(|command| {
615            if let StorageCommand::RunIngestion(ingestion) = command {
616                if &ingestion.id == id {
617                    Some(ingestion.description.clone())
618                } else {
619                    None
620                }
621            } else {
622                None
623            }
624        })
625    }
626
627    /// Returns the export description for the given export ID, if it
628    /// exists.
629    ///
630    /// This function searches through the command history to find the most
631    /// recent RunSinkCommand for the specified export ID and returns
632    /// its description.  Returns None if no ingestion with the given ID is
633    /// found.
634    pub fn get_export_description(
635        &self,
636        id: &GlobalId,
637    ) -> Option<StorageSinkDesc<CollectionMetadata>> {
638        if !self.active_exports.contains_key(id) {
639            return None;
640        }
641
642        self.history.iter().rev().find_map(|command| {
643            if let StorageCommand::RunSink(sink) = command {
644                if &sink.id == id {
645                    Some(sink.description.clone())
646                } else {
647                    None
648                }
649            } else {
650                None
651            }
652        })
653    }
654
655    /// Updates internal state based on incoming compaction commands.
656    fn absorb_compaction(&mut self, id: GlobalId, frontier: Antichain<Timestamp>) {
657        tracing::debug!(?self.active_ingestions, ?id, ?frontier, "allow_compaction");
658
659        if frontier.is_empty() {
660            self.active_ingestions.remove(&id);
661            self.ingestion_exports.remove(&id);
662            self.active_exports.remove(&id);
663        }
664    }
665
666    /// Resolves an object to the replicas actively running it.
667    ///
668    /// Shared by [`Self::active_replicas`], [`Self::is_active_replica`], and
669    /// [`Self::get_active_replicas_for_object`], which differ only in how they
670    /// project the result.
671    fn active_replica_ids(&self, id: &GlobalId) -> ActiveReplicas<'_> {
672        // An empty set to borrow for objects whose scheduling target is gone.
673        static EMPTY: BTreeSet<ReplicaId> = BTreeSet::new();
674
675        if let Some(ingestion_id) = self.ingestion_exports.get(id) {
676            match self.active_ingestions.get(ingestion_id) {
677                Some(ingestion) => ActiveReplicas::Scheduled(&ingestion.active_replicas),
678                // The ingestion has already been compacted away (aka. stopped).
679                None => ActiveReplicas::Scheduled(&EMPTY),
680            }
681        } else if let Some(ingestion) = self.active_ingestions.get(id) {
682            // A new-syntax source lists only its tables in `source_exports`, so
683            // the primary ingestion id is not in `ingestion_exports`.
684            ActiveReplicas::Scheduled(&ingestion.active_replicas)
685        } else if let Some(export) = self.active_exports.get(id) {
686            ActiveReplicas::Scheduled(&export.active_replicas)
687        } else {
688            // Objects that have no per-replica scheduling (e.g. tables and
689            // webhooks) run on all replicas.
690            ActiveReplicas::All
691        }
692    }
693
694    /// Returns the replicas that are actively running the given object (ingestion or export).
695    fn active_replicas(&mut self, id: &GlobalId) -> Box<dyn Iterator<Item = &mut Replica> + '_> {
696        // Take an owned copy of the scheduled set so the immutable borrow of
697        // `self` ends before we borrow `self.replicas` mutably below. The set
698        // is tiny (one entry per replica) and this is not a per-row path.
699        let scheduled = match self.active_replica_ids(id) {
700            ActiveReplicas::All => None,
701            ActiveReplicas::Scheduled(replicas) => Some(replicas.clone()),
702        };
703        match scheduled {
704            None => Box::new(self.replicas.values_mut()),
705            Some(scheduled) => Box::new(self.replicas.iter_mut().filter_map(
706                move |(replica_id, replica)| scheduled.contains(replica_id).then_some(replica),
707            )),
708        }
709    }
710
711    /// Returns whether the given replica is actively running the given object (ingestion or export).
712    fn is_active_replica(&self, id: &GlobalId, replica_id: &ReplicaId) -> bool {
713        match self.active_replica_ids(id) {
714            ActiveReplicas::All => true,
715            ActiveReplicas::Scheduled(replicas) => replicas.contains(replica_id),
716        }
717    }
718
719    /// Refresh the controller state metrics for this instance.
720    ///
721    /// We could also do state metric updates directly in response to state changes, but that would
722    /// mean littering the code with metric update calls. Encapsulating state metric maintenance in
723    /// a single method is less noisy.
724    ///
725    /// This method is invoked by `Controller::maintain`, which we expect to be called once per
726    /// second during normal operation.
727    pub(super) fn refresh_state_metrics(&self) {
728        let connected_replica_count = self.replicas.values().filter(|r| r.is_connected()).count();
729
730        self.metrics
731            .connected_replica_count
732            .set(u64::cast_from(connected_replica_count));
733    }
734
735    /// Returns the set of replica IDs that are actively running the given
736    /// object (ingestion, ingestion export (aka. subsource), or export).
737    pub fn get_active_replicas_for_object(&self, id: &GlobalId) -> BTreeSet<ReplicaId> {
738        match self.active_replica_ids(id) {
739            ActiveReplicas::All => self.replicas.keys().copied().collect(),
740            ActiveReplicas::Scheduled(replicas) => replicas.clone(),
741        }
742    }
743}
744
745/// Replica-specific configuration.
746#[derive(Clone, Debug)]
747pub(super) struct ReplicaConfig {
748    pub build_info: &'static BuildInfo,
749    pub location: ClusterReplicaLocation,
750    pub grpc_client: GrpcClientParameters,
751}
752
753/// State maintained about individual replicas.
754#[derive(Debug)]
755pub struct Replica {
756    /// Replica configuration.
757    config: ReplicaConfig,
758    /// A sender for commands for the replica.
759    ///
760    /// If sending to this channel fails, the replica has failed and requires
761    /// rehydration.
762    command_tx: mpsc::UnboundedSender<StorageCommand>,
763    /// A handle to the task that aborts it when the replica is dropped.
764    task: AbortOnDropHandle<()>,
765    /// Flag reporting whether the replica connection has been established.
766    connected: Arc<AtomicBool>,
767}
768
769impl Replica {
770    /// Creates a new [`Replica`].
771    fn new(
772        id: ReplicaId,
773        config: ReplicaConfig,
774        metrics: ReplicaMetrics,
775        response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
776    ) -> Self {
777        let (command_tx, command_rx) = mpsc::unbounded_channel();
778        let connected = Arc::new(AtomicBool::new(false));
779
780        let task = mz_ore::task::spawn(
781            || "storage-replica-{id}",
782            ReplicaTask {
783                replica_id: id,
784                config: config.clone(),
785                metrics: metrics.clone(),
786                connected: Arc::clone(&connected),
787                command_rx,
788                response_tx,
789            }
790            .run(),
791        );
792
793        Self {
794            config,
795            command_tx,
796            task: task.abort_on_drop(),
797            connected,
798        }
799    }
800
801    /// Sends a command to the replica.
802    fn send(&self, command: StorageCommand) {
803        // Send failures ignored, we'll check for failed replicas separately.
804        let _ = self.command_tx.send(command);
805    }
806
807    /// Determine if this replica has failed. This is true if the replica
808    /// task has terminated.
809    fn failed(&self) -> bool {
810        self.task.is_finished()
811    }
812
813    /// Determine if the replica connection has been established.
814    pub(super) fn is_connected(&self) -> bool {
815        self.connected.load(atomic::Ordering::Relaxed)
816    }
817}
818
819type StorageCtpClient = transport::Client<StorageCommand, StorageResponse>;
820type ReplicaClient = Partitioned<StorageCtpClient, StorageCommand, StorageResponse>;
821
822/// A task handling communication with a replica.
823struct ReplicaTask {
824    /// The ID of the replica.
825    replica_id: ReplicaId,
826    /// Replica configuration.
827    config: ReplicaConfig,
828    /// Replica metrics.
829    metrics: ReplicaMetrics,
830    /// Flag to report successful replica connection.
831    connected: Arc<AtomicBool>,
832    /// A channel upon which commands intended for the replica are delivered.
833    command_rx: mpsc::UnboundedReceiver<StorageCommand>,
834    /// A channel upon which responses from the replica are delivered.
835    response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
836}
837
838impl ReplicaTask {
839    /// Runs the replica task.
840    async fn run(self) {
841        let replica_id = self.replica_id;
842        info!(%replica_id, "starting replica task");
843
844        let client = self.connect().await;
845        match self.run_message_loop(client).await {
846            Ok(()) => info!(%replica_id, "stopped replica task"),
847            Err(error) => warn!(%replica_id, %error, "replica task failed"),
848        }
849    }
850
851    /// Connects to the replica.
852    ///
853    /// The connection is retried forever (with backoff) and this method returns only after
854    /// a connection was successfully established.
855    async fn connect(&self) -> ReplicaClient {
856        let try_connect = async move |retry: RetryState| {
857            let version = self.config.build_info.semver_version();
858            let client_params = &self.config.grpc_client;
859
860            let connect_start = Instant::now();
861            let connect_timeout = client_params.connect_timeout.unwrap_or(Duration::MAX);
862            let keepalive_timeout = client_params
863                .http2_keep_alive_timeout
864                .unwrap_or(Duration::MAX);
865
866            let connect_result = StorageCtpClient::connect_partitioned(
867                self.config.location.ctl_addrs.clone(),
868                version,
869                connect_timeout,
870                keepalive_timeout,
871                self.metrics.clone(),
872            )
873            .await;
874
875            self.metrics.observe_connect_time(connect_start.elapsed());
876
877            connect_result.inspect_err(|error| {
878                let next_backoff = retry.next_backoff.unwrap();
879                if retry.i >= mz_service::retry::INFO_MIN_RETRIES {
880                    info!(
881                        replica_id = %self.replica_id, ?next_backoff,
882                        "error connecting to replica: {error:#}",
883                    );
884                } else {
885                    debug!(
886                        replica_id = %self.replica_id, ?next_backoff,
887                        "error connecting to replica: {error:#}",
888                    );
889                }
890            })
891        };
892
893        let client = Retry::default()
894            .clamp_backoff(Duration::from_secs(1))
895            .retry_async(try_connect)
896            .await
897            .expect("retries forever");
898
899        self.metrics.observe_connect();
900        self.connected.store(true, atomic::Ordering::Relaxed);
901
902        client
903    }
904
905    /// Runs the message loop.
906    ///
907    /// Returns (with an `Err`) if it encounters an error condition (e.g. the replica disconnects).
908    /// If no error condition is encountered, the task runs until the controller disconnects from
909    /// the command channel, or the task is dropped.
910    async fn run_message_loop(mut self, mut client: ReplicaClient) -> Result<(), anyhow::Error> {
911        loop {
912            select! {
913                // Command from controller to forward to replica.
914                // `tokio::sync::mpsc::UnboundedReceiver::recv` is documented as cancel safe.
915                command = self.command_rx.recv() => {
916                    let Some(mut command) = command else {
917                        tracing::debug!(%self.replica_id, "controller is no longer interested in this replica, shutting down message loop");
918                        break;
919                    };
920
921                    self.specialize_command(&mut command);
922                    client.send(command).await?;
923                },
924                // Response from replica to forward to controller.
925                // `GenericClient::recv` implementations are required to be cancel safe.
926                response = client.recv() => {
927                    let Some(response) = response? else {
928                        bail!("replica unexpectedly gracefully terminated connection");
929                    };
930
931                    if self.response_tx.send((Some(self.replica_id), response)).is_err() {
932                        tracing::debug!(%self.replica_id, "controller (receiver) is no longer interested in this replica, shutting down message loop");
933                        break;
934                    }
935                }
936            }
937        }
938
939        Ok(())
940    }
941
942    /// Specialize a command for the given replica configuration.
943    ///
944    /// Most [`StorageCommand`]s are independent of the target replica, but some contain
945    /// replica-specific fields that must be adjusted before sending.
946    fn specialize_command(&self, command: &mut StorageCommand) {
947        if let StorageCommand::Hello { nonce } = command {
948            *nonce = Uuid::new_v4();
949        }
950    }
951}