Skip to main content

mz_adapter/config/
sync.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
10use std::collections::BTreeSet;
11use std::sync::Arc;
12use std::time::Duration;
13
14use mz_controller::clusters::ReplicaLocation;
15use mz_controller_types::{ClusterId, ReplicaId};
16use mz_dyncfg::ParameterScope;
17use tokio::time;
18
19use crate::Client;
20use crate::catalog::Catalog;
21use crate::config::{
22    ClusterEvalContext, ClusterScopeContext, ReplicaEvalContext, ReplicaScopeContext,
23    ScopedParameters, ScopedParametersScope, SynchronizedParameters, SystemParameterBackend,
24    SystemParameterFrontend, SystemParameterSyncConfig,
25};
26
27/// Run a loop that periodically pulls system parameters defined in the
28/// LaunchDarkly-backed [SystemParameterFrontend] and pushes modified values to the
29/// `ALTER SYSTEM`-backed [SystemParameterBackend].
30///
31/// `boot_frontend` is the frontend the boot-time sync pulled from, if any. The
32/// loop adopts it on its first enabled tick instead of building a second one.
33pub async fn system_parameter_sync(
34    sync_config: SystemParameterSyncConfig,
35    mut boot_frontend: Option<Arc<SystemParameterFrontend>>,
36    adapter_client: Client,
37    tick_interval: Option<Duration>,
38) -> Result<(), anyhow::Error> {
39    let Some(tick_interval) = tick_interval else {
40        tracing::info!("skipping system parameter sync as tick_interval = None");
41        return Ok(());
42    };
43
44    // Keep a client handle for catalog snapshots and the per-replica scoped
45    // config push, since the backend consumes its own clone.
46    let scoped_client = adapter_client.clone();
47
48    // Ensure the frontend client is initialized. Wrapped in `Arc` so a clone can
49    // be shared with the coordinator for synchronous create-time scoped
50    // resolution.
51    let mut frontend = Option::<Arc<SystemParameterFrontend>>::None; // lazy initialize the frontend below
52    let mut backend = SystemParameterBackend::new(adapter_client).await?;
53
54    // Tick every `tick_duration` ms, skipping missed ticks.
55    let mut interval = time::interval(tick_interval);
56    interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
57
58    // Run the synchronization loop.
59    tracing::info!(
60        "synchronizing system parameter values every {} seconds",
61        tick_interval.as_secs()
62    );
63
64    let mut params = SynchronizedParameters::default();
65    loop {
66        // Wait for the next sync period
67        interval.tick().await;
68
69        // Fetch current parameter values from the backend
70        backend.pull(&mut params).await;
71
72        if !params.enable_launchdarkly() && sync_config.backend_config.is_launch_darkly() {
73            // The boot-time sync does not consult `enable_launchdarkly`, so its
74            // frontend is dropped here like a running one.
75            if frontend.is_some() || boot_frontend.is_some() {
76                tracing::info!("stopping system parameter frontend");
77                frontend = None;
78                boot_frontend = None;
79            } else {
80                tracing::info!("system parameter sync is disabled; not syncing")
81            }
82
83            // Don't do anything until the next loop.
84            continue;
85        }
86
87        if frontend.is_none() {
88            let new_frontend = match boot_frontend.take() {
89                Some(boot_frontend) => boot_frontend,
90                None => {
91                    tracing::info!("initializing system parameter frontend");
92                    Arc::new(SystemParameterFrontend::from(&sync_config).await?)
93                }
94            };
95            // Share the frontend with the coordinator so the create-cluster /
96            // create-replica paths can resolve a new object's scoped overrides
97            // synchronously, instead of waiting for the next tick.
98            scoped_client.install_scoped_system_parameter_frontend(Arc::clone(&new_frontend));
99            frontend = Some(new_frontend);
100        }
101
102        // Pull latest state from frontend and push changes to backend.
103        let frontend = frontend.as_ref().expect("frontend exists");
104        if frontend.pull(&mut params) {
105            backend.push(&mut params).await;
106        }
107
108        // Reconcile the scoped (per-cluster and per-replica) parameters. We do
109        // this every tick (independent of whether the environment-wide values
110        // changed) so the overrides track the current set of live objects.
111        sync_scoped_params(&scoped_client, frontend, &params).await;
112    }
113}
114
115/// Evaluate the scoped parameters (cluster-coherent and replica-local) for the
116/// currently live clusters and replicas and push the resulting overrides to the
117/// coordinator's working copy.
118async fn sync_scoped_params(
119    client: &Client,
120    frontend: &SystemParameterFrontend,
121    params: &SynchronizedParameters,
122) {
123    // The reconcile prunes every override absent from the desired state, so
124    // running it without one would durably drop every scoped override until the
125    // config-sync file is readable again.
126    if !frontend.has_scoped_desired_state() {
127        return;
128    }
129
130    let catalog = client.catalog_snapshot_expensive().await;
131
132    // Push the desired state to the coordinator, which holds the working copy
133    // and resolves each layer at its boundary: the controller's per-replica
134    // dyncfg push for `replica`, plan-time `OptimizerFeatureOverrides` for
135    // `cluster`. Scope removals to the live objects in this snapshot, so an
136    // object created between this snapshot and the apply (folding its override
137    // into its own create transaction) is not wiped by this reconcile.
138    let prune_scope = ScopedParametersScope {
139        clusters: catalog.clusters().map(|cluster| cluster.id).collect(),
140        replicas: catalog
141            .clusters()
142            .flat_map(|cluster| cluster.replicas().map(|replica| replica.replica_id))
143            .collect(),
144    };
145    let scoped = evaluate_scoped_parameters(frontend, params, &catalog, None, None);
146    client
147        .update_scoped_system_parameters(scoped, prune_scope)
148        .await;
149}
150
151/// Evaluate the scoped parameters (cluster-coherent and replica-local) for the
152/// live objects, optionally restricted to a subset of cluster or replica ids.
153///
154/// The full pass (both filters `None`) is the sync loop's per-tick reconcile.
155/// The create path passes `Some(..)` to resolve just the newly-created objects
156/// synchronously, so they observe their overrides in their first controller
157/// configuration or first plan rather than after the next tick. Returns the
158/// sparse desired overrides for the evaluated objects.
159pub(crate) fn evaluate_scoped_parameters(
160    frontend: &SystemParameterFrontend,
161    params: &SynchronizedParameters,
162    catalog: &Catalog,
163    cluster_filter: Option<&BTreeSet<ClusterId>>,
164    replica_filter: Option<&BTreeSet<ReplicaId>>,
165) -> ScopedParameters {
166    let system_config = catalog.system_config();
167
168    // The synced parameters, partitioned by scope class. The scope declaration
169    // bounds evaluation to exactly the flags in use: an environment with no
170    // scoped flags evaluates neither pass.
171    let replica_param_names: Vec<&'static str> = system_config
172        .iter_synced()
173        .filter(|var| var.scope() == ParameterScope::Replica)
174        .map(|var| var.name())
175        .collect();
176    let cluster_param_names: Vec<&'static str> = system_config
177        .iter_synced()
178        .filter(|var| var.scope() == ParameterScope::Cluster)
179        .map(|var| var.name())
180        .collect();
181
182    let replica = if replica_param_names.is_empty() {
183        Default::default()
184    } else {
185        let replicas = build_replica_eval_contexts(catalog, replica_filter);
186        frontend.pull_replica_overrides(params, &replica_param_names, &replicas)
187    };
188    let cluster = if cluster_param_names.is_empty() {
189        Default::default()
190    } else {
191        let clusters = build_cluster_eval_contexts(catalog, cluster_filter);
192        frontend.pull_cluster_overrides(params, &cluster_param_names, &clusters)
193    };
194
195    ScopedParameters { cluster, replica }
196}
197
198/// Build a [`ClusterEvalContext`] for each live cluster in the catalog, skipping
199/// clusters absent from `filter` when one is given.
200fn build_cluster_eval_contexts(
201    catalog: &Catalog,
202    filter: Option<&BTreeSet<ClusterId>>,
203) -> Vec<ClusterEvalContext> {
204    catalog
205        .clusters()
206        .filter(|cluster| filter.is_none_or(|f| f.contains(&cluster.id)))
207        .map(|cluster| ClusterEvalContext {
208            cluster_id: cluster.id,
209            cluster: ClusterScopeContext {
210                id: cluster.id.to_string(),
211                name: cluster.name.clone(),
212                is_builtin: cluster.id.is_system(),
213            },
214        })
215        .collect()
216}
217
218/// Build a [`ReplicaEvalContext`] for each live managed replica in the catalog,
219/// skipping replicas absent from `filter` when one is given.
220fn build_replica_eval_contexts(
221    catalog: &Catalog,
222    filter: Option<&BTreeSet<ReplicaId>>,
223) -> Vec<ReplicaEvalContext> {
224    // An empty filter cannot match any replica, so skip the per-cluster scan.
225    // The create-cluster path passes an empty set to resolve only the cluster
226    // scope.
227    if filter.is_some_and(|f| f.is_empty()) {
228        return Vec::new();
229    }
230
231    let mut contexts = Vec::new();
232    for cluster in catalog.clusters() {
233        let is_builtin = cluster.id.is_system();
234        let cluster_ctx = ClusterScopeContext {
235            id: cluster.id.to_string(),
236            name: cluster.name.clone(),
237            is_builtin,
238        };
239        for replica in cluster.replicas() {
240            if filter.is_some_and(|f| !f.contains(&replica.replica_id)) {
241                continue;
242            }
243            // Only managed replicas have a size (and therefore a size family).
244            let ReplicaLocation::Managed(location) = &replica.config.location else {
245                continue;
246            };
247            let replica_ctx = ReplicaScopeContext {
248                id: replica.replica_id.to_string(),
249                name: replica.name.clone(),
250                is_builtin,
251                size: location.size.clone(),
252                size_family: location.allocation.family().to_string(),
253                cluster_id: cluster.id.to_string(),
254                cluster_name: cluster.name.clone(),
255            };
256            contexts.push(ReplicaEvalContext {
257                cluster_id: cluster.id,
258                replica_id: replica.replica_id,
259                cluster: cluster_ctx.clone(),
260                replica: replica_ctx,
261            });
262        }
263    }
264    contexts
265}