Skip to main content

micromegas/servers/
flight_sql_server.rs

1use crate::config::DEFAULT_FLIGHTSQL_LISTEN_ADDR;
2use anyhow::{Context, Result};
3use micromegas_analytics::lakehouse::lakehouse_context::LakehouseContext;
4use micromegas_analytics::lakehouse::partition_cache::LivePartitionProvider;
5use micromegas_analytics::lakehouse::read_scope::IsolationConfig;
6use micromegas_analytics::lakehouse::session_configurator::SessionConfigurator;
7use micromegas_analytics::lakehouse::static_tables_configurator::StaticTablesConfigurator;
8use micromegas_analytics::lakehouse::view_definition_store::PgViewDefinitionStore;
9use micromegas_analytics::lakehouse::view_factory::{ViewFactory, default_view_factory};
10use micromegas_analytics::lakehouse::view_registry::ViewRegistry;
11use micromegas_auth::db_api_key::{ApiKeyTable, dedicated_key_store_pool};
12use micromegas_auth::db_audience_grants::{DbAudienceGrantsConfig, DbAudienceGrantsSource};
13use micromegas_auth::default_provider::ProviderBuilder;
14use micromegas_auth::policy::{AudienceGrants, AudienceReadPolicy, ReadPolicy};
15use micromegas_auth::tower::AuthService;
16use micromegas_auth::types::AuthProvider;
17use micromegas_ingestion::data_lake_connection::{DataLakeConnection, WritablePolicy};
18use micromegas_ingestion::sql_migration::warn_if_data_lake_schema_stale;
19use micromegas_tracing::prelude::*;
20use std::future::Future;
21use std::net::SocketAddr;
22use std::pin::Pin;
23use std::sync::Arc;
24use std::time::Duration;
25
26use arrow_flight::flight_service_server::FlightServiceServer;
27use datafusion::execution::runtime_env::RuntimeEnv;
28use tonic::transport::Server;
29use tower::ServiceBuilder;
30use tower::layer::layer_fn;
31
32use super::connect_info_layer::ConnectedIncoming;
33use super::flight_sql_service_impl::FlightSqlServiceImpl;
34use super::grpc_health_service::GrpcHealthService;
35use super::log_uri_service::LogUriService;
36
37type ViewFactoryFn = Box<
38    dyn FnOnce(
39            Arc<RuntimeEnv>,
40            Arc<DataLakeConnection>,
41        ) -> Pin<Box<dyn Future<Output = Result<ViewFactory>> + Send>>
42        + Send,
43>;
44
45/// The auth provider plus the default `ReadPolicy` `build_and_serve` resolves per branch, before
46/// `self.read_policy` (if set) overrides it.
47type AuthAndDefaults = (Option<Arc<dyn AuthProvider>>, Arc<dyn ReadPolicy>);
48
49/// The env is not read at all when the caller supplied a config: `with_isolation_config` wins
50/// regardless, so reading anyway would invent a startup failure on a variable the caller
51/// deliberately overrode.
52fn resolve_isolation_config(
53    explicit: Option<Arc<IsolationConfig>>,
54) -> Result<Arc<IsolationConfig>> {
55    match explicit {
56        Some(config) => Ok(config),
57        None => Ok(Arc::new(IsolationConfig::from_env()?)),
58    }
59}
60
61/// Builder for assembling and running a FlightSQL server.
62///
63/// Encapsulates the full setup sequence: data lake connection, lakehouse migration,
64/// runtime env, view factory, partition provider, session configurator, auth, and
65/// the gRPC tower layer stack.
66///
67/// # Example
68///
69/// ```rust,no_run
70/// use micromegas::servers::flight_sql_server::FlightSqlServer;
71///
72/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
73/// FlightSqlServer::builder()
74///     .with_default_auth()
75///     .build_and_serve()
76///     .await?;
77/// # Ok(())
78/// # }
79/// ```
80pub struct FlightSqlServer;
81
82impl FlightSqlServer {
83    pub fn builder() -> FlightSqlServerBuilder {
84        FlightSqlServerBuilder::default()
85    }
86}
87
88pub struct FlightSqlServerBuilder {
89    view_factory_fn: Option<ViewFactoryFn>,
90    session_configurator: Option<Arc<dyn SessionConfigurator>>,
91    auth_provider: Option<Arc<dyn AuthProvider>>,
92    read_policy: Option<Arc<dyn ReadPolicy>>,
93    isolation_config: Option<Arc<IsolationConfig>>,
94    use_default_auth: bool,
95    max_decoding_message_size: usize,
96    listen_addr: SocketAddr,
97    shutdown_grace: Duration,
98    injected_lakehouse: Option<Arc<LakehouseContext>>,
99    injected_shutdown: Option<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>,
100    health_listen_addr: Option<SocketAddr>,
101}
102
103impl Default for FlightSqlServerBuilder {
104    fn default() -> Self {
105        Self {
106            view_factory_fn: None,
107            session_configurator: None,
108            auth_provider: None,
109            read_policy: None,
110            isolation_config: None,
111            use_default_auth: false,
112            max_decoding_message_size: 100 * 1024 * 1024,
113            listen_addr: DEFAULT_FLIGHTSQL_LISTEN_ADDR,
114            shutdown_grace: Duration::from_secs(25),
115            injected_lakehouse: None,
116            injected_shutdown: None,
117            health_listen_addr: None,
118        }
119    }
120}
121
122impl FlightSqlServerBuilder {
123    /// Override the default view factory with a custom closure.
124    ///
125    /// The closure receives the runtime and data lake created by the builder.
126    pub fn with_view_factory_fn<F, Fut>(mut self, f: F) -> Self
127    where
128        F: FnOnce(Arc<RuntimeEnv>, Arc<DataLakeConnection>) -> Fut + Send + 'static,
129        Fut: Future<Output = Result<ViewFactory>> + Send + 'static,
130    {
131        self.view_factory_fn = Some(Box::new(move |runtime, lake| Box::pin(f(runtime, lake))));
132        self
133    }
134
135    /// Override the default session configurator.
136    ///
137    /// By default the builder loads static tables from `MICROMEGAS_STATIC_TABLES_URL`.
138    /// Use this to replace that behavior entirely.
139    pub fn with_session_configurator(mut self, cfg: Arc<dyn SessionConfigurator>) -> Self {
140        self.session_configurator = Some(cfg);
141        self
142    }
143
144    /// Set an explicit auth provider.
145    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
146        self.auth_provider = Some(provider);
147        self.use_default_auth = false;
148        self
149    }
150
151    /// Set an explicit `ReadPolicy`, resolved once per request against the caller's
152    /// `AuthContext`. This policy wins on every `build_and_serve` branch,
153    /// overriding that branch's own default. When never called, the default depends on how auth
154    /// is configured: with `with_default_auth()` or `with_auth_provider(..)`,
155    /// `AudienceReadPolicy::default()` backed by the DB grant store (so the seeded `public`
156    /// read row still resolves); with auth left disabled, `AudienceReadPolicy` with an empty
157    /// grant map, though that branch never actually resolves it. **Not** `ReadScope::All`: the
158    /// absent-`AuthContext`-extension convention already supplies `All` when no provider is
159    /// configured, so this default must not duplicate
160    /// that decision -- it only ever resolves a scope when an `AuthContext` is present to resolve
161    /// one from.
162    pub fn with_read_policy(mut self, policy: Arc<dyn ReadPolicy>) -> Self {
163        self.read_policy = Some(policy);
164        self
165    }
166
167    /// Set an explicit `IsolationConfig` -- the data-isolation deployment knob
168    /// (`MICROMEGAS_PUBLIC_VIEW_SETS`) consumed by the row-level filter
169    /// (`OwnershipRewrite`). Mirrors `with_read_policy`: wins regardless of how auth is
170    /// configured, overriding the single default `build_and_serve` would otherwise resolve via
171    /// `IsolationConfig::from_env()`.
172    pub fn with_isolation_config(mut self, config: Arc<IsolationConfig>) -> Self {
173        self.isolation_config = Some(config);
174        self
175    }
176
177    /// Use the default auth provider from env vars during build.
178    ///
179    /// Errors if no auth providers are configured (fail-fast).
180    pub fn with_default_auth(mut self) -> Self {
181        self.use_default_auth = true;
182        self.auth_provider = None;
183        self
184    }
185
186    /// Set the max decoding message size (default: 100 MB).
187    pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
188        self.max_decoding_message_size = bytes;
189        self
190    }
191
192    /// Set the listen address (default: [`DEFAULT_FLIGHTSQL_LISTEN_ADDR`], `0.0.0.0:50051`).
193    pub fn with_listen_addr(mut self, addr: SocketAddr) -> Self {
194        self.listen_addr = addr;
195        self
196    }
197
198    /// Set the grace period for graceful shutdown on SIGTERM (default: 25s).
199    pub fn with_shutdown_grace(mut self, grace: Duration) -> Self {
200        self.shutdown_grace = grace;
201        self
202    }
203
204    /// Inject a pre-built `LakehouseContext` instead of calling `LakehouseContext::from_env`.
205    ///
206    /// Useful for the monolith, which constructs one shared context for all lake-backed roles.
207    pub fn with_lakehouse(mut self, lakehouse: Arc<LakehouseContext>) -> Self {
208        self.injected_lakehouse = Some(lakehouse);
209        self
210    }
211
212    /// Inject a custom shutdown future instead of the default `wait_for_sigterm()`.
213    ///
214    /// The monolith passes `fanout.subscribe()` here so all roles shut down from one signal.
215    pub fn with_shutdown(mut self, shutdown: impl Future<Output = ()> + Send + 'static) -> Self {
216        self.injected_shutdown = Some(Box::pin(shutdown));
217        self
218    }
219
220    /// Spawn a lightweight HTTP sidecar (`/health`, `/ready`) on `addr`.
221    ///
222    /// Enables plain-HTTP ALB health checks without changing the gRPC protocol.
223    /// If not set, no sidecar is started.
224    pub fn with_health_addr(mut self, addr: SocketAddr) -> Self {
225        self.health_listen_addr = Some(addr);
226        self
227    }
228
229    /// Build and run the FlightSQL server.
230    ///
231    /// Runs the full setup sequence and blocks until the server shuts down.
232    pub async fn build_and_serve(self) -> Result<()> {
233        // Resolved first, ahead of the Postgres/object-store setup below, so a malformed
234        // `MICROMEGAS_PUBLIC_VIEW_SETS` fails fast at no cost.
235        let isolation_config = resolve_isolation_config(self.isolation_config)?;
236
237        // Use injected lakehouse or build one from environment. The non-injected path is
238        // standalone FlightSQL, the one role that prefers a writable primary but falls back to
239        // a read-only replica rather than refuse to start (see `high-availability.md`); an
240        // injected lakehouse (the monolith) shares its lake pool with the ingestion role, which
241        // stays strict, so it is never `Prefer` here.
242        let lakehouse = if let Some(lh) = self.injected_lakehouse {
243            lh
244        } else {
245            LakehouseContext::from_env(WritablePolicy::Prefer).await?
246        };
247        let data_lake = lakehouse.lake().clone();
248        let probe_lake = lakehouse.lake().clone();
249        // Cloned here, before `lakehouse` is moved into `FlightSqlServiceImpl::new` below, so the
250        // refresh task (spawned further down, once the shutdown fanout exists) has its own
251        // handle. Only the FlightSQL server builder spawns this -- other `LakehouseContext`
252        // holders (maintenance daemon, tests) keep an empty snapshot and never deny anything.
253        let query_denials = lakehouse.query_denials().clone();
254        info!(
255            "created lakehouse context with metadata cache: {:?}",
256            lakehouse.metadata_cache()
257        );
258
259        let base_view_factory: Arc<ViewFactory> = if let Some(factory_fn) = self.view_factory_fn {
260            Arc::new(factory_fn(lakehouse.runtime().clone(), data_lake.clone()).await?)
261        } else {
262            Arc::new(
263                default_view_factory(
264                    lakehouse.runtime().clone(),
265                    data_lake.clone(),
266                    lakehouse.default_audience(),
267                )
268                .await?,
269            )
270        };
271
272        let partition_provider =
273            Arc::new(LivePartitionProvider::new(lakehouse.lake().db_pool.clone()));
274        // Cloned here, before `lakehouse` is moved into `FlightSqlServiceImpl::new`
275        // below, so `dedicated_key_store_pool` has a pool to read pool options
276        // from.
277        let lake_pool_for_keys = lakehouse.lake().db_pool.clone();
278
279        let session_configurator: Arc<dyn SessionConfigurator> =
280            if let Some(cfg) = self.session_configurator {
281                cfg
282            } else {
283                StaticTablesConfigurator::from_env(
284                    "MICROMEGAS_STATIC_TABLES_URL",
285                    lakehouse.runtime().clone(),
286                )
287                .await?
288            };
289
290        // The shared registry: `base_view_factory` (code-driven views) plus every DDL-defined
291        // global view (`lakehouse_view_set_definitions`), rebuilt on
292        // `MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS`. Loaded once, synchronously, before
293        // `serve()` -- failing startup on error, the way `migrate_lakehouse` already does --
294        // so there is never a window after startup where a DDL-defined view (or the now-seeded
295        // `log_stats`) is absent from `current()`.
296        let view_registry = Arc::new(ViewRegistry::new(
297            base_view_factory,
298            Arc::new(PgViewDefinitionStore::new(lakehouse.lake().db_pool.clone())),
299            lakehouse.runtime().clone(),
300            data_lake,
301            session_configurator.clone(),
302        ));
303        view_registry
304            .reload()
305            .await
306            .with_context(|| "initial view_registry reload")?;
307
308        // `FlightSqlServiceImpl::new` takes the resolved `ReadPolicy` as a constructor argument,
309        // so auth/policy resolution happens here, before construction.
310        //
311        // `self.read_policy` (set via `with_read_policy`) must win on every branch below --
312        // each branch only computes its own *default*, used solely when the caller never set an
313        // explicit policy. This keeps `with_read_policy` order-independent with respect to
314        // `with_auth_provider` / `with_default_auth`.
315        let (auth_provider, default_policy): AuthAndDefaults =
316            if let Some(provider) = self.auth_provider {
317                // Injected-provider path (the monolith's `with_auth_provider` call): the caller is
318                // expected to pair `with_auth_provider` with its own `with_read_policy` call, but
319                // when it didn't, this default must still resolve the seeded `public` read grant
320                // rather than the empty set -- an empty-grant-map default was only ever safe
321                // while `resolve` had a built-in `PUBLIC_AUDIENCE` arm; with that arm removed it
322                // would silently deny every query for an embedder that forgot `with_read_policy`.
323                // So this builds the same store-backed policy the `use_default_auth` branch
324                // below does.
325                //
326                // Built only when `self.read_policy` is unset -- `with_read_policy` always wins
327                // below, and the only in-repo `with_auth_provider` caller (the monolith) always
328                // pairs the two, so skipping this avoids a DB round trip whenever it would be
329                // discarded anyway.
330                let policy: Arc<dyn ReadPolicy> = if self.read_policy.is_none() {
331                    let key_store_pool = dedicated_key_store_pool(&lake_pool_for_keys);
332                    warn_if_data_lake_schema_stale(&lake_pool_for_keys).await;
333                    let audience_grants_config = DbAudienceGrantsConfig::from_env_with_prefix("");
334                    let audience_grants_store = Arc::new(DbAudienceGrantsSource::new(
335                        key_store_pool,
336                        Duration::from_secs(audience_grants_config.cache_ttl_secs),
337                    ));
338                    Arc::new(AudienceReadPolicy::default().with_store(audience_grants_store))
339                } else {
340                    Arc::new(AudienceReadPolicy::new(AudienceGrants::empty()))
341                };
342                (Some(provider), policy)
343            } else if self.use_default_auth {
344                let key_store_pool = dedicated_key_store_pool(&lake_pool_for_keys);
345                let group_store_pool = dedicated_key_store_pool(&lake_pool_for_keys);
346                warn_if_data_lake_schema_stale(&lake_pool_for_keys).await;
347                let provider = match ProviderBuilder::new("")
348                    .with_db_key_store(key_store_pool, ApiKeyTable::Analytics)
349                    .with_group_store(group_store_pool)
350                    .build()
351                    .await?
352                {
353                    Some(provider) => provider,
354                    None => {
355                        anyhow::bail!(
356                            "Authentication required but no auth providers configured. Set \
357                         MICROMEGAS_OIDC_CONFIG, or populate the analytics_api_keys DB table"
358                        );
359                    }
360                };
361                // One shared snapshot cache for this process, built from
362                // its own dedicated pool -- not `key_store_pool` above, which
363                // `DbApiKeyAuthProvider` already owns -- via the same `dedicated_key_store_pool`
364                // convention. The cache TTL is the flat, unprefixed
365                // `MICROMEGAS_AUTH_CACHE_TTL_SECONDS`; the `prefix` argument below is passed only
366                // for call-site symmetry -- `from_env_with_prefix` never actually consults it.
367                let audience_grants_pool = dedicated_key_store_pool(&lake_pool_for_keys);
368                let audience_grants_config = DbAudienceGrantsConfig::from_env_with_prefix("");
369                let audience_grants_store = Arc::new(DbAudienceGrantsSource::new(
370                    audience_grants_pool,
371                    Duration::from_secs(audience_grants_config.cache_ttl_secs),
372                ));
373                let policy: Arc<dyn ReadPolicy> =
374                    Arc::new(AudienceReadPolicy::default().with_store(audience_grants_store));
375                (Some(provider), policy)
376            } else {
377                info!("Authentication disabled");
378                // No `AuthContext` extension is ever inserted on this path (no `AuthService`
379                // provider configured), so the absent-extension convention already supplies
380                // `ReadScope::All` -- this default policy is never actually resolved against a
381                // real caller, but must still exist since `FlightSqlServiceImpl::new` requires
382                // one. This never-resolved property is now load-bearing: with the built-in
383                // `PUBLIC_AUDIENCE` read arm removed, an empty grant map resolves to the empty
384                // set, not `{public}` -- fine here only because `resolve` is never actually
385                // called on this branch.
386                (
387                    None,
388                    Arc::new(AudienceReadPolicy::new(AudienceGrants::empty())),
389                )
390            };
391        let read_policy = self.read_policy.unwrap_or(default_policy);
392
393        let svc = FlightServiceServer::new(FlightSqlServiceImpl::new(
394            lakehouse,
395            partition_provider,
396            view_registry.clone(),
397            session_configurator,
398            read_policy,
399            isolation_config,
400        ))
401        .max_decoding_message_size(self.max_decoding_message_size);
402
403        let layer = ServiceBuilder::new()
404            .layer(layer_fn(GrpcHealthService::new))
405            .layer(layer_fn(|service| LogUriService { service }))
406            .layer(layer_fn(move |inner| AuthService {
407                inner,
408                auth_provider: auth_provider.clone(),
409            }))
410            .into_inner();
411
412        use super::shutdown::{ShutdownFanout, wait_for_sigterm};
413
414        info!("Listening on {:?}", self.listen_addr);
415        let listener = std::net::TcpListener::bind(self.listen_addr)?;
416        let incoming = ConnectedIncoming::from_std_listener(listener)?;
417
418        // Use injected shutdown future or default to SIGTERM
419        let shutdown_future: Pin<Box<dyn Future<Output = ()> + Send + 'static>> = self
420            .injected_shutdown
421            .unwrap_or_else(|| Box::pin(wait_for_sigterm()));
422        let fanout = ShutdownFanout::new(shutdown_future);
423        let grace_secs = self.shutdown_grace.as_secs();
424        let grace = self.shutdown_grace;
425
426        // Admin-managed query deny list: refreshes the shared snapshot every
427        // `MICROMEGAS_QUERY_DENY_REFRESH_SECONDS`, once immediately and then on a tick, until
428        // this replica shuts down.
429        query_denials.spawn_refresh_task(fanout.subscribe());
430
431        // DDL-defined view sets: refreshes `view_registry.current()` every
432        // `MICROMEGAS_VIEW_DEFINITION_REFRESH_SECONDS`, until this replica shuts down. A
433        // same-node `CREATE`/`DROP MATERIALIZED VIEW` reloads inline instead (see
434        // `flight_sql_service_impl.rs::execute_view_ddl`); this bounds how long a *different*
435        // node lags a definition change.
436        view_registry.spawn_refresh_task(fanout.subscribe());
437
438        if let Some(health_addr) = self.health_listen_addr {
439            use super::readiness::ReadinessProbe;
440            use axum::Extension;
441            use axum::Router;
442            use axum::routing::get;
443            use tokio::net::TcpListener;
444
445            let probe = std::sync::Arc::new(ReadinessProbe::new(probe_lake));
446            let sidecar_listener = TcpListener::bind(health_addr).await?;
447            let shutdown_rx = fanout.subscribe();
448            tokio::spawn(async move {
449                async fn sidecar_ready(
450                    Extension(p): Extension<std::sync::Arc<ReadinessProbe>>,
451                ) -> axum::http::StatusCode {
452                    if p.check_ready().await {
453                        axum::http::StatusCode::OK
454                    } else {
455                        axum::http::StatusCode::SERVICE_UNAVAILABLE
456                    }
457                }
458                let sidecar_app = Router::new()
459                    .route("/health", get(|| async { axum::http::StatusCode::OK }))
460                    .route("/ready", get(sidecar_ready))
461                    .layer(Extension(probe));
462                let _ = axum::serve(sidecar_listener, sidecar_app)
463                    .with_graceful_shutdown(shutdown_rx)
464                    .await;
465                info!("FlightSQL health sidecar stopped");
466            });
467            info!("FlightSQL health sidecar listening on {health_addr}");
468        }
469
470        let serve = Server::builder()
471            .layer(layer)
472            .add_service(svc)
473            .serve_with_incoming_shutdown(incoming, fanout.subscribe());
474
475        let deadline = {
476            let d = fanout.subscribe();
477            async move {
478                d.await;
479                tokio::time::sleep(grace).await;
480            }
481        };
482
483        tokio::select! {
484            res = serve => {
485                info!("drain completed");
486                res?;
487            }
488            _ = deadline => {
489                warn!("grace period of {grace_secs}s elapsed with work still in flight");
490            }
491        }
492
493        info!("bye");
494        Ok(())
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use serial_test::serial;
502
503    const PUBLIC_VIEW_SETS_VAR: &str = "MICROMEGAS_PUBLIC_VIEW_SETS";
504
505    /// Clears the var on drop so a failing assertion in one test can't leak state into the next.
506    struct EnvGuard;
507
508    impl Drop for EnvGuard {
509        fn drop(&mut self) {
510            // SAFETY: tests are serialized with `#[serial]`.
511            unsafe {
512                std::env::remove_var(PUBLIC_VIEW_SETS_VAR);
513            }
514        }
515    }
516
517    #[test]
518    #[serial]
519    fn env_set_is_parsed() {
520        let _guard = EnvGuard;
521        // SAFETY: serialized via `#[serial]`.
522        unsafe {
523            std::env::set_var(PUBLIC_VIEW_SETS_VAR, "log_stats,images");
524        }
525        let config = resolve_isolation_config(None).expect("resolve_isolation_config");
526        assert_eq!(
527            config.public_view_sets,
528            vec!["log_stats".to_string(), "images".to_string()]
529        );
530    }
531
532    #[test]
533    #[serial]
534    fn env_unset_is_empty() {
535        let _guard = EnvGuard;
536        // SAFETY: serialized via `#[serial]`.
537        unsafe {
538            std::env::remove_var(PUBLIC_VIEW_SETS_VAR);
539        }
540        let config = resolve_isolation_config(None).expect("resolve_isolation_config");
541        assert!(
542            config.public_view_sets.is_empty(),
543            "an unset var must resolve to no public view sets"
544        );
545    }
546
547    #[test]
548    #[serial]
549    fn env_malformed_is_err() {
550        let _guard = EnvGuard;
551        // SAFETY: serialized via `#[serial]`.
552        unsafe {
553            std::env::set_var(PUBLIC_VIEW_SETS_VAR, r#"["log_stats"]"#);
554        }
555        let err =
556            resolve_isolation_config(None).expect_err("a JSON-array-shaped value must be rejected");
557        assert!(err.to_string().contains(PUBLIC_VIEW_SETS_VAR));
558    }
559
560    /// An explicit config wins even when the env is set to a different value -- pinning that
561    /// `with_isolation_config` skips the env read entirely on this path, not merely that it
562    /// takes precedence over a matching value.
563    #[test]
564    #[serial]
565    fn explicit_config_wins_over_a_different_env_value() {
566        let _guard = EnvGuard;
567        // SAFETY: serialized via `#[serial]`.
568        unsafe {
569            std::env::set_var(PUBLIC_VIEW_SETS_VAR, "log_stats");
570        }
571        let explicit = Arc::new(IsolationConfig {
572            public_view_sets: vec!["images".to_string()],
573        });
574        let config =
575            resolve_isolation_config(Some(explicit.clone())).expect("resolve_isolation_config");
576        assert_eq!(config.public_view_sets, explicit.public_view_sets);
577    }
578
579    /// Same as above, but with the env set to a value that would fail to parse -- proving the
580    /// explicit config really does skip the env read, rather than merely winning after a
581    /// successful-but-discarded parse.
582    #[test]
583    #[serial]
584    fn explicit_config_wins_over_a_malformed_env_value() {
585        let _guard = EnvGuard;
586        // SAFETY: serialized via `#[serial]`.
587        unsafe {
588            std::env::set_var(PUBLIC_VIEW_SETS_VAR, r#"["log_stats"]"#);
589        }
590        let explicit = Arc::new(IsolationConfig {
591            public_view_sets: vec!["images".to_string()],
592        });
593        let config =
594            resolve_isolation_config(Some(explicit.clone())).expect("resolve_isolation_config");
595        assert_eq!(config.public_view_sets, explicit.public_view_sets);
596    }
597}