Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dynamic Instances

#[dynamic_config] gives a type one configuration — the right default, and a ceiling three kinds of program hit: multi-tenant services that want one configuration per tenant, tests that want two side by side without inventing marker types, and host-language bindings with no Rust type per user class at all. Dynamic<T> is the same engine with the storage owned by the value: its own snapshot, its own hooks, its own watcher identity, nothing global.

#![allow(unused)]
fn main() {
use dynamic_config::{Builder, Dynamic};

let acme  = Dynamic::new(Builder::<Tenant>::new("tenant").file("acme.json"));
let umbra = Dynamic::new(Builder::<Tenant>::new("tenant").file("umbra.json"));

let a = acme.init_and_current()?;                     // Arc<Tenant>
umbra.init()?;
}

init_and_current() is init() with the snapshot it installed still in hand. It is worth more here than on the type surface: an instance's current() is an Option, so the split form ends in an expect whose message only restates the line above it. What comes back is that install's snapshot — a reload landing a moment later moves current() and leaves this one alone, which is what "the configuration this program started with" means.

The builder inside is the same builder with every capability intact — files, discovery, the environment, profiles, validate, the last-known-good cache. Dynamic::new only redirects where a successful load installs: into a cell the instance owns instead of the type's static.

What changes, and what deliberately does not

current() returns an Option. The type-level current() panics before init() with the type's name in the message; an instance has no name to blame, so absence is an answer: None until the first successful install. Everything else about reading is identical — one atomic load, take the Arc once per request and reuse it.

One watcher per instance. The watcher registry keys types by TypeId, which is meaningless for instances — every Dynamic<Value> is the same type — so each instance carries a process-unique identity instead. Two instances of one T watch side by side; a second watch() on the same instance is AlreadyExists, exactly the one-watcher-per-owner contract the type surface has, and dropping the handle frees the slot the same way.

#![allow(unused)]
fn main() {
let _watch_a = acme.watch(Duration::from_millis(250))?;
let _watch_b = umbra.watch(Duration::from_millis(250))?;   // side by side
}

Hooks, status() and changes() are per instance. on_reload / on_reload_scoped — and their event forms on_reload_with / on_reload_with_scoped, which carry the reload reason — fire only for the instance they were registered on; status() counts only its own installs and failures; and changes() is woken only by its own installs — with the same first-install contract as the type surface: a handle taken before init() resolves on the first install, so it doubles as "wake me when this configuration exists". The handle co-owns the storage, so it outliving the Dynamic is safe rather than subtle.

Diagnostics answer through the builder. source_of, is_set, check, explain, snapshot — the instance does not re-wrap them, because the builder is where its sources live:

#![allow(unused)]
fn main() {
let origin = acme.builder().source_of("port")?;
let report = acme.builder().check()?;
}

No Clone. A Dynamic is an owner; share one behind an Arc when several places read it. That keeps "who stops the watcher" a question with one answer.

The resolved tree as data

Some boundaries need the configuration as values, not as a type to deserialize into — a language binding handing the tree to another runtime's validator, an exporter. Snapshot::to_value walks the resolved tree into an owned Value — seven shapes, no lifetimes, no loader types in the signature, and never a JSON round trip:

#![allow(unused)]
fn main() {
use dynamic_config::Value;

let tree = acme.builder().snapshot()?.to_value();

assert_eq!(tree.get("port"), Some(&Value::Integer(5432)));
}

This is configuration handover, not a diagnostic: like extract, it carries real values — secrets included. The paths-only rule governs what this crate prints, not what it hands the program.

Where the type surface still wins

A single global configuration read all over a program is what the attribute is for: AppConfig::current() from anywhere, no value to thread through call sites, compile-time knowledge of secrets for the redacted cache, and the generated methods (set_default, bind_env, remote stores) that live on type-level statics. Reach for Dynamic when the configuration's identity is a value in your program — a tenant, a test case, a foreign class — and for the attribute when it is the program itself.