1use std::collections::BTreeMap;
11use std::fs;
12use std::path::PathBuf;
13use std::time::Duration;
14
15use bytes::Bytes;
16use derivative::Derivative;
17use futures::TryStreamExt;
18use launchdarkly_sdk_transport::{ByteStream, HttpTransport, ResponseFuture};
19use launchdarkly_server_sdk as ld;
20use mz_build_info::BuildInfo;
21use mz_cloud_provider::CloudProvider;
22use mz_cluster_client::ReplicaId;
23use mz_controller_types::ClusterId;
24use mz_ore::metrics::UIntGauge;
25use mz_ore::now::NowFn;
26use mz_sql::catalog::EnvironmentId;
27use serde_json::Value as JsonValue;
28use tokio::time;
29use tracing::warn;
30
31use crate::config::{
32 Metrics, SynchronizedParameters, SystemParameterSyncClientConfig, SystemParameterSyncConfig,
33};
34
35#[derive(Derivative)]
37#[derivative(Debug)]
38pub struct SystemParameterFrontend {
39 client: SystemParameterFrontendClient,
41 key_map: BTreeMap<String, String>,
45 env_id: EnvironmentId,
48 build_info: &'static BuildInfo,
50 metrics: Metrics,
52}
53
54#[derive(Derivative)]
55#[derivative(Debug)]
56pub enum SystemParameterFrontendClient {
57 File {
58 path: PathBuf,
59 },
60 LaunchDarkly {
61 #[derivative(Debug = "ignore")]
63 client: ld::Client,
64 ctx: ld::Context,
67 },
68}
69
70impl SystemParameterFrontendClient {}
71
72impl SystemParameterFrontend {
73 pub async fn from(sync_config: &SystemParameterSyncConfig) -> Result<Self, anyhow::Error> {
79 match &sync_config.backend_config {
80 super::SystemParameterSyncClientConfig::File { path } => Ok(Self {
81 client: SystemParameterFrontendClient::File { path: path.clone() },
82 key_map: sync_config.key_map.clone(),
83 env_id: sync_config.env_id.clone(),
84 build_info: sync_config.build_info,
85 metrics: sync_config.metrics.clone(),
86 }),
87 SystemParameterSyncClientConfig::LaunchDarkly {
88 sdk_key,
89 base_uri,
90 now_fn,
91 } => Ok(Self {
92 client: SystemParameterFrontendClient::LaunchDarkly {
93 client: ld_client(sdk_key, base_uri.as_deref(), &sync_config.metrics, now_fn)
94 .await?,
95 ctx: ld_ctx(&sync_config.env_id, sync_config.build_info, None, None)?,
99 },
100 env_id: sync_config.env_id.clone(),
101 build_info: sync_config.build_info,
102 metrics: sync_config.metrics.clone(),
103 key_map: sync_config.key_map.clone(),
104 }),
105 }
106 }
107
108 pub fn pull(&self, params: &mut SynchronizedParameters) -> bool {
112 let mut changed = false;
113 for param_name in params.synchronized().into_iter() {
114 let flag_name = self
115 .key_map
116 .get(param_name)
117 .map(|flag_name| flag_name.as_str())
118 .unwrap_or(param_name);
119
120 let flag_str = match self.client {
121 SystemParameterFrontendClient::LaunchDarkly {
122 ref client,
123 ref ctx,
124 } => {
125 let flag_var = client.variation(ctx, flag_name, params.get(param_name));
126 match flag_var {
127 ld::FlagValue::Bool(v) => v.to_string(),
128 ld::FlagValue::Str(v) => v,
129 ld::FlagValue::Number(v) => v.to_string(),
130 ld::FlagValue::Json(v) => v.to_string(),
131 }
132 }
133 SystemParameterFrontendClient::File { ref path } => {
134 let file_contents = fs::read_to_string(path)
135 .inspect_err(|e| warn!("Could not open system paraemter sync file {}", e))
136 .unwrap_or_default();
137 let values: BTreeMap<String, JsonValue> = serde_json::from_str(&file_contents)
138 .inspect_err(|e| warn!("Could not open system paraemter sync file {:?}", e))
139 .unwrap_or_default();
140 values
141 .get(flag_name)
142 .and_then(|o| match o {
143 serde_json::Value::String(v) => Some(v.to_string()),
144 serde_json::Value::Number(v) => Some(v.to_string()),
145 serde_json::Value::Bool(v) => Some(v.to_string()),
146 serde_json::Value::Object(_) => Some(o.to_string()),
147 serde_json::Value::Array(_) => Some(o.to_string()),
148 serde_json::Value::Null => None,
149 })
150 .unwrap_or_else(|| params.get(param_name))
151 }
152 };
153
154 let old = params.get(param_name);
155 let change = params.modify(param_name, flag_str.as_str());
156 if change {
157 tracing::debug!(
158 %param_name, %old, new = %flag_str,
159 "updating system param",
160 );
161 }
162 self.metrics.params_changed.inc_by(u64::from(change));
163 changed |= change;
164 }
165
166 changed
167 }
168
169 pub fn pull_replica_overrides(
178 &self,
179 params: &SynchronizedParameters,
180 param_names: &[&'static str],
181 replicas: &[ReplicaEvalContext],
182 ) -> BTreeMap<ReplicaId, BTreeMap<String, String>> {
183 let mut out: BTreeMap<ReplicaId, BTreeMap<String, String>> = BTreeMap::new();
184
185 let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
186 return out;
188 };
189
190 if param_names.is_empty() {
191 return out;
192 }
193
194 for replica in replicas {
195 let ctx = match ld_ctx(
196 &self.env_id,
197 self.build_info,
198 Some(&replica.cluster),
199 Some(&replica.replica),
200 ) {
201 Ok(ctx) => ctx,
202 Err(e) => {
203 warn!(
204 replica_id = %replica.replica.id,
205 "could not build scoped LD context: {e}"
206 );
207 continue;
208 }
209 };
210
211 let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
212 if !overrides.is_empty() {
213 out.insert(replica.replica_id, overrides);
214 }
215 }
216
217 out
218 }
219
220 pub fn pull_cluster_overrides(
228 &self,
229 params: &SynchronizedParameters,
230 param_names: &[&'static str],
231 clusters: &[ClusterEvalContext],
232 ) -> BTreeMap<ClusterId, BTreeMap<String, String>> {
233 let mut out: BTreeMap<ClusterId, BTreeMap<String, String>> = BTreeMap::new();
234
235 let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
236 return out;
238 };
239
240 if param_names.is_empty() {
241 return out;
242 }
243
244 for cluster in clusters {
245 let ctx = match ld_ctx(&self.env_id, self.build_info, Some(&cluster.cluster), None) {
246 Ok(ctx) => ctx,
247 Err(e) => {
248 warn!(
249 cluster_id = %cluster.cluster.id,
250 "could not build scoped LD context: {e}"
251 );
252 continue;
253 }
254 };
255
256 let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
257 if !overrides.is_empty() {
258 out.insert(cluster.cluster_id, overrides);
259 }
260 }
261
262 out
263 }
264
265 fn evaluate_scoped_overrides(
272 &self,
273 client: &ld::Client,
274 ctx: &ld::Context,
275 params: &SynchronizedParameters,
276 param_names: &[&'static str],
277 ) -> BTreeMap<String, String> {
278 let mut overrides = BTreeMap::new();
279 for ¶m_name in param_names {
280 let flag_name = self
281 .key_map
282 .get(param_name)
283 .map(|flag_name| flag_name.as_str())
284 .unwrap_or(param_name);
285
286 let base = params.get(param_name);
287 let flag_var = client.variation(ctx, flag_name, base.clone());
291 let value = match flag_var {
292 ld::FlagValue::Bool(v) => v.to_string(),
293 ld::FlagValue::Str(v) => v,
294 ld::FlagValue::Number(v) => v.to_string(),
295 ld::FlagValue::Json(v) => v.to_string(),
296 };
297
298 let differs = match params.canonicalize(param_name, &value) {
315 Some(canonical) => canonical != base,
316 None => false,
324 };
325 if differs {
326 overrides.insert(param_name.to_string(), value);
327 }
328 }
329 overrides
330 }
331}
332
333#[derive(Clone, Debug)]
336pub struct ReplicaEvalContext {
337 pub cluster_id: ClusterId,
339 pub replica_id: ReplicaId,
341 pub cluster: ClusterScopeContext,
343 pub replica: ReplicaScopeContext,
345}
346
347#[derive(Clone, Debug)]
350pub struct ClusterEvalContext {
351 pub cluster_id: ClusterId,
353 pub cluster: ClusterScopeContext,
355}
356
357#[derive(Clone)]
365struct MetricsTransport<T> {
366 inner: T,
367 last_success_gauge: UIntGauge,
368 now_fn: NowFn,
369}
370
371impl<T: HttpTransport> HttpTransport for MetricsTransport<T> {
372 fn request(&self, request: http::Request<Option<Bytes>>) -> ResponseFuture {
373 let inner_fut = self.inner.request(request);
374 let gauge = self.last_success_gauge.clone();
375 let now_fn = self.now_fn.clone();
376 Box::pin(async move {
377 let resp = inner_fut.await?;
378 if resp.status().is_success() {
379 gauge.set(now_fn() / 1000);
380 let (parts, body) = resp.into_parts();
381 let wrapped: ByteStream = Box::pin(body.inspect_ok(move |_| {
382 gauge.set(now_fn() / 1000);
383 }));
384 Ok(http::Response::from_parts(parts, wrapped))
385 } else {
386 Ok(resp)
387 }
388 })
389 }
390}
391
392fn ld_config(
393 api_key: &str,
394 base_uri: Option<&str>,
395 metrics: &Metrics,
396 now_fn: &NowFn,
397) -> ld::Config {
398 let read_timeout = match std::env::var("MZ_LAUNCHDARKLY_READ_TIMEOUT") {
410 Ok(v) => humantime::parse_duration(&v).unwrap_or_else(|e| {
411 tracing::error!(
414 "ignoring unparseable MZ_LAUNCHDARKLY_READ_TIMEOUT {v:?}: {e}; \
415 falling back to default"
416 );
417 Duration::from_secs(300)
418 }),
419 Err(_) => Duration::from_secs(300),
420 };
421
422 let transport = launchdarkly_sdk_transport::HyperTransport::builder()
427 .connect_timeout(Duration::from_secs(10))
428 .read_timeout(read_timeout)
429 .build_https()
430 .expect("failed to create HTTPS transport");
431
432 let cse_transport = MetricsTransport {
433 inner: transport.clone(),
434 last_success_gauge: metrics.last_cse_time_seconds.clone(),
435 now_fn: now_fn.clone(),
436 };
437 let data_source_transport = MetricsTransport {
438 inner: transport,
439 last_success_gauge: metrics.last_sse_time_seconds.clone(),
440 now_fn: now_fn.clone(),
441 };
442
443 let mut event_processor = ld::EventProcessorBuilder::new();
444 event_processor.transport(cse_transport);
445
446 let mut data_source = ld::StreamingDataSourceBuilder::new();
447 data_source.transport(data_source_transport);
448
449 let mut config = ld::ConfigBuilder::new(api_key)
450 .event_processor(&event_processor)
451 .data_source(&data_source);
452 if let Some(base_uri) = base_uri {
453 let mut endpoints = ld::ServiceEndpointsBuilder::new();
454 endpoints.relay_proxy(base_uri);
455 config = config.service_endpoints(&endpoints);
456 }
457 config.build().expect("valid config")
458}
459
460async fn ld_client(
461 api_key: &str,
462 base_uri: Option<&str>,
463 metrics: &Metrics,
464 now_fn: &NowFn,
465) -> Result<ld::Client, anyhow::Error> {
466 let ld_client = ld::Client::build(ld_config(api_key, base_uri, metrics, now_fn))?;
467 tracing::info!("waiting for SystemParameterFrontend to initialize");
468 ld_client.start_with_default_executor();
469
470 let max_backoff = Duration::from_secs(60);
471 let mut backoff = Duration::from_secs(5);
472 let timeout = Duration::from_secs(10);
473
474 loop {
476 match ld_client.wait_for_initialization(timeout).await {
477 Some(true) => break,
478 Some(false) => tracing::warn!("SystemParameterFrontend failed to initialize"),
479 None => tracing::warn!("SystemParameterFrontend initialization timed out"),
480 }
481
482 time::sleep(backoff).await;
483 backoff = (backoff * 2).min(max_backoff);
484 }
485
486 tracing::info!("successfully initialized SystemParameterFrontend");
487
488 Ok(ld_client)
489}
490
491#[derive(Clone, Debug)]
500pub struct ClusterScopeContext {
501 pub id: String,
503 pub name: String,
505 pub is_builtin: bool,
507}
508
509#[derive(Clone, Debug)]
517pub struct ReplicaScopeContext {
518 pub id: String,
520 pub name: String,
522 pub is_builtin: bool,
524 pub size: String,
529 pub size_family: String,
533 pub cluster_id: String,
535 pub cluster_name: String,
537}
538
539fn cluster_context(cluster: &ClusterScopeContext) -> Result<ld::Context, anyhow::Error> {
544 ld::ContextBuilder::new(cluster.id.as_str())
545 .anonymous(true) .kind("cluster")
547 .set_string("cluster_id", cluster.id.clone())
548 .set_string("cluster_name", cluster.name.clone())
549 .set_string("is_builtin", cluster.is_builtin.to_string())
550 .build()
551 .map_err(|e| anyhow::anyhow!(e))
552}
553
554fn replica_context(replica: &ReplicaScopeContext) -> Result<ld::Context, anyhow::Error> {
559 ld::ContextBuilder::new(replica.id.as_str())
560 .anonymous(true) .kind("replica")
562 .set_string("replica_id", replica.id.clone())
563 .set_string("replica_name", replica.name.clone())
564 .set_string("is_builtin", replica.is_builtin.to_string())
565 .set_string("replica_size", replica.size.clone())
566 .set_string("replica_size_family", replica.size_family.clone())
567 .set_string("cluster_id", replica.cluster_id.clone())
568 .set_string("cluster_name", replica.cluster_name.clone())
569 .build()
570 .map_err(|e| anyhow::anyhow!(e))
571}
572
573fn ld_ctx(
582 env_id: &EnvironmentId,
583 build_info: &'static BuildInfo,
584 cluster: Option<&ClusterScopeContext>,
585 replica: Option<&ReplicaScopeContext>,
586) -> Result<ld::Context, anyhow::Error> {
587 let mut ctx_builder = ld::MultiContextBuilder::new();
594
595 if env_id.cloud_provider() != &CloudProvider::Local {
596 ctx_builder.add_context(
597 ld::ContextBuilder::new(env_id.to_string())
598 .kind("environment")
599 .set_string("cloud_provider", env_id.cloud_provider().to_string())
600 .set_string("cloud_provider_region", env_id.cloud_provider_region())
601 .set_string("organization_id", env_id.organization_id().to_string())
602 .set_string("ordinal", env_id.ordinal().to_string())
603 .build()
604 .map_err(|e| anyhow::anyhow!(e))?,
605 );
606 ctx_builder.add_context(
607 ld::ContextBuilder::new(env_id.organization_id().to_string())
608 .kind("organization")
609 .build()
610 .map_err(|e| anyhow::anyhow!(e))?,
611 );
612 } else {
613 ctx_builder.add_context(
618 ld::ContextBuilder::new("anonymous-dev@materialize.com")
619 .anonymous(true) .kind("environment")
621 .set_string("cloud_provider", env_id.cloud_provider().to_string())
622 .set_string("cloud_provider_region", env_id.cloud_provider_region())
623 .set_string("organization_id", uuid::Uuid::nil().to_string())
624 .set_string("ordinal", env_id.ordinal().to_string())
625 .build()
626 .map_err(|e| anyhow::anyhow!(e))?,
627 );
628 ctx_builder.add_context(
629 ld::ContextBuilder::new(uuid::Uuid::nil().to_string())
630 .anonymous(true) .kind("organization")
632 .build()
633 .map_err(|e| anyhow::anyhow!(e))?,
634 );
635 };
636
637 ctx_builder.add_context(
638 ld::ContextBuilder::new(build_info.sha)
639 .kind("build")
640 .set_string("semver_version", build_info.semver_version().to_string())
641 .build()
642 .map_err(|e| anyhow::anyhow!(e))?,
643 );
644
645 if let Some(cluster) = cluster {
649 ctx_builder.add_context(cluster_context(cluster)?);
650 }
651 if let Some(replica) = replica {
652 ctx_builder.add_context(replica_context(replica)?);
653 }
654
655 ctx_builder.build().map_err(|e| anyhow::anyhow!(e))
656}
657
658#[cfg(test)]
659mod tests {
660 use std::sync::Arc;
661 use std::sync::atomic::{AtomicU64, Ordering};
662
663 use futures::StreamExt;
664 use launchdarkly_sdk_transport::{ByteStream, TransportError};
665 use mz_build_info::DUMMY_BUILD_INFO;
666 use mz_ore::metrics::MetricsRegistry;
667
668 use super::*;
669
670 fn env_id() -> EnvironmentId {
671 EnvironmentId::for_tests()
672 }
673
674 #[mz_ore::test]
675 fn builds_cluster_scoped_context() {
676 let cluster = ClusterScopeContext {
679 id: "s2".into(),
680 name: "mz_catalog_server".into(),
681 is_builtin: true,
682 };
683 ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), None)
684 .expect("cluster-scoped context builds");
685 }
686
687 #[mz_ore::test]
688 fn builds_replica_scoped_context() {
689 let cluster = ClusterScopeContext {
692 id: "u1".into(),
693 name: "quickstart".into(),
694 is_builtin: false,
695 };
696 let replica = ReplicaScopeContext {
697 id: "u1-replica-1".into(),
698 name: "r1".into(),
699 is_builtin: false,
700 size: "D.1-xsmall".into(),
701 size_family: "D".into(),
702 cluster_id: "u1".into(),
703 cluster_name: "quickstart".into(),
704 };
705 ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), Some(&replica))
706 .expect("replica-scoped context builds");
707 }
708
709 #[mz_ore::test]
710 fn environment_wide_context_is_unscoped() {
711 ld_ctx(&env_id(), &DUMMY_BUILD_INFO, None, None).expect("environment-wide context builds");
712 }
713
714 #[derive(Clone)]
718 struct FakeSseTransport;
719
720 impl HttpTransport for FakeSseTransport {
721 fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
722 let body: ByteStream = Box::pin(futures::stream::iter(vec![
723 Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
724 Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag1\"}\n\n")),
725 Ok(Bytes::from("event: patch\ndata: {\"key\":\"flag2\"}\n\n")),
726 ]));
727 Box::pin(async move {
728 http::Response::builder()
729 .status(200)
730 .body(body)
731 .map_err(|e| TransportError::new(std::io::Error::other(e)))
732 })
733 }
734 }
735
736 #[derive(Clone)]
738 struct FailingTransport;
739
740 impl HttpTransport for FailingTransport {
741 fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
742 Box::pin(async move {
743 Err(TransportError::new(std::io::Error::new(
744 std::io::ErrorKind::ConnectionRefused,
745 "connection refused",
746 )))
747 })
748 }
749 }
750
751 #[derive(Clone)]
755 struct MidStreamFailureTransport;
756
757 impl HttpTransport for MidStreamFailureTransport {
758 fn request(&self, _request: http::Request<Option<Bytes>>) -> ResponseFuture {
759 let body: ByteStream = Box::pin(futures::stream::iter(vec![
760 Ok(Bytes::from("event: put\ndata: {\"flags\":{}}\n\n")),
761 Err(TransportError::new(std::io::Error::new(
762 std::io::ErrorKind::TimedOut,
763 "body timed out",
764 ))),
765 ]));
766 Box::pin(async move {
767 http::Response::builder()
768 .status(200)
769 .body(body)
770 .map_err(|e| TransportError::new(std::io::Error::other(e)))
771 })
772 }
773 }
774
775 fn test_gauge(registry: &MetricsRegistry, name: &str) -> UIntGauge {
776 registry.register(mz_ore::metric!(
777 name: name,
778 help: "test gauge",
779 ))
780 }
781
782 #[mz_ore::test(tokio::test)]
786 async fn test_metric_updated_on_body_chunks() -> Result<(), anyhow::Error> {
787 let time = Arc::new(AtomicU64::new(1_000_000));
788 let time_clone = Arc::clone(&time);
789 let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
790
791 let registry = MetricsRegistry::new();
792 let gauge = test_gauge(®istry, "test_sse_gauge");
793
794 let transport = MetricsTransport {
795 inner: FakeSseTransport,
796 last_success_gauge: gauge.clone(),
797 now_fn,
798 };
799
800 assert_eq!(gauge.get(), 0);
801
802 let request = http::Request::builder()
803 .uri("https://stream.launchdarkly.com/all")
804 .body(None)?;
805 let response = transport.request(request).await?;
806
807 assert_eq!(gauge.get(), 1000);
808
809 time.store(2_800_000, Ordering::SeqCst);
810
811 let mut body = response.into_body();
812 let mut event_count = 0;
813 while let Some(Ok(_chunk)) = body.next().await {
814 event_count += 1;
815 }
816 assert_eq!(event_count, 3);
817
818 assert_eq!(gauge.get(), 2800);
819 Ok(())
820 }
821
822 #[mz_ore::test(tokio::test)]
823 async fn test_cse_metric_updates_correctly_per_request() -> Result<(), anyhow::Error> {
824 let time = Arc::new(AtomicU64::new(1_000_000));
825 let time_clone = Arc::clone(&time);
826 let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
827
828 let registry = MetricsRegistry::new();
829 let gauge = test_gauge(®istry, "test_cse_gauge");
830
831 let transport = MetricsTransport {
832 inner: FakeSseTransport,
833 last_success_gauge: gauge.clone(),
834 now_fn,
835 };
836
837 let req = || -> Result<http::Request<Option<Bytes>>, http::Error> {
838 http::Request::builder()
839 .uri("https://events.launchdarkly.com/bulk")
840 .body(None)
841 };
842
843 let _ = transport.request(req()?).await?;
844 assert_eq!(gauge.get(), 1000);
845
846 time.store(2_000_000, Ordering::SeqCst);
847 let _ = transport.request(req()?).await?;
848 assert_eq!(gauge.get(), 2000);
849
850 time.store(3_000_000, Ordering::SeqCst);
851 let _ = transport.request(req()?).await?;
852 assert_eq!(gauge.get(), 3000);
853 Ok(())
854 }
855
856 #[mz_ore::test(tokio::test)]
857 async fn test_metric_not_updated_on_failed_request() -> Result<(), anyhow::Error> {
858 let now_fn = NowFn::from(|| 5_000_000u64);
859
860 let registry = MetricsRegistry::new();
861 let gauge = test_gauge(®istry, "test_fail_gauge");
862
863 let transport = MetricsTransport {
864 inner: FailingTransport,
865 last_success_gauge: gauge.clone(),
866 now_fn,
867 };
868
869 let request = http::Request::builder()
870 .uri("https://stream.launchdarkly.com/all")
871 .body(None)?;
872 let result = transport.request(request).await;
873 assert!(result.is_err());
874 assert_eq!(gauge.get(), 0, "gauge must not update on transport error");
875 Ok(())
876 }
877
878 #[mz_ore::test(tokio::test)]
883 async fn test_metric_frozen_on_midstream_error() -> Result<(), anyhow::Error> {
884 let time = Arc::new(AtomicU64::new(1_000_000));
885 let time_clone = Arc::clone(&time);
886 let now_fn = NowFn::from(move || time_clone.load(Ordering::SeqCst));
887
888 let registry = MetricsRegistry::new();
889 let gauge = test_gauge(®istry, "test_midstream_gauge");
890
891 let transport = MetricsTransport {
892 inner: MidStreamFailureTransport,
893 last_success_gauge: gauge.clone(),
894 now_fn,
895 };
896
897 let request = http::Request::builder()
899 .uri("https://stream.launchdarkly.com/all")
900 .body(None)?;
901 let response = transport.request(request).await?;
902 assert_eq!(gauge.get(), 1000);
903
904 time.store(2_000_000, Ordering::SeqCst);
906 let mut body = response.into_body();
907 assert!(matches!(body.next().await, Some(Ok(_))));
908 assert_eq!(gauge.get(), 2000);
909
910 time.store(9_000_000, Ordering::SeqCst);
913 assert!(matches!(body.next().await, Some(Err(_))));
914 assert_eq!(
915 gauge.get(),
916 2000,
917 "gauge must freeze on mid-stream error so the staleness alert can fire"
918 );
919 Ok(())
920 }
921}