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)]
55pub(crate) struct Instance {
56 pub workload_class: Option<String>,
60 replicas: BTreeMap<ReplicaId, Replica>,
62 active_ingestions: BTreeMap<GlobalId, ActiveIngestion>,
68 ingestion_exports: BTreeMap<GlobalId, GlobalId>,
70 active_exports: BTreeMap<GlobalId, ActiveExport>,
76 history: CommandHistory,
79 replica_dyncfg_overrides: BTreeMap<ReplicaId, ConfigUpdates>,
84 metrics: InstanceMetrics,
86 now: NowFn,
88 response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
94}
95
96#[derive(Debug)]
97struct ActiveIngestion {
98 active_replicas: BTreeSet<ReplicaId>,
100}
101
102#[derive(Debug)]
103struct ActiveExport {
104 active_replicas: BTreeSet<ReplicaId>,
106}
107
108enum ActiveReplicas<'a> {
111 Scheduled(&'a BTreeSet<ReplicaId>),
115 All,
117}
118
119impl Instance {
120 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 nonce: Default::default(),
146 });
147
148 instance
149 }
150
151 pub fn replica_ids(&self) -> impl Iterator<Item = ReplicaId> + '_ {
153 self.replicas.keys().copied()
154 }
155
156 pub fn add_replica(&mut self, id: ReplicaId, config: ReplicaConfig) {
158 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 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 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 pub fn drop_replica(&mut self, id: ReplicaId) {
221 let replica = self.replicas.remove(&id);
222
223 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 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 pub fn active_ingestions(&self) -> impl Iterator<Item = &GlobalId> {
279 self.active_ingestions.keys()
280 }
281
282 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 pub fn active_exports(&self) -> impl Iterator<Item = &GlobalId> {
296 self.active_exports.keys()
297 }
298
299 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 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 let _ = self
347 .response_tx
348 .send((None, StorageResponse::StatusUpdate(update)));
349 }
350 }
351
352 pub fn update_replica_dyncfg_overrides(
356 &mut self,
357 overrides: BTreeMap<ReplicaId, ConfigUpdates>,
358 ) {
359 self.replica_dyncfg_overrides = overrides;
360 }
361
362 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 pub fn send(&mut self, command: StorageCommand) {
382 self.history.push(command.clone());
384
385 match command.clone() {
386 StorageCommand::RunIngestion(ingestion) => {
387 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 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 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 fn absorb_ingestion(&mut self, ingestion: RunIngestionCommand) {
441 let existing_ingestion_state = self.active_ingestions.get_mut(&ingestion.id);
442
443 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 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 let ingestion_state = ActiveIngestion {
461 active_replicas: BTreeSet::new(),
462 };
463 self.active_ingestions.insert(ingestion.id, ingestion_state);
464
465 self.update_scheduling(false);
467 }
468 }
469
470 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 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 let export_state = ActiveExport {
490 active_replicas: BTreeSet::new(),
491 };
492 self.active_exports.insert(export.id, export_state);
493
494 self.update_scheduling(false);
496 }
497 }
498
499 fn update_scheduling(&mut self, send_commands: bool) {
518 #[derive(Debug)]
519 enum ObjectId {
520 Ingestion(GlobalId),
521 Export(GlobalId),
522 }
523 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 scheduling_preferences.push((ObjectId::Export(*export_id), true));
546 }
547
548 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 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 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 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 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 fn active_replica_ids(&self, id: &GlobalId) -> ActiveReplicas<'_> {
722 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 None => ActiveReplicas::Scheduled(&EMPTY),
730 }
731 } else if let Some(ingestion) = self.active_ingestions.get(id) {
732 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 ActiveReplicas::All
741 }
742 }
743
744 fn active_replicas(&mut self, id: &GlobalId) -> Box<dyn Iterator<Item = &mut Replica> + '_> {
746 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 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 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 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#[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#[derive(Debug)]
805pub struct Replica {
806 config: ReplicaConfig,
808 command_tx: mpsc::UnboundedSender<StorageCommand>,
813 task: AbortOnDropHandle<()>,
815 connected: Arc<AtomicBool>,
817}
818
819impl Replica {
820 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 fn send(&self, command: StorageCommand) {
853 let _ = self.command_tx.send(command);
855 }
856
857 fn failed(&self) -> bool {
860 self.task.is_finished()
861 }
862
863 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
872struct ReplicaTask {
874 replica_id: ReplicaId,
876 config: ReplicaConfig,
878 metrics: ReplicaMetrics,
880 connected: Arc<AtomicBool>,
882 command_rx: mpsc::UnboundedReceiver<StorageCommand>,
884 response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
886}
887
888impl ReplicaTask {
889 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 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 async fn run_message_loop(mut self, mut client: ReplicaClient) -> Result<(), anyhow::Error> {
961 loop {
962 select! {
963 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 = 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 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) => ¶ms.dyncfg_updates,
1020 other => panic!("expected UpdateConfiguration, got {other:?}"),
1021 }
1022 }
1023
1024 #[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}