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

Hot Reload & Watching

Reading is lock-free

The snapshot lives in a OnceLock<ArcSwap<T>>. current() clones an Arc out of it, so a reload never blocks a request handler, and a reader that already holds an Arc keeps its own generation.

Call current() once per unit of work and reuse the Arc. Calling it twice inside one request can straddle a reload and observe two configurations.

Reloading cannot take the process down

A reload re-runs the builder's load(). If the new configuration is invalid, or a file is caught half-written, the error is reported and the previous snapshot stays in place. A bad edit degrades to "no change".

watch

#![allow(unused)]
fn main() {
let builder = DbConfig::builder("db").file("config.toml");
builder.init()?;

builder.watch(Duration::from_millis(250))?.detach();
}

watch(debounce) on the builder reloads the snapshot when a file changes. Requires the watch feature. Each reload loads through this builder and installs into the type's snapshot, firing on_reload hooks and waking changes() exactly as any other install does; a configured cache is rewritten after each clean reload.

The returned handle owns the watcher — dropping it stops watching:

#![allow(unused)]
fn main() {
builder.watch(debounce)?.detach();       // a server: watch for the whole process
let _watch = builder.watch(debounce)?;   // a test, a subcommand: stop with the scope
}

One watcher per type: a second watch() while one runs is AlreadyExists, whoever started the first.

The watcher observes the directory holding each file rather than the file itself: editors and mv-based atomic saves replace the inode, which silently detaches a file-level watch. That is also what makes a Kubernetes ConfigMap update — delivered as a ..data symlink swap — visible at all.

A reload that fails is logged and the previous snapshot is kept.

debounce

The Duration handed to watch. One editor save typically emits several filesystem events; waiting out a quiet period collapses them into one reload. 250 milliseconds is a reasonable place to start.

Polling instead of notification

#![allow(unused)]
fn main() {
builder.watch_with(
    Duration::from_millis(250),
    WatchMode::Poll { interval: Duration::from_secs(2) },
)?;
}

watch_with chooses the detection strategy explicitly: WatchMode::Native is what plain watch uses, and WatchMode::Poll re-reads on an interval instead of waiting for a notification.

Polling is needed because inotify and its equivalents do not fire on many network and overlay filesystems — NFS, some Docker bind mounts, some CI runners. The failure is silent: the watch registers and simply never delivers, so there is nothing to detect and fall back from. It has to be chosen deliberately.

Reacting to a reload

#![allow(unused)]
fn main() {
DbConfig::on_reload(|previous, current| {
    if previous.pool_size != current.pool_size {
        pool.resize(current.pool_size);
    }
});
}

The callback runs on whichever thread performed the reload — the watcher thread, usually — so keep it short. Installing the first snapshot is not a reload, so init() does not fire it. A callback that panics is caught and logged; the callbacks after it still run. With the async feature, changes() is the same idea for a task that would rather await than be called back.

on_reload is permanent — right for wiring that lives as long as the process. A subsystem with a shorter life uses on_reload_scoped, which returns a HookGuard; the callback runs until the guard is dropped:

#![allow(unused)]
fn main() {
let guard = DbConfig::on_reload_scoped(|_previous, current| {
    metrics.set_pool_size(current.pool_size);
});

drop(guard); // the callback is unregistered
}

The guard is #[must_use]: binding it to _ drops it immediately, and the callback never fires.

Reloading the configuration reloads nothing built from it — a changed database_url does not reconnect anything. That boundary, and the patterns for your side of it, get their own chapter: The Reload Lifecycle.

All of it, or none of it

Two structs over one file reload independently, and for a moment after an edit one is new while the other is old. Usually nobody notices. When it matters — a certificate path and the port it is served on — group them:

#![allow(unused)]
fn main() {
let group = ReloadGroup::new()
    .with::<ServerConfig>()
    .with::<TlsConfig>();

group.reload()?;
}

Every member loads and validates before any member is installed, so a failure anywhere leaves every member on its previous snapshot — including the ones that loaded cleanly. The commits are not one atomic operation; they are three Arc swaps with no fallible work between them, which is the part that actually goes wrong.