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_cluster_client::params::GrpcClientParameters;
24use mz_dyncfg::ConfigUpdates;
25use mz_ore::cast::CastFrom;
26use mz_ore::now::NowFn;
27use mz_ore::retry::{Retry, RetryState};
28use mz_ore::task::AbortOnDropHandle;
29use mz_repr::{GlobalId, Timestamp};
30use mz_service::client::{GenericClient, Partitioned};
31use mz_service::transport;
32use mz_storage_client::client::{
33 RunIngestionCommand, RunSinkCommand, Status, StatusUpdate, StorageCommand, StorageResponse,
34};
35use mz_storage_client::metrics::{InstanceMetrics, ReplicaMetrics};
36use mz_storage_types::sinks::StorageSinkDesc;
37use mz_storage_types::sources::{IngestionDescription, SourceConnection};
38use timely::progress::Antichain;
39use tokio::select;
40use tokio::sync::mpsc;
41use tracing::{debug, info, warn};
42use uuid::Uuid;
43
44use crate::history::CommandHistory;
45
46#[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 metrics: InstanceMetrics,
81 now: NowFn,
83 response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
89}
90
91#[derive(Debug)]
92struct ActiveIngestion {
93 active_replicas: BTreeSet<ReplicaId>,
95}
96
97#[derive(Debug)]
98struct ActiveExport {
99 active_replicas: BTreeSet<ReplicaId>,
101}
102
103enum ActiveReplicas<'a> {
106 Scheduled(&'a BTreeSet<ReplicaId>),
110 All,
112}
113
114impl Instance {
115 pub fn new(
117 workload_class: Option<String>,
118 metrics: InstanceMetrics,
119 now: NowFn,
120 instance_response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
121 ) -> Self {
122 let history = CommandHistory::new(metrics.for_history());
123
124 let mut instance = Self {
125 workload_class,
126 replicas: Default::default(),
127 active_ingestions: Default::default(),
128 ingestion_exports: Default::default(),
129 active_exports: BTreeMap::new(),
130 history,
131 metrics,
132 now,
133 response_tx: instance_response_tx,
134 };
135
136 instance.send(StorageCommand::Hello {
137 nonce: Default::default(),
140 });
141
142 instance
143 }
144
145 pub fn replica_ids(&self) -> impl Iterator<Item = ReplicaId> + '_ {
147 self.replicas.keys().copied()
148 }
149
150 pub fn add_replica(
155 &mut self,
156 id: ReplicaId,
157 config: ReplicaConfig,
158 dyncfg_override: ConfigUpdates,
159 ) {
160 self.history.reduce();
162
163 let metrics = self.metrics.for_replica(id);
164 let replica = Replica::new(
165 id,
166 config,
167 dyncfg_override,
168 metrics,
169 self.response_tx.clone(),
170 );
171
172 self.replicas.insert(id, replica);
173
174 self.update_scheduling(false);
175
176 self.replay_commands(id);
177 }
178
179 pub fn replay_commands(&mut self, replica_id: ReplicaId) {
181 let commands = self.history.iter().cloned();
182
183 let filtered_commands = commands
184 .filter_map(|command| match command {
185 StorageCommand::RunIngestion(ingestion) => {
186 if self.is_active_replica(&ingestion.id, &replica_id) {
187 Some(StorageCommand::RunIngestion(ingestion))
188 } else {
189 None
190 }
191 }
192 StorageCommand::RunSink(sink) => {
193 if self.is_active_replica(&sink.id, &replica_id) {
194 Some(StorageCommand::RunSink(sink))
195 } else {
196 None
197 }
198 }
199 StorageCommand::AllowCompaction(id, upper) => {
200 if self.is_active_replica(&id, &replica_id) {
201 Some(StorageCommand::AllowCompaction(id, upper))
202 } else {
203 None
204 }
205 }
206 command => Some(command),
207 })
208 .collect::<Vec<_>>();
209
210 let replica = self
211 .replicas
212 .get_mut(&replica_id)
213 .expect("replica must exist");
214
215 for command in filtered_commands {
218 let command = Self::specialize_command_for_replica(command, &replica.dyncfg_override);
219 replica.send(command);
220 }
221 }
222
223 pub fn drop_replica(&mut self, id: ReplicaId) {
225 let replica = self.replicas.remove(&id);
226
227 let mut needs_rescheduling = false;
228 for (ingestion_id, ingestion) in self.active_ingestions.iter_mut() {
229 let was_running = ingestion.active_replicas.remove(&id);
230 if was_running {
231 tracing::debug!(
232 %ingestion_id,
233 replica_id = %id,
234 "ingestion was running on dropped replica, updating scheduling decisions"
235 );
236 needs_rescheduling = true;
237 }
238 }
239 for (export_id, export) in self.active_exports.iter_mut() {
240 let was_running = export.active_replicas.remove(&id);
241 if was_running {
242 tracing::debug!(
243 %export_id,
244 replica_id = %id,
245 "export was running on dropped replica, updating scheduling decisions"
246 );
247 needs_rescheduling = true;
248 }
249 }
250
251 tracing::info!(%id, %needs_rescheduling, "dropped replica");
252
253 if needs_rescheduling {
254 self.update_scheduling(true);
255 }
256
257 if replica.is_some() && self.replicas.is_empty() {
258 self.update_paused_statuses();
259 }
260 }
261
262 pub fn rehydrate_failed_replicas(&mut self) {
264 let replicas = self.replicas.iter();
265 let failed_replicas: Vec<_> = replicas
266 .filter_map(|(id, replica)| replica.failed().then_some(*id))
267 .collect();
268
269 for id in failed_replicas {
270 let replica = self.replicas.remove(&id).expect("must exist");
271 self.add_replica(id, replica.config, replica.dyncfg_override);
272 }
273 }
274
275 pub fn active_ingestions(&self) -> impl Iterator<Item = &GlobalId> {
278 self.active_ingestions.keys()
279 }
280
281 pub fn active_ingestion_exports(&self) -> impl Iterator<Item = &GlobalId> {
289 let ingestion_exports = self.ingestion_exports.keys();
290 self.active_ingestions.keys().chain(ingestion_exports)
291 }
292
293 pub fn active_exports(&self) -> impl Iterator<Item = &GlobalId> {
295 self.active_exports.keys()
296 }
297
298 fn update_paused_statuses(&mut self) {
300 let now = mz_ore::now::to_datetime((self.now)());
301 let make_update = |id, object_type| StatusUpdate {
302 id,
303 status: Status::Paused,
304 timestamp: now,
305 error: None,
306 hints: BTreeSet::from([format!(
307 "There is currently no replica running this {object_type}"
308 )]),
309 namespaced_errors: Default::default(),
310 replica_id: None,
311 };
312
313 self.history.reduce();
314
315 let mut status_updates = Vec::new();
316 for command in self.history.iter() {
317 match command {
318 StorageCommand::RunIngestion(ingestion) => {
319 let old_style_ingestion =
320 ingestion.id != ingestion.description.remap_collection_id;
321 let subsource_ids = ingestion.description.collection_ids().filter(|id| {
322 let should_discard =
327 old_style_ingestion && id == &ingestion.description.remap_collection_id;
328 !should_discard
329 });
330 for id in subsource_ids {
331 status_updates.push(make_update(id, "source"));
332 }
333 }
334 StorageCommand::RunSink(sink) => {
335 status_updates.push(make_update(sink.id, "sink"));
336 }
337 _ => (),
338 }
339 }
340
341 for update in status_updates {
342 let _ = self
346 .response_tx
347 .send((None, StorageResponse::StatusUpdate(update)));
348 }
349 }
350
351 pub fn update_replica_dyncfg_overrides(
356 &mut self,
357 mut overrides: BTreeMap<ReplicaId, ConfigUpdates>,
358 ) {
359 for (id, replica) in self.replicas.iter_mut() {
360 replica.dyncfg_override = overrides.remove(id).unwrap_or_default();
361 }
362 }
363
364 fn specialize_command_for_replica(
369 mut command: StorageCommand,
370 dyncfg_override: &ConfigUpdates,
371 ) -> StorageCommand {
372 if let StorageCommand::UpdateConfiguration(params) = &mut command
373 && !dyncfg_override.updates.is_empty()
374 {
375 params.dyncfg_updates.extend(dyncfg_override.clone());
376 }
377 command
378 }
379
380 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 for replica in self.replicas.values() {
420 let command = Self::specialize_command_for_replica(
421 command.clone(),
422 &replica.dyncfg_override,
423 );
424 replica.send(command);
425 }
426 }
427 }
428
429 if command.installs_objects() && self.replicas.is_empty() {
430 self.update_paused_statuses();
431 }
432 }
433
434 fn absorb_ingestion(&mut self, ingestion: RunIngestionCommand) {
439 let existing_ingestion_state = self.active_ingestions.get_mut(&ingestion.id);
440
441 for id in ingestion.description.source_exports.keys() {
443 self.ingestion_exports.insert(id.clone(), ingestion.id);
444 }
445
446 if let Some(ingestion_state) = existing_ingestion_state {
447 tracing::debug!(
452 ingestion_id = %ingestion.id,
453 active_replicas = %ingestion_state.active_replicas.iter().map(|id| id.to_string()).join(", "),
454 "updating ingestion"
455 );
456 } else {
457 let ingestion_state = ActiveIngestion {
459 active_replicas: BTreeSet::new(),
460 };
461 self.active_ingestions.insert(ingestion.id, ingestion_state);
462
463 self.update_scheduling(false);
465 }
466 }
467
468 fn absorb_export(&mut self, export: RunSinkCommand) {
473 let existing_export_state = self.active_exports.get_mut(&export.id);
474
475 if let Some(export_state) = existing_export_state {
476 tracing::debug!(
481 export_id = %export.id,
482 active_replicas = %export_state.active_replicas.iter().map(|id| id.to_string()).join(", "),
483 "updating export"
484 );
485 } else {
486 let export_state = ActiveExport {
488 active_replicas: BTreeSet::new(),
489 };
490 self.active_exports.insert(export.id, export_state);
491
492 self.update_scheduling(false);
494 }
495 }
496
497 fn update_scheduling(&mut self, send_commands: bool) {
516 #[derive(Debug)]
517 enum ObjectId {
518 Ingestion(GlobalId),
519 Export(GlobalId),
520 }
521 let mut scheduling_preferences: Vec<(ObjectId, bool)> = Vec::new();
526
527 for ingestion_id in self.active_ingestions.keys() {
528 let ingestion_description = self
529 .get_ingestion_description(ingestion_id)
530 .expect("missing ingestion description");
531
532 let prefers_single_replica = ingestion_description
533 .desc
534 .connection
535 .prefers_single_replica();
536
537 scheduling_preferences
538 .push((ObjectId::Ingestion(*ingestion_id), prefers_single_replica));
539 }
540
541 for export_id in self.active_exports.keys() {
542 scheduling_preferences.push((ObjectId::Export(*export_id), true));
544 }
545
546 let mut commands_by_replica: BTreeMap<ReplicaId, Vec<ObjectId>> = BTreeMap::new();
548
549 for (object_id, prefers_single_replica) in scheduling_preferences {
550 let active_replicas = match object_id {
551 ObjectId::Ingestion(ingestion_id) => {
552 &mut self
553 .active_ingestions
554 .get_mut(&ingestion_id)
555 .expect("missing ingestion state")
556 .active_replicas
557 }
558 ObjectId::Export(export_id) => {
559 &mut self
560 .active_exports
561 .get_mut(&export_id)
562 .expect("missing ingestion state")
563 .active_replicas
564 }
565 };
566
567 if prefers_single_replica {
568 if active_replicas.is_empty() {
570 let target_replica = self.replicas.keys().min().copied();
571 if let Some(first_replica_id) = target_replica {
572 tracing::info!(
573 object_id = ?object_id,
574 replica_id = %first_replica_id,
575 "scheduling single-replica object");
576 active_replicas.insert(first_replica_id);
577
578 commands_by_replica
579 .entry(first_replica_id)
580 .or_default()
581 .push(object_id);
582 }
583 } else {
584 tracing::info!(
585 ?object_id,
586 active_replicas = %active_replicas.iter().map(|id| id.to_string()).join(", "),
587 "single-replica object already running, not scheduling again",
588 );
589 }
590 } else {
591 let current_replica_ids: BTreeSet<_> = self.replicas.keys().copied().collect();
592 let unscheduled_replicas: Vec<_> = current_replica_ids
593 .difference(active_replicas)
594 .copied()
595 .collect();
596 for replica_id in unscheduled_replicas {
597 tracing::info!(
598 ?object_id,
599 %replica_id,
600 "scheduling multi-replica object"
601 );
602 active_replicas.insert(replica_id);
603 }
604 }
605 }
606
607 if send_commands {
608 for (replica_id, object_ids) in commands_by_replica {
609 let mut ingestion_commands = vec![];
610 let mut export_commands = vec![];
611 for object_id in object_ids {
612 match object_id {
613 ObjectId::Ingestion(id) => {
614 ingestion_commands.push(RunIngestionCommand {
615 id,
616 description: self
617 .get_ingestion_description(&id)
618 .expect("missing ingestion description")
619 .clone(),
620 });
621 }
622 ObjectId::Export(id) => {
623 export_commands.push(RunSinkCommand {
624 id,
625 description: self
626 .get_export_description(&id)
627 .expect("missing export description")
628 .clone(),
629 });
630 }
631 }
632 }
633 for ingestion in ingestion_commands {
634 let replica = self.replicas.get_mut(&replica_id).expect("missing replica");
635 let ingestion = Box::new(ingestion);
636 replica.send(StorageCommand::RunIngestion(ingestion));
637 }
638 for export in export_commands {
639 let replica = self.replicas.get_mut(&replica_id).expect("missing replica");
640 let export = Box::new(export);
641 replica.send(StorageCommand::RunSink(export));
642 }
643 }
644 }
645 }
646
647 pub fn get_ingestion_description(
655 &self,
656 id: &GlobalId,
657 ) -> Option<IngestionDescription<CollectionMetadata>> {
658 if !self.active_ingestions.contains_key(id) {
659 return None;
660 }
661
662 self.history.iter().rev().find_map(|command| {
663 if let StorageCommand::RunIngestion(ingestion) = command {
664 if &ingestion.id == id {
665 Some(ingestion.description.clone())
666 } else {
667 None
668 }
669 } else {
670 None
671 }
672 })
673 }
674
675 pub fn get_export_description(
683 &self,
684 id: &GlobalId,
685 ) -> Option<StorageSinkDesc<CollectionMetadata>> {
686 if !self.active_exports.contains_key(id) {
687 return None;
688 }
689
690 self.history.iter().rev().find_map(|command| {
691 if let StorageCommand::RunSink(sink) = command {
692 if &sink.id == id {
693 Some(sink.description.clone())
694 } else {
695 None
696 }
697 } else {
698 None
699 }
700 })
701 }
702
703 fn absorb_compaction(&mut self, id: GlobalId, frontier: Antichain<Timestamp>) {
705 tracing::debug!(?self.active_ingestions, ?id, ?frontier, "allow_compaction");
706
707 if frontier.is_empty() {
708 self.active_ingestions.remove(&id);
709 self.ingestion_exports.remove(&id);
710 self.active_exports.remove(&id);
711 }
712 }
713
714 fn active_replica_ids(&self, id: &GlobalId) -> ActiveReplicas<'_> {
720 static EMPTY: BTreeSet<ReplicaId> = BTreeSet::new();
722
723 if let Some(ingestion_id) = self.ingestion_exports.get(id) {
724 match self.active_ingestions.get(ingestion_id) {
725 Some(ingestion) => ActiveReplicas::Scheduled(&ingestion.active_replicas),
726 None => ActiveReplicas::Scheduled(&EMPTY),
728 }
729 } else if let Some(ingestion) = self.active_ingestions.get(id) {
730 ActiveReplicas::Scheduled(&ingestion.active_replicas)
733 } else if let Some(export) = self.active_exports.get(id) {
734 ActiveReplicas::Scheduled(&export.active_replicas)
735 } else {
736 ActiveReplicas::All
739 }
740 }
741
742 fn active_replicas(&mut self, id: &GlobalId) -> Box<dyn Iterator<Item = &mut Replica> + '_> {
744 let scheduled = match self.active_replica_ids(id) {
748 ActiveReplicas::All => None,
749 ActiveReplicas::Scheduled(replicas) => Some(replicas.clone()),
750 };
751 match scheduled {
752 None => Box::new(self.replicas.values_mut()),
753 Some(scheduled) => Box::new(self.replicas.iter_mut().filter_map(
754 move |(replica_id, replica)| scheduled.contains(replica_id).then_some(replica),
755 )),
756 }
757 }
758
759 fn is_active_replica(&self, id: &GlobalId, replica_id: &ReplicaId) -> bool {
761 match self.active_replica_ids(id) {
762 ActiveReplicas::All => true,
763 ActiveReplicas::Scheduled(replicas) => replicas.contains(replica_id),
764 }
765 }
766
767 pub(super) fn refresh_state_metrics(&self) {
776 let connected_replica_count = self.replicas.values().filter(|r| r.is_connected()).count();
777
778 self.metrics
779 .connected_replica_count
780 .set(u64::cast_from(connected_replica_count));
781 }
782
783 pub fn get_active_replicas_for_object(&self, id: &GlobalId) -> BTreeSet<ReplicaId> {
786 match self.active_replica_ids(id) {
787 ActiveReplicas::All => self.replicas.keys().copied().collect(),
788 ActiveReplicas::Scheduled(replicas) => replicas.clone(),
789 }
790 }
791}
792
793#[derive(Clone, Debug)]
795pub(super) struct ReplicaConfig {
796 pub build_info: &'static BuildInfo,
797 pub location: ClusterReplicaLocation,
798 pub grpc_client: GrpcClientParameters,
799}
800
801#[derive(Debug)]
803pub struct Replica {
804 config: ReplicaConfig,
806 dyncfg_override: ConfigUpdates,
811 command_tx: mpsc::UnboundedSender<StorageCommand>,
816 task: AbortOnDropHandle<()>,
818 connected: Arc<AtomicBool>,
820}
821
822impl Replica {
823 fn new(
825 id: ReplicaId,
826 config: ReplicaConfig,
827 dyncfg_override: ConfigUpdates,
828 metrics: ReplicaMetrics,
829 response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
830 ) -> Self {
831 let (command_tx, command_rx) = mpsc::unbounded_channel();
832 let connected = Arc::new(AtomicBool::new(false));
833
834 let task = mz_ore::task::spawn(
835 || "storage-replica-{id}",
836 ReplicaTask {
837 replica_id: id,
838 config: config.clone(),
839 metrics: metrics.clone(),
840 connected: Arc::clone(&connected),
841 command_rx,
842 response_tx,
843 }
844 .run(),
845 );
846
847 Self {
848 config,
849 dyncfg_override,
850 command_tx,
851 task: task.abort_on_drop(),
852 connected,
853 }
854 }
855
856 fn send(&self, command: StorageCommand) {
858 let _ = self.command_tx.send(command);
860 }
861
862 fn failed(&self) -> bool {
865 self.task.is_finished()
866 }
867
868 pub(super) fn is_connected(&self) -> bool {
870 self.connected.load(atomic::Ordering::Relaxed)
871 }
872}
873
874type StorageCtpClient = transport::Client<StorageCommand, StorageResponse>;
875type ReplicaClient = Partitioned<StorageCtpClient, StorageCommand, StorageResponse>;
876
877struct ReplicaTask {
879 replica_id: ReplicaId,
881 config: ReplicaConfig,
883 metrics: ReplicaMetrics,
885 connected: Arc<AtomicBool>,
887 command_rx: mpsc::UnboundedReceiver<StorageCommand>,
889 response_tx: mpsc::UnboundedSender<(Option<ReplicaId>, StorageResponse)>,
891}
892
893impl ReplicaTask {
894 async fn run(self) {
896 let replica_id = self.replica_id;
897 info!(%replica_id, "starting replica task");
898
899 let client = self.connect().await;
900 match self.run_message_loop(client).await {
901 Ok(()) => info!(%replica_id, "stopped replica task"),
902 Err(error) => warn!(%replica_id, %error, "replica task failed"),
903 }
904 }
905
906 async fn connect(&self) -> ReplicaClient {
911 let try_connect = async move |retry: RetryState| {
912 let version = self.config.build_info.semver_version();
913 let client_params = &self.config.grpc_client;
914
915 let connect_start = Instant::now();
916 let connect_timeout = client_params.connect_timeout.unwrap_or(Duration::MAX);
917 let keepalive_timeout = client_params
918 .http2_keep_alive_timeout
919 .unwrap_or(Duration::MAX);
920
921 let connect_result = StorageCtpClient::connect_partitioned(
922 self.config.location.ctl_addrs.clone(),
923 version,
924 connect_timeout,
925 keepalive_timeout,
926 self.metrics.clone(),
927 )
928 .await;
929
930 self.metrics.observe_connect_time(connect_start.elapsed());
931
932 connect_result.inspect_err(|error| {
933 let next_backoff = retry.next_backoff.unwrap();
934 if retry.i >= mz_service::retry::INFO_MIN_RETRIES {
935 info!(
936 replica_id = %self.replica_id, ?next_backoff,
937 "error connecting to replica: {error:#}",
938 );
939 } else {
940 debug!(
941 replica_id = %self.replica_id, ?next_backoff,
942 "error connecting to replica: {error:#}",
943 );
944 }
945 })
946 };
947
948 let client = Retry::default()
949 .clamp_backoff(Duration::from_secs(1))
950 .retry_async(try_connect)
951 .await
952 .expect("retries forever");
953
954 self.metrics.observe_connect();
955 self.connected.store(true, atomic::Ordering::Relaxed);
956
957 client
958 }
959
960 async fn run_message_loop(mut self, mut client: ReplicaClient) -> Result<(), anyhow::Error> {
966 loop {
967 select! {
968 command = self.command_rx.recv() => {
971 let Some(mut command) = command else {
972 tracing::debug!(%self.replica_id, "controller is no longer interested in this replica, shutting down message loop");
973 break;
974 };
975
976 self.specialize_command(&mut command);
977 client.send(command).await?;
978 },
979 response = client.recv() => {
982 let Some(response) = response? else {
983 bail!("replica unexpectedly gracefully terminated connection");
984 };
985
986 if self.response_tx.send((Some(self.replica_id), response)).is_err() {
987 tracing::debug!(%self.replica_id, "controller (receiver) is no longer interested in this replica, shutting down message loop");
988 break;
989 }
990 }
991 }
992 }
993
994 Ok(())
995 }
996
997 fn specialize_command(&self, command: &mut StorageCommand) {
1002 if let StorageCommand::Hello { nonce } = command {
1003 *nonce = Uuid::new_v4();
1004 }
1005 }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use mz_dyncfg::{ConfigUpdates, ConfigVal};
1011 use mz_storage_types::dyncfgs::ENABLE_UPSERT_PAGED_SPILL;
1012 use mz_storage_types::parameters::StorageParameters;
1013
1014 use super::{Instance, StorageCommand};
1015
1016 fn update_configuration_command() -> StorageCommand {
1017 StorageCommand::UpdateConfiguration(Box::new(StorageParameters::default()))
1018 }
1019
1020 fn dyncfg_updates(command: &StorageCommand) -> &ConfigUpdates {
1021 match command {
1022 StorageCommand::UpdateConfiguration(params) => ¶ms.dyncfg_updates,
1023 other => panic!("expected UpdateConfiguration, got {other:?}"),
1024 }
1025 }
1026
1027 #[mz_ore::test]
1031 fn update_configuration_applies_replica_override() {
1032 let mut over = ConfigUpdates::default();
1033 over.add(&ENABLE_UPSERT_PAGED_SPILL, true);
1034
1035 let command =
1036 Instance::specialize_command_for_replica(update_configuration_command(), &over);
1037 assert_eq!(
1038 dyncfg_updates(&command)
1039 .updates
1040 .get(ENABLE_UPSERT_PAGED_SPILL.name()),
1041 Some(&ConfigVal::Bool(true)),
1042 );
1043
1044 let command = Instance::specialize_command_for_replica(
1045 update_configuration_command(),
1046 &ConfigUpdates::default(),
1047 );
1048 assert_eq!(
1049 dyncfg_updates(&command)
1050 .updates
1051 .get(ENABLE_UPSERT_PAGED_SPILL.name()),
1052 None,
1053 );
1054 }
1055}