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