Skip to main content

mz_adapter/config/
frontend.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::BTreeMap;
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17use bytes::Bytes;
18use derivative::Derivative;
19use futures::TryStreamExt;
20use launchdarkly_sdk_transport::{ByteStream, HttpTransport, ResponseFuture};
21use launchdarkly_server_sdk as ld;
22use mz_build_info::BuildInfo;
23use mz_cloud_provider::CloudProvider;
24use mz_cluster_client::ReplicaId;
25use mz_controller_types::ClusterId;
26use mz_ore::metrics::UIntGauge;
27use mz_ore::now::NowFn;
28use mz_sql::catalog::EnvironmentId;
29use serde_json::Value as JsonValue;
30use tokio::time;
31use tracing::warn;
32
33use crate::config::{
34    Metrics, SynchronizedParameters, SystemParameterSyncClientConfig, SystemParameterSyncConfig,
35};
36
37/// A frontend client for pulling [SynchronizedParameters] from LaunchDarkly.
38#[derive(Derivative)]
39#[derivative(Debug)]
40pub struct SystemParameterFrontend {
41    /// An SDK client to mediate interactions with the LaunchDarkly and json config file clients.
42    client: SystemParameterFrontendClient,
43    /// A map from parameter names to LaunchDarkly feature keys
44    /// to use when populating the [SynchronizedParameters]
45    /// instance in [SystemParameterFrontend::pull].
46    key_map: BTreeMap<String, String>,
47    /// The environment ID, used to build scoped (`cluster` / `replica`)
48    /// evaluation contexts.
49    env_id: EnvironmentId,
50    /// Build info, used to build scoped evaluation contexts.
51    build_info: &'static BuildInfo,
52    /// Frontend metrics.
53    metrics: Metrics,
54    /// The config-sync file as of the last [`SystemParameterFrontend::pull`], or
55    /// `None` before the first one. Never populated for the LaunchDarkly client.
56    config_file: Mutex<Option<CachedConfigFile>>,
57}
58
59#[derive(Derivative)]
60#[derivative(Debug)]
61pub enum SystemParameterFrontendClient {
62    File {
63        path: PathBuf,
64    },
65    LaunchDarkly {
66        /// An SDK client to mediate interactions with the LaunchDarkly client.
67        #[derivative(Debug = "ignore")]
68        client: Arc<ld::Client>,
69        /// The context to use when querying LaunchDarkly using the SDK.
70        /// This scopes down queries to a specific key.
71        ctx: ld::Context,
72    },
73}
74
75impl SystemParameterFrontendClient {}
76
77impl Drop for SystemParameterFrontendClient {
78    fn drop(&mut self) {
79        let client = match self {
80            SystemParameterFrontendClient::LaunchDarkly { client, ctx: _ } => client,
81            SystemParameterFrontendClient::File { path: _ } => return,
82        };
83        // Dropping an `ld::Client` without `close` disconnects its event
84        // dispatcher thread, which logs an error and discards the analytics
85        // events it has not sent yet. `close` blocks until the final flush
86        // completes, so it must not run on an async worker thread.
87        let client = Arc::clone(client);
88        if tokio::runtime::Handle::try_current().is_ok() {
89            mz_ore::task::spawn_blocking(
90                || "SystemParameterFrontend close",
91                move || client.close(),
92            );
93        } else {
94            client.close();
95        }
96    }
97}
98
99/// The parsed contents of the config-sync file, a JSON object whose keys are
100/// parameter names.
101#[derive(Debug, Default, PartialEq)]
102struct ConfigFile {
103    /// Environment-wide values, keyed by the parameter's external name.
104    environment: BTreeMap<String, JsonValue>,
105}
106
107impl ConfigFile {
108    /// Parses the config-sync file's contents, or `None` if the document is not a
109    /// JSON object.
110    ///
111    /// `None` is "no information about any parameter", which callers must keep
112    /// distinct from a valid but empty document.
113    fn parse(contents: &str) -> Option<Self> {
114        match serde_json::from_str(contents) {
115            Ok(environment) => Some(Self { environment }),
116            Err(e) => {
117                warn!("could not parse system parameter sync file: {e}");
118                None
119            }
120        }
121    }
122}
123
124/// The most recent read of the config-sync file and its parse.
125#[derive(Debug)]
126struct CachedConfigFile {
127    /// The contents the cache was built from, or `None` if that read failed.
128    /// The next read is compared against it, so the file is re-parsed, and its
129    /// warnings logged, only when it changes.
130    contents: Option<String>,
131    /// The parse of `contents`, or `None` if the read failed or the document was
132    /// not a JSON object.
133    current: Option<Arc<ConfigFile>>,
134}
135
136/// Renders a JSON value as the raw parameter string the backend parses.
137///
138/// `null` yields `None`, meaning the file expresses no opinion for this
139/// parameter, so its current value stands.
140fn json_param_value(value: &JsonValue) -> Option<String> {
141    match value {
142        JsonValue::String(v) => Some(v.clone()),
143        JsonValue::Number(v) => Some(v.to_string()),
144        JsonValue::Bool(v) => Some(v.to_string()),
145        JsonValue::Object(_) | JsonValue::Array(_) => Some(value.to_string()),
146        JsonValue::Null => None,
147    }
148}
149
150impl SystemParameterFrontend {
151    /// Create a new [SystemParameterFrontend] initialize.
152    ///
153    /// This will create and initialize an [ld::Client] instance. The
154    /// [ld::Client::wait_for_initialization] call will be attempted in a loop with an
155    /// exponential backoff with power `2s` and max duration `60s`.
156    pub async fn from(sync_config: &SystemParameterSyncConfig) -> Result<Self, anyhow::Error> {
157        match &sync_config.backend_config {
158            super::SystemParameterSyncClientConfig::File { path } => Ok(Self {
159                client: SystemParameterFrontendClient::File { path: path.clone() },
160                key_map: sync_config.key_map.clone(),
161                env_id: sync_config.env_id.clone(),
162                build_info: sync_config.build_info,
163                metrics: sync_config.metrics.clone(),
164                config_file: Mutex::new(None),
165            }),
166            SystemParameterSyncClientConfig::LaunchDarkly {
167                sdk_key,
168                base_uri,
169                now_fn,
170            } => Ok(Self {
171                client: SystemParameterFrontendClient::LaunchDarkly {
172                    client: ld_client(sdk_key, base_uri.as_deref(), &sync_config.metrics, now_fn)
173                        .await?,
174                    // The environment-wide context carries no cluster/replica
175                    // scope. Scoped evaluation passes a `cluster` or `replica`
176                    // context per pass via [`ld_ctx`].
177                    ctx: ld_ctx(&sync_config.env_id, sync_config.build_info, None, None)?,
178                },
179                env_id: sync_config.env_id.clone(),
180                build_info: sync_config.build_info,
181                metrics: sync_config.metrics.clone(),
182                key_map: sync_config.key_map.clone(),
183                config_file: Mutex::new(None),
184            }),
185        }
186    }
187
188    /// Pull the current values for all [SynchronizedParameters] from the
189    /// [SystemParameterFrontend] and return `true` iff at least one parameter
190    /// value was modified.
191    pub fn pull(&self, params: &mut SynchronizedParameters) -> bool {
192        // Read the file once per tick rather than once per parameter, so a
193        // rewrite landing mid-loop cannot be observed as a torn read.
194        let file = match &self.client {
195            SystemParameterFrontendClient::File { path } => {
196                self.refresh_config_file(path, fs::read_to_string(path))
197            }
198            SystemParameterFrontendClient::LaunchDarkly { .. } => None,
199        };
200
201        let mut changed = false;
202        for param_name in params.synchronized().into_iter() {
203            let flag_name = self
204                .key_map
205                .get(param_name)
206                .map(|flag_name| flag_name.as_str())
207                .unwrap_or(param_name);
208
209            let flag_str = match self.client {
210                SystemParameterFrontendClient::LaunchDarkly {
211                    ref client,
212                    ref ctx,
213                } => {
214                    let flag_var = client.variation(ctx, flag_name, params.get(param_name));
215                    match flag_var {
216                        ld::FlagValue::Bool(v) => v.to_string(),
217                        ld::FlagValue::Str(v) => v,
218                        ld::FlagValue::Number(v) => v.to_string(),
219                        ld::FlagValue::Json(v) => v.to_string(),
220                    }
221                }
222                // A parameter the file does not mention, and every parameter when
223                // the file could not be read or parsed, keeps its current value.
224                SystemParameterFrontendClient::File { .. } => file
225                    .as_ref()
226                    .and_then(|file| file.environment.get(flag_name))
227                    .and_then(json_param_value)
228                    .unwrap_or_else(|| params.get(param_name)),
229            };
230
231            let old = params.get(param_name);
232            let change = params.modify(param_name, flag_str.as_str());
233            if change {
234                tracing::debug!(
235                    %param_name, %old, new = %flag_str,
236                    "updating system param",
237                );
238            }
239            self.metrics.params_changed.inc_by(u64::from(change));
240            changed |= change;
241        }
242
243        changed
244    }
245
246    /// Refreshes the cached config-sync file from `read`, the outcome of reading
247    /// it at `path`, and returns its parse.
248    ///
249    /// Unchanged contents return the cached parse without re-parsing, so a bad
250    /// file is warned about once per change rather than on every tick.
251    fn refresh_config_file(
252        &self,
253        path: &Path,
254        read: io::Result<String>,
255    ) -> Option<Arc<ConfigFile>> {
256        let mut cache = self
257            .config_file
258            .lock()
259            .expect("config file cache lock poisoned");
260
261        if let Some(cached) = &*cache {
262            if cached.contents.as_deref() == read.as_deref().ok() {
263                return cached.current.clone();
264            }
265        }
266
267        let contents = match read {
268            Ok(contents) => Some(contents),
269            Err(e) => {
270                warn!(
271                    "could not read system parameter sync file {}: {e}",
272                    path.display()
273                );
274                None
275            }
276        };
277        let current = contents
278            .as_deref()
279            .and_then(ConfigFile::parse)
280            .map(Arc::new);
281        *cache = Some(CachedConfigFile {
282            contents,
283            current: current.clone(),
284        });
285
286        current
287    }
288
289    /// Whether the frontend knows the desired state of the scoped parameters.
290    ///
291    /// `false` when the most recent read of the config-sync file failed or did
292    /// not parse. The scoped reconcile prunes every override absent from the
293    /// desired state, so callers must skip it while this is `false`. Always
294    /// `true` for LaunchDarkly.
295    pub fn has_scoped_desired_state(&self) -> bool {
296        match &self.client {
297            SystemParameterFrontendClient::LaunchDarkly { .. } => true,
298            SystemParameterFrontendClient::File { .. } => self
299                .config_file
300                .lock()
301                .expect("config file cache lock poisoned")
302                .as_ref()
303                .is_some_and(|cached| cached.current.is_some()),
304        }
305    }
306
307    /// Evaluates the replica-local scoped parameters for each given replica and
308    /// returns, per cluster and replica, the parameter values that differ from
309    /// the environment-wide value held in `params`.
310    ///
311    /// Only the LaunchDarkly client performs scoped evaluation. The file
312    /// client returns an empty map (replicas fall back to the environment-wide value).
313    /// The returned map is sparse: replicas (and clusters) with no overriding
314    /// value are omitted.
315    pub fn pull_replica_overrides(
316        &self,
317        params: &SynchronizedParameters,
318        param_names: &[&'static str],
319        replicas: &[ReplicaEvalContext],
320    ) -> BTreeMap<ReplicaId, BTreeMap<String, String>> {
321        let mut out: BTreeMap<ReplicaId, BTreeMap<String, String>> = BTreeMap::new();
322
323        let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
324            // The file client has no notion of scoped evaluation.
325            return out;
326        };
327
328        if param_names.is_empty() {
329            return out;
330        }
331
332        for replica in replicas {
333            let ctx = match ld_ctx(
334                &self.env_id,
335                self.build_info,
336                Some(&replica.cluster),
337                Some(&replica.replica),
338            ) {
339                Ok(ctx) => ctx,
340                Err(e) => {
341                    warn!(
342                        replica_id = %replica.replica.id,
343                        "could not build scoped LD context: {e}"
344                    );
345                    continue;
346                }
347            };
348
349            let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
350            if !overrides.is_empty() {
351                out.insert(replica.replica_id, overrides);
352            }
353        }
354
355        out
356    }
357
358    /// Evaluates the cluster-coherent scoped parameters for each given cluster
359    /// and returns, per cluster, the parameter values that differ from the
360    /// environment-wide value held in `params`. Evaluated replica-free (the
361    /// `cluster` context kind), so the value cannot vary by replica.
362    ///
363    /// Only the LaunchDarkly client performs scoped evaluation. The file
364    /// client returns an empty map. The returned map is sparse.
365    pub fn pull_cluster_overrides(
366        &self,
367        params: &SynchronizedParameters,
368        param_names: &[&'static str],
369        clusters: &[ClusterEvalContext],
370    ) -> BTreeMap<ClusterId, BTreeMap<String, String>> {
371        let mut out: BTreeMap<ClusterId, BTreeMap<String, String>> = BTreeMap::new();
372
373        let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
374            // The file client has no notion of scoped evaluation.
375            return out;
376        };
377
378        if param_names.is_empty() {
379            return out;
380        }
381
382        for cluster in clusters {
383            let ctx = match ld_ctx(&self.env_id, self.build_info, Some(&cluster.cluster), None) {
384                Ok(ctx) => ctx,
385                Err(e) => {
386                    warn!(
387                        cluster_id = %cluster.cluster.id,
388                        "could not build scoped LD context: {e}"
389                    );
390                    continue;
391                }
392            };
393
394            let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
395            if !overrides.is_empty() {
396                out.insert(cluster.cluster_id, overrides);
397            }
398        }
399
400        out
401    }
402
403    /// Evaluates each of `param_names` against `ctx`, returning only the values
404    /// that differ from the environment-wide value held in `params`. Shared by
405    /// the cluster and replica passes, so the returned map is sparse.
406    ///
407    /// We record on the differs-from-env test, not the `variation_detail`
408    /// reason. The inline comment at the recording decision explains why.
409    fn evaluate_scoped_overrides(
410        &self,
411        client: &ld::Client,
412        ctx: &ld::Context,
413        params: &SynchronizedParameters,
414        param_names: &[&'static str],
415    ) -> BTreeMap<String, String> {
416        let mut overrides = BTreeMap::new();
417        for &param_name in param_names {
418            let flag_name = self
419                .key_map
420                .get(param_name)
421                .map(|flag_name| flag_name.as_str())
422                .unwrap_or(param_name);
423
424            let base = params.get(param_name);
425            // Evaluate with `base` as the default, so a silent LD (flag absent,
426            // off, error, failed prerequisite) resolves back to the env-wide
427            // value and is dropped by the difference test below.
428            let flag_var = client.variation(ctx, flag_name, base.clone());
429            let value = match flag_var {
430                ld::FlagValue::Bool(v) => v.to_string(),
431                ld::FlagValue::Str(v) => v,
432                ld::FlagValue::Number(v) => v.to_string(),
433                ld::FlagValue::Json(v) => v.to_string(),
434            };
435
436            // Record iff the scoped evaluation *differs* from the env-wide value.
437            // The `variation_detail` reason is the wrong signal: it cannot say
438            // which context kind's clause matched (an env-level rule and a
439            // cluster-specific rule both report `RuleMatch`), and `Fallthrough`
440            // serves the env-wide value to every object. Comparing against the
441            // env-wide baseline is the only signal that means "this scope context
442            // changed the answer", which is what must beat a manual `FEATURES`
443            // pin and what keeps the durable collections sparse. See the scoped
444            // feature flags design, §Resolution.
445            //
446            // Compare in the parameter's canonical encoding. `base` is the
447            // var-formatted env-wide value (a `bool` is `"on"`/`"off"`), whereas
448            // the raw LaunchDarkly value spells a boolean `"true"`/`"false"`, so a
449            // direct string compare would treat every boolean flag as differing,
450            // even on `Fallthrough`. We still *store* the raw `value` (downstream
451            // consumers parse `"true"`/`"false"`). Only the decision is canonical.
452            let differs = match params.canonicalize(param_name, &value) {
453                Some(canonical) => canonical != base,
454                // LaunchDarkly served a value that does not parse for this
455                // parameter's type (e.g. a malformed boolean like `"maybe"`).
456                // Never record it: storing an unparseable value would poison
457                // resolution. The optimizer's `bool` decode, for one, panics on
458                // every plan for a cluster-coherent override it cannot parse.
459                // Treat it as "no scoped opinion" and fall back to the env-wide
460                // value.
461                None => false,
462            };
463            if differs {
464                overrides.insert(param_name.to_string(), value);
465            }
466        }
467        overrides
468    }
469}
470
471/// The identity of a single live replica, used to evaluate replica-local scoped
472/// parameters in [`SystemParameterFrontend::pull_replica_overrides`].
473#[derive(Clone, Debug)]
474pub struct ReplicaEvalContext {
475    /// The owning cluster's id.
476    pub cluster_id: ClusterId,
477    /// The replica's id.
478    pub replica_id: ReplicaId,
479    /// The owning cluster's scope context (for the replica-free, cluster pass).
480    pub cluster: ClusterScopeContext,
481    /// The replica's scope context.
482    pub replica: ReplicaScopeContext,
483}
484
485/// The identity of a single live cluster, used to evaluate cluster-coherent
486/// scoped parameters in [`SystemParameterFrontend::pull_cluster_overrides`].
487#[derive(Clone, Debug)]
488pub struct ClusterEvalContext {
489    /// The cluster's id.
490    pub cluster_id: ClusterId,
491    /// The cluster's scope context (replica-free).
492    pub cluster: ClusterScopeContext,
493}
494
495/// An [`HttpTransport`] wrapper that records timestamps on successful HTTP
496/// responses. Used to populate Prometheus metrics that track LaunchDarkly
497/// connectivity health.
498///
499/// Two instances are created — one for the event processor (CSE metric, tracks
500/// outbound event sends) and one for the streaming data source (SSE metric,
501/// tracks inbound SSE events).
502#[derive(Clone)]
503struct MetricsTransport<T> {
504    inner: T,
505    last_success_gauge: UIntGauge,
506    now_fn: NowFn,
507}
508
509impl<T: HttpTransport> HttpTransport for MetricsTransport<T> {
510    fn request(&self, request: http::Request<Option<Bytes>>) -> ResponseFuture {
511        let inner_fut = self.inner.request(request);
512        let gauge = self.last_success_gauge.clone();
513        let now_fn = self.now_fn.clone();
514        Box::pin(async move {
515            let resp = inner_fut.await?;
516            if resp.status().is_success() {
517                gauge.set(now_fn() / 1000);
518                let (parts, body) = resp.into_parts();
519                let wrapped: ByteStream = Box::pin(body.inspect_ok(move |_| {
520                    gauge.set(now_fn() / 1000);
521                }));
522                Ok(http::Response::from_parts(parts, wrapped))
523            } else {
524                Ok(resp)
525            }
526        })
527    }
528}
529
530fn ld_config(
531    api_key: &str,
532    base_uri: Option<&str>,
533    metrics: &Metrics,
534    now_fn: &NowFn,
535) -> ld::Config {
536    // How long a body read on the streaming connection may stay idle before
537    // the transport surfaces a timeout error to the data source. This is the
538    // error class of incident-984 (a silently-dead connection). Overridable
539    // via a hidden env var so tests can trigger the timeout path in seconds
540    // instead of minutes (see test/launchdarkly-reconnect).
541    //
542    // The default must stay above LaunchDarkly's streaming heartbeat interval
543    // (roughly 3 minutes per LD's documentation), or a healthy idle stream
544    // would trip the timeout and reconnect spuriously. Benign now that
545    // reconnects work, but wasteful. The same constant lives in
546    // `mz-dyncfg-launchdarkly`.
547    let read_timeout = match std::env::var("MZ_LAUNCHDARKLY_READ_TIMEOUT") {
548        Ok(v) => humantime::parse_duration(&v).unwrap_or_else(|e| {
549            // Don't silently fall back: a typo here (e.g. `5sec`) would
550            // otherwise present as an unexplained timeout far downstream.
551            tracing::error!(
552                "ignoring unparseable MZ_LAUNCHDARKLY_READ_TIMEOUT {v:?}: {e}; \
553                 falling back to default"
554            );
555            Duration::from_secs(300)
556        }),
557        Err(_) => Duration::from_secs(300),
558    };
559
560    let transport = mz_dyncfg_launchdarkly::https_transport(Duration::from_secs(10), read_timeout)
561        .expect("failed to create HTTPS transport");
562
563    let cse_transport = MetricsTransport {
564        inner: transport.clone(),
565        last_success_gauge: metrics.last_cse_time_seconds.clone(),
566        now_fn: now_fn.clone(),
567    };
568    let data_source_transport = MetricsTransport {
569        inner: transport,
570        last_success_gauge: metrics.last_sse_time_seconds.clone(),
571        now_fn: now_fn.clone(),
572    };
573
574    let mut event_processor = ld::EventProcessorBuilder::new();
575    event_processor.transport(cse_transport);
576
577    let mut data_source = ld::StreamingDataSourceBuilder::new();
578    data_source.transport(data_source_transport);
579
580    let mut config = ld::ConfigBuilder::new(api_key)
581        .event_processor(&event_processor)
582        .data_source(&data_source);
583    if let Some(base_uri) = base_uri {
584        let mut endpoints = ld::ServiceEndpointsBuilder::new();
585        endpoints.relay_proxy(base_uri);
586        config = config.service_endpoints(&endpoints);
587    }
588    config.build().expect("valid config")
589}
590
591async fn ld_client(
592    api_key: &str,
593    base_uri: Option<&str>,
594    metrics: &Metrics,
595    now_fn: &NowFn,
596) -> Result<Arc<ld::Client>, anyhow::Error> {
597    let ld_client = ld::Client::build(ld_config(api_key, base_uri, metrics, now_fn))?;
598    tracing::info!("waiting for SystemParameterFrontend to initialize");
599    ld_client.start_with_default_executor();
600
601    let max_backoff = Duration::from_secs(60);
602    let mut backoff = Duration::from_secs(5);
603    let timeout = Duration::from_secs(10);
604
605    // TODO(materialize#38055): fix retry logic
606    loop {
607        match ld_client.wait_for_initialization(timeout).await {
608            Some(true) => break,
609            Some(false) => tracing::warn!("SystemParameterFrontend failed to initialize"),
610            None => tracing::warn!("SystemParameterFrontend initialization timed out"),
611        }
612
613        time::sleep(backoff).await;
614        backoff = (backoff * 2).min(max_backoff);
615    }
616
617    tracing::info!("successfully initialized SystemParameterFrontend");
618
619    Ok(Arc::new(ld_client))
620}
621
622/// Identity of a cluster, used to build a `cluster` context kind for
623/// cluster-coherent scoped feature flags.
624///
625/// Exposes both `id` and `name`: an LD rule that targets `cluster_id` is an
626/// incarnation pin that dies on drop/recreate (ids are never reused), while a
627/// rule targeting `cluster_name` / `is_builtin` is a durable role predicate
628/// that re-applies to any matching cluster. See the scoped feature flags
629/// design.
630#[derive(Clone, Debug)]
631pub struct ClusterScopeContext {
632    /// The cluster's catalog id, e.g. `s2` or `u1`.
633    pub id: String,
634    /// The cluster's name, e.g. `mz_catalog_server`.
635    pub name: String,
636    /// Whether the cluster is a builtin (system) cluster.
637    pub is_builtin: bool,
638}
639
640/// Identity of a replica, used to build a `replica` context kind for
641/// replica-local scoped feature flags.
642///
643/// Carries the owning cluster's identity as attributes so that replica-local
644/// flags can be cluster-targeted without a second evaluation, and the replica
645/// size and size *family* so flags can be keyed by size family (e.g. legacy
646/// sizes keep `lgalloc`). See the scoped feature flags design.
647#[derive(Clone, Debug)]
648pub struct ReplicaScopeContext {
649    /// The replica's catalog id.
650    pub id: String,
651    /// The replica's name.
652    pub name: String,
653    /// Whether the replica belongs to a builtin (system) cluster.
654    pub is_builtin: bool,
655    /// The replica's full size name, e.g. `D.1-xsmall` or a legacy t-shirt size
656    /// like `xsmall`. This is the fine-grained targeting axis. The coarse axis
657    /// is [`Self::size_family`]. The two are distinct: `D.1-xsmall` is a size,
658    /// `D` is its family.
659    pub size: String,
660    /// The replica's size family, e.g. `D` or `legacy`. The coarse targeting
661    /// axis, derived from the size map rather than the size name (see
662    /// [`Self::size`]).
663    pub size_family: String,
664    /// The owning cluster's catalog id.
665    pub cluster_id: String,
666    /// The owning cluster's name.
667    pub cluster_name: String,
668}
669
670/// Builds a single `cluster` context kind from a [`ClusterScopeContext`].
671///
672/// Deliberately replica-free: cluster-coherent flags must resolve identically
673/// across a cluster's replicas, so no replica/size attributes appear here.
674fn cluster_context(cluster: &ClusterScopeContext) -> Result<ld::Context, anyhow::Error> {
675    ld::ContextBuilder::new(cluster.id.as_str())
676        .anonymous(true) // keep the LD dashboard Contexts list clean
677        .kind("cluster")
678        .set_string("cluster_id", cluster.id.clone())
679        .set_string("cluster_name", cluster.name.clone())
680        .set_string("is_builtin", cluster.is_builtin.to_string())
681        .build()
682        .map_err(|e| anyhow::anyhow!(e))
683}
684
685/// Builds a single `replica` context kind from a [`ReplicaScopeContext`].
686///
687/// Includes the owning cluster's identity so a rule can combine both axes,
688/// e.g. "size family `D` *and* cluster `foo`".
689fn replica_context(replica: &ReplicaScopeContext) -> Result<ld::Context, anyhow::Error> {
690    ld::ContextBuilder::new(replica.id.as_str())
691        .anonymous(true) // keep the LD dashboard Contexts list clean
692        .kind("replica")
693        .set_string("replica_id", replica.id.clone())
694        .set_string("replica_name", replica.name.clone())
695        .set_string("is_builtin", replica.is_builtin.to_string())
696        .set_string("replica_size", replica.size.clone())
697        .set_string("replica_size_family", replica.size_family.clone())
698        .set_string("cluster_id", replica.cluster_id.clone())
699        .set_string("cluster_name", replica.cluster_name.clone())
700        .build()
701        .map_err(|e| anyhow::anyhow!(e))
702}
703
704/// Builds a multi-context for evaluating scoped feature flags.
705///
706/// Composes the base contexts (`environment` + `organization` + `build`) with:
707/// - a `cluster` context for cluster-coherent (replica-free) resolution, and/or
708/// - a `replica` context for replica-local resolution.
709///
710/// The environment-wide pass passes `None` for both. This is the single entry
711/// point the sync loop uses to evaluate each scoped pass.
712fn ld_ctx(
713    env_id: &EnvironmentId,
714    build_info: &'static BuildInfo,
715    cluster: Option<&ClusterScopeContext>,
716    replica: Option<&ReplicaScopeContext>,
717) -> Result<ld::Context, anyhow::Error> {
718    // Register multiple contexts for this client.
719    //
720    // Unfortunately, it seems that the order in which conflicting targeting
721    // rules are applied depends on the definition order of feature flag
722    // variations rather than on the order in which context are registered with
723    // the multi-context builder.
724    let mut ctx_builder = ld::MultiContextBuilder::new();
725
726    if env_id.cloud_provider() != &CloudProvider::Local {
727        ctx_builder.add_context(
728            ld::ContextBuilder::new(env_id.to_string())
729                .kind("environment")
730                .set_string("cloud_provider", env_id.cloud_provider().to_string())
731                .set_string("cloud_provider_region", env_id.cloud_provider_region())
732                .set_string("organization_id", env_id.organization_id().to_string())
733                .set_string("ordinal", env_id.ordinal().to_string())
734                .build()
735                .map_err(|e| anyhow::anyhow!(e))?,
736        );
737        ctx_builder.add_context(
738            ld::ContextBuilder::new(env_id.organization_id().to_string())
739                .kind("organization")
740                .build()
741                .map_err(|e| anyhow::anyhow!(e))?,
742        );
743    } else {
744        // If cloud_provider is 'local', use anonymous `environment` and
745        // `organization` contexts with fixed keys, as otherwise we will create
746        // a lot of additional contexts (which are the billable entity for
747        // LaunchDarkly).
748        ctx_builder.add_context(
749            ld::ContextBuilder::new("anonymous-dev@materialize.com")
750                .anonymous(true) // exclude this user from the dashboard
751                .kind("environment")
752                .set_string("cloud_provider", env_id.cloud_provider().to_string())
753                .set_string("cloud_provider_region", env_id.cloud_provider_region())
754                .set_string("organization_id", uuid::Uuid::nil().to_string())
755                .set_string("ordinal", env_id.ordinal().to_string())
756                .build()
757                .map_err(|e| anyhow::anyhow!(e))?,
758        );
759        ctx_builder.add_context(
760            ld::ContextBuilder::new(uuid::Uuid::nil().to_string())
761                .anonymous(true) // exclude this user from the dashboard
762                .kind("organization")
763                .build()
764                .map_err(|e| anyhow::anyhow!(e))?,
765        );
766    };
767
768    ctx_builder.add_context(
769        ld::ContextBuilder::new(build_info.sha)
770            .kind("build")
771            .set_string("semver_version", build_info.semver_version().to_string())
772            .build()
773            .map_err(|e| anyhow::anyhow!(e))?,
774    );
775
776    // Cluster-coherent resolution evaluates with a `cluster` context (no
777    // replica attributes). Replica-local resolution additionally carries a
778    // `replica` context. The environment-wide pass carries neither.
779    if let Some(cluster) = cluster {
780        ctx_builder.add_context(cluster_context(cluster)?);
781    }
782    if let Some(replica) = replica {
783        ctx_builder.add_context(replica_context(replica)?);
784    }
785
786    ctx_builder.build().map_err(|e| anyhow::anyhow!(e))
787}
788
789#[cfg(test)]
790mod tests {
791    use std::sync::atomic::{AtomicU64, Ordering};
792
793    use futures::StreamExt;
794    use launchdarkly_sdk_transport::{ByteStream, TransportError};
795    use mz_build_info::DUMMY_BUILD_INFO;
796    use mz_ore::metrics::MetricsRegistry;
797
798    use super::*;
799
800    fn env_id() -> EnvironmentId {
801        EnvironmentId::for_tests()
802    }
803
804    /// A file-backed frontend reading `path`.
805    fn file_frontend_at(path: &Path) -> SystemParameterFrontend {
806        SystemParameterFrontend {
807            client: SystemParameterFrontendClient::File { path: path.into() },
808            key_map: BTreeMap::new(),
809            env_id: env_id(),
810            build_info: &DUMMY_BUILD_INFO,
811            metrics: Metrics::register_into(&MetricsRegistry::new()),
812            config_file: Mutex::new(None),
813        }
814    }
815
816    /// A file-backed frontend for tests that drive it from contents they pass
817    /// in, so its path is never read.
818    fn file_frontend() -> SystemParameterFrontend {
819        file_frontend_at(Path::new(CONFIG_PATH))
820    }
821
822    /// The path a [`file_frontend`] reports in its warnings. Never opened.
823    const CONFIG_PATH: &str = "/nonexistent/system-params.json";
824
825    /// Parses a document the test knows to be a JSON object.
826    fn parse(contents: &str) -> ConfigFile {
827        ConfigFile::parse(contents).expect("document is a JSON object")
828    }
829
830    #[mz_ore::test]
831    fn test_parse_flat_file_is_environment_wide() {
832        let file = parse(
833            r#"{
834                "max_connections": 1000,
835                "allowed_cluster_replica_sizes": "'25cc', '50cc'",
836                "enable_lgalloc": false
837            }"#,
838        );
839
840        assert_eq!(
841            file.environment.keys().collect::<Vec<_>>(),
842            vec![
843                "allowed_cluster_replica_sizes",
844                "enable_lgalloc",
845                "max_connections"
846            ]
847        );
848
849        // Every JSON scalar renders to the raw string the backend parses.
850        assert_eq!(
851            json_param_value(&file.environment["max_connections"]).as_deref(),
852            Some("1000")
853        );
854        assert_eq!(
855            json_param_value(&file.environment["enable_lgalloc"]).as_deref(),
856            Some("false")
857        );
858        assert_eq!(
859            json_param_value(&file.environment["allowed_cluster_replica_sizes"]).as_deref(),
860            Some("'25cc', '50cc'")
861        );
862        // An explicit `null` expresses no opinion, leaving the value alone.
863        let null = parse(r#"{"max_connections": null}"#);
864        assert_eq!(json_param_value(&null.environment["max_connections"]), None);
865    }
866
867    /// A document that is not a JSON object carries no information, which is
868    /// distinct from an empty document.
869    #[mz_ore::test]
870    fn test_parse_rejects_non_object_document() {
871        assert_eq!(ConfigFile::parse("[]"), None);
872        assert_eq!(ConfigFile::parse("not json"), None);
873        assert_eq!(ConfigFile::parse("{}"), Some(ConfigFile::default()));
874    }
875
876    #[mz_ore::test]
877    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decNumberFromInt32` on OS `linux`
878    fn test_config_file_cached_until_it_changes() {
879        let frontend = file_frontend();
880        let read = |contents: &str| {
881            frontend
882                .refresh_config_file(Path::new(CONFIG_PATH), Ok(contents.to_string()))
883                .expect("document is a JSON object")
884        };
885
886        let first = read(r#"{"max_connections": 1000}"#);
887        let again = read(r#"{"max_connections": 1000}"#);
888        assert!(Arc::ptr_eq(&first, &again), "unchanged file was re-parsed");
889
890        let changed = read(r#"{"max_connections": 2000}"#);
891        assert!(
892            !Arc::ptr_eq(&first, &changed),
893            "changed file was not parsed"
894        );
895    }
896
897    #[mz_ore::test]
898    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decNumberFromInt32` on OS `linux`
899    fn test_unreadable_file_has_no_scoped_desired_state() {
900        let frontend = file_frontend();
901        let refresh = |read: io::Result<String>| {
902            frontend.refresh_config_file(Path::new(CONFIG_PATH), read);
903        };
904
905        refresh(Ok(r#"{"max_connections": 1000}"#.to_string()));
906        assert!(frontend.has_scoped_desired_state());
907
908        for failure in [
909            Err(io::Error::from(io::ErrorKind::NotFound)),
910            Ok("}not json{".to_string()),
911            Ok("[]".to_string()),
912        ] {
913            refresh(failure);
914            assert!(!frontend.has_scoped_desired_state());
915
916            refresh(Ok(r#"{"max_connections": 1000}"#.to_string()));
917            assert!(frontend.has_scoped_desired_state());
918        }
919
920        // An empty document is a complete desired state of "no overrides".
921        refresh(Ok("{}".to_string()));
922        assert!(frontend.has_scoped_desired_state());
923    }
924
925    #[mz_ore::test]
926    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decNumberFromInt32` on OS `linux`
927    fn test_pull_from_file() {
928        let path = std::env::temp_dir().join(format!(
929            "mz-adapter-test-pull-from-file-{}.json",
930            std::process::id()
931        ));
932        let frontend = file_frontend_at(&path);
933        let mut params = SynchronizedParameters::default();
934        let max_connections = params.get("max_connections");
935        assert_ne!(max_connections, "1000");
936
937        fs::write(
938            &path,
939            r#"{"max_connections": 1000, "enable_lgalloc": false, "max_tables": null}"#,
940        )
941        .expect("can write the test file");
942        assert!(frontend.pull(&mut params));
943        assert_eq!(params.get("max_connections"), "1000");
944        assert_eq!(params.get("enable_lgalloc"), "off");
945        assert_eq!(
946            params.get("max_tables"),
947            SynchronizedParameters::default().get("max_tables"),
948            "null changed the value"
949        );
950
951        // Unparseable or missing, the file leaves every current value alone.
952        fs::write(&path, "}not json{").expect("can write the test file");
953        assert!(!frontend.pull(&mut params));
954        assert!(!frontend.has_scoped_desired_state());
955        fs::remove_file(&path).expect("can remove the test file");
956        assert!(!frontend.pull(&mut params));
957        assert!(!frontend.has_scoped_desired_state());
958        assert_eq!(params.get("max_connections"), "1000");
959        assert_eq!(params.get("enable_lgalloc"), "off");
960    }
961
962    #[mz_ore::test]
963    fn builds_cluster_scoped_context() {
964        // Cluster-coherent resolution evaluates with a replica-free `cluster`
965        // context.
966        let cluster = ClusterScopeContext {
967            id: "s2".into(),
968            name: "mz_catalog_server".into(),
969            is_builtin: true,
970        };
971        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), None)
972            .expect("cluster-scoped context builds");
973    }
974
975    #[mz_ore::test]
976    fn builds_replica_scoped_context() {
977        // Replica-local resolution carries both a `cluster` and a `replica`
978        // context so a rule can combine size family and cluster.
979        let cluster = ClusterScopeContext {
980            id: "u1".into(),
981            name: "quickstart".into(),
982            is_builtin: false,
983        };
984        let replica = ReplicaScopeContext {
985            id: "u1-replica-1".into(),
986            name: "r1".into(),
987            is_builtin: false,
988            size: "D.1-xsmall".into(),
989            size_family: "D".into(),
990            cluster_id: "u1".into(),
991            cluster_name: "quickstart".into(),
992        };
993        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), Some(&replica))
994            .expect("replica-scoped context builds");
995    }
996
997    #[mz_ore::test]
998    fn environment_wide_context_is_unscoped() {
999        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, None, None).expect("environment-wide context builds");
1000    }
1001
1002    /// A fake transport that simulates a long-lived SSE streaming connection:
1003    /// returns 200 OK immediately, then delivers multiple SSE events as body
1004    /// chunks (exactly how LaunchDarkly's streaming data source works).
1005    #[derive(Clone)]
1006    struct FakeSseTransport;
1007
1008    impl HttpTransport for FakeSseTransport {
1009        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
1010            let body: ByteStream = Box::pin(futures::stream::iter(vec![
1011                Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
1012                Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag1\"}\n\n")),
1013                Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag2\"}\n\n")),
1014            ]));
1015            Box::pin(async move {
1016                http::Response::builder()
1017                    .status(200)
1018                    .body(body)
1019                    .map_err(|e| TransportError::new(std::io::Error::other(e)))
1020            })
1021        }
1022    }
1023
1024    /// A fake transport that returns an error, simulating a failed connection.
1025    #[derive(Clone)]
1026    struct FailingTransport;
1027
1028    impl HttpTransport for FailingTransport {
1029        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
1030            Box::pin(async move {
1031                Err(TransportError::new(std::io::Error::new(
1032                    std::io::ErrorKind::ConnectionRefused,
1033                    "connection refused",
1034                )))
1035            })
1036        }
1037    }
1038
1039    /// A fake transport that returns 200 OK, delivers one event, then errors
1040    /// mid-stream with a timeout: the non-Eof stream error a dropped long-lived
1041    /// SSE connection surfaces.
1042    #[derive(Clone)]
1043    struct MidStreamFailureTransport;
1044
1045    impl HttpTransport for MidStreamFailureTransport {
1046        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
1047            let body: ByteStream = Box::pin(futures::stream::iter(vec![
1048                Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
1049                Err(TransportError::new(std::io::Error::new(
1050                    std::io::ErrorKind::TimedOut,
1051                    "body timed out",
1052                ))),
1053            ]));
1054            Box::pin(async move {
1055                http::Response::builder()
1056                    .status(200)
1057                    .body(body)
1058                    .map_err(|e| TransportError::new(std::io::Error::other(e)))
1059            })
1060        }
1061    }
1062
1063    fn test_gauge(registry: &MetricsRegistry, name: &str) -> UIntGauge {
1064        registry.register(mz_ore::metric!(
1065            name: name,
1066            help: "test gauge",
1067        ))
1068    }
1069
1070    /// Verifies that MetricsTransport updates the gauge on each body chunk,
1071    /// not just on the initial HTTP 200 response head. This matters for
1072    /// long-lived streaming connections where SSE events arrive as body chunks.
1073    #[mz_ore::test(tokio::test)]
1074    async fn test_metric_updated_on_body_chunks() -> Result<(), anyhow::Error> {
1075        let time = Arc::new(AtomicU64::new(1_000_000));
1076        let time_clone = Arc::clone(&time);
1077        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
1078
1079        let registry = MetricsRegistry::new();
1080        let gauge = test_gauge(&registry, "test_sse_gauge");
1081
1082        let transport = MetricsTransport {
1083            inner: FakeSseTransport,
1084            last_success_gauge: gauge.clone(),
1085            now_fn,
1086        };
1087
1088        assert_eq!(gauge.get(), 0);
1089
1090        let request = http::Request::builder()
1091            .uri("https://stream.launchdarkly.com/all")
1092            .body(None)?;
1093        let response = transport.request(request).await?;
1094
1095        assert_eq!(gauge.get(), 1000);
1096
1097        time.store(2_800_000, Ordering::SeqCst);
1098
1099        let mut body = response.into_body();
1100        let mut event_count = 0;
1101        while let Some(Ok(_chunk)) = body.next().await {
1102            event_count += 1;
1103        }
1104        assert_eq!(event_count, 3);
1105
1106        assert_eq!(gauge.get(), 2800);
1107        Ok(())
1108    }
1109
1110    #[mz_ore::test(tokio::test)]
1111    async fn test_cse_metric_updates_correctly_per_request() -> Result<(), anyhow::Error> {
1112        let time = Arc::new(AtomicU64::new(1_000_000));
1113        let time_clone = Arc::clone(&time);
1114        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
1115
1116        let registry = MetricsRegistry::new();
1117        let gauge = test_gauge(&registry, "test_cse_gauge");
1118
1119        let transport = MetricsTransport {
1120            inner: FakeSseTransport,
1121            last_success_gauge: gauge.clone(),
1122            now_fn,
1123        };
1124
1125        let req = || -> Result<http::Request<Option<Bytes>>, http::Error> {
1126            http::Request::builder()
1127                .uri("https://events.launchdarkly.com/bulk")
1128                .body(None)
1129        };
1130
1131        let _ = transport.request(req()?).await?;
1132        assert_eq!(gauge.get(), 1000);
1133
1134        time.store(2_000_000, Ordering::SeqCst);
1135        let _ = transport.request(req()?).await?;
1136        assert_eq!(gauge.get(), 2000);
1137
1138        time.store(3_000_000, Ordering::SeqCst);
1139        let _ = transport.request(req()?).await?;
1140        assert_eq!(gauge.get(), 3000);
1141        Ok(())
1142    }
1143
1144    #[mz_ore::test(tokio::test)]
1145    async fn test_metric_not_updated_on_failed_request() -> Result<(), anyhow::Error> {
1146        let now_fn = NowFn::from(|| 5_000_000u64);
1147
1148        let registry = MetricsRegistry::new();
1149        let gauge = test_gauge(&registry, "test_fail_gauge");
1150
1151        let transport = MetricsTransport {
1152            inner: FailingTransport,
1153            last_success_gauge: gauge.clone(),
1154            now_fn,
1155        };
1156
1157        let request = http::Request::builder()
1158            .uri("https://stream.launchdarkly.com/all")
1159            .body(None)?;
1160        let result = transport.request(request).await;
1161        assert!(result.is_err());
1162        assert_eq!(gauge.get(), 0, "gauge must not update on transport error");
1163        Ok(())
1164    }
1165
1166    /// Verifies that when an SSE connection returns 200 OK and then dies
1167    /// mid-stream, `last_sse_time_seconds` advances only for the events that
1168    /// arrived and then freezes — the frozen timestamp is what lets the
1169    /// staleness alert detect a stuck data source.
1170    #[mz_ore::test(tokio::test)]
1171    async fn test_metric_frozen_on_midstream_error() -> Result<(), anyhow::Error> {
1172        let time = Arc::new(AtomicU64::new(1_000_000));
1173        let time_clone = Arc::clone(&time);
1174        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
1175
1176        let registry = MetricsRegistry::new();
1177        let gauge = test_gauge(&registry, "test_midstream_gauge");
1178
1179        let transport = MetricsTransport {
1180            inner: MidStreamFailureTransport,
1181            last_success_gauge: gauge.clone(),
1182            now_fn,
1183        };
1184
1185        // The 200 OK response head updates the gauge.
1186        let request = http::Request::builder()
1187            .uri("https://stream.launchdarkly.com/all")
1188            .body(None)?;
1189        let response = transport.request(request).await?;
1190        assert_eq!(gauge.get(), 1000);
1191
1192        // The first event arrives and advances the gauge.
1193        time.store(2_000_000, Ordering::SeqCst);
1194        let mut body = response.into_body();
1195        assert!(matches!(body.next().await, Some(Ok(_))));
1196        assert_eq!(gauge.get(), 2000);
1197
1198        // The stream then errors mid-flight. Time has moved forward, but the
1199        // gauge must stay frozen at the last successful event.
1200        time.store(9_000_000, Ordering::SeqCst);
1201        assert!(matches!(body.next().await, Some(Err(_))));
1202        assert_eq!(
1203            gauge.get(),
1204            2000,
1205            "gauge must freeze on mid-stream error so the staleness alert can fire"
1206        );
1207        Ok(())
1208    }
1209}