Serving HTTP
A web service reads configuration on nearly every request, from several threads, while a reload may land at any moment. Three things follow from that, and the first two need no crate beyond this one.
Read at the use site, never at boot
#![allow(unused)] fn main() { async fn handler() -> String { let server = ServerConfig::current(); // one atomic load format!("{}:{}", server.host(), server.port()) } }
Not web::Data<ServerConfig>, not a field on an AppState, not a static
captured at startup. Handing a snapshot to an application factory freezes it
at the moment the factory ran, and every later reload lands somewhere nobody
reads.
current() costs an arc-swap load — measured at 85 instructions and zero
allocations. Reading it per request is cheaper than the framework's own
routing.
Some configuration is start-up configuration
#![allow(unused)] fn main() { let listener = TcpListener::bind((server.host(), server.port())).await?; }
A listener cannot move to a new port without dropping every connection on
the old one. port reloads like everything else, and the bound socket does
not care. Say so where it is bound rather than implying otherwise: a
deployment that must change ports restarts.
The same is true of anything built once from a value — a connection pool sized at startup, a thread count, a memory-mapped file. The Reload Lifecycle is about that boundary.
Two sections in one handler
Here is where a crate earns its place. current() says to call it once per
request and reuse the Arc, and with one section that is easy:
#![allow(unused)] fn main() { let server = ServerConfig::current(); }
With two it is not, because the same generation is a property of a pair of reads that no single call site can see:
#![allow(unused)] fn main() { async fn handler() -> String { let server = ServerConfig::current(); // generation 7 // a reload lands here let features = FeaturesConfig::current(); // generation 8 // this response now mixes two documents } }
Both reads are correct. The response is not.
dynamic-config-axum and
dynamic-config-actix take the
reading once, before the handler runs, and hand the result to every
extractor in it:
[dependencies]
dynamic-config-axum = "0.1"
use dynamic_config_axum::{Config, SnapshotLayer};
use dynamic_config_web_core::sections;
async fn handler(
Config(server): Config<ServerConfig>,
Config(features): Config<FeaturesConfig>,
) -> String {
// One reading. These cannot be different generations.
format!("{} {}", server.port(), features.cache())
}
let app = Router::new()
.route("/", get(handler))
.layer(SnapshotLayer::new(sections![ServerConfig, FeaturesConfig]));
Actix Web is the same two pieces through its own seams:
App::new()
.wrap(DynamicConfig::new(sections![ServerConfig, FeaturesConfig]))
.service(handler);
And Loco, which is axum underneath, takes the same layer
through the Initializer it asks a library for — one line in Hooks:
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![DynamicConfig::boxed(sections![ServerConfig, FeaturesConfig])])
}
Loco's own config/development.yaml is a different thing and should stay
one: its database URL, worker mode and listen port are read once at boot,
which is right for all three. Keep the settings an operator turns during an
incident in their own file, with their own sections.
sections![A, B] expands to || A::try_current() for each name. A
Dynamic<T> instance works too — register the
closure yourself with Sections::new().section(move || handle.current()).
The crates own no lifecycle. Loading, watching and the
WatchHandle stay in main, exactly as they were; adding
the layer changes no line of that code.
Health and metrics
The crates ship no routes, because the pieces are already public and a handler over them is shorter than a route surface is to adopt:
async fn readyz() -> (StatusCode, Json<Value>) {
let status = ServerConfig::status();
let code = if status.generation == 0 || !status.is_healthy() {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::OK
};
(code, Json(json!({ "generation": status.generation })))
}
Two questions, not one: /healthz says the process is alive and must not
fail on configuration — a process that cannot reload should stop receiving
traffic, not be restarted into reading the same broken file — while
/readyz is where nothing ever loaded and the reloads are failing
answer 503. Telemetry has the whole surface, including
Exposition for a /metrics body.
Pre-forking servers
A watcher is a thread, and a thread does not survive fork(). Start the
watcher after the fork — in a worker's own startup, not in a parent that
pre-loads the application. The parent's init() is inherited by the
children, which is a saving: one parse, N workers.
The examples
| Example | Shows |
|---|---|
axum_hello | one section, read per handler, with no extra crate |
actix_hello | the same across Actix's worker threads |
<framework>_two_sections | two sections that must agree — one per framework, and the Loco one drives the initializer through after_routes |