mz_adapter/config/frontend.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::Duration;
15
16use derivative::Derivative;
17use hyper_tls::HttpsConnector;
18use launchdarkly_server_sdk as ld;
19use mz_build_info::BuildInfo;
20use mz_cloud_provider::CloudProvider;
21use mz_cluster_client::ReplicaId;
22use mz_controller_types::ClusterId;
23use mz_ore::now::NowFn;
24use mz_sql::catalog::EnvironmentId;
25use serde_json::Value as JsonValue;
26use tokio::time;
27use tracing::warn;
28
29use crate::config::{
30 Metrics, SynchronizedParameters, SystemParameterSyncClientConfig, SystemParameterSyncConfig,
31};
32
33/// A frontend client for pulling [SynchronizedParameters] from LaunchDarkly.
34#[derive(Derivative)]
35#[derivative(Debug)]
36pub struct SystemParameterFrontend {
37 /// An SDK client to mediate interactions with the LaunchDarkly and json config file clients.
38 client: SystemParameterFrontendClient,
39 /// A map from parameter names to LaunchDarkly feature keys
40 /// to use when populating the [SynchronizedParameters]
41 /// instance in [SystemParameterFrontend::pull].
42 key_map: BTreeMap<String, String>,
43 /// The environment ID, used to build scoped (`cluster` / `replica`)
44 /// evaluation contexts.
45 env_id: EnvironmentId,
46 /// Build info, used to build scoped evaluation contexts.
47 build_info: &'static BuildInfo,
48 /// Frontend metrics.
49 metrics: Metrics,
50}
51
52#[derive(Derivative)]
53#[derivative(Debug)]
54pub enum SystemParameterFrontendClient {
55 File {
56 path: PathBuf,
57 },
58 LaunchDarkly {
59 /// An SDK client to mediate interactions with the LaunchDarkly client.
60 #[derivative(Debug = "ignore")]
61 client: ld::Client,
62 /// The context to use when querying LaunchDarkly using the SDK.
63 /// This scopes down queries to a specific key.
64 ctx: ld::Context,
65 },
66}
67
68impl SystemParameterFrontendClient {}
69
70impl SystemParameterFrontend {
71 /// Create a new [SystemParameterFrontend] initialize.
72 ///
73 /// This will create and initialize an [ld::Client] instance. The
74 /// [ld::Client::initialized_async] call will be attempted in a loop with an
75 /// exponential backoff with power `2s` and max duration `60s`.
76 pub async fn from(sync_config: &SystemParameterSyncConfig) -> Result<Self, anyhow::Error> {
77 match &sync_config.backend_config {
78 super::SystemParameterSyncClientConfig::File { path } => Ok(Self {
79 client: SystemParameterFrontendClient::File { path: path.clone() },
80 key_map: sync_config.key_map.clone(),
81 env_id: sync_config.env_id.clone(),
82 build_info: sync_config.build_info,
83 metrics: sync_config.metrics.clone(),
84 }),
85 SystemParameterSyncClientConfig::LaunchDarkly { sdk_key, now_fn } => Ok(Self {
86 client: SystemParameterFrontendClient::LaunchDarkly {
87 client: ld_client(sdk_key, &sync_config.metrics, now_fn).await?,
88 // The environment-wide context carries no cluster/replica
89 // scope. Scoped evaluation passes a `cluster` or `replica`
90 // context per pass via [`ld_ctx`].
91 ctx: ld_ctx(&sync_config.env_id, sync_config.build_info, None, None)?,
92 },
93 env_id: sync_config.env_id.clone(),
94 build_info: sync_config.build_info,
95 metrics: sync_config.metrics.clone(),
96 key_map: sync_config.key_map.clone(),
97 }),
98 }
99 }
100
101 /// Pull the current values for all [SynchronizedParameters] from the
102 /// [SystemParameterFrontend] and return `true` iff at least one parameter
103 /// value was modified.
104 pub fn pull(&self, params: &mut SynchronizedParameters) -> bool {
105 let mut changed = false;
106 for param_name in params.synchronized().into_iter() {
107 let flag_name = self
108 .key_map
109 .get(param_name)
110 .map(|flag_name| flag_name.as_str())
111 .unwrap_or(param_name);
112
113 let flag_str = match self.client {
114 SystemParameterFrontendClient::LaunchDarkly {
115 ref client,
116 ref ctx,
117 } => {
118 let flag_var = client.variation(ctx, flag_name, params.get(param_name));
119 match flag_var {
120 ld::FlagValue::Bool(v) => v.to_string(),
121 ld::FlagValue::Str(v) => v,
122 ld::FlagValue::Number(v) => v.to_string(),
123 ld::FlagValue::Json(v) => v.to_string(),
124 }
125 }
126 SystemParameterFrontendClient::File { ref path } => {
127 let file_contents = fs::read_to_string(path)
128 .inspect_err(|e| warn!("Could not open system paraemter sync file {}", e))
129 .unwrap_or_default();
130 let values: BTreeMap<String, JsonValue> = serde_json::from_str(&file_contents)
131 .inspect_err(|e| warn!("Could not open system paraemter sync file {:?}", e))
132 .unwrap_or_default();
133 values
134 .get(flag_name)
135 .and_then(|o| match o {
136 serde_json::Value::String(v) => Some(v.to_string()),
137 serde_json::Value::Number(v) => Some(v.to_string()),
138 serde_json::Value::Bool(v) => Some(v.to_string()),
139 serde_json::Value::Object(_) => Some(o.to_string()),
140 serde_json::Value::Array(_) => Some(o.to_string()),
141 serde_json::Value::Null => None,
142 })
143 .unwrap_or_else(|| params.get(param_name))
144 }
145 };
146
147 let old = params.get(param_name);
148 let change = params.modify(param_name, flag_str.as_str());
149 if change {
150 tracing::debug!(
151 %param_name, %old, new = %flag_str,
152 "updating system param",
153 );
154 }
155 self.metrics.params_changed.inc_by(u64::from(change));
156 changed |= change;
157 }
158
159 changed
160 }
161
162 /// Evaluates the replica-local scoped parameters for each given replica and
163 /// returns, per cluster and replica, the parameter values that differ from
164 /// the environment-wide value held in `params`.
165 ///
166 /// Only the LaunchDarkly client performs scoped evaluation. The file
167 /// client returns an empty map (replicas fall back to the environment-wide value).
168 /// The returned map is sparse: replicas (and clusters) with no overriding
169 /// value are omitted.
170 pub fn pull_replica_overrides(
171 &self,
172 params: &SynchronizedParameters,
173 param_names: &[&'static str],
174 replicas: &[ReplicaEvalContext],
175 ) -> BTreeMap<ReplicaId, BTreeMap<String, String>> {
176 let mut out: BTreeMap<ReplicaId, BTreeMap<String, String>> = BTreeMap::new();
177
178 let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
179 // The file client has no notion of scoped evaluation.
180 return out;
181 };
182
183 if param_names.is_empty() {
184 return out;
185 }
186
187 for replica in replicas {
188 let ctx = match ld_ctx(
189 &self.env_id,
190 self.build_info,
191 Some(&replica.cluster),
192 Some(&replica.replica),
193 ) {
194 Ok(ctx) => ctx,
195 Err(e) => {
196 warn!(
197 replica_id = %replica.replica.id,
198 "could not build scoped LD context: {e}"
199 );
200 continue;
201 }
202 };
203
204 let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
205 if !overrides.is_empty() {
206 out.insert(replica.replica_id, overrides);
207 }
208 }
209
210 out
211 }
212
213 /// Evaluates the cluster-coherent scoped parameters for each given cluster
214 /// and returns, per cluster, the parameter values that differ from the
215 /// environment-wide value held in `params`. Evaluated replica-free (the
216 /// `cluster` context kind), so the value cannot vary by replica.
217 ///
218 /// Only the LaunchDarkly client performs scoped evaluation. The file
219 /// client returns an empty map. The returned map is sparse.
220 pub fn pull_cluster_overrides(
221 &self,
222 params: &SynchronizedParameters,
223 param_names: &[&'static str],
224 clusters: &[ClusterEvalContext],
225 ) -> BTreeMap<ClusterId, BTreeMap<String, String>> {
226 let mut out: BTreeMap<ClusterId, BTreeMap<String, String>> = BTreeMap::new();
227
228 let SystemParameterFrontendClient::LaunchDarkly { client, .. } = &self.client else {
229 // The file client has no notion of scoped evaluation.
230 return out;
231 };
232
233 if param_names.is_empty() {
234 return out;
235 }
236
237 for cluster in clusters {
238 let ctx = match ld_ctx(&self.env_id, self.build_info, Some(&cluster.cluster), None) {
239 Ok(ctx) => ctx,
240 Err(e) => {
241 warn!(
242 cluster_id = %cluster.cluster.id,
243 "could not build scoped LD context: {e}"
244 );
245 continue;
246 }
247 };
248
249 let overrides = self.evaluate_scoped_overrides(client, &ctx, params, param_names);
250 if !overrides.is_empty() {
251 out.insert(cluster.cluster_id, overrides);
252 }
253 }
254
255 out
256 }
257
258 /// Evaluates each of `param_names` against `ctx`, returning only the values
259 /// that differ from the environment-wide value held in `params`. Shared by
260 /// the cluster and replica passes, so the returned map is sparse.
261 ///
262 /// We record on the differs-from-env test, not the `variation_detail`
263 /// reason. The inline comment at the recording decision explains why.
264 fn evaluate_scoped_overrides(
265 &self,
266 client: &ld::Client,
267 ctx: &ld::Context,
268 params: &SynchronizedParameters,
269 param_names: &[&'static str],
270 ) -> BTreeMap<String, String> {
271 let mut overrides = BTreeMap::new();
272 for ¶m_name in param_names {
273 let flag_name = self
274 .key_map
275 .get(param_name)
276 .map(|flag_name| flag_name.as_str())
277 .unwrap_or(param_name);
278
279 let base = params.get(param_name);
280 // Evaluate with `base` as the default, so a silent LD (flag absent,
281 // off, error, failed prerequisite) resolves back to the env-wide
282 // value and is dropped by the difference test below.
283 let flag_var = client.variation(ctx, flag_name, base.clone());
284 let value = match flag_var {
285 ld::FlagValue::Bool(v) => v.to_string(),
286 ld::FlagValue::Str(v) => v,
287 ld::FlagValue::Number(v) => v.to_string(),
288 ld::FlagValue::Json(v) => v.to_string(),
289 };
290
291 // Record iff the scoped evaluation *differs* from the env-wide value.
292 // The `variation_detail` reason is the wrong signal: it cannot say
293 // which context kind's clause matched (an env-level rule and a
294 // cluster-specific rule both report `RuleMatch`), and `Fallthrough`
295 // serves the env-wide value to every object. Comparing against the
296 // env-wide baseline is the only signal that means "this scope context
297 // changed the answer", which is what must beat a manual `FEATURES`
298 // pin and what keeps the durable collections sparse. See the scoped
299 // feature flags design, §Resolution.
300 //
301 // Compare in the parameter's canonical encoding. `base` is the
302 // var-formatted env-wide value (a `bool` is `"on"`/`"off"`), whereas
303 // the raw LaunchDarkly value spells a boolean `"true"`/`"false"`, so a
304 // direct string compare would treat every boolean flag as differing,
305 // even on `Fallthrough`. We still *store* the raw `value` (downstream
306 // consumers parse `"true"`/`"false"`). Only the decision is canonical.
307 let differs = match params.canonicalize(param_name, &value) {
308 Some(canonical) => canonical != base,
309 // LaunchDarkly served a value that does not parse for this
310 // parameter's type (e.g. a malformed boolean like `"maybe"`).
311 // Never record it: storing an unparseable value would poison
312 // resolution. The optimizer's `bool` decode, for one, panics on
313 // every plan for a cluster-coherent override it cannot parse.
314 // Treat it as "no scoped opinion" and fall back to the env-wide
315 // value.
316 None => false,
317 };
318 if differs {
319 overrides.insert(param_name.to_string(), value);
320 }
321 }
322 overrides
323 }
324}
325
326/// The identity of a single live replica, used to evaluate replica-local scoped
327/// parameters in [`SystemParameterFrontend::pull_replica_overrides`].
328#[derive(Clone, Debug)]
329pub struct ReplicaEvalContext {
330 /// The owning cluster's id.
331 pub cluster_id: ClusterId,
332 /// The replica's id.
333 pub replica_id: ReplicaId,
334 /// The owning cluster's scope context (for the replica-free, cluster pass).
335 pub cluster: ClusterScopeContext,
336 /// The replica's scope context.
337 pub replica: ReplicaScopeContext,
338}
339
340/// The identity of a single live cluster, used to evaluate cluster-coherent
341/// scoped parameters in [`SystemParameterFrontend::pull_cluster_overrides`].
342#[derive(Clone, Debug)]
343pub struct ClusterEvalContext {
344 /// The cluster's id.
345 pub cluster_id: ClusterId,
346 /// The cluster's scope context (replica-free).
347 pub cluster: ClusterScopeContext,
348}
349
350fn ld_config(api_key: &str, metrics: &Metrics) -> ld::Config {
351 ld::ConfigBuilder::new(api_key)
352 .event_processor(
353 ld::EventProcessorBuilder::new()
354 .https_connector(HttpsConnector::new())
355 .on_success({
356 let last_cse_time_seconds = metrics.last_cse_time_seconds.clone();
357 Arc::new(move |result| {
358 if let Ok(ts) = u64::try_from(result.time_from_server / 1000) {
359 last_cse_time_seconds.set(ts);
360 } else {
361 tracing::warn!(
362 "Cannot convert time_from_server / 1000 from u128 to u64"
363 );
364 }
365 })
366 }),
367 )
368 .data_source(ld::StreamingDataSourceBuilder::new().https_connector(HttpsConnector::new()))
369 .build()
370 .expect("valid config")
371}
372
373async fn ld_client(
374 api_key: &str,
375 metrics: &Metrics,
376 now_fn: &NowFn,
377) -> Result<ld::Client, anyhow::Error> {
378 let ld_client = ld::Client::build(ld_config(api_key, metrics))?;
379 tracing::info!("waiting for SystemParameterFrontend to initialize");
380 // Start and initialize LD client for the frontend. The callback passed
381 // will export the last time when an SSE event from the LD server was
382 // received in a Prometheus metric.
383 ld_client.start_with_default_executor_and_callback({
384 let last_sse_time_seconds = metrics.last_sse_time_seconds.clone();
385 let now_fn = now_fn.clone();
386 Arc::new(move |_ev| {
387 let ts = now_fn() / 1000;
388 last_sse_time_seconds.set(ts);
389 })
390 });
391
392 let max_backoff = Duration::from_secs(60);
393 let mut backoff = Duration::from_secs(5);
394 let timeout = Duration::from_secs(10);
395
396 // TODO(materialize#32030): fix retry logic
397 loop {
398 match ld_client.wait_for_initialization(timeout).await {
399 Some(true) => break,
400 Some(false) => tracing::warn!("SystemParameterFrontend failed to initialize"),
401 None => tracing::warn!("SystemParameterFrontend initialization timed out"),
402 }
403
404 time::sleep(backoff).await;
405 backoff = (backoff * 2).min(max_backoff);
406 }
407
408 tracing::info!("successfully initialized SystemParameterFrontend");
409
410 Ok(ld_client)
411}
412
413/// Identity of a cluster, used to build a `cluster` context kind for
414/// cluster-coherent scoped feature flags.
415///
416/// Exposes both `id` and `name`: an LD rule that targets `cluster_id` is an
417/// incarnation pin that dies on drop/recreate (ids are never reused), while a
418/// rule targeting `cluster_name` / `is_builtin` is a durable role predicate
419/// that re-applies to any matching cluster. See the scoped feature flags
420/// design.
421#[derive(Clone, Debug)]
422pub struct ClusterScopeContext {
423 /// The cluster's catalog id, e.g. `s2` or `u1`.
424 pub id: String,
425 /// The cluster's name, e.g. `mz_catalog_server`.
426 pub name: String,
427 /// Whether the cluster is a builtin (system) cluster.
428 pub is_builtin: bool,
429}
430
431/// Identity of a replica, used to build a `replica` context kind for
432/// replica-local scoped feature flags.
433///
434/// Carries the owning cluster's identity as attributes so that replica-local
435/// flags can be cluster-targeted without a second evaluation, and the replica
436/// size and size *family* so flags can be keyed by size family (e.g. legacy
437/// sizes keep `lgalloc`). See the scoped feature flags design.
438#[derive(Clone, Debug)]
439pub struct ReplicaScopeContext {
440 /// The replica's catalog id.
441 pub id: String,
442 /// The replica's name.
443 pub name: String,
444 /// Whether the replica belongs to a builtin (system) cluster.
445 pub is_builtin: bool,
446 /// The replica's full size name, e.g. `D.1-xsmall` or a legacy t-shirt size
447 /// like `xsmall`. This is the fine-grained targeting axis. The coarse axis
448 /// is [`Self::size_family`]. The two are distinct: `D.1-xsmall` is a size,
449 /// `D` is its family.
450 pub size: String,
451 /// The replica's size family, e.g. `D` or `legacy`. The coarse targeting
452 /// axis, derived from the size map rather than the size name (see
453 /// [`Self::size`]).
454 pub size_family: String,
455 /// The owning cluster's catalog id.
456 pub cluster_id: String,
457 /// The owning cluster's name.
458 pub cluster_name: String,
459}
460
461/// Builds a single `cluster` context kind from a [`ClusterScopeContext`].
462///
463/// Deliberately replica-free: cluster-coherent flags must resolve identically
464/// across a cluster's replicas, so no replica/size attributes appear here.
465fn cluster_context(cluster: &ClusterScopeContext) -> Result<ld::Context, anyhow::Error> {
466 ld::ContextBuilder::new(cluster.id.as_str())
467 .anonymous(true) // keep the LD dashboard Contexts list clean
468 .kind("cluster")
469 .set_string("cluster_id", cluster.id.clone())
470 .set_string("cluster_name", cluster.name.clone())
471 .set_string("is_builtin", cluster.is_builtin.to_string())
472 .build()
473 .map_err(|e| anyhow::anyhow!(e))
474}
475
476/// Builds a single `replica` context kind from a [`ReplicaScopeContext`].
477///
478/// Includes the owning cluster's identity so a rule can combine both axes,
479/// e.g. "size family `D` *and* cluster `foo`".
480fn replica_context(replica: &ReplicaScopeContext) -> Result<ld::Context, anyhow::Error> {
481 ld::ContextBuilder::new(replica.id.as_str())
482 .anonymous(true) // keep the LD dashboard Contexts list clean
483 .kind("replica")
484 .set_string("replica_id", replica.id.clone())
485 .set_string("replica_name", replica.name.clone())
486 .set_string("is_builtin", replica.is_builtin.to_string())
487 .set_string("replica_size", replica.size.clone())
488 .set_string("replica_size_family", replica.size_family.clone())
489 .set_string("cluster_id", replica.cluster_id.clone())
490 .set_string("cluster_name", replica.cluster_name.clone())
491 .build()
492 .map_err(|e| anyhow::anyhow!(e))
493}
494
495/// Builds a multi-context for evaluating scoped feature flags.
496///
497/// Composes the base contexts (`environment` + `organization` + `build`) with:
498/// - a `cluster` context for cluster-coherent (replica-free) resolution, and/or
499/// - a `replica` context for replica-local resolution.
500///
501/// The environment-wide pass passes `None` for both. This is the single entry
502/// point the sync loop uses to evaluate each scoped pass.
503fn ld_ctx(
504 env_id: &EnvironmentId,
505 build_info: &'static BuildInfo,
506 cluster: Option<&ClusterScopeContext>,
507 replica: Option<&ReplicaScopeContext>,
508) -> Result<ld::Context, anyhow::Error> {
509 // Register multiple contexts for this client.
510 //
511 // Unfortunately, it seems that the order in which conflicting targeting
512 // rules are applied depends on the definition order of feature flag
513 // variations rather than on the order in which context are registered with
514 // the multi-context builder.
515 let mut ctx_builder = ld::MultiContextBuilder::new();
516
517 if env_id.cloud_provider() != &CloudProvider::Local {
518 ctx_builder.add_context(
519 ld::ContextBuilder::new(env_id.to_string())
520 .kind("environment")
521 .set_string("cloud_provider", env_id.cloud_provider().to_string())
522 .set_string("cloud_provider_region", env_id.cloud_provider_region())
523 .set_string("organization_id", env_id.organization_id().to_string())
524 .set_string("ordinal", env_id.ordinal().to_string())
525 .build()
526 .map_err(|e| anyhow::anyhow!(e))?,
527 );
528 ctx_builder.add_context(
529 ld::ContextBuilder::new(env_id.organization_id().to_string())
530 .kind("organization")
531 .build()
532 .map_err(|e| anyhow::anyhow!(e))?,
533 );
534 } else {
535 // If cloud_provider is 'local', use anonymous `environment` and
536 // `organization` contexts with fixed keys, as otherwise we will create
537 // a lot of additional contexts (which are the billable entity for
538 // LaunchDarkly).
539 ctx_builder.add_context(
540 ld::ContextBuilder::new("anonymous-dev@materialize.com")
541 .anonymous(true) // exclude this user from the dashboard
542 .kind("environment")
543 .set_string("cloud_provider", env_id.cloud_provider().to_string())
544 .set_string("cloud_provider_region", env_id.cloud_provider_region())
545 .set_string("organization_id", uuid::Uuid::nil().to_string())
546 .set_string("ordinal", env_id.ordinal().to_string())
547 .build()
548 .map_err(|e| anyhow::anyhow!(e))?,
549 );
550 ctx_builder.add_context(
551 ld::ContextBuilder::new(uuid::Uuid::nil().to_string())
552 .anonymous(true) // exclude this user from the dashboard
553 .kind("organization")
554 .build()
555 .map_err(|e| anyhow::anyhow!(e))?,
556 );
557 };
558
559 ctx_builder.add_context(
560 ld::ContextBuilder::new(build_info.sha)
561 .kind("build")
562 .set_string("semver_version", build_info.semver_version().to_string())
563 .build()
564 .map_err(|e| anyhow::anyhow!(e))?,
565 );
566
567 // Cluster-coherent resolution evaluates with a `cluster` context (no
568 // replica attributes). Replica-local resolution additionally carries a
569 // `replica` context. The environment-wide pass carries neither.
570 if let Some(cluster) = cluster {
571 ctx_builder.add_context(cluster_context(cluster)?);
572 }
573 if let Some(replica) = replica {
574 ctx_builder.add_context(replica_context(replica)?);
575 }
576
577 ctx_builder.build().map_err(|e| anyhow::anyhow!(e))
578}
579
580#[cfg(test)]
581mod tests {
582 use mz_build_info::DUMMY_BUILD_INFO;
583
584 use super::*;
585
586 fn env_id() -> EnvironmentId {
587 EnvironmentId::for_tests()
588 }
589
590 #[mz_ore::test]
591 fn builds_cluster_scoped_context() {
592 // Cluster-coherent resolution evaluates with a replica-free `cluster`
593 // context.
594 let cluster = ClusterScopeContext {
595 id: "s2".into(),
596 name: "mz_catalog_server".into(),
597 is_builtin: true,
598 };
599 ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), None)
600 .expect("cluster-scoped context builds");
601 }
602
603 #[mz_ore::test]
604 fn builds_replica_scoped_context() {
605 // Replica-local resolution carries both a `cluster` and a `replica`
606 // context so a rule can combine size family and cluster.
607 let cluster = ClusterScopeContext {
608 id: "u1".into(),
609 name: "quickstart".into(),
610 is_builtin: false,
611 };
612 let replica = ReplicaScopeContext {
613 id: "u1-replica-1".into(),
614 name: "r1".into(),
615 is_builtin: false,
616 size: "D.1-xsmall".into(),
617 size_family: "D".into(),
618 cluster_id: "u1".into(),
619 cluster_name: "quickstart".into(),
620 };
621 ld_ctx(&env_id(), &DUMMY_BUILD_INFO, Some(&cluster), Some(&replica))
622 .expect("replica-scoped context builds");
623 }
624
625 #[mz_ore::test]
626 fn environment_wide_context_is_unscoped() {
627 ld_ctx(&env_id(), &DUMMY_BUILD_INFO, None, None).expect("environment-wide context builds");
628 }
629}