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    // NOTE: `HyperTransport` auto-detects the `HTTP_PROXY`/`HTTPS_PROXY`/
561    // `NO_PROXY` env vars and routes through a configured proxy. No exposure
562    // today (our cloud pods set no proxy vars, self-managed never builds an LD
563    // client), but worth knowing if proxy vars ever appear on a pod.
564    let transport = mz_dyncfg_launchdarkly::https_transport(Duration::from_secs(10), read_timeout)
565        .expect("failed to create HTTPS transport");
566
567    let cse_transport = MetricsTransport {
568        inner: transport.clone(),
569        last_success_gauge: metrics.last_cse_time_seconds.clone(),
570        now_fn: now_fn.clone(),
571    };
572    let data_source_transport = MetricsTransport {
573        inner: transport,
574        last_success_gauge: metrics.last_sse_time_seconds.clone(),
575        now_fn: now_fn.clone(),
576    };
577
578    let mut event_processor = ld::EventProcessorBuilder::new();
579    event_processor.transport(cse_transport);
580
581    let mut data_source = ld::StreamingDataSourceBuilder::new();
582    data_source.transport(data_source_transport);
583
584    let mut config = ld::ConfigBuilder::new(api_key)
585        .event_processor(&event_processor)
586        .data_source(&data_source);
587    if let Some(base_uri) = base_uri {
588        let mut endpoints = ld::ServiceEndpointsBuilder::new();
589        endpoints.relay_proxy(base_uri);
590        config = config.service_endpoints(&endpoints);
591    }
592    config.build().expect("valid config")
593}
594
595async fn ld_client(
596    api_key: &str,
597    base_uri: Option<&str>,
598    metrics: &Metrics,
599    now_fn: &NowFn,
600) -> Result<Arc<ld::Client>, anyhow::Error> {
601    let ld_client = ld::Client::build(ld_config(api_key, base_uri, metrics, now_fn))?;
602    tracing::info!("waiting for SystemParameterFrontend to initialize");
603    ld_client.start_with_default_executor();
604
605    let max_backoff = Duration::from_secs(60);
606    let mut backoff = Duration::from_secs(5);
607    let timeout = Duration::from_secs(10);
608
609    // TODO(materialize#38055): fix retry logic
610    loop {
611        match ld_client.wait_for_initialization(timeout).await {
612            Some(true) => break,
613            Some(false) => tracing::warn!("SystemParameterFrontend failed to initialize"),
614            None => tracing::warn!("SystemParameterFrontend initialization timed out"),
615        }
616
617        time::sleep(backoff).await;
618        backoff = (backoff * 2).min(max_backoff);
619    }
620
621    tracing::info!("successfully initialized SystemParameterFrontend");
622
623    Ok(Arc::new(ld_client))
624}
625
626/// Identity of a cluster, used to build a `cluster` context kind for
627/// cluster-coherent scoped feature flags.
628///
629/// Exposes both `id` and `name`: an LD rule that targets `cluster_id` is an
630/// incarnation pin that dies on drop/recreate (ids are never reused), while a
631/// rule targeting `cluster_name` / `is_builtin` is a durable role predicate
632/// that re-applies to any matching cluster. See the scoped feature flags
633/// design.
634#[derive(Clone, Debug)]
635pub struct ClusterScopeContext {
636    /// The cluster's catalog id, e.g. `s2` or `u1`.
637    pub id: String,
638    /// The cluster's name, e.g. `mz_catalog_server`.
639    pub name: String,
640    /// Whether the cluster is a builtin (system) cluster.
641    pub is_builtin: bool,
642}
643
644/// Identity of a replica, used to build a `replica` context kind for
645/// replica-local scoped feature flags.
646///
647/// Carries the owning cluster's identity as attributes so that replica-local
648/// flags can be cluster-targeted without a second evaluation, and the replica
649/// size and size *family* so flags can be keyed by size family (e.g. legacy
650/// sizes keep `lgalloc`). See the scoped feature flags design.
651#[derive(Clone, Debug)]
652pub struct ReplicaScopeContext {
653    /// The replica's catalog id.
654    pub id: String,
655    /// The replica's name.
656    pub name: String,
657    /// Whether the replica belongs to a builtin (system) cluster.
658    pub is_builtin: bool,
659    /// The replica's full size name, e.g. `D.1-xsmall` or a legacy t-shirt size
660    /// like `xsmall`. This is the fine-grained targeting axis. The coarse axis
661    /// is [`Self::size_family`]. The two are distinct: `D.1-xsmall` is a size,
662    /// `D` is its family.
663    pub size: String,
664    /// The replica's size family, e.g. `D` or `legacy`. The coarse targeting
665    /// axis, derived from the size map rather than the size name (see
666    /// [`Self::size`]).
667    pub size_family: String,
668    /// The owning cluster's catalog id.
669    pub cluster_id: String,
670    /// The owning cluster's name.
671    pub cluster_name: String,
672}
673
674/// Builds a single `cluster` context kind from a [`ClusterScopeContext`].
675///
676/// Deliberately replica-free: cluster-coherent flags must resolve identically
677/// across a cluster's replicas, so no replica/size attributes appear here.
678fn cluster_context(cluster: &ClusterScopeContext) -> Result<ld::Context, anyhow::Error> {
679    ld::ContextBuilder::new(cluster.id.as_str())
680        .anonymous(true) // keep the LD dashboard Contexts list clean
681        .kind("cluster")
682        .set_string("cluster_id", cluster.id.clone())
683        .set_string("cluster_name", cluster.name.clone())
684        .set_string("is_builtin", cluster.is_builtin.to_string())
685        .build()
686        .map_err(|e| anyhow::anyhow!(e))
687}
688
689/// Builds a single `replica` context kind from a [`ReplicaScopeContext`].
690///
691/// Includes the owning cluster's identity so a rule can combine both axes,
692/// e.g. "size family `D` *and* cluster `foo`".
693fn replica_context(replica: &ReplicaScopeContext) -> Result<ld::Context, anyhow::Error> {
694    ld::ContextBuilder::new(replica.id.as_str())
695        .anonymous(true) // keep the LD dashboard Contexts list clean
696        .kind("replica")
697        .set_string("replica_id", replica.id.clone())
698        .set_string("replica_name", replica.name.clone())
699        .set_string("is_builtin", replica.is_builtin.to_string())
700        .set_string("replica_size", replica.size.clone())
701        .set_string("replica_size_family", replica.size_family.clone())
702        .set_string("cluster_id", replica.cluster_id.clone())
703        .set_string("cluster_name", replica.cluster_name.clone())
704        .build()
705        .map_err(|e| anyhow::anyhow!(e))
706}
707
708/// Builds a multi-context for evaluating scoped feature flags.
709///
710/// Composes the base contexts (`environment` + `organization` + `build`) with:
711/// - a `cluster` context for cluster-coherent (replica-free) resolution, and/or
712/// - a `replica` context for replica-local resolution.
713///
714/// The environment-wide pass passes `None` for both. This is the single entry
715/// point the sync loop uses to evaluate each scoped pass.
716fn ld_ctx(
717    env_id: &EnvironmentId,
718    build_info: &'static BuildInfo,
719    cluster: Option<&ClusterScopeContext>,
720    replica: Option<&ReplicaScopeContext>,
721) -> Result<ld::Context, anyhow::Error> {
722    // Register multiple contexts for this client.
723    //
724    // Unfortunately, it seems that the order in which conflicting targeting
725    // rules are applied depends on the definition order of feature flag
726    // variations rather than on the order in which context are registered with
727    // the multi-context builder.
728    let mut ctx_builder = ld::MultiContextBuilder::new();
729
730    if env_id.cloud_provider() != &CloudProvider::Local {
731        ctx_builder.add_context(
732            ld::ContextBuilder::new(env_id.to_string())
733                .kind("environment")
734                .set_string("cloud_provider", env_id.cloud_provider().to_string())
735                .set_string("cloud_provider_region", env_id.cloud_provider_region())
736                .set_string("organization_id", env_id.organization_id().to_string())
737                .set_string("ordinal", env_id.ordinal().to_string())
738                .build()
739                .map_err(|e| anyhow::anyhow!(e))?,
740        );
741        ctx_builder.add_context(
742            ld::ContextBuilder::new(env_id.organization_id().to_string())
743                .kind("organization")
744                .build()
745                .map_err(|e| anyhow::anyhow!(e))?,
746        );
747    } else {
748        // If cloud_provider is 'local', use anonymous `environment` and
749        // `organization` contexts with fixed keys, as otherwise we will create
750        // a lot of additional contexts (which are the billable entity for
751        // LaunchDarkly).
752        ctx_builder.add_context(
753            ld::ContextBuilder::new("anonymous-dev@materialize.com")
754                .anonymous(true) // exclude this user from the dashboard
755                .kind("environment")
756                .set_string("cloud_provider", env_id.cloud_provider().to_string())
757                .set_string("cloud_provider_region", env_id.cloud_provider_region())
758                .set_string("organization_id", uuid::Uuid::nil().to_string())
759                .set_string("ordinal", env_id.ordinal().to_string())
760                .build()
761                .map_err(|e| anyhow::anyhow!(e))?,
762        );
763        ctx_builder.add_context(
764            ld::ContextBuilder::new(uuid::Uuid::nil().to_string())
765                .anonymous(true) // exclude this user from the dashboard
766                .kind("organization")
767                .build()
768                .map_err(|e| anyhow::anyhow!(e))?,
769        );
770    };
771
772    ctx_builder.add_context(
773        ld::ContextBuilder::new(build_info.sha)
774            .kind("build")
775            .set_string("semver_version", build_info.semver_version().to_string())
776            .build()
777            .map_err(|e| anyhow::anyhow!(e))?,
778    );
779
780    // Cluster-coherent resolution evaluates with a `cluster` context (no
781    // replica attributes). Replica-local resolution additionally carries a
782    // `replica` context. The environment-wide pass carries neither.
783    if let Some(cluster) = cluster {
784        ctx_builder.add_context(cluster_context(cluster)?);
785    }
786    if let Some(replica) = replica {
787        ctx_builder.add_context(replica_context(replica)?);
788    }
789
790    ctx_builder.build().map_err(|e| anyhow::anyhow!(e))
791}
792
793#[cfg(test)]
794mod tests {
795    use std::sync::atomic::{AtomicU64, Ordering};
796
797    use futures::StreamExt;
798    use launchdarkly_sdk_transport::{ByteStream, TransportError};
799    use mz_build_info::DUMMY_BUILD_INFO;
800    use mz_ore::metrics::MetricsRegistry;
801
802    use super::*;
803
804    fn env_id() -> EnvironmentId {
805        EnvironmentId::for_tests()
806    }
807
808    /// A file-backed frontend reading `path`.
809    fn file_frontend_at(path: &Path) -> SystemParameterFrontend {
810        SystemParameterFrontend {
811            client: SystemParameterFrontendClient::File { path: path.into() },
812            key_map: BTreeMap::new(),
813            env_id: env_id(),
814            build_info: &DUMMY_BUILD_INFO,
815            metrics: Metrics::register_into(&MetricsRegistry::new()),
816            config_file: Mutex::new(None),
817        }
818    }
819
820    /// A file-backed frontend for tests that drive it from contents they pass
821    /// in, so its path is never read.
822    fn file_frontend() -> SystemParameterFrontend {
823        file_frontend_at(Path::new(CONFIG_PATH))
824    }
825
826    /// The path a [`file_frontend`] reports in its warnings. Never opened.
827    const CONFIG_PATH: &str = "/nonexistent/system-params.json";
828
829    /// Parses a document the test knows to be a JSON object.
830    fn parse(contents: &str) -> ConfigFile {
831        ConfigFile::parse(contents).expect("document is a JSON object")
832    }
833
834    #[mz_ore::test]
835    fn test_parse_flat_file_is_environment_wide() {
836        let file = parse(
837            r#"{
838                "max_connections": 1000,
839                "allowed_cluster_replica_sizes": "'25cc', '50cc'",
840                "enable_lgalloc": false
841            }"#,
842        );
843
844        assert_eq!(
845            file.environment.keys().collect::<Vec<_>>(),
846            vec![
847                "allowed_cluster_replica_sizes",
848                "enable_lgalloc",
849                "max_connections"
850            ]
851        );
852
853        // Every JSON scalar renders to the raw string the backend parses.
854        assert_eq!(
855            json_param_value(&file.environment["max_connections"]).as_deref(),
856            Some("1000")
857        );
858        assert_eq!(
859            json_param_value(&file.environment["enable_lgalloc"]).as_deref(),
860            Some("false")
861        );
862        assert_eq!(
863            json_param_value(&file.environment["allowed_cluster_replica_sizes"]).as_deref(),
864            Some("'25cc', '50cc'")
865        );
866        // An explicit `null` expresses no opinion, leaving the value alone.
867        let null = parse(r#"{"max_connections": null}"#);
868        assert_eq!(json_param_value(&null.environment["max_connections"]), None);
869    }
870
871    /// A document that is not a JSON object carries no information, which is
872    /// distinct from an empty document.
873    #[mz_ore::test]
874    fn test_parse_rejects_non_object_document() {
875        assert_eq!(ConfigFile::parse("[]"), None);
876        assert_eq!(ConfigFile::parse("not json"), None);
877        assert_eq!(ConfigFile::parse("{}"), Some(ConfigFile::default()));
878    }
879
880    #[mz_ore::test]
881    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decNumberFromInt32` on OS `linux`
882    fn test_config_file_cached_until_it_changes() {
883        let frontend = file_frontend();
884        let read = |contents: &str| {
885            frontend
886                .refresh_config_file(Path::new(CONFIG_PATH), Ok(contents.to_string()))
887                .expect("document is a JSON object")
888        };
889
890        let first = read(r#"{"max_connections": 1000}"#);
891        let again = read(r#"{"max_connections": 1000}"#);
892        assert!(Arc::ptr_eq(&first, &again), "unchanged file was re-parsed");
893
894        let changed = read(r#"{"max_connections": 2000}"#);
895        assert!(
896            !Arc::ptr_eq(&first, &changed),
897            "changed file was not parsed"
898        );
899    }
900
901    #[mz_ore::test]
902    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decNumberFromInt32` on OS `linux`
903    fn test_unreadable_file_has_no_scoped_desired_state() {
904        let frontend = file_frontend();
905        let refresh = |read: io::Result<String>| {
906            frontend.refresh_config_file(Path::new(CONFIG_PATH), read);
907        };
908
909        refresh(Ok(r#"{"max_connections": 1000}"#.to_string()));
910        assert!(frontend.has_scoped_desired_state());
911
912        for failure in [
913            Err(io::Error::from(io::ErrorKind::NotFound)),
914            Ok("}not json{".to_string()),
915            Ok("[]".to_string()),
916        ] {
917            refresh(failure);
918            assert!(!frontend.has_scoped_desired_state());
919
920            refresh(Ok(r#"{"max_connections": 1000}"#.to_string()));
921            assert!(frontend.has_scoped_desired_state());
922        }
923
924        // An empty document is a complete desired state of "no overrides".
925        refresh(Ok("{}".to_string()));
926        assert!(frontend.has_scoped_desired_state());
927    }
928
929    #[mz_ore::test]
930    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `decNumberFromInt32` on OS `linux`
931    fn test_pull_from_file() {
932        let path = std::env::temp_dir().join(format!(
933            "mz-adapter-test-pull-from-file-{}.json",
934            std::process::id()
935        ));
936        let frontend = file_frontend_at(&path);
937        let mut params = SynchronizedParameters::default();
938        let max_connections = params.get("max_connections");
939        assert_ne!(max_connections, "1000");
940
941        fs::write(
942            &path,
943            r#"{"max_connections": 1000, "enable_lgalloc": false, "max_tables": null}"#,
944        )
945        .expect("can write the test file");
946        assert!(frontend.pull(&mut params));
947        assert_eq!(params.get("max_connections"), "1000");
948        assert_eq!(params.get("enable_lgalloc"), "off");
949        assert_eq!(
950            params.get("max_tables"),
951            SynchronizedParameters::default().get("max_tables"),
952            "null changed the value"
953        );
954
955        // Unparseable or missing, the file leaves every current value alone.
956        fs::write(&path, "}not json{").expect("can write the test file");
957        assert!(!frontend.pull(&mut params));
958        assert!(!frontend.has_scoped_desired_state());
959        fs::remove_file(&path).expect("can remove the test file");
960        assert!(!frontend.pull(&mut params));
961        assert!(!frontend.has_scoped_desired_state());
962        assert_eq!(params.get("max_connections"), "1000");
963        assert_eq!(params.get("enable_lgalloc"), "off");
964    }
965
966    #[mz_ore::test]
967    fn builds_cluster_scoped_context() {
968        // Cluster-coherent resolution evaluates with a replica-free `cluster`
969        // context.
970        let cluster = ClusterScopeContext {
971            id: "s2".into(),
972            name: "mz_catalog_server".into(),
973            is_builtin: true,
974        };
975        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), None)
976            .expect("cluster-scoped context builds");
977    }
978
979    #[mz_ore::test]
980    fn builds_replica_scoped_context() {
981        // Replica-local resolution carries both a `cluster` and a `replica`
982        // context so a rule can combine size family and cluster.
983        let cluster = ClusterScopeContext {
984            id: "u1".into(),
985            name: "quickstart".into(),
986            is_builtin: false,
987        };
988        let replica = ReplicaScopeContext {
989            id: "u1-replica-1".into(),
990            name: "r1".into(),
991            is_builtin: false,
992            size: "D.1-xsmall".into(),
993            size_family: "D".into(),
994            cluster_id: "u1".into(),
995            cluster_name: "quickstart".into(),
996        };
997        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), Some(&replica))
998            .expect("replica-scoped context builds");
999    }
1000
1001    #[mz_ore::test]
1002    fn environment_wide_context_is_unscoped() {
1003        ld_ctx(&env_id(), &DUMMY_BUILD_INFO, None, None).expect("environment-wide context builds");
1004    }
1005
1006    /// A fake transport that simulates a long-lived SSE streaming connection:
1007    /// returns 200 OK immediately, then delivers multiple SSE events as body
1008    /// chunks (exactly how LaunchDarkly's streaming data source works).
1009    #[derive(Clone)]
1010    struct FakeSseTransport;
1011
1012    impl HttpTransport for FakeSseTransport {
1013        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
1014            let body: ByteStream = Box::pin(futures::stream::iter(vec![
1015                Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
1016                Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag1\"}\n\n")),
1017                Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag2\"}\n\n")),
1018            ]));
1019            Box::pin(async move {
1020                http::Response::builder()
1021                    .status(200)
1022                    .body(body)
1023                    .map_err(|e| TransportError::new(std::io::Error::other(e)))
1024            })
1025        }
1026    }
1027
1028    /// A fake transport that returns an error, simulating a failed connection.
1029    #[derive(Clone)]
1030    struct FailingTransport;
1031
1032    impl HttpTransport for FailingTransport {
1033        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
1034            Box::pin(async move {
1035                Err(TransportError::new(std::io::Error::new(
1036                    std::io::ErrorKind::ConnectionRefused,
1037                    "connection refused",
1038                )))
1039            })
1040        }
1041    }
1042
1043    /// A fake transport that returns 200 OK, delivers one event, then errors
1044    /// mid-stream with a timeout: the non-Eof stream error a dropped long-lived
1045    /// SSE connection surfaces.
1046    #[derive(Clone)]
1047    struct MidStreamFailureTransport;
1048
1049    impl HttpTransport for MidStreamFailureTransport {
1050        fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
1051            let body: ByteStream = Box::pin(futures::stream::iter(vec![
1052                Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
1053                Err(TransportError::new(std::io::Error::new(
1054                    std::io::ErrorKind::TimedOut,
1055                    "body timed out",
1056                ))),
1057            ]));
1058            Box::pin(async move {
1059                http::Response::builder()
1060                    .status(200)
1061                    .body(body)
1062                    .map_err(|e| TransportError::new(std::io::Error::other(e)))
1063            })
1064        }
1065    }
1066
1067    fn test_gauge(registry: &MetricsRegistry, name: &str) -> UIntGauge {
1068        registry.register(mz_ore::metric!(
1069            name: name,
1070            help: "test gauge",
1071        ))
1072    }
1073
1074    /// Verifies that MetricsTransport updates the gauge on each body chunk,
1075    /// not just on the initial HTTP 200 response head. This matters for
1076    /// long-lived streaming connections where SSE events arrive as body chunks.
1077    #[mz_ore::test(tokio::test)]
1078    async fn test_metric_updated_on_body_chunks() -> Result<(), anyhow::Error> {
1079        let time = Arc::new(AtomicU64::new(1_000_000));
1080        let time_clone = Arc::clone(&time);
1081        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
1082
1083        let registry = MetricsRegistry::new();
1084        let gauge = test_gauge(&registry, "test_sse_gauge");
1085
1086        let transport = MetricsTransport {
1087            inner: FakeSseTransport,
1088            last_success_gauge: gauge.clone(),
1089            now_fn,
1090        };
1091
1092        assert_eq!(gauge.get(), 0);
1093
1094        let request = http::Request::builder()
1095            .uri("https://stream.launchdarkly.com/all")
1096            .body(None)?;
1097        let response = transport.request(request).await?;
1098
1099        assert_eq!(gauge.get(), 1000);
1100
1101        time.store(2_800_000, Ordering::SeqCst);
1102
1103        let mut body = response.into_body();
1104        let mut event_count = 0;
1105        while let Some(Ok(_chunk)) = body.next().await {
1106            event_count += 1;
1107        }
1108        assert_eq!(event_count, 3);
1109
1110        assert_eq!(gauge.get(), 2800);
1111        Ok(())
1112    }
1113
1114    #[mz_ore::test(tokio::test)]
1115    async fn test_cse_metric_updates_correctly_per_request() -> Result<(), anyhow::Error> {
1116        let time = Arc::new(AtomicU64::new(1_000_000));
1117        let time_clone = Arc::clone(&time);
1118        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
1119
1120        let registry = MetricsRegistry::new();
1121        let gauge = test_gauge(&registry, "test_cse_gauge");
1122
1123        let transport = MetricsTransport {
1124            inner: FakeSseTransport,
1125            last_success_gauge: gauge.clone(),
1126            now_fn,
1127        };
1128
1129        let req = || -> Result<http::Request<Option<Bytes>>, http::Error> {
1130            http::Request::builder()
1131                .uri("https://events.launchdarkly.com/bulk")
1132                .body(None)
1133        };
1134
1135        let _ = transport.request(req()?).await?;
1136        assert_eq!(gauge.get(), 1000);
1137
1138        time.store(2_000_000, Ordering::SeqCst);
1139        let _ = transport.request(req()?).await?;
1140        assert_eq!(gauge.get(), 2000);
1141
1142        time.store(3_000_000, Ordering::SeqCst);
1143        let _ = transport.request(req()?).await?;
1144        assert_eq!(gauge.get(), 3000);
1145        Ok(())
1146    }
1147
1148    #[mz_ore::test(tokio::test)]
1149    async fn test_metric_not_updated_on_failed_request() -> Result<(), anyhow::Error> {
1150        let now_fn = NowFn::from(|| 5_000_000u64);
1151
1152        let registry = MetricsRegistry::new();
1153        let gauge = test_gauge(&registry, "test_fail_gauge");
1154
1155        let transport = MetricsTransport {
1156            inner: FailingTransport,
1157            last_success_gauge: gauge.clone(),
1158            now_fn,
1159        };
1160
1161        let request = http::Request::builder()
1162            .uri("https://stream.launchdarkly.com/all")
1163            .body(None)?;
1164        let result = transport.request(request).await;
1165        assert!(result.is_err());
1166        assert_eq!(gauge.get(), 0, "gauge must not update on transport error");
1167        Ok(())
1168    }
1169
1170    /// Verifies that when an SSE connection returns 200 OK and then dies
1171    /// mid-stream, `last_sse_time_seconds` advances only for the events that
1172    /// arrived and then freezes — the frozen timestamp is what lets the
1173    /// staleness alert detect a stuck data source.
1174    #[mz_ore::test(tokio::test)]
1175    async fn test_metric_frozen_on_midstream_error() -> Result<(), anyhow::Error> {
1176        let time = Arc::new(AtomicU64::new(1_000_000));
1177        let time_clone = Arc::clone(&time);
1178        let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
1179
1180        let registry = MetricsRegistry::new();
1181        let gauge = test_gauge(&registry, "test_midstream_gauge");
1182
1183        let transport = MetricsTransport {
1184            inner: MidStreamFailureTransport,
1185            last_success_gauge: gauge.clone(),
1186            now_fn,
1187        };
1188
1189        // The 200 OK response head updates the gauge.
1190        let request = http::Request::builder()
1191            .uri("https://stream.launchdarkly.com/all")
1192            .body(None)?;
1193        let response = transport.request(request).await?;
1194        assert_eq!(gauge.get(), 1000);
1195
1196        // The first event arrives and advances the gauge.
1197        time.store(2_000_000, Ordering::SeqCst);
1198        let mut body = response.into_body();
1199        assert!(matches!(body.next().await, Some(Ok(_))));
1200        assert_eq!(gauge.get(), 2000);
1201
1202        // The stream then errors mid-flight. Time has moved forward, but the
1203        // gauge must stay frozen at the last successful event.
1204        time.store(9_000_000, Ordering::SeqCst);
1205        assert!(matches!(body.next().await, Some(Err(_))));
1206        assert_eq!(
1207            gauge.get(),
1208            2000,
1209            "gauge must freeze on mid-stream error so the staleness alert can fire"
1210        );
1211        Ok(())
1212    }
1213}