Skip to main content

mz_controller/
clusters.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//! Cluster management.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14use std::num::NonZero;
15use std::str::FromStr;
16use std::sync::Arc;
17use std::sync::LazyLock;
18use std::time::Duration;
19
20use anyhow::anyhow;
21use bytesize::ByteSize;
22use chrono::{DateTime, Utc};
23use futures::stream::{BoxStream, StreamExt};
24use mz_cluster_client::client::{ClusterReplicaLocation, TimelyConfig};
25use mz_compute_client::logging::LogVariant;
26use mz_compute_types::config::{ComputeReplicaConfig, ComputeReplicaLogging};
27use mz_controller_types::dyncfgs::{
28    ARRANGEMENT_EXERT_PROPORTIONALITY, CONTROLLER_PAST_GENERATION_REPLICA_CLEANUP_RETRY_INTERVAL,
29    ENABLE_TIMELY_ZERO_COPY, ENABLE_TIMELY_ZERO_COPY_LGALLOC, TIMELY_ZERO_COPY_LIMIT,
30};
31use mz_controller_types::{ClusterId, ReplicaId};
32use mz_orchestrator::NamespacedOrchestrator;
33use mz_orchestrator::{
34    CpuLimit, DiskLimit, LabelSelectionLogic, LabelSelector, MemoryLimit, Service, ServiceConfig,
35    ServiceEvent, ServicePort,
36};
37use mz_ore::cast::CastInto;
38use mz_ore::task::{self, AbortOnDropHandle};
39use mz_ore::{halt, instrument};
40use mz_repr::GlobalId;
41use mz_repr::adt::numeric::Numeric;
42use regex::Regex;
43use serde::{Deserialize, Serialize};
44use tokio::time;
45use tracing::{error, info, warn};
46
47use crate::Controller;
48
49/// Configures a cluster.
50pub struct ClusterConfig {
51    /// The logging variants to enable on the compute instance.
52    ///
53    /// Each logging variant is mapped to the identifier under which to register
54    /// the arrangement storing the log's data.
55    pub arranged_logs: BTreeMap<LogVariant, GlobalId>,
56    /// An optional arbitrary string that describes the class of the workload
57    /// this cluster is running (e.g., `production` or `staging`).
58    pub workload_class: Option<String>,
59}
60
61/// The status of a cluster.
62pub type ClusterStatus = mz_orchestrator::ServiceStatus;
63
64/// Configures a cluster replica.
65#[derive(Clone, Debug, Serialize, PartialEq)]
66pub struct ReplicaConfig {
67    /// The location of the replica.
68    pub location: ReplicaLocation,
69    /// Configuration for the compute half of the replica.
70    pub compute: ComputeReplicaConfig,
71}
72
73/// Configures the resource allocation for a cluster replica.
74#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
75pub struct ReplicaAllocation {
76    /// The memory limit for each process in the replica.
77    pub memory_limit: Option<MemoryLimit>,
78    /// The CPU limit for each process in the replica.
79    pub cpu_limit: Option<CpuLimit>,
80    /// The CPU limit for each process in the replica.
81    pub cpu_request: Option<CpuLimit>,
82    /// The disk limit for each process in the replica.
83    pub disk_limit: Option<DiskLimit>,
84    /// The number of processes in the replica.
85    pub scale: NonZero<u16>,
86    /// The number of worker threads in the replica.
87    pub workers: NonZero<usize>,
88    /// The number of credits per hour that the replica consumes.
89    #[serde(deserialize_with = "mz_repr::adt::numeric::str_serde::deserialize")]
90    pub credits_per_hour: Numeric,
91    /// Whether each process has exclusive access to its CPU cores.
92    #[serde(default)]
93    pub cpu_exclusive: bool,
94    /// Whether this size represents a modern "cc" size rather than a legacy
95    /// T-shirt size.
96    #[serde(default = "default_true")]
97    pub is_cc: bool,
98    /// The size *family* this size belongs to, e.g. the size `D.1-xsmall`
99    /// belongs to family `D` and the legacy t-shirt sizes belong to family
100    /// `legacy`. The family is the coarse axis and is *not* a prefix of the size
101    /// name in general. Used as the
102    /// `replica_size_family` attribute when evaluating replica-local scoped
103    /// feature flags (see the scoped feature flags design). When unset, the
104    /// family falls back to a value derived from [`Self::is_cc`] via
105    /// [`ReplicaAllocation::family`].
106    #[serde(default)]
107    pub family: Option<String>,
108    /// Whether instances of this type use swap as the spill-to-disk mechanism.
109    #[serde(default)]
110    pub swap_enabled: bool,
111    /// Whether instances of this type can be created.
112    #[serde(default)]
113    pub disabled: bool,
114    /// Additional node selectors.
115    #[serde(default)]
116    pub selectors: BTreeMap<String, String>,
117}
118
119impl ReplicaAllocation {
120    /// The name of the size family this allocation belongs to, used as the
121    /// `replica_size_family` attribute when evaluating replica-local scoped
122    /// feature flags.
123    ///
124    /// Falls back to a value derived from [`Self::is_cc`] when [`Self::family`]
125    /// is unset: `"cc"` for modern sizes and `"legacy"` for the legacy t-shirt
126    /// sizes. This keeps the legacy family targetable even before every size
127    /// gains an explicit `family` in the size configuration.
128    pub fn family(&self) -> &str {
129        match &self.family {
130            Some(family) => family.as_str(),
131            None if self.is_cc => "cc",
132            None => "legacy",
133        }
134    }
135}
136
137fn default_true() -> bool {
138    true
139}
140
141#[mz_ore::test]
142// We test this particularly because we deserialize values from strings.
143#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decContextDefault` on OS `linux`
144fn test_replica_allocation_deserialization() {
145    use bytesize::ByteSize;
146    use mz_ore::{assert_err, assert_ok};
147
148    let data = r#"
149        {
150            "cpu_limit": 1.0,
151            "memory_limit": "10GiB",
152            "disk_limit": "100MiB",
153            "scale": 16,
154            "workers": 1,
155            "credits_per_hour": "16",
156            "swap_enabled": true,
157            "selectors": {
158                "key1": "value1",
159                "key2": "value2"
160            }
161        }"#;
162
163    let replica_allocation: ReplicaAllocation = serde_json::from_str(data)
164        .expect("deserialization from JSON succeeds for ReplicaAllocation");
165
166    assert_eq!(
167        replica_allocation,
168        ReplicaAllocation {
169            credits_per_hour: 16.into(),
170            disk_limit: Some(DiskLimit(ByteSize::mib(100))),
171            disabled: false,
172            memory_limit: Some(MemoryLimit(ByteSize::gib(10))),
173            cpu_limit: Some(CpuLimit::from_millicpus(1000)),
174            cpu_request: None,
175            cpu_exclusive: false,
176            is_cc: true,
177            family: None,
178            swap_enabled: true,
179            scale: NonZero::new(16).unwrap(),
180            workers: NonZero::new(1).unwrap(),
181            selectors: BTreeMap::from([
182                ("key1".to_string(), "value1".to_string()),
183                ("key2".to_string(), "value2".to_string())
184            ]),
185        }
186    );
187
188    let data = r#"
189        {
190            "cpu_limit": 0,
191            "memory_limit": "0GiB",
192            "disk_limit": "0MiB",
193            "scale": 1,
194            "workers": 1,
195            "credits_per_hour": "0",
196            "cpu_exclusive": true,
197            "disabled": true
198        }"#;
199
200    let replica_allocation: ReplicaAllocation = serde_json::from_str(data)
201        .expect("deserialization from JSON succeeds for ReplicaAllocation");
202
203    assert_eq!(
204        replica_allocation,
205        ReplicaAllocation {
206            credits_per_hour: 0.into(),
207            disk_limit: Some(DiskLimit(ByteSize::mib(0))),
208            disabled: true,
209            memory_limit: Some(MemoryLimit(ByteSize::gib(0))),
210            cpu_limit: Some(CpuLimit::from_millicpus(0)),
211            cpu_request: None,
212            cpu_exclusive: true,
213            is_cc: true,
214            family: None,
215            swap_enabled: false,
216            scale: NonZero::new(1).unwrap(),
217            workers: NonZero::new(1).unwrap(),
218            selectors: Default::default(),
219        }
220    );
221
222    // `scale` and `workers` must be non-zero.
223    let data = r#"{"scale": 0, "workers": 1, "credits_per_hour": "0"}"#;
224    assert_err!(serde_json::from_str::<ReplicaAllocation>(data));
225    let data = r#"{"scale": 1, "workers": 0, "credits_per_hour": "0"}"#;
226    assert_err!(serde_json::from_str::<ReplicaAllocation>(data));
227    let data = r#"{"scale": 1, "workers": 1, "credits_per_hour": "0"}"#;
228    assert_ok!(serde_json::from_str::<ReplicaAllocation>(data));
229}
230
231#[mz_ore::test]
232#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decContextDefault` on OS `linux`
233fn test_replica_allocation_family() {
234    let parse = |json: &str| -> ReplicaAllocation {
235        serde_json::from_str(json).expect("deserialization from JSON succeeds")
236    };
237
238    // An explicit `family` is used verbatim.
239    assert_eq!(
240        parse(r#"{"scale": 1, "workers": 1, "credits_per_hour": "0", "family": "D"}"#).family(),
241        "D"
242    );
243    // Without an explicit `family`, modern (`is_cc`) sizes fall back to "cc".
244    // `is_cc` defaults to true.
245    assert_eq!(
246        parse(r#"{"scale": 1, "workers": 1, "credits_per_hour": "0"}"#).family(),
247        "cc"
248    );
249    // Without an explicit `family`, legacy (non-`is_cc`) sizes fall back to
250    // "legacy".
251    assert_eq!(
252        parse(r#"{"scale": 1, "workers": 1, "credits_per_hour": "0", "is_cc": false}"#).family(),
253        "legacy"
254    );
255    // An explicit family wins even for a legacy size.
256    assert_eq!(
257        parse(
258            r#"{"scale": 1, "workers": 1, "credits_per_hour": "0", "is_cc": false, "family": "legacy-special"}"#
259        )
260        .family(),
261        "legacy-special"
262    );
263}
264
265/// Configures the location of a cluster replica.
266#[derive(Clone, Debug, Serialize, PartialEq)]
267pub enum ReplicaLocation {
268    /// An unmanaged replica.
269    Unmanaged(UnmanagedReplicaLocation),
270    /// A managed replica.
271    Managed(ManagedReplicaLocation),
272}
273
274impl ReplicaLocation {
275    /// Returns the number of processes specified by this replica location.
276    pub fn num_processes(&self) -> usize {
277        match self {
278            ReplicaLocation::Unmanaged(UnmanagedReplicaLocation {
279                computectl_addrs, ..
280            }) => computectl_addrs.len(),
281            ReplicaLocation::Managed(ManagedReplicaLocation { allocation, .. }) => {
282                allocation.scale.cast_into()
283            }
284        }
285    }
286
287    pub fn billed_as(&self) -> Option<&str> {
288        match self {
289            ReplicaLocation::Managed(ManagedReplicaLocation { billed_as, .. }) => {
290                billed_as.as_deref()
291            }
292            ReplicaLocation::Unmanaged(_) => None,
293        }
294    }
295
296    pub fn internal(&self) -> bool {
297        match self {
298            ReplicaLocation::Managed(ManagedReplicaLocation { internal, .. }) => *internal,
299            ReplicaLocation::Unmanaged(_) => false,
300        }
301    }
302
303    /// Returns the number of workers specified by this replica location.
304    ///
305    /// `None` for unmanaged replicas, whose worker count we don't know.
306    pub fn workers(&self) -> Option<usize> {
307        match self {
308            ReplicaLocation::Managed(ManagedReplicaLocation { allocation, .. }) => {
309                Some(allocation.workers.get() * self.num_processes())
310            }
311            ReplicaLocation::Unmanaged(_) => None,
312        }
313    }
314
315    /// Whether the replica is durably marked `pending`.
316    ///
317    /// Vestigial: no path creates one anymore. A crash on a version that still
318    /// staged reconfigurations through overlap replicas could have left one
319    /// behind, and the catalog-open migration reaps those.
320    pub fn pending(&self) -> bool {
321        match self {
322            ReplicaLocation::Managed(ManagedReplicaLocation { pending, .. }) => *pending,
323            ReplicaLocation::Unmanaged(_) => false,
324        }
325    }
326}
327
328/// The "role" of a cluster, which is currently used to determine the
329/// severity of alerts for problems with its replicas.
330#[derive(Debug, Clone)]
331pub enum ClusterRole {
332    /// The existence and proper functioning of the cluster's replicas is
333    /// business-critical for Materialize.
334    SystemCritical,
335    /// Assuming no bugs, the cluster's replicas should always exist and function
336    /// properly. If it doesn't, however, that is less urgent than
337    /// would be the case for a `SystemCritical` replica.
338    System,
339    /// The cluster is controlled by the user, and might go down for
340    /// reasons outside our control (e.g., OOMs).
341    User,
342}
343
344/// The location of an unmanaged replica.
345#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
346pub struct UnmanagedReplicaLocation {
347    /// The network addresses of the storagectl endpoints for each process in
348    /// the replica.
349    pub storagectl_addrs: Vec<String>,
350    /// The network addresses of the computectl endpoints for each process in
351    /// the replica.
352    pub computectl_addrs: Vec<String>,
353}
354
355/// The location of a managed replica.
356#[derive(Clone, Debug, Serialize, PartialEq)]
357pub struct ManagedReplicaLocation {
358    /// The resource allocation for the replica.
359    pub allocation: ReplicaAllocation,
360    /// SQL size parameter used for allocation
361    pub size: String,
362    /// If `true`, Materialize support owns this replica.
363    pub internal: bool,
364    /// Optional SQL size parameter used for billing.
365    pub billed_as: Option<String>,
366    /// The availability zones the replica may be placed in; empty means
367    /// unconstrained.
368    ///
369    /// For a replica of a managed cluster this is the cluster's
370    /// `AVAILABILITY ZONES` pool; for a replica of an unmanaged cluster it is
371    /// the single user-pinned `AVAILABILITY ZONE`, as a zero- or one-element
372    /// list.
373    ///
374    /// Not serialized: this is re-derived from the cluster config at
375    /// concretization, not read back from a durable record.
376    #[serde(skip)]
377    pub availability_zones: Vec<String>,
378    /// See [`ReplicaLocation::pending`].
379    pub pending: bool,
380}
381
382impl ManagedReplicaLocation {
383    /// Return the size which should be used to determine billing-related information.
384    pub fn size_for_billing(&self) -> &str {
385        self.billed_as.as_deref().unwrap_or(&self.size)
386    }
387}
388
389/// Configures logging for a cluster replica.
390pub type ReplicaLogging = ComputeReplicaLogging;
391
392/// Identifier of a process within a replica.
393pub type ProcessId = u64;
394
395/// An event describing a change in status of a cluster replica process.
396#[derive(Debug, Clone, Serialize)]
397pub struct ClusterEvent {
398    pub cluster_id: ClusterId,
399    pub replica_id: ReplicaId,
400    pub process_id: ProcessId,
401    pub status: ClusterStatus,
402    /// Cumulative restart count of the process, propagated from the orchestrator.
403    /// See [`mz_orchestrator::ServiceEvent::restart_count`].
404    pub restart_count: u64,
405    pub time: DateTime<Utc>,
406}
407
408impl Controller {
409    /// Creates a cluster with the specified identifier and configuration.
410    ///
411    /// A cluster is a combination of a storage instance and a compute instance.
412    /// A cluster has zero or more replicas; each replica colocates the storage
413    /// and compute layers on the same physical resources.
414    pub fn create_cluster(
415        &mut self,
416        id: ClusterId,
417        config: ClusterConfig,
418    ) -> Result<(), anyhow::Error> {
419        self.storage
420            .create_instance(id, config.workload_class.clone());
421        self.compute
422            .create_instance(id, config.arranged_logs, config.workload_class)?;
423        Ok(())
424    }
425
426    /// Updates the workload class for a cluster.
427    ///
428    /// # Panics
429    ///
430    /// Panics if the instance does not exist in the StorageController or the ComputeController.
431    pub fn update_cluster_workload_class(&mut self, id: ClusterId, workload_class: Option<String>) {
432        self.storage
433            .update_instance_workload_class(id, workload_class.clone());
434        self.compute
435            .update_instance_workload_class(id, workload_class)
436            .expect("instance exists");
437    }
438
439    /// Drops the specified cluster.
440    ///
441    /// # Panics
442    ///
443    /// Panics if the cluster still has replicas.
444    pub fn drop_cluster(&mut self, id: ClusterId) {
445        self.storage.drop_instance(id);
446        self.compute.drop_instance(id);
447    }
448
449    /// Creates a replica of the specified cluster with the specified identifier
450    /// and configuration.
451    pub fn create_replica(
452        &mut self,
453        cluster_id: ClusterId,
454        replica_id: ReplicaId,
455        cluster_name: String,
456        replica_name: String,
457        role: ClusterRole,
458        config: ReplicaConfig,
459        enable_worker_core_affinity: bool,
460        enable_storage_introspection_logs: bool,
461    ) -> Result<(), anyhow::Error> {
462        let storage_location: ClusterReplicaLocation;
463        let compute_location: ClusterReplicaLocation;
464        let metrics_task: Option<AbortOnDropHandle<()>>;
465
466        match config.location {
467            ReplicaLocation::Unmanaged(UnmanagedReplicaLocation {
468                storagectl_addrs,
469                computectl_addrs,
470            }) => {
471                compute_location = ClusterReplicaLocation {
472                    ctl_addrs: computectl_addrs,
473                };
474                storage_location = ClusterReplicaLocation {
475                    ctl_addrs: storagectl_addrs,
476                };
477                metrics_task = None;
478            }
479            ReplicaLocation::Managed(m) => {
480                let (service, metrics_task_join_handle) = self.provision_replica(
481                    cluster_id,
482                    replica_id,
483                    cluster_name,
484                    replica_name,
485                    role,
486                    m,
487                    enable_worker_core_affinity,
488                    enable_storage_introspection_logs,
489                )?;
490                storage_location = ClusterReplicaLocation {
491                    ctl_addrs: service.addresses("storagectl"),
492                };
493                compute_location = ClusterReplicaLocation {
494                    ctl_addrs: service.addresses("computectl"),
495                };
496                metrics_task = Some(metrics_task_join_handle);
497
498                // Register the replica for HTTP proxying.
499                let http_addresses = service.addresses("internal-http");
500                self.replica_http_locator
501                    .register_replica(cluster_id, replica_id, http_addresses);
502            }
503        }
504
505        self.storage
506            .connect_replica(cluster_id, replica_id, storage_location);
507        self.compute.add_replica_to_instance(
508            cluster_id,
509            replica_id,
510            compute_location,
511            config.compute,
512        )?;
513
514        if let Some(task) = metrics_task {
515            self.metrics_tasks.insert(replica_id, task);
516        }
517
518        Ok(())
519    }
520
521    /// Drops the specified replica of the specified cluster.
522    pub fn drop_replica(
523        &mut self,
524        cluster_id: ClusterId,
525        replica_id: ReplicaId,
526    ) -> Result<(), anyhow::Error> {
527        // We unconditionally deprovision even for unmanaged replicas to avoid
528        // needing to keep track of which replicas are managed and which are
529        // unmanaged. Deprovisioning is a no-op if the replica ID was never
530        // provisioned.
531        self.deprovision_replica(cluster_id, replica_id, self.deploy_generation)?;
532        self.metrics_tasks.remove(&replica_id);
533
534        // Remove HTTP addresses from the locator.
535        self.replica_http_locator
536            .remove_replica(cluster_id, replica_id);
537
538        // The coordinator only re-pushes the override map when the scoped
539        // configuration itself changes, so a dropped replica's entry would
540        // otherwise be retained until the next such change.
541        self.replica_dyncfg_overrides.remove(&replica_id);
542
543        self.compute.drop_replica(cluster_id, replica_id)?;
544        self.storage.drop_replica(cluster_id, replica_id);
545        Ok(())
546    }
547
548    /// Removes replicas from past generations in a background task.
549    pub(crate) fn remove_past_generation_replicas_in_background(&self) {
550        let deploy_generation = self.deploy_generation;
551        let dyncfg = Arc::clone(self.compute.dyncfg());
552        let orchestrator = Arc::clone(&self.orchestrator);
553        task::spawn(
554            || "controller_remove_past_generation_replicas",
555            async move {
556                info!("attempting to remove past generation replicas");
557                loop {
558                    match try_remove_past_generation_replicas(&*orchestrator, deploy_generation)
559                        .await
560                    {
561                        Ok(()) => {
562                            info!("successfully removed past generation replicas");
563                            return;
564                        }
565                        Err(e) => {
566                            let interval =
567                                CONTROLLER_PAST_GENERATION_REPLICA_CLEANUP_RETRY_INTERVAL
568                                    .get(&dyncfg);
569                            warn!(%e, "failed to remove past generation replicas; will retry in {interval:?}");
570                            time::sleep(interval).await;
571                        }
572                    }
573                }
574            },
575        );
576    }
577
578    /// Remove replicas that are orphaned in the current generation.
579    #[instrument]
580    pub async fn remove_orphaned_replicas(
581        &mut self,
582        next_user_replica_id: u64,
583        next_system_replica_id: u64,
584    ) -> Result<(), anyhow::Error> {
585        let desired: BTreeSet<_> = self.metrics_tasks.keys().copied().collect();
586
587        let actual: BTreeSet<_> = self
588            .orchestrator
589            .list_services()
590            .await?
591            .iter()
592            .map(|s| ReplicaServiceName::from_str(s))
593            .collect::<Result<_, _>>()?;
594
595        for ReplicaServiceName {
596            cluster_id,
597            replica_id,
598            generation,
599        } in actual
600        {
601            // We limit our attention here to replicas from the current deploy
602            // generation. Replicas from past generations are cleaned up during
603            // `Controller::allow_writes`.
604            if generation != self.deploy_generation {
605                continue;
606            }
607
608            let smaller_next = match replica_id {
609                ReplicaId::User(id) if id >= next_user_replica_id => {
610                    Some(ReplicaId::User(next_user_replica_id))
611                }
612                ReplicaId::System(id) if id >= next_system_replica_id => {
613                    Some(ReplicaId::System(next_system_replica_id))
614                }
615                _ => None,
616            };
617            if let Some(next) = smaller_next {
618                // Found a replica in the orchestrator with a higher replica ID
619                // than what we are aware of. This must have been created by an
620                // environmentd that's competing for control of this generation.
621                // Abort to let the other process have full control.
622                halt!("found replica ID ({replica_id}) in orchestrator >= next ID ({next})");
623            }
624            if !desired.contains(&replica_id) {
625                self.deprovision_replica(cluster_id, replica_id, generation)?;
626            }
627        }
628
629        Ok(())
630    }
631
632    pub fn events_stream(&self) -> BoxStream<'static, ClusterEvent> {
633        let deploy_generation = self.deploy_generation;
634
635        fn translate_event(event: ServiceEvent) -> Result<(ClusterEvent, u64), anyhow::Error> {
636            let ReplicaServiceName {
637                cluster_id,
638                replica_id,
639                generation: replica_generation,
640                ..
641            } = event.service_id.parse()?;
642
643            let event = ClusterEvent {
644                cluster_id,
645                replica_id,
646                process_id: event.process_id,
647                status: event.status,
648                restart_count: event.restart_count,
649                time: event.time,
650            };
651
652            Ok((event, replica_generation))
653        }
654
655        let stream = self
656            .orchestrator
657            .watch_services()
658            .map(|event| event.and_then(translate_event))
659            .filter_map(move |event| async move {
660                match event {
661                    Ok((event, replica_generation)) => {
662                        if replica_generation == deploy_generation {
663                            Some(event)
664                        } else {
665                            None
666                        }
667                    }
668                    Err(error) => {
669                        error!("service watch error: {error}");
670                        None
671                    }
672                }
673            });
674
675        Box::pin(stream)
676    }
677
678    /// Provisions a replica with the service orchestrator.
679    fn provision_replica(
680        &self,
681        cluster_id: ClusterId,
682        replica_id: ReplicaId,
683        cluster_name: String,
684        replica_name: String,
685        role: ClusterRole,
686        location: ManagedReplicaLocation,
687        enable_worker_core_affinity: bool,
688        enable_storage_introspection_logs: bool,
689    ) -> Result<(Box<dyn Service>, AbortOnDropHandle<()>), anyhow::Error> {
690        let service_name = ReplicaServiceName {
691            cluster_id,
692            replica_id,
693            generation: self.deploy_generation,
694        }
695        .to_string();
696        let role_label = match role {
697            ClusterRole::SystemCritical => "system-critical",
698            ClusterRole::System => "system",
699            ClusterRole::User => "user",
700        };
701        let environment_id = self.connection_context().environment_id.clone();
702        let aws_external_id_prefix = self.connection_context().aws_external_id_prefix.clone();
703        let aws_connection_role_arn = self.connection_context().aws_connection_role_arn.clone();
704        let persist_pubsub_url = self.persist_pubsub_url.clone();
705        let secrets_args = self.secrets_args.to_flags();
706
707        // TODO(teskje): use the same values as for compute?
708        let storage_proto_timely_config = TimelyConfig {
709            arrangement_exert_proportionality: 1337,
710            ..Default::default()
711        };
712        // These configure the replica's process rather than environmentd's, so
713        // they are `ParameterScope::Replica` and must be read through this
714        // replica's scoped overrides. They are baked into the process
715        // configuration at provisioning time, so a later change to either the
716        // environment-wide value or the override reaches the replica only when
717        // it is next provisioned.
718        let overrides = self.replica_dyncfg_overrides.get(&replica_id);
719        let compute_proto_timely_config = TimelyConfig {
720            arrangement_exert_proportionality: ARRANGEMENT_EXERT_PROPORTIONALITY
721                .get_with_overrides(&self.dyncfg, overrides),
722            enable_zero_copy: ENABLE_TIMELY_ZERO_COPY.get_with_overrides(&self.dyncfg, overrides),
723            enable_zero_copy_lgalloc: ENABLE_TIMELY_ZERO_COPY_LGALLOC
724                .get_with_overrides(&self.dyncfg, overrides),
725            zero_copy_limit: TIMELY_ZERO_COPY_LIMIT.get_with_overrides(&self.dyncfg, overrides),
726            ..Default::default()
727        };
728
729        let mut disk_limit = location.allocation.disk_limit;
730        let memory_limit = location.allocation.memory_limit;
731        let mut memory_request = None;
732
733        if location.allocation.swap_enabled {
734            // The disk limit we specify in the service config decides whether or not the replica
735            // gets a scratch disk attached. We want to avoid attaching disks to swap replicas, so
736            // make sure to set the disk limit accordingly.
737            disk_limit = Some(DiskLimit::ZERO);
738
739            // We want to keep the memory request equal to the memory limit, to avoid
740            // over-provisioning and ensure replicas have predictable performance. However, to
741            // enable swap, Kubernetes currently requires that request and limit are different.
742            memory_request = memory_limit.map(|MemoryLimit(limit)| {
743                let request = ByteSize::b(limit.as_u64() - 1);
744                MemoryLimit(request)
745            });
746        }
747
748        let service = self.orchestrator.ensure_service(
749            &service_name,
750            ServiceConfig {
751                app_name: "clusterd".into(),
752                image: self.clusterd_image.clone(),
753                init_container_image: self.init_container_image.clone(),
754                args: Box::new(move |assigned| {
755                    let storage_timely_config = TimelyConfig {
756                        workers: location.allocation.workers.get(),
757                        addresses: assigned.peer_addresses("storage"),
758                        ..storage_proto_timely_config
759                    };
760                    let compute_timely_config = TimelyConfig {
761                        workers: location.allocation.workers.get(),
762                        addresses: assigned.peer_addresses("compute"),
763                        ..compute_proto_timely_config
764                    };
765
766                    let mut args = vec![
767                        format!(
768                            "--storage-controller-listen-addr={}",
769                            assigned.listen_addrs["storagectl"]
770                        ),
771                        format!(
772                            "--compute-controller-listen-addr={}",
773                            assigned.listen_addrs["computectl"]
774                        ),
775                        format!(
776                            "--internal-http-listen-addr={}",
777                            assigned.listen_addrs["internal-http"]
778                        ),
779                        format!("--opentelemetry-resource=cluster_id={}", cluster_id),
780                        format!("--opentelemetry-resource=replica_id={}", replica_id),
781                        format!("--persist-pubsub-url={}", persist_pubsub_url),
782                        format!("--environment-id={}", environment_id),
783                        format!(
784                            "--storage-timely-config={}",
785                            storage_timely_config.to_string(),
786                        ),
787                        format!(
788                            "--compute-timely-config={}",
789                            compute_timely_config.to_string(),
790                        ),
791                    ];
792                    if let Some(aws_external_id_prefix) = &aws_external_id_prefix {
793                        args.push(format!(
794                            "--aws-external-id-prefix={}",
795                            aws_external_id_prefix
796                        ));
797                    }
798                    if let Some(aws_connection_role_arn) = &aws_connection_role_arn {
799                        args.push(format!(
800                            "--aws-connection-role-arn={}",
801                            aws_connection_role_arn
802                        ));
803                    }
804                    if let Some(memory_limit) = location.allocation.memory_limit {
805                        args.push(format!(
806                            "--announce-memory-limit={}",
807                            memory_limit.0.as_u64()
808                        ));
809                    }
810                    if location.allocation.cpu_exclusive && enable_worker_core_affinity {
811                        args.push("--worker-core-affinity".into());
812                    }
813                    if enable_storage_introspection_logs {
814                        args.push("--enable-storage-introspection-logs".into());
815                    }
816                    if location.allocation.is_cc {
817                        args.push("--is-cc".into());
818                    }
819
820                    // If swap is enabled, make the replica limit its own heap usage based on the
821                    // configured memory and disk limits.
822                    if location.allocation.swap_enabled
823                        && let Some(memory_limit) = location.allocation.memory_limit
824                        && let Some(disk_limit) = location.allocation.disk_limit
825                        // Currently, the way for replica sizes to request unlimited swap is to
826                        // specify a `disk_limit` of 0. Ideally we'd change this to make them
827                        // specify no disk limit instead, but for now we need to special-case here.
828                        && disk_limit != DiskLimit::ZERO
829                    {
830                        let heap_limit = memory_limit.0 + disk_limit.0;
831                        args.push(format!("--heap-limit={}", heap_limit.as_u64()));
832                    }
833
834                    args.extend(secrets_args.clone());
835                    args
836                }),
837                ports: vec![
838                    ServicePort {
839                        name: "storagectl".into(),
840                        port_hint: 2100,
841                    },
842                    // To simplify the changes to tests, the port
843                    // chosen here is _after_ the compute ones.
844                    // TODO(petrosagg): fix the numerical ordering here
845                    ServicePort {
846                        name: "storage".into(),
847                        port_hint: 2103,
848                    },
849                    ServicePort {
850                        name: "computectl".into(),
851                        port_hint: 2101,
852                    },
853                    ServicePort {
854                        name: "compute".into(),
855                        port_hint: 2102,
856                    },
857                    ServicePort {
858                        name: "internal-http".into(),
859                        port_hint: 6878,
860                    },
861                ],
862                cpu_limit: location.allocation.cpu_limit,
863                cpu_request: location.allocation.cpu_request,
864                memory_limit,
865                memory_request,
866                scale: location.allocation.scale,
867                labels: BTreeMap::from([
868                    ("replica-id".into(), replica_id.to_string()),
869                    ("cluster-id".into(), cluster_id.to_string()),
870                    ("generation".into(), self.deploy_generation.to_string()),
871                    ("type".into(), "cluster".into()),
872                    ("replica-role".into(), role_label.into()),
873                    ("workers".into(), location.allocation.workers.to_string()),
874                    (
875                        "size".into(),
876                        location
877                            .size
878                            .to_string()
879                            .replace("=", "-")
880                            .replace(",", "_"),
881                    ),
882                ]),
883                annotations: BTreeMap::from([
884                    (
885                        "replica-name".into(),
886                        format!("{cluster_name}.{replica_name}"),
887                    ),
888                    ("cluster-name".into(), cluster_name),
889                ]),
890                // An empty list means no AZ constraint; a non-empty one pins
891                // placement to those zones.
892                availability_zones: Some(location.availability_zones).filter(|azs| !azs.is_empty()),
893                // This provides the orchestrator with some label selectors that
894                // are used to constraint the scheduling of replicas, based on
895                // its internal configuration.
896                //
897                // Selectors include `generation` so that scheduling constraints
898                // (anti-affinity, topology spread) only consider pods of the same
899                // deploy generation. Otherwise, during a generation rollout, the
900                // new-generation pods would be constrained by the placement of
901                // old-generation pods that are about to be torn down, which can
902                // prevent the new pods from scheduling (e.g., when only one AZ
903                // has capacity but it is already occupied by an old-generation
904                // pod).
905                other_replicas_selector: vec![
906                    LabelSelector {
907                        label_name: "cluster-id".to_string(),
908                        logic: LabelSelectionLogic::Eq {
909                            value: cluster_id.to_string(),
910                        },
911                    },
912                    // Select other replicas (but not oneself)
913                    LabelSelector {
914                        label_name: "replica-id".into(),
915                        logic: LabelSelectionLogic::NotEq {
916                            value: replica_id.to_string(),
917                        },
918                    },
919                    LabelSelector {
920                        label_name: "generation".into(),
921                        logic: LabelSelectionLogic::Eq {
922                            value: self.deploy_generation.to_string(),
923                        },
924                    },
925                ],
926                replicas_selector: vec![
927                    LabelSelector {
928                        label_name: "cluster-id".to_string(),
929                        // Select ALL replicas.
930                        logic: LabelSelectionLogic::Eq {
931                            value: cluster_id.to_string(),
932                        },
933                    },
934                    LabelSelector {
935                        label_name: "generation".into(),
936                        logic: LabelSelectionLogic::Eq {
937                            value: self.deploy_generation.to_string(),
938                        },
939                    },
940                ],
941                disk_limit,
942                node_selector: location.allocation.selectors,
943            },
944        )?;
945
946        let metrics_task = mz_ore::task::spawn(|| format!("replica-metrics-{replica_id}"), {
947            let tx = self.metrics_tx.clone();
948            let orchestrator = Arc::clone(&self.orchestrator);
949            let service_name = service_name.clone();
950            async move {
951                const METRICS_INTERVAL: Duration = Duration::from_secs(60);
952
953                // TODO[btv] -- I tried implementing a `watch_metrics` function,
954                // similar to `watch_services`, but it crashed due to
955                // https://github.com/kube-rs/kube/issues/1092 .
956                //
957                // If `metrics-server` can be made to fill in `resourceVersion`,
958                // or if that bug is fixed, we can try that again rather than using this inelegant
959                // loop.
960                let mut interval = tokio::time::interval(METRICS_INTERVAL);
961                loop {
962                    interval.tick().await;
963                    match orchestrator.fetch_service_metrics(&service_name).await {
964                        Ok(metrics) => {
965                            let _ = tx.send((replica_id, metrics));
966                        }
967                        Err(e) => {
968                            warn!("failed to get metrics for replica {replica_id}: {e}");
969                        }
970                    }
971                }
972            }
973        });
974
975        Ok((service, metrics_task.abort_on_drop()))
976    }
977
978    /// Deprovisions a replica with the service orchestrator.
979    fn deprovision_replica(
980        &self,
981        cluster_id: ClusterId,
982        replica_id: ReplicaId,
983        generation: u64,
984    ) -> Result<(), anyhow::Error> {
985        let service_name = ReplicaServiceName {
986            cluster_id,
987            replica_id,
988            generation,
989        }
990        .to_string();
991        self.orchestrator.drop_service(&service_name)
992    }
993}
994
995/// Remove all replicas from past generations.
996async fn try_remove_past_generation_replicas(
997    orchestrator: &dyn NamespacedOrchestrator,
998    deploy_generation: u64,
999) -> Result<(), anyhow::Error> {
1000    let services: BTreeSet<_> = orchestrator.list_services().await?.into_iter().collect();
1001
1002    for service in services {
1003        let name: ReplicaServiceName = service.parse()?;
1004        if name.generation < deploy_generation {
1005            info!(
1006                cluster_id = %name.cluster_id,
1007                replica_id = %name.replica_id,
1008                "removing past generation replica",
1009            );
1010            orchestrator.drop_service(&service)?;
1011        }
1012    }
1013
1014    Ok(())
1015}
1016
1017/// Represents the name of a cluster replica service in the orchestrator.
1018#[derive(PartialEq, Eq, PartialOrd, Ord)]
1019pub struct ReplicaServiceName {
1020    pub cluster_id: ClusterId,
1021    pub replica_id: ReplicaId,
1022    pub generation: u64,
1023}
1024
1025impl fmt::Display for ReplicaServiceName {
1026    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1027        let ReplicaServiceName {
1028            cluster_id,
1029            replica_id,
1030            generation,
1031        } = self;
1032        write!(f, "{cluster_id}-replica-{replica_id}-gen-{generation}")
1033    }
1034}
1035
1036impl FromStr for ReplicaServiceName {
1037    type Err = anyhow::Error;
1038
1039    fn from_str(s: &str) -> Result<Self, Self::Err> {
1040        static SERVICE_NAME_RE: LazyLock<Regex> = LazyLock::new(|| {
1041            Regex::new(r"(?-u)^([us]\d+)-replica-([us]\d+)(?:-gen-(\d+))?$").unwrap()
1042        });
1043
1044        let caps = SERVICE_NAME_RE
1045            .captures(s)
1046            .ok_or_else(|| anyhow!("invalid service name: {s}"))?;
1047
1048        Ok(ReplicaServiceName {
1049            cluster_id: caps.get(1).unwrap().as_str().parse().unwrap(),
1050            replica_id: caps.get(2).unwrap().as_str().parse().unwrap(),
1051            // Old versions of Materialize did not include generations in
1052            // replica service names. Synthesize generation 0 if absent.
1053            // TODO: remove this in the next version of Materialize.
1054            generation: caps.get(3).map_or("0", |m| m.as_str()).parse().unwrap(),
1055        })
1056    }
1057}