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