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)
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 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#[derive(Clone, Debug)]
631pub struct ClusterScopeContext {
632 pub id: String,
634 pub name: String,
636 pub is_builtin: bool,
638}
639
640#[derive(Clone, Debug)]
648pub struct ReplicaScopeContext {
649 pub id: String,
651 pub name: String,
653 pub is_builtin: bool,
655 pub size: String,
660 pub size_family: String,
664 pub cluster_id: String,
666 pub cluster_name: String,
668}
669
670fn cluster_context(cluster: &ClusterScopeContext) -> Result<ld::Context, anyhow::Error> {
675 ld::ContextBuilder::new(cluster.id.as_str())
676 .anonymous(true) .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
685fn replica_context(replica: &ReplicaScopeContext) -> Result<ld::Context, anyhow::Error> {
690 ld::ContextBuilder::new(replica.id.as_str())
691 .anonymous(true) .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
704fn 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 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 ctx_builder.add_context(
749 ld::ContextBuilder::new("anonymous-dev@materialize.com")
750 .anonymous(true) .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) .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 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 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 fn file_frontend() -> SystemParameterFrontend {
819 file_frontend_at(Path::new(CONFIG_PATH))
820 }
821
822 const CONFIG_PATH: &str = "/nonexistent/system-params.json";
824
825 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 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 let null = parse(r#"{"max_connections": null}"#);
864 assert_eq!(json_param_value(&null.environment["max_connections"]), None);
865 }
866
867 #[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)] 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)] 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 refresh(Ok("{}".to_string()));
922 assert!(frontend.has_scoped_desired_state());
923 }
924
925 #[mz_ore::test]
926 #[cfg_attr(miri, ignore)] 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 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 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 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 #[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 #[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 #[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 #[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(®istry, "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(®istry, "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(®istry, "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 #[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(®istry, "test_midstream_gauge");
1178
1179 let transport = MetricsTransport {
1180 inner: MidStreamFailureTransport,
1181 last_success_gauge: gauge.clone(),
1182 now_fn,
1183 };
1184
1185 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 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 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}