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