1use 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#[derive(Debug)]
56pub(crate) struct Instance {
57 pub workload_class: Option<String>,
61 replicas: BTreeMap<ReplicaId, Replica>,
63 active_ingestions: BTreeMap<GlobalId, ActiveIngestion>,
69 ingestion_exports: BTreeMap<GlobalId, GlobalId>,
71 active_exports: BTreeMap<GlobalId, ActiveExport>,
77 history: CommandHistory,
80 replica_dyncfg_overrides: BTreeMap<ReplicaId, ConfigUpdates>,
85 metrics: InstanceMetrics,
87 now: NowFn,
89 response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
95}
96
97#[derive(Debug)]
98struct ActiveIngestion {
99 active_replicas: BTreeSet<ReplicaId>,
101}
102
103#[derive(Debug)]
104struct ActiveExport {
105 active_replicas: BTreeSet<ReplicaId>,
107}
108
109enum ActiveReplicas<'a> {
112 Scheduled(&'a BTreeSet<ReplicaId>),
116 All,
118}
119
120impl Instance {
121 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 nonce: Default::default(),
147 });
148
149 instance
150 }
151
152 pub fn replica_ids(&self) -> impl Iterator<Item = ReplicaId> + '_ {
154 self.replicas.keys().copied()
155 }
156
157 pub fn add_replica(&mut self, id: ReplicaId, config: ReplicaConfig) {
159 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 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 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 pub fn drop_replica(&mut self, id: ReplicaId) {
223 let replica = self.replicas.remove(&id);
224
225 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 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 pub fn active_ingestions(&self) -> impl Iterator<Item = &GlobalId> {
281 self.active_ingestions.keys()
282 }
283
284 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 pub fn active_exports(&self) -> impl Iterator<Item = &GlobalId> {
298 self.active_exports.keys()
299 }
300
301 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 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 let _ = self
349 .response_tx
350 .send((None, StorageResponse::StatusUpdate(update)));
351 }
352 }
353
354 pub fn update_replica_dyncfg_overrides(
358 &mut self,
359 overrides: BTreeMap<ReplicaId, ConfigUpdates>,
360 ) {
361 self.replica_dyncfg_overrides = overrides;
362 }
363
364 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 pub fn send(&mut self, command: StorageCommand) {
384 self.history.push(command.clone());
386
387 match command.clone() {
388 StorageCommand::RunIngestion(ingestion) => {
389 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 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 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 fn absorb_ingestion(&mut self, ingestion: RunIngestionCommand) {
443 let existing_ingestion_state = self.active_ingestions.get_mut(&ingestion.id);
444
445 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 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 let ingestion_state = ActiveIngestion {
463 active_replicas: BTreeSet::new(),
464 };
465 self.active_ingestions.insert(ingestion.id, ingestion_state);
466
467 self.update_scheduling(false);
469 }
470 }
471
472 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 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 let export_state = ActiveExport {
492 active_replicas: BTreeSet::new(),
493 };
494 self.active_exports.insert(export.id, export_state);
495
496 self.update_scheduling(false);
498 }
499 }
500
501 fn update_scheduling(&mut self, send_commands: bool) {
520 #[derive(Debug)]
521 enum ObjectId {
522 Ingestion(GlobalId),
523 Export(GlobalId),
524 }
525 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 scheduling_preferences.push((ObjectId::Export(*export_id), true));
548 }
549
550 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 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 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 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 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 fn active_replica_ids(&self, id: &GlobalId) -> ActiveReplicas<'_> {
724 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 None => ActiveReplicas::Scheduled(&EMPTY),
732 }
733 } else if let Some(ingestion) = self.active_ingestions.get(id) {
734 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 ActiveReplicas::All
743 }
744 }
745
746 fn active_replicas(&mut self, id: &GlobalId) -> Box<dyn Iterator<Item = &mut Replica> + '_> {
748 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 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 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 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#[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#[derive(Debug)]
807pub struct Replica {
808 config: ReplicaConfig,
810 command_tx: mpsc::UnboundedSender<StorageCommand>,
815 task: AbortOnDropHandle<()>,
817 connected: Arc<AtomicBool>,
819}
820
821impl Replica {
822 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 fn send(&self, command: StorageCommand) {
855 let _ = self.command_tx.send(command);
857 }
858
859 fn failed(&self) -> bool {
862 self.task.is_finished()
863 }
864
865 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
874struct ReplicaTask {
876 replica_id: ReplicaId,
878 config: ReplicaConfig,
880 metrics: ReplicaMetrics,
882 connected: Arc<AtomicBool>,
884 command_rx: mpsc::UnboundedReceiver<StorageCommand>,
886 response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
888}
889
890impl ReplicaTask {
891 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 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 async fn run_message_loop(mut self, mut client: ReplicaClient) -> Result<(), anyhow::Error> {
963 loop {
964 select! {
965 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 = 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 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) => ¶ms.dyncfg_updates,
1022 other => panic!("expected UpdateConfiguration, got {other:?}"),
1023 }
1024 }
1025
1026 #[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}