Why configuration is a snapshot, not a shared value¶
Config::get() hands back an Arc<C> rather than a reference or a lock guard. That
one choice is what makes reload safe, and it is worth understanding before you write
code that holds configuration for any length of time.
What goes wrong if config is mutated in place¶
The naive design keeps one config object and updates its fields when the file changes. It breaks in two ways at once.
The first is tearing. A request handler reads config.host, the reload thread
updates both host and port, and the handler then reads config.port — it now
holds half of the old configuration and half of the new one. Nothing crashed, and the
resulting behaviour makes no sense against either version of the file.
The second is contention. Preventing the tear with a lock means every read of every setting takes that lock, on every request, forever, to protect against a write that happens roughly never.
What the atomic swap does instead¶
A reload does not modify the value the process is using. It parses the sources into a
completely new value and then swaps a pointer to it, in one atomic operation, using
arc-swap.
A reader that calls get() gets an Arc to whichever value the pointer named at that
instant, and holds it. If a reload happens a microsecond later, the reader is
unaffected — it keeps the whole of the old value, consistently, for as long as it
holds the Arc, and the memory is freed when the last holder lets go.
So reads are cheap — an atomic load and a refcount bump, no lock, no parse — and a reader is never handed an inconsistent mixture.
What "hold a snapshot" means in practice¶
Take a snapshot at the start of a unit of work and use it throughout:
rather than calling config.get() at each field access, which reintroduces exactly
the inconsistency the design removes — two get() calls either side of a reload
return different values.
The corollary is the one that surprises people: a long-lived snapshot never updates.
A worker that calls get() once at start-up and keeps that Arc for the process
lifetime is immune to reload, which is correct behaviour for a value that must not
change mid-run and a bug if you wanted the worker to follow the config. If it should
follow, either take a fresh snapshot per unit of work or subscribe.
Why a failed reload leaves everything alone¶
reload() parses first and swaps second. If parsing fails — someone saved a config
file mid-edit, or an environment variable has a value that will not fit the field —
there is nothing to swap, the stored pointer is untouched, and the error goes back to
the caller.
The process carries on with the last configuration that was known to be valid. This matters most for a running service, where the alternative failure modes are both worse: falling back to defaults would silently change behaviour, and treating it as fatal would let one bad keystroke in an editor take the service down.
Subscribers are not woken on a failed reload either, which keeps a simple promise intact: every value a subscriber observes is a value that also parsed successfully and is stored.
What a subscriber is promised, and what it is not¶
subscribe() returns a tokio::sync::watch::Receiver. The promises are:
- It holds the current value immediately, so a subscriber created long after start-up sees the newest configuration rather than the one the process began with.
changed().awaitresolves after each successful reload.- Dropping every receiver does not break anything; later reloads and later subscriptions still work.
What it is not is a change feed. A watch channel keeps only the latest value, so a subscriber that is slow, or that was not awaiting at the time, sees the newest value and not the intermediate ones — two reloads in quick succession can wake it once. There is also no diff: a subscriber is told that configuration changed, not which key changed, and works out for itself whether the part it cares about moved.
That is the right shape for the job it exists for — a connection pool resizing, a logger changing level — where what matters is the current setting rather than the history of edits.
Why reload is explicit by default¶
Nothing re-reads configuration unless something asks it to. reload() is a call the
tool makes: on a signal, on a timer, from an admin endpoint, or from the file watcher
under the hot-reload feature.
Keeping it explicit means the common case — a CLI that reads configuration once and exits — costs one parse and carries no watcher, no thread and no extra dependencies. It also puts the tool in charge of when configuration may change, which matters for anything that has to be consistent across a batch of work.
Next¶
- Reload configuration at runtime — the recipe, including the watcher
- What rtb-config does not do — the watcher's limits