1use 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#[derive(Derivative)]
39#[derivative(Debug)]
40pub struct SystemParameterFrontend {
41 client: SystemParameterFrontendClient,
43 key_map: BTreeMap<String, String>,
47 env_id: EnvironmentId,
50 build_info: &'static BuildInfo,
52 metrics: Metrics,
54 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 #[derivative(Debug = "ignore")]
68 client: Arc<ld::Client>,
69 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 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#[derive(Debug, Default, PartialEq)]
102struct ConfigFile {
103 environment: BTreeMap<String, JsonValue>,
105}
106
107impl ConfigFile {
108 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#[derive(Debug)]
126struct CachedConfigFile {
127 contents: Option<String>,
131 current: Option<Arc<ConfigFile>>,
134}
135
136fn 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 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 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 pub fn pull(&self, params: &mut SynchronizedParameters) -> bool {
192 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 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 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 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 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 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 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 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 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 ¶m_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 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 let differs = match params.canonicalize(param_name, &value) {
453 Some(canonical) => canonical != base,
454 None => false,
462 };
463 if differs {
464 overrides.insert(param_name.to_string(), value);
465 }
466 }
467 overrides
468 }
469}
470
471#[derive(Clone, Debug)]
474pub struct ReplicaEvalContext {
475 pub cluster_id: ClusterId,
477 pub replica_id: ReplicaId,
479 pub cluster: ClusterScopeContext,
481 pub replica: ReplicaScopeContext,
483}
484
485#[derive(Clone, Debug)]
488pub struct ClusterEvalContext {
489 pub cluster_id: ClusterId,
491 pub cluster: ClusterScopeContext,
493}
494
495#[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 let read_timeout = match std::env::var("MZ_LAUNCHDARKLY_READ_TIMEOUT") {
548 Ok(v) => humantime::parse_duration(&v).unwrap_or_else(|e| {
549 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)
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 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#[derive(Clone, Debug)]
635pub struct ClusterScopeContext {
636 pub id: String,
638 pub name: String,
640 pub is_builtin: bool,
642}
643
644#[derive(Clone, Debug)]
652pub struct ReplicaScopeContext {
653 pub id: String,
655 pub name: String,
657 pub is_builtin: bool,
659 pub size: String,
664 pub size_family: String,
668 pub cluster_id: String,
670 pub cluster_name: String,
672}
673
674fn cluster_context(cluster: &ClusterScopeContext) -> Result<ld::Context, anyhow::Error> {
679 ld::ContextBuilder::new(cluster.id.as_str())
680 .anonymous(true) .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
689fn replica_context(replica: &ReplicaScopeContext) -> Result<ld::Context, anyhow::Error> {
694 ld::ContextBuilder::new(replica.id.as_str())
695 .anonymous(true) .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
708fn 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 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 ctx_builder.add_context(
753 ld::ContextBuilder::new("anonymous-dev@materialize.com")
754 .anonymous(true) .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) .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 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 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 fn file_frontend() -> SystemParameterFrontend {
823 file_frontend_at(Path::new(CONFIG_PATH))
824 }
825
826 const CONFIG_PATH: &str = "/nonexistent/system-params.json";
828
829 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 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 let null = parse(r#"{"max_connections": null}"#);
868 assert_eq!(json_param_value(&null.environment["max_connections"]), None);
869 }
870
871 #[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)] 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)] 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 refresh(Ok("{}".to_string()));
926 assert!(frontend.has_scoped_desired_state());
927 }
928
929 #[mz_ore::test]
930 #[cfg_attr(miri, ignore)] 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 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 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 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 #[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 #[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 #[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 #[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(®istry, "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(®istry, "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(®istry, "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 #[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(®istry, "test_midstream_gauge");
1182
1183 let transport = MetricsTransport {
1184 inner: MidStreamFailureTransport,
1185 last_success_gauge: gauge.clone(),
1186 now_fn,
1187 };
1188
1189 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 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 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}