Skip to content

Lazy Config

LazyConfig<T>

Rust Edition Layout Protocol Cross-Process Fast_path

Thundering-herd-proof distributed config fetch. Composite over SharedOnceCell<T> : the first caller’s CAS wins, runs the fetcher closure, and publishes; concurrent losers block until the publish, then read the canonical value. Across N processes mapping the same file, the fetcher runs at most ONCE, no matter how many of them call get_or_fetch concurrently.

The “config-fetch primitive that beats the thundering herd” primitive. try_get_loaded at 10.11 ns vs std::sync::OnceLock 10.01 ns (essentially tied) and Mutex<Option<T>> 17.77 ns (1.76x faster). Post-load get_or_fetch_loaded at 1.65 ns vs std OnceLock 1.06 ns (1.55x slower). The architectural lever: same per-op cost envelope as the in-process baseline AND cross-process visibility AND single-fetch-across-N-processes guarantee.

Constraints (read first):

  • Native sidecar integration: the struct carries a HandshakeHeader + ObservationRing and implements subetha_sidecar::AdaptiveInstance. Wrap in SidecarBox::new to register with the global sidecar; raw create() / open() return the unregistered type unchanged.

  • T: Copy + Send + Sync + 'static, fixed payload sized to ONCE_PAYLOAD_BYTES (56 bytes).

  • Fetcher runs at most once across all callers: the CAS-then-fetch protocol from SharedOnceCell::get_or_init guarantees that exactly one caller transitions EMPTY -> INITIALIZING and runs the fetcher; concurrent losers block on the INITIALIZING -> INITIALIZED transition.

  • force_set for admin override: bypasses the fetcher; the first force_set wins (returns true), subsequent ones lose (return false) without changing the value.

  • try_get returns None when unloaded: callers needing the blocking behavior use get_or_fetch; callers preferring to poll use try_get + retry.

  • Cross-process backed by MMF.


Table of contents


What it is

    block-beta
  columns 1
  cell["SharedOnceCell of T - one 64 B cache line: state atomic (EMPTY / INIT / INITIALIZED), payload (56 B for T)"]
  classDef cellC fill:#1e3a8a,color:#ffffff
  class cell cellC
  

LazyConfig is a thin wrapper over SharedOnceCell. The cell holds the entire protocol; LazyConfig adds the config-fetch-shaped API (get_or_fetch, try_get, is_loaded, force_set).


Protocol

get_or_fetch(fetcher)

state = load atomic state
if state == INITIALIZED:
   SeqLock-read payload
   return cached value

# CAS race: EMPTY -> INITIALIZING
if CAS(state, EMPTY, INITIALIZING) succeeds:
   value = fetcher()        # I won; run the fetcher
   SeqLock-write payload(value)
   store(state, INITIALIZED, Release)
   return value
else:
   # I lost the CAS; spin on state until it reaches INITIALIZED
   while load(state) != INITIALIZED:
       spin / yield
   SeqLock-read payload
   return cached value

The state machine: EMPTY -> INITIALIZING -> INITIALIZED. Exactly one CAS wins the EMPTY->INITIALIZING transition; that caller runs the fetcher. Losers block on the INITIALIZING->INITIALIZED transition.

try_get()

if load(state) != INITIALIZED:
   return None
SeqLock-read payload
return Some(value)

One atomic load + one SeqLock read. No blocking.


Bench evidence

Bench harness: crates/subetha-cxc/benches/lazy_config.rs. Captured 2026-06-02 on Windows 11 / Zen+ R7 2700, Criterion with --sample-size=15 --warm-up-time=1 --measurement-time=2.

OpLazyConfig (mmf)std::sync::OnceLockMutex<Option<T>>mmf relative
try_get loaded10.11 ns10.01 ns17.77 nstied with std, 1.76x faster than mutex
get_or_fetch loaded1.65 ns1.06 nsn/a1.55x slower than std
is_loaded1.34 nsn/an/aone atomic load

Reading the trade-offs

  1. try_get is tied with std::sync::OnceLock. Both pay one atomic load + one read. The MMF SeqLock + cross-process visibility costs nothing measurable on the hot read path.
  2. get_or_fetch post-load is 1.55x slower than std. The SeqLock retry-on-mismatch loop is the cost of cross-process safety against concurrent writers in OTHER processes; std OnceLock has no such concern (single-process only).
  3. The mutex baseline pays 1.76x for try_get. Lock acquire
    • read + lock release vs one atomic load + one SeqLock read.
  4. The architectural lever is what std cannot do at any cost. Cross-process single-fetch guarantee. Mutex<Option<T>> and std::sync::OnceLock are both in-process only; they cannot prevent N processes from each independently running the fetcher.

Rule 3b bench audit

  • Fair contenders: std::sync::OnceLock is the textbook in-process equivalent; Mutex<Option<T>> is the naive lock-guarded shape every “lazy cache” lands on without atomics.
  • No thread::spawn inside b.iter: all single-threaded. Concurrent fetch-once validation lives in the source unit test fetch_runs_at_most_once_under_concurrency (16 threads, asserts fetcher ran exactly once).
  • Sizing: single-cell primitive, no working-set parameter.
  • MMF lifecycle managed: create + load via get_or_fetch
    • ops + drop + remove_file.

What the numbers do NOT show

  • Thundering-herd prevention across processes: the architectural claim. N processes call get_or_fetch concurrently; exactly one runs the fetcher; the other N-1 read the published value. Neither in-process baseline can do this at any cost.
  • Cold-start fetch cost: the first caller pays the CAS
    • fetcher closure + payload-write cost. After that, every reader gets the cached value at try_get speed (10.11 ns).
  • Cross-process contended fetch: M threads racing the CAS in P processes. Exactly one wins; the others spin briefly until INITIALIZED. The CAS contention scales with thread count but bounded by the spin window.

Worked examples

Distributed service: load config once per cluster

use subetha_cxc::LazyConfig;

#[derive(Clone, Copy)]
#[repr(C)]
struct AppConfig {
    max_conns: u32,
    ttl_ms: u32,
    feature_flags: u32,
}

let lc: LazyConfig<AppConfig> = LazyConfig::open("/tmp/app.cfg").unwrap();
let cfg = lc.get_or_fetch(|| {
    // Network fetch from Consul / etcd / DNS.
    fetch_config_from_consul()
});

// Subsequent processes opening the same file see cfg cached.

Polling readers vs blocking readers

use subetha_cxc::LazyConfig;

let lc: LazyConfig<u64> = LazyConfig::open("/tmp/lc.bin").unwrap();

// Blocking reader: waits if not loaded.
let v = lc.get_or_fetch(|| panic!("expected pre-loaded"));

// Polling reader: returns None if not loaded.
if let Some(v) = lc.try_get() {
    use_value(v);
} else {
    // Not loaded yet; retry later.
}

Admin-override workflow

use subetha_cxc::LazyConfig;

let lc: LazyConfig<u32> = LazyConfig::create("/tmp/lc.bin").unwrap();

// First admin push wins.
assert!(lc.force_set(42));
assert_eq!(lc.try_get(), Some(42));

// Second admin push loses (config already set).
assert!(!lc.force_set(99));
assert_eq!(lc.try_get(), Some(42));

Use case patterns

Pattern: cross-process config bootstrap

A pool of N processes each call get_or_fetch(fetch_from_consul) at startup. Exactly one runs the network fetch; the others block briefly then read the published config. The backend service sees ONE request instead of N.

Pattern: lazy initialization of expensive resources

Configuration that takes seconds to compute (parsing TLS certs, building a routing table, hashing a corpus) initialized on first use; subsequent calls see the cached result at 10.11 ns.

Pattern: pre-loaded daemon + late-arriving workers

A daemon process calls force_set at startup to publish config. Worker processes started later see is_loaded() == true immediately and skip fetch entirely.


Known limitations

  • Payload size capped at 56 bytes: same as ONCE_PAYLOAD_BYTES. Larger T needs pointer indirection or the bigger-payload cell variants.
  • No re-fetch: once loaded, the cell is permanently initialized. Configuration that changes over time needs a different primitive (e.g., a versioned cell + a refresh protocol).
  • Fetcher panic propagation: if the winning fetcher panics, the cell stays in INITIALIZING and other callers spin indefinitely. The fetcher must not panic; wrap any fallible work in a Result and have the fetcher return a default-or-error value.
  • No timeout on get_or_fetch: callers blocked behind the CAS winner spin forever if the winner stalls. Wrap the fetcher in a timeout-respecting closure.
  • Cross-process backed by MMF.

Common pitfalls

  • Treating the fetcher as a constructor with side effects. The fetcher runs at most once across all processes; any side effect (e.g., logging “I fetched”) happens once per cell lifetime, not once per process. Move per-process side effects outside the closure.

  • Panicking in the fetcher. The cell stays in INITIALIZING forever; subsequent callers spin. Catch errors inside the fetcher and return a sentinel value.

  • Mismatched type at open vs create. The cell stores exactly size_of::<T>() bytes; opening with a different T reads garbage bytes as T. Pin T in a shared spec.

  • Calling get_or_fetch from inside the fetcher. Reentrant call deadlocks: the calling thread already holds the INITIALIZING state and waits for itself to finish.

  • Wrapping in a Mutex. Pointless; the CAS + SeqLock protocol is already concurrency-safe.


References

  • Source: crates/subetha-cxc/src/lazy_config.rs (272 lines, 8 unit tests covering unloaded read, first fetch, cached second fetch, 16-thread concurrent fetch-runs-once, cross-handle visibility, force_set wins-first then loses, disk persistence, and struct config round-trip).
  • Bench: crates/subetha-cxc/benches/lazy_config.rs (try_get, get_or_fetch on loaded config, is_loaded vs std::sync::OnceLock<T> and Mutex<Option<T>>).
  • Underlying primitive: SHARED_ONCE_CELL.md - the CAS-based 3-state machine LazyConfig wraps.
  • Sibling primitive: SHARED_CELL.md - mutable cell; LazyConfig is the write-once specialization.
  • Composes with: SHARED_BROADCAST_RING.md - for config change notifications when paired with a versioned publish scheme.