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