Skip to content

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:

let cfg = config.get();          // one snapshot
serve(&cfg.host, cfg.port);      // consistent for this request

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().await resolves 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