diff --git a/hyperactor/src/attrs.rs b/hyperactor/src/attrs.rs index c3ed49fd9..148da4cb6 100644 --- a/hyperactor/src/attrs.rs +++ b/hyperactor/src/attrs.rs @@ -466,6 +466,12 @@ impl Attrs { ) { self.values.insert(name, value); } + + /// Internal getter by key name for explicitly-set values (no + /// defaults). + pub(crate) fn get_value_by_name(&self, name: &'static str) -> Option<&dyn SerializableValue> { + self.values.get(name).map(|b| b.as_ref()) + } } impl Clone for Attrs { diff --git a/hyperactor/src/config.rs b/hyperactor/src/config.rs index ff1f9c1aa..2b44893f1 100644 --- a/hyperactor/src/config.rs +++ b/hyperactor/src/config.rs @@ -8,9 +8,30 @@ //! Configuration for Hyperactor. //! -//! This module provides a centralized way to manage configuration settings for Hyperactor. -//! It uses the attrs system for type-safe, flexible configuration management that supports -//! environment variables, YAML files, and temporary modifications for tests. +//! This module provides a centralized, type-safe way to manage +//! configuration settings using the `attrs` system. The system +//! supports multiple sources (YAML files, environment variables, +//! runtime-provided values, and temporary test overrides) with +//! serde-based serialization. +//! +//! Configuration is resolved via a **layered model**: `TestOverride → +//! Runtime → Env → File → Default`. +//! +//! - Reads (`global::get`, `global::get_cloned`) resolve in that +//! order, with Defaults last. +//! - `global::attrs()` returns a **complete snapshot** of the +//! effective configuration at call time: it materializes defaults for +//! keys not set in any layer, and omits meta-only keys (like +//! `CONFIG_ENV_VAR`) unless explicitly set. +//! - When a hyperactor parent process spawns a bootstrap child to +//! host procs, it can capture its effective configuration via +//! `global::attrs()` and pass that snapshot to the child. The child +//! installs this snapshot as its `Runtime` layer, ensuring that the +//! parent's configuration values "win" inside the child process. +//! +//! This design provides flexibility (easy overrides in tests, runtime +//! updates, YAML-based configuration) while ensuring type safety and +//! predictable resolution order. use std::env; use std::fs::File; @@ -195,21 +216,122 @@ pub mod global { use crate::attrs::AttrValue; use crate::attrs::Key; - /// Global configuration instance, initialized from environment variables. - static CONFIG: LazyLock>> = - LazyLock::new(|| Arc::new(RwLock::new(from_env()))); + /// Configuration source layers in priority order. + /// + /// Resolution order is always: **TestOverride -> Runtime -> Env + /// -> File -> Default**. + /// + /// Smaller `priority()` number = higher precedence. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum Source { + /// Values loaded from configuration files (e.g., YAML). This + /// is the lowest-priority explicit source. + File, + /// Values read from environment variables at process startup. + /// Higher priority than File, but lower than + /// Runtime/TestOverride. + Env, + /// Values set programmatically at runtime. Highest stable + /// priority layer; only overridden by TestOverride. + Runtime, + /// Ephemeral values inserted by tests via + /// `ConfigLock::override_key`. Always wins over all other + /// sources; removed when the guard drops. + TestOverride, + } + + /// Return the numeric priority for a source. + /// + /// Smaller number = higher precedence. Matches the documented + /// order: TestOverride (0) -> Runtime (1) -> Env (2) -> File (3). + fn priority(s: Source) -> u8 { + match s { + Source::TestOverride => 0, + Source::Runtime => 1, + Source::Env => 2, + Source::File => 3, + } + } + + /// A single configuration layer in the global store. + /// + /// Each `Layer` wraps a [`Source`] and its associated [`Attrs`] + /// values. Layers are kept in priority order and consulted during + /// resolution. + #[derive(Clone)] + struct Layer { + /// The origin of this layer (File, Env, Runtime, or + /// TestOverride). + source: Source, + /// The set of attributes explicitly provided by this source. + attrs: Attrs, + } + + /// The full set of configuration layers in priority order. + /// + /// `Layers` wraps a vector of [`Layer`]s, always kept sorted by + /// [`priority`] (lowest number = highest precedence). + /// + /// Resolution (`get`, `get_cloned`, `attrs`) consults `ordered` + /// from front to back, returning the first value found for each + /// key and falling back to defaults if none are set in any layer. + struct Layers { + /// Kept sorted by `priority` (lowest number first = highest + /// priority). + ordered: Vec, + } + + /// Global layered configuration store. + /// + /// This is the single authoritative store for configuration in + /// the process. It is always present, protected by an `RwLock`, + /// and holds a [`Layers`] struct containing all active sources. + /// + /// On startup it is seeded with a single [`Source::Env`] layer + /// (values loaded from process environment variables). Additional + /// layers can be installed later via [`set`] or cleared with + /// [`clear`]. Reads (`get`, `get_cloned`, `attrs`) consult the + /// layers in priority order. + /// + /// In tests, a [`Source::TestOverride`] layer is pushed on demand + /// by [`ConfigLock::override_key`]. This layer always takes + /// precedence and is automatically removed when the guard drops. + /// + /// In normal operation, a parent process may capture its config + /// with [`attrs`] and pass it to a child during bootstrap. The + /// child installs this snapshot as its [`Source::Runtime`] layer, + /// ensuring the parent's values override Env/File/Defaults. + static LAYERS: LazyLock>> = LazyLock::new(|| { + let env = super::from_env(); + let layers = Layers { + ordered: vec![Layer { + source: Source::Env, + attrs: env, + }], + }; + Arc::new(RwLock::new(layers)) + }); - /// Acquire the global configuration lock for testing. + /// Acquire the global configuration lock. + /// + /// This lock serializes all mutations of the global + /// configuration, ensuring they cannot clobber each other. It + /// returns a [`ConfigLock`] guard, which must be held for the + /// duration of any mutation (e.g. inserting or overriding + /// values). /// - /// This function returns a ConfigLock that acts as both a write lock guard (preventing - /// other tests from modifying global config concurrently) and as the only way to - /// create configuration overrides. + /// Most commonly used in tests, where it provides exclusive + /// access to push a [`Source::TestOverride`] layer via + /// [`ConfigLock::override_key`]. The override layer is + /// automatically removed when the guard drops, restoring the + /// original state. /// - /// Example usage: - /// ```ignore rust - /// let config = hyperactor::config::global::lock(); - /// let _guard = config.override_key(CONFIG_KEY, "value"); - /// // ... test code using the overridden config ... + /// # Example + /// ```rust,ignore + /// let lock = hyperactor::config::global::lock(); + /// let _guard = lock.override_key(CONFIG_KEY, "test_value"); + /// // Code under test sees the overridden config. + /// // On drop, the key is restored. /// ``` pub fn lock() -> ConfigLock { static MUTEX: LazyLock> = LazyLock::new(|| std::sync::Mutex::new(())); @@ -218,57 +340,201 @@ pub mod global { } } - /// Initialize the global configuration from environment variables + /// Initialize the global configuration from environment + /// variables. + /// + /// Reads values from process environment variables, using the + /// `CONFIG_ENV_VAR` meta-attribute declared on each key to find + /// its mapping. The resulting values are installed as the + /// [`Source::Env`] layer. Keys without a corresponding + /// environment variable fall back to defaults or higher-priority + /// sources. + /// + /// Typically invoked once at process startup to overlay config + /// values from the environment. Repeated calls replace the + /// existing Env layer. pub fn init_from_env() { - let config = from_env(); - let mut global_config = CONFIG.write().unwrap(); - *global_config = config; + set(Source::Env, super::from_env()); } - /// Initialize the global configuration from a YAML file + /// Initialize the global configuration from a YAML file. + /// + /// Loads values from the specified YAML file and installs them as + /// the [`Source::File`] layer. This is the lowest-priority + /// explicit source: values from Env, Runtime, or TestOverride + /// layers always take precedence. Keys not present in the file + /// fall back to their defaults or higher-priority sources. + /// + /// Typically invoked once at process startup to provide a + /// baseline configuration. Repeated calls replace the existing + /// File layer. pub fn init_from_yaml>(path: P) -> Result<(), anyhow::Error> { - let config = from_yaml(path)?; - let mut global_config = CONFIG.write().unwrap(); - *global_config = config; + let file = super::from_yaml(path)?; + set(Source::File, file); Ok(()) } - /// Get a key from the global configuration. Currently only available for Copy types. - /// `get` assumes that the key has a default value. + /// Get a key from the global configuration (Copy types). + /// + /// Resolution order: TestOverride -> Runtime -> Env -> File -> + /// Default. Panics if the key has no default and is not set in + /// any layer. pub fn get(key: Key) -> T { - *CONFIG.read().unwrap().get(key).unwrap() + let layers = LAYERS.read().unwrap(); + for layer in &layers.ordered { + if layer.attrs.contains_key(key) { + return *layer.attrs.get(key).unwrap(); + } + } + *key.default().expect("key must have a default") } - /// Get a key from the global configuration by cloning the value. + /// Get a key by cloning the value. + /// + /// Resolution order: TestOverride -> Runtime -> Env -> File -> + /// Default. Panics if the key has no default and is not set in + /// any layer. pub fn get_cloned(key: Key) -> T { - CONFIG.read().unwrap().get(key).unwrap().clone() + let layers = LAYERS.read().unwrap(); + for layer in &layers.ordered { + if layer.attrs.contains_key(key) { + return layer.attrs.get(key).unwrap().clone(); + } + } + key.default().expect("key must have a default").clone() } - /// Get the global attrs + /// Insert or replace a configuration layer for the given source. + /// + /// If a layer with the same [`Source`] already exists, its + /// contents are replaced with the provided `attrs`. Otherwise a + /// new layer is added. After insertion, layers are re-sorted so + /// that higher-priority sources (e.g. [`Source::TestOverride`], + /// [`Source::Runtime`]) appear before lower-priority ones + /// ([`Source::Env`], [`Source::File`]). + /// + /// This function is used by initialization routines (e.g. + /// `init_from_env`, `init_from_yaml`) and by tests when + /// overriding configuration values. + pub fn set(source: Source, attrs: Attrs) { + let mut g = LAYERS.write().unwrap(); + if let Some(l) = g.ordered.iter_mut().find(|l| l.source == source) { + l.attrs = attrs; + } else { + g.ordered.push(Layer { source, attrs }); + } + g.ordered.sort_by_key(|l| priority(l.source)); // TestOverride < Runtime < Env < File + } + + /// Remove the configuration layer for the given [`Source`], if + /// present. + /// + /// After this call, values from that source will no longer + /// contribute to resolution in [`get`], [`get_cloned`], or + /// [`attrs`]. Defaults and any remaining layers continue to apply + /// in their normal priority order. + pub(crate) fn clear(source: Source) { + let mut g = LAYERS.write().unwrap(); + g.ordered.retain(|l| l.source != source); + } + + /// Return a complete, merged snapshot of the effective + /// configuration. + /// + /// Resolution per key: + /// 1) First explicit value found in layers (TestOverride → + /// Runtime → Env → File). + /// 2) Otherwise, the key's default (if any). + /// + /// Notes: + /// - This materializes defaults into the returned Attrs so it's + /// self-contained. pub fn attrs() -> Attrs { - CONFIG.read().unwrap().clone() + let layers = LAYERS.read().unwrap(); + let mut merged = Attrs::new(); + + // Iterate all declared keys (registered via `declare_attrs!` + // + inventory). + for info in inventory::iter::() { + let name = info.name; + + // Try to resolve from highest -> lowest priority layer. + let mut chosen: Option> = None; + for layer in &layers.ordered { + if let Some(v) = layer.attrs.get_value_by_name(name) { + chosen = Some(v.cloned()); + break; + } + } + + // If no explicit value, materialize the default if there + // is one. + let boxed = match chosen { + Some(b) => b, + None => { + if let Some(default) = info.default { + default.cloned() + } else { + // No explicit value and no default — skip + // this key. + continue; + } + } + }; + + merged.insert_value_by_name_unchecked(name, boxed); + } + + merged } - /// Reset the global configuration to defaults (for testing only) + /// Reset the global configuration to only Defaults (for testing). + /// + /// This clears all explicit layers (`File`, `Env`, `Runtime`, and + /// `TestOverride`). Subsequent lookups will resolve keys entirely + /// from their declared defaults. /// - /// Note: This should be called from within with_test_lock() to ensure thread safety. - /// Available in all builds to support tests in other crates. + /// Note: Should be called while holding [`global::lock`] in + /// tests, to ensure no concurrent modifications happen. pub fn reset_to_defaults() { - let mut config = CONFIG.write().unwrap(); - *config = Attrs::new(); + let mut g = LAYERS.write().unwrap(); + g.ordered.clear(); } - /// A guard that holds the global configuration lock and provides override functionality. + fn test_override_index(layers: &Layers) -> Option { + layers + .ordered + .iter() + .position(|l| matches!(l.source, Source::TestOverride)) + } + + fn ensure_test_override_layer_mut<'a>(layers: &'a mut Layers) -> &'a mut Attrs { + if let Some(i) = test_override_index(layers) { + return &mut layers.ordered[i].attrs; + } + layers.ordered.push(Layer { + source: Source::TestOverride, + attrs: Attrs::new(), + }); + layers.ordered.sort_by_key(|l| priority(l.source)); + let i = test_override_index(layers).expect("just inserted TestOverride layer"); + &mut layers.ordered[i].attrs + } + + /// A guard that holds the global configuration lock and provides + /// override functionality. /// - /// This struct acts as both a lock guard (preventing other tests from modifying global config) - /// and as the only way to create configuration overrides. Override guards cannot outlive - /// this ConfigLock, ensuring proper synchronization. + /// This struct acts as both a lock guard (preventing other tests + /// from modifying global config) and as the only way to create + /// configuration overrides. Override guards cannot outlive this + /// ConfigLock, ensuring proper synchronization. pub struct ConfigLock { _guard: std::sync::MutexGuard<'static, ()>, } impl ConfigLock { - /// Create a configuration override that will be restored when the guard is dropped. + /// Create a configuration override that will be restored when + /// the guard is dropped. /// /// The returned guard must not outlive this ConfigLock. pub fn override_key<'a, T: AttrValue>( @@ -276,33 +542,73 @@ pub mod global { key: crate::attrs::Key, value: T, ) -> ConfigValueGuard<'a, T> { - let orig = { - let mut config = CONFIG.write().unwrap(); - let orig = config.remove_value(key); - config.set(key, value.clone()); - orig - }; - - let orig_env = if let Some(env_var) = key.attrs().get(CONFIG_ENV_VAR) { - let orig = std::env::var(env_var).ok(); - // SAFETY: this is used in tests - unsafe { - std::env::set_var(env_var, value.display()); - } - Some((env_var.clone(), orig)) - } else { - None + // Write into the single TestOverride layer (create if + // needed). + let (prev_in_layer, orig_env) = { + let mut guard = LAYERS.write().unwrap(); + let layer_attrs = ensure_test_override_layer_mut(&mut guard); + // Save any previous override for this key in the the + // TestOverride layer. + let prev = layer_attrs.remove_value(key); + // Set new override value. + layer_attrs.set(key, value.clone()); + // Mirror env var. + let orig_env = if let Some(env_var) = key.attrs().get(CONFIG_ENV_VAR) { + let orig = std::env::var(env_var).ok(); + // SAFETY: Mutating process-global environment + // variables is not thread-safe. This path is used + // only in tests while holding the global + // ConfigLock, which serializes config mutations + // across the process. Tests are single-threaded + // with respect to env changes, so there are no + // concurrent readers/writers. We also record the + // original value and restore it in + // ConfigValueGuard::drop. + unsafe { + std::env::set_var(env_var, value.display()); + } + Some((env_var.clone(), orig)) + } else { + None + }; + (prev, orig_env) }; ConfigValueGuard { key, - orig, + orig: prev_in_layer, // previous value for this key *inside* TestOverride layer orig_env, _phantom: PhantomData, } } } + /// When a [`ConfigLock`] is dropped, the special + /// [`Source::TestOverride`] layer (if present) is removed + /// entirely. This discards all temporary overrides created under + /// the lock, ensuring they cannot leak into subsequent tests or + /// callers. Other layers (`Runtime`, `Env`, `File`, and defaults) + /// are left untouched. + /// + /// Note: individual values within the TestOverride layer may + /// already have been restored by [`ConfigValueGuard`]s as they + /// drop. This final removal guarantees no residual layer remains + /// once the lock itself is released. + impl Drop for ConfigLock { + fn drop(&mut self) { + let mut guard = LAYERS.write().unwrap(); + if let Some(pos) = guard + .ordered + .iter() + .position(|l| matches!(l.source, Source::TestOverride)) + { + guard.ordered.remove(pos); + } + // No need to restore anything else; underlying layers + // remain intact. + } + } + /// A guard that restores a single configuration value when dropped pub struct ConfigValueGuard<'a, T: 'static> { key: crate::attrs::Key, @@ -312,24 +618,48 @@ pub mod global { _phantom: PhantomData<&'a ()>, } + /// When a [`ConfigValueGuard`] is dropped, it restores the + /// configuration state for the key it was guarding: + /// + /// - If there was a previous override for this key in the + /// [`Source::TestOverride`] layer, that value is reinserted. + /// - If this guard was the only override for the key, the entry + /// is removed from the layer entirely (leaving underlying layers + /// or defaults to apply). + /// - If the key declared a `CONFIG_ENV_VAR`, the corresponding + /// process environment variable is restored to its original value + /// (or removed if it didn't exist). + /// + /// This ensures that overrides applied via + /// [`ConfigLock::override_key`] are always reverted cleanly when + /// the guard is dropped, without leaking state into subsequent + /// tests or callers. impl Drop for ConfigValueGuard<'_, T> { fn drop(&mut self) { - let mut config = CONFIG.write().unwrap(); - if let Some(orig) = self.orig.take() { - config.insert_value(self.key, orig); - } else { - config.remove_value(self.key); - } - if let Some((key, value)) = self.orig_env.take() { - if let Some(value) = value { - // SAFETY: this is used in tests - unsafe { - std::env::set_var(key, value); - } + let mut guard = LAYERS.write().unwrap(); + + if let Some(i) = test_override_index(&guard) { + let layer_attrs = &mut guard.ordered[i].attrs; + + if let Some(prev) = self.orig.take() { + layer_attrs.insert_value(self.key, prev); } else { - // SAFETY: this is used in tests - unsafe { - std::env::remove_var(&key); + // remove without needing T: AttrValue + let _ = layer_attrs.remove_value(self.key); + } + } + + if let Some((k, v)) = self.orig_env.take() { + // SAFETY: process-global environment variables are + // not thread-safe to mutate. This override/restore + // path is only ever used in single-threaded test + // code, and is serialized by the global ConfigLock to + // avoid races between tests. + unsafe { + if let Some(v) = v { + std::env::set_var(k, v); + } else { + std::env::remove_var(&k); } } } @@ -589,4 +919,203 @@ mod tests { Duration::from_secs(30) ); } + + #[test] + fn test_layer_precedence_env_over_file_and_replacement() { + let _lock = global::lock(); + global::reset_to_defaults(); + + // File sets a value. + let mut file = Attrs::new(); + file[CODEC_MAX_FRAME_LENGTH] = 1111; + global::set(global::Source::File, file); + + // Env sets a different value. + let mut env = Attrs::new(); + env[CODEC_MAX_FRAME_LENGTH] = 2222; + global::set(global::Source::Env, env); + + // Env should win over File. + assert_eq!(global::get(CODEC_MAX_FRAME_LENGTH), 2222); + + // Replace Env layer with a new value. + let mut env2 = Attrs::new(); + env2[CODEC_MAX_FRAME_LENGTH] = 3333; + global::set(global::Source::Env, env2); + + assert_eq!(global::get(CODEC_MAX_FRAME_LENGTH), 3333); + } + + #[test] + fn test_runtime_overrides_and_clear_restores_lower_layers() { + let _lock = global::lock(); + global::reset_to_defaults(); + + // File baseline. + let mut file = Attrs::new(); + file[MESSAGE_DELIVERY_TIMEOUT] = Duration::from_secs(30); + global::set(global::Source::File, file); + + // Env override. + let mut env = Attrs::new(); + env[MESSAGE_DELIVERY_TIMEOUT] = Duration::from_secs(40); + global::set(global::Source::Env, env); + + // Runtime beats both. + let mut rt = Attrs::new(); + rt[MESSAGE_DELIVERY_TIMEOUT] = Duration::from_secs(50); + global::set(global::Source::Runtime, rt); + + assert_eq!( + global::get(MESSAGE_DELIVERY_TIMEOUT), + Duration::from_secs(50) + ); + + // Clearing Runtime should reveal Env again. + global::clear(global::Source::Runtime); + + // With the Runtime layer gone, Env still wins over File. + assert_eq!( + global::get(MESSAGE_DELIVERY_TIMEOUT), + Duration::from_secs(40) + ); + } + + #[test] + fn test_attrs_snapshot_materializes_defaults_and_omits_meta() { + let _lock = global::lock(); + global::reset_to_defaults(); + + // No explicit layers: values should come from Defaults. + let snap = global::attrs(); + + // A few representative defaults are materialized: + assert_eq!(snap[CODEC_MAX_FRAME_LENGTH], 10 * 1024 * 1024 * 1024); + assert_eq!(snap[MESSAGE_DELIVERY_TIMEOUT], Duration::from_secs(30)); + + // CONFIG_ENV_VAR has no default and wasn't explicitly set: + // should be omitted. + let json = serde_json::to_string(&snap).unwrap(); + assert!( + !json.contains("config_env_var"), + "CONFIG_ENV_VAR must not appear in snapshot unless explicitly set" + ); + } + + #[test] + fn test_parent_child_snapshot_as_runtime_layer() { + let _lock = global::lock(); + global::reset_to_defaults(); + + // Parent effective config (pretend it's a parent process). + let mut parent_env = Attrs::new(); + parent_env[MESSAGE_ACK_EVERY_N_MESSAGES] = 12345; + global::set(global::Source::Env, parent_env); + + let parent_snap = global::attrs(); + + // "Child" process: start clean, install parent snapshot as + // Runtime. + global::reset_to_defaults(); + global::set(global::Source::Runtime, parent_snap); + + // Child should observe parent's effective value (as highest + // stable layer). + assert_eq!(global::get(MESSAGE_ACK_EVERY_N_MESSAGES), 12345); + } + + #[test] + fn test_testoverride_layer_override_and_env_restore() { + let lock = global::lock(); + global::reset_to_defaults(); + + assert_eq!( + global::get(MESSAGE_DELIVERY_TIMEOUT), + Duration::from_secs(30) + ); + + // SAFETY: single-threaded test. + unsafe { + std::env::remove_var("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT"); + } + + { + let _guard = lock.override_key(MESSAGE_DELIVERY_TIMEOUT, Duration::from_secs(99)); + // Override wins: + assert_eq!( + global::get(MESSAGE_DELIVERY_TIMEOUT), + Duration::from_secs(99) + ); + + // Env should be mirrored to the same duration (string may + // be "1m 39s") + let s = std::env::var("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT").unwrap(); + let parsed = humantime::parse_duration(&s).unwrap(); + assert_eq!(parsed, Duration::from_secs(99)); + } + + // After drop, value and env restored: + assert_eq!( + global::get(MESSAGE_DELIVERY_TIMEOUT), + Duration::from_secs(30) + ); + assert!(std::env::var("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT").is_err()); + } + + #[test] + fn test_reset_to_defaults_clears_all_layers() { + let _lock = global::lock(); + global::reset_to_defaults(); + + // Seed multiple layers. + let mut file = Attrs::new(); + file[SPLIT_MAX_BUFFER_SIZE] = 7; + global::set(global::Source::File, file); + + let mut env = Attrs::new(); + env[SPLIT_MAX_BUFFER_SIZE] = 8; + global::set(global::Source::Env, env); + + let mut rt = Attrs::new(); + rt[SPLIT_MAX_BUFFER_SIZE] = 9; + global::set(global::Source::Runtime, rt); + + // Sanity: highest wins. + assert_eq!(global::get(SPLIT_MAX_BUFFER_SIZE), 9); + + // Reset clears all explicit layers; defaults apply. + global::reset_to_defaults(); + assert_eq!(global::get(SPLIT_MAX_BUFFER_SIZE), 5); // default + } + + #[test] + fn test_get_cloned_resolution_matches_get() { + let _lock = global::lock(); + global::reset_to_defaults(); + + let mut env = Attrs::new(); + env[CHANNEL_MULTIPART] = false; + global::set(global::Source::Env, env); + + assert!(!global::get(CHANNEL_MULTIPART)); + let v = global::get_cloned(CHANNEL_MULTIPART); + assert!(!v); + } + + #[test] + fn test_attrs_snapshot_respects_layer_precedence_per_key() { + let _lock = global::lock(); + global::reset_to_defaults(); + + let mut file = Attrs::new(); + file[MESSAGE_TTL_DEFAULT] = 10; + global::set(global::Source::File, file); + + let mut env = Attrs::new(); + env[MESSAGE_TTL_DEFAULT] = 20; + global::set(global::Source::Env, env); + + let snap = global::attrs(); + assert_eq!(snap[MESSAGE_TTL_DEFAULT], 20); // Env beats File + } } diff --git a/hyperactor_mesh/src/bootstrap.rs b/hyperactor_mesh/src/bootstrap.rs index e067e3690..6079f83b9 100644 --- a/hyperactor_mesh/src/bootstrap.rs +++ b/hyperactor_mesh/src/bootstrap.rs @@ -40,6 +40,7 @@ use hyperactor::channel::Tx; use hyperactor::clock::Clock; use hyperactor::clock::RealClock; use hyperactor::config::CONFIG_ENV_VAR; +use hyperactor::config::global as config; use hyperactor::declare_attrs; use hyperactor::host; use hyperactor::host::Host; @@ -212,7 +213,10 @@ pub enum Bootstrap { backend_addr: ChannelAddr, /// The callback address used to indicate successful spawning. callback_addr: ChannelAddr, - /// Config snapshot for the child. + /// Optional config snapshot (`hyperactor::config::Attrs`) + /// captured by the parent. If present, the child installs it + /// as the `Runtime` layer so the parent's effective config + /// takes precedence over Env/File/Defaults. config: Option, }, @@ -224,7 +228,10 @@ pub enum Bootstrap { /// If specified, use the provided command instead of /// [`BootstrapCommand::current`]. command: Option, - /// Config snapshot for the child. + /// Optional config snapshot (`hyperactor::config::Attrs`) + /// captured by the parent. If present, the child installs it + /// as the `Runtime` layer so the parent’s effective config + /// takes precedence over Env/File/Defaults. config: Option, }, @@ -320,10 +327,9 @@ impl Bootstrap { callback_addr, config, } => { - if config.is_some() { - tracing::debug!( - "bootstrap: Proc received config snapshot (carried, not applied)" - ); + if let Some(attrs) = config { + config::set(config::Source::Runtime, attrs); + tracing::debug!("bootstrap: installed Runtime config snapshot (Proc)"); } else { tracing::debug!("bootstrap: no config snapshot provided (Proc)"); } @@ -353,10 +359,9 @@ impl Bootstrap { command, config, } => { - if config.is_some() { - tracing::debug!( - "bootstrap: Host received config snapshot (carried, not applied)" - ); + if let Some(attrs) = config { + config::set(config::Source::Runtime, attrs); + tracing::debug!("bootstrap: installed Runtime config snapshot (Host)"); } else { tracing::debug!("bootstrap: no config snapshot provided (Host)"); }