decdn_node/runtime/reload.rs
1//! Hot-reload of mutable configuration fields on SIGHUP.
2//!
3//! Reloadable fields (applied in place; no restart required):
4//! - `observability.log_level`
5//! - `cache.pinned_hashes`
6//! - all of `security.*` — the live `ConnectionLimiter` swaps its
7//! `Arc<Semaphore>` wholesale on reload (already-held permits drain
8//! into the previous semaphore on drop; new acquires hit the new
9//! one) and rebuilds its keyed [`governor`] rate limiter from the
10//! new quota, swapping it in under an `RwLock`. Token-bucket state
11//! is *not* preserved across the rebuild. `0` in any `security.*`
12//! field disables that layer.
13//! - the `[content]` denylist (`content.denied_hashes`,
14//! `content.denied_origins`) — hot reload here is load-bearing, not a
15//! convenience: ADR 011 sizes this mechanism to the one-hour removal
16//! clock, so a takedown must never wait on a daemon restart.
17//! - all of `[load_shed]` — the live `LoadShedController` swaps its
18//! policy in place.
19//!
20//! This list is the authority; `NodeCommand::Reload`'s help text in
21//! `decdn-common` repeats it for operators, so extend both together.
22//!
23//! Any non-reloadable field the file carries gets a "requires restart"
24//! message (logged on presence, not on change) — the runtime would
25//! otherwise need to tear down the iroh endpoint, the metrics listener,
26//! etc., which is far beyond the scope of a quick reload.
27//!
28//! The reload entry point ([`RuntimeReloadState::reload`]) is also
29//! called directly by tests, so its only side effects are mutating
30//! values inside [`RuntimeReloadState`] and emitting `tracing` events.
31//! It does not touch any global state.
32//!
33//! On parse error, previous values are retained: the reload is
34//! best-effort and a malformed file must never crash a running node.
35//!
36//! ## Section trait
37//!
38//! Each reloadable knob is a `ReloadableSection` impl. The trait
39//! drives a fixed three-phase iteration in [`RuntimeReloadState::reload`]:
40//!
41//! 1. **Resolve every section.** A single [`ConfigDiagnostics`] is threaded
42//! through every section's `resolve`, so an operator who broke
43//! multiple fields sees them all in one error rather than fixing them
44//! one SIGHUP at a time, and collapsed once at the end; any problem
45//! → return early with previous values retained for *every* section
46//! (the all-or-nothing contract). Resolved values
47//! are stashed in a per-section buffer cell
48//! (`Mutex<Option<Self::Resolved>>`) so the trait stays `dyn`-safe
49//! despite each section having its own `Resolved` type; every section
50//! populates its buffer on every resolve (a sentinel placeholder if
51//! it pushed to the bag). The early return skips the swap phase, and
52//! the next reload's `clear_buffer` evicts any leftover sentinel.
53//! 2. **Run every `fallible_commit`.** The log-level filter swap is the
54//! only currently-fallible commit. This phase is the rollback
55//! boundary: commits already applied stay applied; a later failure
56//! aborts the rest. The trait makes this boundary explicit.
57//! 3. **Run every `infallible_swap`.** Atomic stores, `ArcSwap` swaps,
58//! `ConnectionLimiter::reload`, and the per-section "applied"
59//! tracing event live here. None can fail.
60//!
61//! The buffer cell is emptied at the start of every `reload()`, filled
62//! by `resolve`, read by `fallible_commit`, and drained by
63//! `infallible_swap`. A panic between `resolve` and `infallible_swap`
64//! would leave a buffer populated; the next `reload()`'s clear step
65//! evicts it before anything else runs.
66
67use std::path::Path;
68use std::sync::Arc;
69
70use crate::dispatch::ConnectionLimiter;
71use anyhow::Context;
72use decdn_common::cli::common::{LogFormat, LogLevel};
73use decdn_common::cli::run::ObservabilityArgs;
74use decdn_common::config::{
75 ConfigDiagnostics, ConfigNotice, ConfigNoticeLevel, FileConfig, ResolvedLoadShed,
76 ResolvedObservability, ResolvedSecurity, load_file_config, parse_pinned_hashes,
77 resolve_load_shed_into, resolve_observability_into, resolve_security_into,
78};
79
80/// Read-only snapshot of the reloadable fields, returned by
81/// [`RuntimeReloadState::current`]. Used by `admin_v1_reload` to report
82/// what's running after a successful reload — operators get back the
83/// values their RPC just applied without having to scrape logs.
84///
85/// `log_level` is `Option` for the same reason `current_log_level` is on
86/// the parent state: no `LogLevel` is running before the first reload
87/// commits one, and none ever runs while `RUST_LOG` drives the live filter.
88#[derive(Debug, Clone, Copy)]
89pub struct ReloadSnapshot {
90 /// The live log level. `None` before the first reload commits one, and
91 /// for the life of the process while `RUST_LOG` drives the live filter.
92 pub log_level: Option<decdn_common::cli::common::LogLevel>,
93}
94
95/// What a [`LogLevelSetter`] did with a requested level.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum LogLevelApply {
98 /// The live filter now runs at the requested level.
99 Installed,
100 /// The live filter came from `RUST_LOG` and stays in place: `RUST_LOG`
101 /// wins over the config file for the life of the process.
102 KeptRustLog,
103}
104
105/// Closure that swaps the live `EnvFilter` to one matching `level`.
106///
107/// Boxed so the runtime can hold it without naming the (large, layered)
108/// concrete subscriber type that `tracing_subscriber::reload::Handle` is
109/// generic over. Returning `anyhow::Result` lets the closure surface
110/// filter-parse failures from the new directive string; the
111/// [`LogLevelApply`] says whether the level was installed at all.
112pub type LogLevelSetter =
113 Box<dyn Fn(LogLevel) -> anyhow::Result<LogLevelApply> + Send + Sync + 'static>;
114
115// ---------------------------------------------------------------------------
116// Reloadable-section trait + section impls
117// ---------------------------------------------------------------------------
118
119/// One reloadable config section.
120///
121/// The three phases (`resolve` → `fallible_commit` → `infallible_swap`)
122/// are driven in lockstep by [`RuntimeReloadState::reload`]: phase N runs
123/// for *every* section before phase N+1 starts. That ordering is what
124/// gives the all-or-nothing contract — a section that fails to resolve
125/// aborts the reload before any other section's commit runs.
126///
127/// Each impl owns a `Mutex<Option<Resolved>>` buffer cell. `resolve`
128/// fills it; `fallible_commit` reads it; `infallible_swap` drains it.
129/// `clear_buffer` empties the cell at the start of every reload to
130/// recover from a panic-mid-reload that left a stale value behind. The
131/// associated `Resolved` type stays internal to each impl, so the
132/// trait stays `dyn`-safe.
133pub(crate) trait ReloadableSection: Send + Sync {
134 /// Stable identifier for tracing event keys. Returning `&'static str`
135 /// keeps it cheap and non-allocating in the hot path.
136 fn name(&self) -> &'static str;
137
138 /// Drop any value lingering in the buffer cell.
139 ///
140 /// Called once per `reload()` before phase 1 to keep the cell
141 /// invariant ("populated only between `resolve` and `infallible_swap`")
142 /// robust across previous panic-mid-reload paths. A poisoned mutex
143 /// is recovered in place — losing the previous (now stale) value
144 /// is the desired outcome.
145 fn clear_buffer(&self);
146
147 /// Phase 1: re-resolve from file, store the result in the section's
148 /// buffer cell. Problems are pushed to the shared `bag` rather than
149 /// returned as a `Result`, so every section's resolve runs before the
150 /// aggregated bag is collapsed in `reload()`. Implementations must
151 /// always populate the buffer (with a sentinel/default value if
152 /// validation failed) — the buffer is only read downstream when the
153 /// bag is empty, so a placeholder there is never observed by the
154 /// commit/swap phases on the failure path.
155 fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics);
156
157 /// Phase 2: any commit step that can fail (e.g. swapping the live
158 /// tracing filter). Default: no-op. The order of `fallible_commit`
159 /// across sections is the same registration order as `resolve` and
160 /// `infallible_swap`. **Rollback boundary:** once one section's
161 /// `fallible_commit` returns `Ok`, that side-effect stays applied
162 /// even if a later section's `fallible_commit` fails. The trait makes
163 /// this boundary explicit.
164 fn fallible_commit(&self) -> anyhow::Result<()> {
165 Ok(())
166 }
167
168 /// Phase 3: drain the buffer and apply the change to live state.
169 /// Must not fail — atomic stores, `Arc` swaps, and `ConnectionLimiter::reload`
170 /// are the only operations allowed here.
171 fn infallible_swap(&self);
172}
173
174/// Emit each resolve-time notice a config resolve recorded, one event apiece.
175///
176/// Shared by the two paths that own a subscriber: `commands::run` replays what
177/// `resolve_config` handed back once `init_tracing` has installed one, and
178/// [`RuntimeReloadState::reload`] emits what the reloading sections recorded.
179/// The SIGHUP half is the one only the caller can serve — a reload re-runs the
180/// section resolvers against a daemon whose subscriber has been live for hours,
181/// and only this layer holds it. Draining the shared bag rather than reaching
182/// into one section means a future reloadable section is covered for free.
183///
184/// Severity comes from the notice: a `Warn` marks a value that weakens a
185/// safety property and an operator alerting on `WARN` should see it, while an
186/// `Info` marks a deliberate opt-out working as configured. `field` rides as a
187/// `tracing` field rather than inside the message, so a JSON stream can be
188/// filtered on it.
189///
190/// Two properties an operator has to know about. Delivery is subject to the
191/// active filter: a node on `log_level = "error"`, or on a `RUST_LOG` that
192/// names other targets, receives none of these. And every reload re-resolves
193/// every section, so an unchanged notice repeats on each SIGHUP — the same
194/// best-effort shape [`warn_restart_required_sections`] has.
195pub(crate) fn emit_config_notices(notices: &[ConfigNotice]) {
196 for notice in notices {
197 match notice.level {
198 ConfigNoticeLevel::Warn => {
199 tracing::warn!(field = %notice.field, "{}", notice.message);
200 }
201 ConfigNoticeLevel::Info => {
202 tracing::info!(field = %notice.field, "{}", notice.message);
203 }
204 }
205 }
206}
207
208/// Set a cache-engine slot owned by a `ReloadableSection`, recovering
209/// the inner value on a poisoned mutex. Used by [`RuntimeReloadState::attach_cache`]
210/// to populate both the pinned-hashes and origin-retry sections from a
211/// single attach call.
212///
213/// The poison flag is intentionally *not* cleared. Reachable poison
214/// here implies a panic in some other code path that held the lock —
215/// extremely rare since the lock's only callers are `attach_cache`
216/// (this function) and the section's `infallible_swap`, both of which
217/// are panic-free under workspace lints. The recovery write is
218/// belt-and-braces: it ensures the engine reference is at least stored
219/// (so e.g. the `pinned.engine` slot still receives the new engine if
220/// it had been transiently poisoned), but the next `infallible_swap`
221/// will see `lock()` return `Err` again and emit the section's
222/// `tracing::error!` and skip its swap. This matches the codebase's
223/// established "recover-the-data, log-loudly, do-not-clear-poison"
224/// pattern (see also `attach_limiter` and the trait doc on
225/// `ReloadableSection::infallible_swap`).
226fn attach_engine_to_section(
227 slot: &std::sync::Mutex<Option<decdn_cache::CacheEngine>>,
228 engine: Option<decdn_cache::CacheEngine>,
229 section: &'static str,
230) {
231 match slot.lock() {
232 Ok(mut guard) => *guard = engine,
233 Err(poisoned) => {
234 tracing::error!(
235 section,
236 "runtime reload cache mutex poisoned during attach; recovering inner state"
237 );
238 *poisoned.into_inner() = engine;
239 }
240 }
241}
242
243/// Helper for the buffer-cell pattern. Each section keeps a
244/// `Mutex<Option<Resolved>>`; this expression captures the "drain or log
245/// and skip" idiom without each `infallible_swap` re-implementing it.
246fn drain_or_log<T>(slot: &std::sync::Mutex<Option<T>>, section: &'static str) -> Option<T> {
247 match slot.lock() {
248 Ok(mut g) => g.take(),
249 Err(poisoned) => {
250 tracing::error!(
251 section,
252 "section buffer mutex poisoned during infallible_swap; \
253 swap skipped (previous value retained on the live path)"
254 );
255 // Drop the (possibly poisoned) value defensively.
256 let _ = poisoned.into_inner().take();
257 None
258 }
259 }
260}
261
262// ----- log_level ----------------------------------------------------------
263
264/// Reloadable observability log level. The full `[observability]`
265/// section isn't reloadable (`metrics_port`, `log_format`, etc. all
266/// need a restart), but the level is. The other sub-fields get a
267/// "requires restart" warning from [`Self::warn_restart_required_changes`]
268/// when their resolved value differs from the one the node started with.
269struct LogLevelSection {
270 cli: ObservabilityArgs,
271 setter: LogLevelSetter,
272 /// Restart-required observability values the node started with.
273 startup: StartupObservability,
274 /// Cached log level last applied. `None` until a reload installs one —
275 /// the live `EnvFilter` at startup may be a `RUST_LOG` directive we
276 /// cannot reflect back into a `LogLevel`, so a `None` forces the setter
277 /// to run and only a cached level suppresses no-op writes. While
278 /// `RUST_LOG` drives the filter the setter keeps it
279 /// ([`LogLevelApply::KeptRustLog`]) and this stays `None`, so every
280 /// reload asks the setter again.
281 current: std::sync::Mutex<Option<LogLevel>>,
282 buf: std::sync::Mutex<Option<ResolvedObservability>>,
283 /// Set by `fallible_commit` so `infallible_swap` knows whether the
284 /// setter actually ran (we only update the cached `current` on a
285 /// real apply, not on a skip — keeps "first reload always applies"
286 /// honest).
287 swap_applied: std::sync::Mutex<bool>,
288}
289
290impl ReloadableSection for LogLevelSection {
291 fn name(&self) -> &'static str {
292 "log_level"
293 }
294 fn clear_buffer(&self) {
295 if let Ok(mut g) = self.buf.lock() {
296 *g = None;
297 }
298 if let Ok(mut g) = self.swap_applied.lock() {
299 *g = false;
300 }
301 }
302 fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
303 let resolved = resolve_observability_into(&self.cli, file.observability.as_ref(), bag);
304 if let Ok(mut g) = self.buf.lock() {
305 *g = Some(resolved);
306 }
307 }
308 fn fallible_commit(&self) -> anyhow::Result<()> {
309 // Read (don't drain) the buffer — `infallible_swap` still needs
310 // the value to update the cached `current` and emit the
311 // tracing line.
312 let new_level = {
313 let g = self
314 .buf
315 .lock()
316 .map_err(|_| anyhow::anyhow!("log-level buffer mutex poisoned"))?;
317 match g.as_ref() {
318 Some(r) => r.log_level,
319 // `resolve` populates the buffer on success; if it's
320 // empty here something upstream skipped the section,
321 // which is a bug. Fail loud rather than silently no-op.
322 None => return Err(anyhow::anyhow!("log-level buffer empty in fallible_commit")),
323 }
324 };
325 // Lock the cached level for the duration of the change so a
326 // racing reload can't observe a half-updated state.
327 let mut current = self
328 .current
329 .lock()
330 .map_err(|_| anyhow::anyhow!("log-level mutex poisoned"))?;
331 // First reload (current is `None`) always applies — see the
332 // struct's `current` doc.
333 let log_level_changed = match *current {
334 None => true,
335 Some(prev) => prev != new_level,
336 };
337 if !log_level_changed {
338 return Ok(());
339 }
340 match (self.setter)(new_level) {
341 Err(err) => {
342 tracing::warn!(error = %err, ?new_level, "failed to apply new log level; previous level retained");
343 return Err(err);
344 }
345 // `RUST_LOG` still drives the live filter, so no file level is
346 // running: leave `current` at `None` and the snapshot reports none.
347 Ok(LogLevelApply::KeptRustLog) => return Ok(()),
348 Ok(LogLevelApply::Installed) => {}
349 }
350 {
351 *current = Some(new_level);
352 if let Ok(mut g) = self.swap_applied.lock() {
353 *g = true;
354 }
355 }
356 Ok(())
357 }
358 fn infallible_swap(&self) {
359 let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
360 return;
361 };
362 let log_level_changed = self.swap_applied.lock().is_ok_and(|g| *g);
363 tracing::info!(
364 section = self.name(),
365 log_level = %resolved.log_level,
366 log_level_changed,
367 "config reload section applied"
368 );
369 }
370}
371
372impl LogLevelSection {
373 /// On each reload, warn for each restart-required observability field whose
374 /// resolved value (CLI/env > file > default) differs from the startup value.
375 /// A field that stays changed warns again on every reload. Reads
376 /// the buffer `resolve` filled, so it runs after the commits and before the
377 /// swap drains it.
378 /// Names fields only: `otlp_endpoint` can carry credentials.
379 fn warn_restart_required_changes(&self) {
380 let Ok(guard) = self.buf.lock() else {
381 return;
382 };
383 let Some(resolved) = guard.as_ref() else {
384 return;
385 };
386 for field in observability_restart_required_changes(&self.startup, resolved) {
387 tracing::warn!(
388 field,
389 "config reload: ignoring change to observability.{field} (requires restart)"
390 );
391 }
392 }
393}
394
395/// The restart-required `[observability]` values a node started with.
396#[derive(Debug)]
397struct StartupObservability {
398 log_format: LogFormat,
399 metrics_port: u16,
400 metrics_bind: std::net::IpAddr,
401 admin_port: Option<u16>,
402 otlp_endpoint: Option<String>,
403}
404
405impl StartupObservability {
406 fn from_resolved(o: &ResolvedObservability) -> Self {
407 Self {
408 log_format: o.log_format,
409 metrics_port: o.metrics_port,
410 metrics_bind: o.metrics_bind,
411 admin_port: o.admin_port,
412 otlp_endpoint: o.otlp_endpoint.clone(),
413 }
414 }
415}
416
417/// Names of the restart-required observability fields whose reloaded value
418/// differs from startup. `log_level` is hot-reloadable and never listed.
419/// Exhaustively destructured so a new field must be classified here.
420fn observability_restart_required_changes(
421 startup: &StartupObservability,
422 reloaded: &ResolvedObservability,
423) -> Vec<&'static str> {
424 let ResolvedObservability {
425 log_level: _, // hot-reloadable
426 log_format,
427 metrics_port,
428 metrics_bind,
429 admin_port,
430 otlp_endpoint,
431 } = reloaded;
432 [
433 ("log_format", startup.log_format != *log_format),
434 ("metrics_port", startup.metrics_port != *metrics_port),
435 ("metrics_bind", startup.metrics_bind != *metrics_bind),
436 ("admin_port", startup.admin_port != *admin_port),
437 ("otlp_endpoint", startup.otlp_endpoint != *otlp_endpoint),
438 ]
439 .into_iter()
440 .filter_map(|(field, changed)| changed.then_some(field))
441 .collect()
442}
443
444// ----- pinned_hashes ------------------------------------------------------
445
446/// Reloadable `cache.pinned_hashes`. The rest of `cache.*`
447/// (`cache_dir`, sizes, origin, `decompress`) is not hot-reloadable.
448struct PinnedHashesSection {
449 /// Optional handle to the live cache engine. `None` in unit tests
450 /// that exercise reload semantics without a real engine; populated
451 /// at runtime via [`RuntimeReloadState::attach_cache`] before the
452 /// SIGHUP select loop runs.
453 engine: std::sync::Mutex<Option<decdn_cache::CacheEngine>>,
454 buf: std::sync::Mutex<Option<decdn_cache::PinnedHashes>>,
455 /// `cache.cache_size_mb` at boot, for the reload-time pin-budget re-check.
456 /// Restart-required, so the boot value stays authoritative.
457 cache_size_mb: u64,
458}
459
460impl ReloadableSection for PinnedHashesSection {
461 fn name(&self) -> &'static str {
462 "pinned_hashes"
463 }
464 fn clear_buffer(&self) {
465 if let Ok(mut g) = self.buf.lock() {
466 *g = None;
467 }
468 }
469 fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
470 // Same shape as `resolve_cache_into`: bag-push on parse failure,
471 // empty placeholder so later sections still run. `reload()`'s
472 // early return guarantees the placeholder never reaches swap.
473 let resolved = bag
474 .try_with(
475 "cache.pinned_hashes",
476 parse_pinned_hashes(file.cache.as_ref().and_then(|c| c.pinned_hashes.as_deref()))
477 .context("invalid cache.pinned_hashes"),
478 )
479 .unwrap_or_else(decdn_cache::PinnedHashes::empty);
480 if let Ok(mut g) = self.buf.lock() {
481 *g = Some(resolved);
482 }
483 }
484 fn infallible_swap(&self) {
485 let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
486 return;
487 };
488 let pinned_count = resolved.len();
489 // The engine slot lock can be poisoned (see
490 // `attach_cache`'s recovery path). On poison we forfeit the
491 // swap rather than panic — operationally indistinguishable
492 // from "cache not yet attached" and the same dedicated tracing
493 // line covers it.
494 let pin_diff = if let Ok(g) = self.engine.lock() {
495 g.as_ref().map(|engine| {
496 let diff = engine.set_pinned(&resolved);
497 // Refresh the origin-held index so a newly-added pin (or a file
498 // dropped into the fs origin) is announced on this reload rather
499 // than only at the next periodic rescan (#1130). Detached so we
500 // honor reload()'s no-await invariant; rescan is idempotent.
501 let engine = engine.clone();
502 let cache_size_mb = self.cache_size_mb;
503 tokio::spawn(async move {
504 engine.rescan_origins().await;
505 // Re-check the pin budget here too, not just at boot: a reload
506 // is the one moment the pin set can grow past the cache ceiling
507 // on a running node.
508 super::warn_if_pins_exceed_cache(&engine, cache_size_mb);
509 });
510 diff
511 })
512 } else {
513 tracing::error!(
514 section = self.name(),
515 "cache engine mutex poisoned in infallible_swap; pinning skipped"
516 );
517 None
518 };
519 let pinned_skipped_no_cache_attached = pin_diff.is_none();
520 tracing::info!(
521 section = self.name(),
522 pinned_hashes = pinned_count,
523 pinned_added = pin_diff.map(|d| d.added),
524 pinned_removed = pin_diff.map(|d| d.removed),
525 pinned_skipped_no_cache_attached,
526 cache_attached = pin_diff.is_some(),
527 "config reload section applied"
528 );
529 }
530}
531
532// ----- content ------------------------------------------------------------
533
534/// Reloadable `[content]` section — the ADR 011 local denylist.
535///
536/// Hot-reloadable is the whole point, not a convenience: ADR 011 §One-hour
537/// removal orders sizes this mechanism to the EU TCO one-hour clock, and a
538/// restart-only denylist would put a daemon bounce (dropping every in-flight
539/// paid stream) on the critical path of discharging a legal order.
540///
541/// Unlike the other sections there is no `attach_*` handle to be missing: the
542/// deny-set is constructed before the handler is, and both hold the same `Arc`.
543/// A swap is therefore always effective.
544struct ContentSection {
545 deny: Arc<crate::content_deny::ContentDenylist>,
546 /// Live cache engine, for the hash half of the denylist. Same lifecycle as
547 /// [`PinnedHashesSection::engine`] — attached via
548 /// [`RuntimeReloadState::attach_cache`] before the reload loop runs.
549 ///
550 /// Hashes go to the engine rather than to `deny` because "will this node
551 /// serve/announce/acquire this hash" has four consumers and ADR 011 needs
552 /// one answer for all of them; see `CacheEngine::refuses`. `deny` keeps the
553 /// origin half, which the cache knows nothing about.
554 engine: std::sync::Mutex<Option<decdn_cache::CacheEngine>>,
555 buf: std::sync::Mutex<Option<decdn_common::config::ResolvedContent>>,
556}
557
558impl ReloadableSection for ContentSection {
559 fn name(&self) -> &'static str {
560 "content"
561 }
562 fn clear_buffer(&self) {
563 if let Ok(mut g) = self.buf.lock() {
564 *g = None;
565 }
566 }
567 fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
568 // Same shape as the other sections: bag-push on parse failure, empty
569 // placeholder so later sections still run. `reload()`'s early return
570 // guarantees the placeholder never reaches swap — which matters more
571 // here than elsewhere, since swapping an empty placeholder would
572 // silently UN-deny everything the operator had denied.
573 let denied_hashes = bag
574 .try_with(
575 "content.denied_hashes",
576 decdn_common::config::parse_denied_hashes(
577 file.content
578 .as_ref()
579 .and_then(|c| c.denied_hashes.as_deref()),
580 )
581 .context("invalid content.denied_hashes"),
582 )
583 .unwrap_or_default();
584 let denied_origins = bag
585 .try_with(
586 "content.denied_origins",
587 decdn_common::config::parse_denied_origins(
588 file.content
589 .as_ref()
590 .and_then(|c| c.denied_origins.as_deref()),
591 )
592 .context("invalid content.denied_origins"),
593 )
594 .unwrap_or_default();
595 if let Ok(mut g) = self.buf.lock() {
596 *g = Some(decdn_common::config::ResolvedContent {
597 denied_hashes,
598 denied_origins,
599 });
600 }
601 }
602 fn infallible_swap(&self) {
603 let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
604 return;
605 };
606 let denied_origins = self.deny.set_local_origins(&resolved);
607 let Ok(engine_guard) = self.engine.lock() else {
608 // Same poison handling as `PinnedHashesSection`: forfeit the swap
609 // rather than panic. Unlike pinning, a forfeited swap here leaves a
610 // takedown undischarged, so it is ERROR and says so.
611 tracing::error!(
612 section = self.name(),
613 "cache engine mutex poisoned; the denied-hash set was NOT updated and any \
614 takedown in this reload is undischarged"
615 );
616 return;
617 };
618 let hash_diff = engine_guard
619 .as_ref()
620 .map(|engine| engine.set_denied(&resolved.denied_hashes));
621 tracing::info!(
622 section = self.name(),
623 denied_hashes = resolved.denied_hashes.len(),
624 denied_hashes_added = hash_diff.map(|d| d.added),
625 denied_hashes_removed = hash_diff.map(|d| d.removed),
626 denied_hashes_skipped_no_cache_attached = hash_diff.is_none(),
627 denied_origins,
628 "config reload section applied"
629 );
630 }
631}
632
633// ----- security -----------------------------------------------------------
634
635/// Reloadable `[security]` section.
636struct SecuritySection {
637 /// Optional handle to the live `ConnectionLimiter`. Same lifecycle
638 /// rules as `PinnedHashesSection::engine` — populated via
639 /// [`RuntimeReloadState::attach_limiter`] before the select loop.
640 limiter: std::sync::Mutex<Option<Arc<ConnectionLimiter>>>,
641 buf: std::sync::Mutex<Option<ResolvedSecurity>>,
642}
643
644impl ReloadableSection for SecuritySection {
645 fn name(&self) -> &'static str {
646 "security"
647 }
648 fn clear_buffer(&self) {
649 if let Ok(mut g) = self.buf.lock() {
650 *g = None;
651 }
652 }
653 fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
654 let resolved = resolve_security_into(file.security.as_ref(), bag);
655 if let Ok(mut g) = self.buf.lock() {
656 *g = Some(resolved);
657 }
658 }
659 fn infallible_swap(&self) {
660 let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
661 return;
662 };
663 let security_attached = if let Ok(g) = self.limiter.lock() {
664 if let Some(lim) = g.as_ref() {
665 lim.reload(&resolved);
666 true
667 } else {
668 false
669 }
670 } else {
671 tracing::error!(
672 section = self.name(),
673 "limiter mutex poisoned in infallible_swap; security swap skipped"
674 );
675 false
676 };
677 tracing::info!(
678 section = self.name(),
679 security_attached,
680 max_concurrent_handlers = resolved.max_concurrent_handlers,
681 per_source_rate_per_sec = resolved.per_source_rate_per_sec,
682 max_tracked_sources = resolved.max_tracked_sources,
683 "config reload section applied"
684 );
685 }
686}
687
688// ----- load shed -----------------------------------------------------------
689
690/// Reloadable `[load_shed]` section: resolves the config block into a
691/// buffer, then swaps the live `LoadShedController`'s policy on
692/// `infallible_swap`.
693struct LoadShedSection {
694 /// Optional handle to the live `LoadShedController`. Same lifecycle
695 /// rules as `SecuritySection::limiter` — populated via
696 /// [`RuntimeReloadState::attach_load_shed`] before the select loop.
697 controller: std::sync::Mutex<Option<Arc<crate::load_shed::LoadShedController>>>,
698 buf: std::sync::Mutex<Option<ResolvedLoadShed>>,
699}
700
701impl ReloadableSection for LoadShedSection {
702 fn name(&self) -> &'static str {
703 "load_shed"
704 }
705 fn clear_buffer(&self) {
706 if let Ok(mut g) = self.buf.lock() {
707 *g = None;
708 }
709 }
710 fn resolve(&self, file: &FileConfig, bag: &mut ConfigDiagnostics) {
711 let resolved = resolve_load_shed_into(file.load_shed.as_ref(), bag);
712 if let Ok(mut g) = self.buf.lock() {
713 *g = Some(resolved);
714 }
715 }
716 fn infallible_swap(&self) {
717 let Some(resolved) = drain_or_log(&self.buf, self.name()) else {
718 return;
719 };
720 let load_shed_attached = if let Ok(g) = self.controller.lock() {
721 if let Some(ctrl) = g.as_ref() {
722 ctrl.reload(&resolved);
723 true
724 } else {
725 false
726 }
727 } else {
728 tracing::error!(
729 section = self.name(),
730 "controller mutex poisoned in infallible_swap; load-shed swap skipped"
731 );
732 false
733 };
734 tracing::info!(
735 section = self.name(),
736 load_shed_attached,
737 policy = ?resolved.policy,
738 egress_budget_mbps = resolved.egress_budget_mbps,
739 max_concurrent_serves_high = resolved.max_concurrent_serves_high,
740 max_concurrent_serves_low = resolved.max_concurrent_serves_low,
741 per_client_serve_cap = resolved.per_client_serve_cap,
742 "config reload section applied"
743 );
744 }
745}
746
747// ---------------------------------------------------------------------------
748// RuntimeReloadState
749// ---------------------------------------------------------------------------
750
751/// Shared, mutable handles for the fields the runtime can hot-reload.
752///
753/// Held inside an `Arc` so the SIGHUP handler and the `ProbeHandler` can
754/// both observe updates. Each section is registered as both a concrete
755/// `Arc<SectionStruct>` (so `attach_*` and accessors can reach into its
756/// state) and as `Arc<dyn ReloadableSection>` (so `reload()` drives the
757/// three-phase iteration without a central match arm per section).
758///
759/// Adding a new reloadable knob: implement `ReloadableSection` for a
760/// new struct, `Arc` it once at construction, and append to the
761/// `sections` vec — no central touch of `RuntimeReloadState::reload`.
762pub struct RuntimeReloadState {
763 log_level: Arc<LogLevelSection>,
764 pinned: Arc<PinnedHashesSection>,
765 security: Arc<SecuritySection>,
766 content: Arc<ContentSection>,
767 load_shed: Arc<LoadShedSection>,
768 /// Iteration order for the three-phase reload: `log_level`,
769 /// `pinned_hashes`, `security`, `content`, then `load_shed`. The order
770 /// matters for reproducibility (operator-visible tracing event order)
771 /// and for the rollback-boundary contract documented on the trait —
772 /// moving `log_level` earlier or later would change which pre-`log_level`
773 /// commits survive a setter failure.
774 sections: Vec<Arc<dyn ReloadableSection>>,
775 /// Serialises concurrent reloads. A SIGHUP racing an `admin_v1_reload`
776 /// (both call [`Self::reload`]) waits here so the two-phase commit of
777 /// one reload never interleaves with another's. Guards no data — the
778 /// per-section buffer cells own the in-flight state — so a poisoned
779 /// lock is recovered in place rather than surfaced: a prior
780 /// panic-mid-reload must not wedge every future reload.
781 reload_lock: std::sync::Mutex<()>,
782 /// Counts failed reloads into `decdn_config_reload_failures_total`. Set
783 /// once by [`Self::attach_metrics`]; a reload before that is not counted.
784 metrics: std::sync::OnceLock<Arc<crate::metrics::Metrics>>,
785}
786
787impl std::fmt::Debug for RuntimeReloadState {
788 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789 f.debug_struct("RuntimeReloadState")
790 .field("current_log_level", &self.log_level.current)
791 .finish_non_exhaustive()
792 }
793}
794
795impl RuntimeReloadState {
796 /// Build a reload state from the resolved-at-startup values plus the
797 /// closure that mutates the live tracing filter.
798 ///
799 /// `current_log_level` is intentionally seeded to `None` rather than
800 /// to `initial.observability.log_level`: at startup the live
801 /// `EnvFilter` may have been built from `RUST_LOG` (see
802 /// `commands::run`'s `try_from_default_env`), in which case the
803 /// resolved config value is *not* what's running. Forcing the first
804 /// reload to apply unconditionally is simpler and more correct than
805 /// trying to reflect the env-filter directive back into a `LogLevel`.
806 pub fn new(
807 observability_cli: ObservabilityArgs,
808 initial: &decdn_common::config::ResolvedConfig,
809 log_level_setter: LogLevelSetter,
810 ) -> Self {
811 let log_level = Arc::new(LogLevelSection {
812 cli: observability_cli,
813 setter: log_level_setter,
814 startup: StartupObservability::from_resolved(&initial.observability),
815 current: std::sync::Mutex::new(None),
816 buf: std::sync::Mutex::new(None),
817 swap_applied: std::sync::Mutex::new(false),
818 });
819 let pinned = Arc::new(PinnedHashesSection {
820 engine: std::sync::Mutex::new(None),
821 buf: std::sync::Mutex::new(None),
822 cache_size_mb: initial.cache.cache_size_mb,
823 });
824 let security = Arc::new(SecuritySection {
825 limiter: std::sync::Mutex::new(None),
826 buf: std::sync::Mutex::new(None),
827 });
828 let content = Arc::new(ContentSection {
829 deny: Arc::new(crate::content_deny::ContentDenylist::new(&initial.content)),
830 engine: std::sync::Mutex::new(None),
831 buf: std::sync::Mutex::new(None),
832 });
833 let load_shed = Arc::new(LoadShedSection {
834 controller: std::sync::Mutex::new(None),
835 buf: std::sync::Mutex::new(None),
836 });
837 // Registration order sets the commit order: log_level,
838 // pinned_hashes, security, content, load_shed. The order matters
839 // for reproducibility (operator-visible tracing event order) and
840 // for the rollback-boundary contract documented on the trait —
841 // moving log_level later or earlier would change which
842 // pre-log_level commits survive a setter failure.
843 let sections: Vec<Arc<dyn ReloadableSection>> = vec![
844 Arc::clone(&log_level) as _,
845 Arc::clone(&pinned) as _,
846 Arc::clone(&security) as _,
847 Arc::clone(&content) as _,
848 Arc::clone(&load_shed) as _,
849 ];
850 Self {
851 log_level,
852 pinned,
853 security,
854 content,
855 load_shed,
856 sections,
857 reload_lock: std::sync::Mutex::new(()),
858 metrics: std::sync::OnceLock::new(),
859 }
860 }
861
862 /// Attach the node metrics so failed reloads are counted. Call once, before
863 /// the SIGHUP select loop runs; a second call keeps the first handle.
864 pub fn attach_metrics(&self, metrics: Arc<crate::metrics::Metrics>) {
865 let _already_attached = self.metrics.set(metrics);
866 }
867
868 /// Attach the live cache engine after it's been built. Must be called
869 /// before the SIGHUP select loop runs — see `runtime::run`.
870 /// Detaching is permitted (pass `None`) but the runtime never needs
871 /// to: the engine outlives the reload state by construction.
872 ///
873 /// **Poison handling.** A poisoned mutex is recovered by replacing
874 /// the inner value via `PoisonError::into_inner()`, but the poison
875 /// flag is *not* cleared — `Mutex::lock()` will return `Err` again
876 /// the next time anyone tries to take the guard. The first
877 /// subsequent `reload()` will therefore skip the pinned-set swap
878 /// for that section (see `PinnedHashesSection::infallible_swap`).
879 /// Recovery here ensures the new engine is at least stored for the
880 /// (non-reload-driven) live path.
881 pub fn attach_cache(&self, engine: Option<decdn_cache::CacheEngine>) {
882 attach_engine_to_section(&self.content.engine, engine.clone(), "content");
883 attach_engine_to_section(&self.pinned.engine, engine, "pinned_hashes");
884 }
885
886 /// The live ADR 011 deny-set, for wiring into the client handler.
887 ///
888 /// Deliberately a getter rather than an `attach_*`: the deny-set is owned
889 /// here and shared out, so there is no window in which the handler holds a
890 /// deny-set the reload path cannot reach. The `attach_*` pattern exists for
891 /// handles built *after* this state; this one is built *with* it.
892 pub fn content_denylist(&self) -> Arc<crate::content_deny::ContentDenylist> {
893 Arc::clone(&self.content.deny)
894 }
895
896 /// Attach the live `ConnectionLimiter` after it's been built. Same
897 /// shape as [`Self::attach_cache`]: must be called before the SIGHUP
898 /// select loop, supports `None` for tests, recovers from a poisoned
899 /// mutex by replacing the inner state.
900 pub fn attach_limiter(&self, limiter: Option<Arc<ConnectionLimiter>>) {
901 match self.security.limiter.lock() {
902 Ok(mut guard) => *guard = limiter,
903 Err(poisoned) => {
904 tracing::error!(
905 "runtime reload limiter mutex poisoned during attach; recovering inner state"
906 );
907 *poisoned.into_inner() = limiter;
908 }
909 }
910 }
911
912 /// Attach the live `LoadShedController` after it's been built. Same
913 /// shape as [`Self::attach_limiter`]: must be called before the SIGHUP
914 /// select loop, supports `None` for tests, recovers from a poisoned
915 /// mutex by replacing the inner state.
916 pub fn attach_load_shed(&self, controller: Option<Arc<crate::load_shed::LoadShedController>>) {
917 match self.load_shed.controller.lock() {
918 Ok(mut guard) => *guard = controller,
919 Err(poisoned) => {
920 tracing::error!(
921 "runtime reload load-shed controller mutex poisoned during attach; \
922 recovering inner state"
923 );
924 *poisoned.into_inner() = controller;
925 }
926 }
927 }
928
929 /// Build a state for tests outside `runtime::reload::tests` that need
930 /// to drive `reload()` end-to-end (e.g. `admin::tests` exercising
931 /// `admin_v1_reload`). Centralised here rather than duplicated per
932 /// test module so the boilerplate `ResolvedConfig` for which fields
933 /// reload reads stays in one place — drift between two copies would
934 /// give different test surfaces for the same code path.
935 #[cfg(test)]
936 // One flat `ResolvedConfig` literal enumerating every reload-relevant field;
937 // it crossed 100 lines once both #651 (`discovery`) and #831 (node-pull cache
938 // knobs) added fields. Splitting the single struct literal across helpers
939 // would obscure which fields the reload path reads, not clarify it.
940 #[allow(clippy::too_many_lines)]
941 pub(crate) fn for_test_with_setter(
942 level: decdn_common::cli::common::LogLevel,
943 log_level_setter: LogLevelSetter,
944 ) -> Self {
945 use std::path::PathBuf;
946
947 use decdn_common::config::{
948 ResolvedBlockchain, ResolvedCache, ResolvedConfig, ResolvedIdentity, ResolvedNetwork,
949 ResolvedObservability, ResolvedPayment, ResolvedSecurity,
950 };
951
952 let cfg = ResolvedConfig {
953 identity: ResolvedIdentity {
954 data_dir: PathBuf::from("/tmp/decdn-test"),
955 region: None,
956 },
957 network: ResolvedNetwork {
958 bind_port: 4433,
959 relay_urls: Vec::new(),
960 discovery: decdn_common::config::ResolvedDiscovery::default(),
961 },
962 blockchain: ResolvedBlockchain {
963 origin_assignment_address: None,
964 origin_directory_positive_ttl_sec:
965 decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_POSITIVE_TTL_SEC,
966 origin_directory_negative_ttl_sec:
967 decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_NEGATIVE_TTL_SEC,
968 origin_directory_cache_capacity:
969 decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_CACHE_CAPACITY,
970 publisher_registry_address: None,
971 rpc_url: "http://localhost:8545".into(),
972 eth_keystore: PathBuf::from("/tmp/keystore.json"),
973 keystore_password_file: None,
974 payment_pool_address: "0x0000000000000000000000000000000000000001".into(),
975 capacity_bond_address: "0x0000000000000000000000000000000000000002".into(),
976 rpc_watchdog_interval_sec: 30,
977 event_poll_interval_ms: 7000,
978 get_logs_max_block_span: decdn_common::config::DEFAULT_GET_LOGS_MAX_BLOCK_SPAN,
979 fee_shares_poll_interval_sec: 3600,
980 redeem_threshold_micro_usdc: 1_000_000,
981 redeem_max_vouchers_per_tx: 300,
982 redeem_interval_secs: 300,
983 buyer_working_deposit_micro_usdc: 10_000_000,
984 buyer_max_approve: true,
985 pool_min_remaining_deposit_micro_usdc: 1_000_000,
986 pool_floor_signer_live_windows: 8,
987 slash_judge_address: "0x0000000000000000000000000000000000000003".to_string(),
988 content_blacklist_address: None,
989 content_blacklist_poll_interval_sec: 600,
990 chain_staleness_grace_sec: 1800,
991 chain_id: decdn_common::config::DEFAULT_CHAIN_ID,
992 },
993 cache: ResolvedCache {
994 cache_dir: PathBuf::from("/tmp/cache"),
995 cache_size_mb: 1024,
996 disk_headroom_mb: 8192,
997 max_blob_size_mb: 128,
998 max_rate_per_mb: 0,
999 origins: Vec::new(),
1000 pinned_hashes: decdn_cache::PinnedHashes::empty(),
1001 origin_retry: decdn_cache::RetryPolicy::default(),
1002 circuit_breaker: decdn_cache::CircuitBreakerPolicy::default(),
1003 user_agent: decdn_cache::DEFAULT_USER_AGENT.to_string(),
1004 gc_interval_sec: 0,
1005 fs_rescan_interval_sec: 0,
1006 origin_probe_ttl_sec: decdn_common::config::DEFAULT_ORIGIN_PROBE_TTL_SEC,
1007 origin_probe_negative_ttl_sec:
1008 decdn_common::config::DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC,
1009 origin_probe_fault_ttl_sec:
1010 decdn_common::config::DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC,
1011 origin_probe_timeout_ms: decdn_common::config::DEFAULT_ORIGIN_PROBE_TIMEOUT_MS,
1012 origin_probe_memo_capacity:
1013 decdn_common::config::DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY,
1014 eviction_high_water_pct: 90,
1015 eviction_target_pct: 80,
1016 eviction_per_sweep_budget: 16,
1017 eviction_tick_secs: 1,
1018 max_probe_holds: decdn_common::config::DEFAULT_MAX_PROBE_HOLDS,
1019 stake_lane_reserved_holds: decdn_common::config::DEFAULT_STAKE_LANE_RESERVED_HOLDS,
1020 node_to_node_pull_through_enabled: false,
1021 relay_foreign_namespaces: decdn_common::config::DEFAULT_RELAY_FOREIGN_NAMESPACES,
1022 node_pull_probe_fanout: decdn_common::config::DEFAULT_NODE_PULL_PROBE_FANOUT,
1023 node_pull_timeout_sec: decdn_common::config::DEFAULT_NODE_PULL_TIMEOUT_SEC,
1024 node_pull_stall_window_sec:
1025 decdn_common::config::DEFAULT_NODE_PULL_STALL_WINDOW_SEC,
1026 node_pull_min_throughput_bps:
1027 decdn_common::config::DEFAULT_NODE_PULL_MIN_THROUGHPUT_BPS,
1028 eviction_policy: decdn_common::config::DEFAULT_EVICTION_POLICY.to_string(),
1029 admission_policy: decdn_common::config::DEFAULT_ADMISSION_POLICY.to_string(),
1030 tinylfu: decdn_common::config::ResolvedTinyLfu {
1031 sketch_bytes: decdn_common::config::DEFAULT_TINYLFU_SKETCH_BYTES,
1032 promotion_threshold: decdn_common::config::DEFAULT_TINYLFU_PROMOTION_THRESHOLD,
1033 probation_target_pct:
1034 decdn_common::config::DEFAULT_TINYLFU_PROBATION_TARGET_PCT,
1035 aging_halflife_sec: decdn_common::config::DEFAULT_TINYLFU_AGING_HALFLIFE_SEC,
1036 },
1037 serve_economics: decdn_common::config::ResolvedServeEconomics {
1038 policy: decdn_common::config::DEFAULT_SERVE_ECONOMICS_POLICY.to_string(),
1039 discount_bps: decdn_common::config::DEFAULT_SERVE_ECONOMICS_DISCOUNT_BPS,
1040 n_max: decdn_common::config::DEFAULT_SERVE_ECONOMICS_N_MAX,
1041 warming_budget: decdn_common::config::DEFAULT_SERVE_ECONOMICS_WARMING_BUDGET,
1042 warming_refill: decdn_common::config::DEFAULT_SERVE_ECONOMICS_WARMING_REFILL,
1043 },
1044 },
1045 // Placeholder rate — `payment.*` is restart-required.
1046 payment: ResolvedPayment {
1047 rate_per_mb: 1,
1048 credit_max: decdn_common::config::DEFAULT_CREDIT_MAX,
1049 frame_target_bytes: decdn_common::config::DEFAULT_FRAME_TARGET_BYTES,
1050 credit_ramp_divisor: decdn_common::config::DEFAULT_CREDIT_RAMP_DIVISOR,
1051 voucher_commit_interval_ms:
1052 decdn_common::config::DEFAULT_VOUCHER_COMMIT_INTERVAL_MS,
1053 },
1054 observability: ResolvedObservability {
1055 log_level: level,
1056 log_format: decdn_common::cli::common::LogFormat::Pretty,
1057 metrics_port: 9090,
1058 metrics_bind: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
1059 admin_port: Some(9191),
1060 otlp_endpoint: None,
1061 },
1062 security: ResolvedSecurity {
1063 max_concurrent_handlers: 256,
1064 per_source_rate_per_sec: 100.0,
1065 per_source_burst: 200,
1066 max_tracked_sources: 4096,
1067 },
1068 load_shed: decdn_common::config::ResolvedLoadShed::default(),
1069 dht: decdn_common::config::ResolvedDht::default(),
1070 probe: decdn_common::config::ResolvedProbe::default(),
1071 receipts: decdn_common::config::ResolvedReceipts::default(),
1072 content: decdn_common::config::ResolvedContent::default(),
1073 };
1074 Self::new(
1075 decdn_common::cli::run::ObservabilityArgs {
1076 log_level: None,
1077 log_format: None,
1078 metrics_port: None,
1079 metrics_bind: None,
1080 admin_port: None,
1081 otlp_endpoint: None,
1082 },
1083 &cfg,
1084 log_level_setter,
1085 )
1086 }
1087
1088 /// Snapshot the post-reload values an operator wants to confirm: the
1089 /// most recently applied log level.
1090 ///
1091 /// `log_level` is `None` until the first successful reload — the
1092 /// startup `EnvFilter` may have been built from `RUST_LOG` rather
1093 /// than the resolved config (see [`Self::new`]'s docs), so reporting
1094 /// the resolved-at-startup level there would be misleading. After
1095 /// any successful reload the field tracks what the setter actually
1096 /// applied.
1097 ///
1098 /// A poisoned `current_log_level` mutex collapses to `None` rather
1099 /// than propagating: the caller is `admin_v1_reload`, which has
1100 /// already received an `Ok(())` from `reload()`; the snapshot is
1101 /// best-effort metadata.
1102 pub fn current(&self) -> ReloadSnapshot {
1103 let log_level = self.log_level.current.lock().ok().and_then(|guard| *guard);
1104 ReloadSnapshot { log_level }
1105 }
1106
1107 /// Re-read the config file at `path` and apply changes to reloadable
1108 /// fields. Triggered by SIGHUP (see `runtime::run`'s select loop). On
1109 /// parse error the previous values are retained and the error is
1110 /// logged; the caller (the SIGHUP arm of the select) discards the
1111 /// returned error so a malformed reload never propagates and stops
1112 /// the node.
1113 ///
1114 /// Three phases, each driven over the registered `sections` in registration
1115 /// order:
1116 ///
1117 /// 1. **Resolve.** Each section re-derives its `Resolved` value from
1118 /// `file` (and any CLI overrides held on the section struct) and
1119 /// stashes it in its buffer cell. Any failure aborts the reload
1120 /// before any side-effect runs — the all-or-nothing contract.
1121 /// 2. **Fallible commit.** The log-level filter swap is the only
1122 /// currently-fallible commit. **Rollback boundary:** commits
1123 /// that already succeeded stay applied even if a later section's
1124 /// `fallible_commit` fails. The trait makes this boundary
1125 /// explicit.
1126 /// 3. **Infallible swap.** Atomic stores, `Arc` swaps, and
1127 /// `ConnectionLimiter::reload`. Each section also emits a
1128 /// `config reload section applied` tracing event keyed by
1129 /// `section = name()`.
1130 ///
1131 /// A single "requires restart" info line is emitted for each
1132 /// non-reloadable section that carries a non-reloadable field in the
1133 /// new file (see `warn_restart_required_sections`) so an operator who
1134 /// edited one of those fields sees "you changed X but it needs a
1135 /// restart". It fires on presence of the field, not on a change to it
1136 /// (no diff against the previous file) — best-effort operator
1137 /// guidance, not a correctness gate.
1138 ///
1139 /// A failed reload counts into `decdn_config_reload_failures_total` once
1140 /// [`Self::attach_metrics`] has run.
1141 // `async` is preserved even though no body is currently `.await`ed:
1142 // the runtime select loop awaits this future inside `tokio::select!`
1143 // (see `runtime::run`), so the signature is part of the contract
1144 // with the caller. A future revision adding `tokio::fs::read_to_string`
1145 // for the config file would also need it.
1146 #[allow(clippy::unused_async)]
1147 pub async fn reload(&self, path: &Path) -> anyhow::Result<()> {
1148 let result = self.reload_sections(path);
1149 if result.is_err()
1150 && let Some(metrics) = self.metrics.get()
1151 {
1152 metrics.config_reload_failure();
1153 }
1154 result
1155 }
1156
1157 /// The body of [`Self::reload`]: load, resolve, validate and commit every
1158 /// section.
1159 #[allow(clippy::cognitive_complexity)] // A few short phase loops; reads better as one unit than split apart.
1160 fn reload_sections(&self, path: &Path) -> anyhow::Result<()> {
1161 let file = match load_file_config(Some(path)) {
1162 Ok(f) => f,
1163 Err(err) => {
1164 tracing::warn!(error = %err, path = %path.display(), "config reload aborted (file load failed); previous values retained for every section");
1165 return Err(err);
1166 }
1167 };
1168
1169 // Serialise concurrent reloads before *any* shared-state mutation.
1170 // `reload()` has no `.await` points, so on a multi-threaded runtime
1171 // two callers (a SIGHUP racing an `admin_v1_reload`) can execute in
1172 // true parallel; the per-section buffer cells are cleared in phase 1
1173 // and drained in phase 3, so the guard must cover clear+resolve
1174 // through commit+swap — otherwise a second reload could clear/
1175 // overwrite the first's freshly-resolved buffers mid-flight and make
1176 // it swap a mixed set. Only the file load above (no shared state)
1177 // stays outside. The lock guards no data itself, so a poisoned lock
1178 // is recovered in place: a prior panic-mid-reload must not wedge
1179 // every future reload. Recovery is logged loudly (this file's
1180 // "recover-the-data, log-loudly, do-not-clear-poison" pattern — see
1181 // `attach_engine_to_section` / `drain_or_log`): a poisoned
1182 // `reload_lock` is the sole surviving evidence that a previous reload
1183 // panicked mid-commit, and swallowing it silently would erase it.
1184 let _reload_guard = match self.reload_lock.lock() {
1185 Ok(guard) => guard,
1186 Err(poisoned) => {
1187 tracing::error!(
1188 "runtime reload_lock poisoned by a prior panic-mid-reload; \
1189 recovering and proceeding (an earlier reload may have \
1190 partially applied before panicking)"
1191 );
1192 poisoned.into_inner()
1193 }
1194 };
1195
1196 // Clear every section's buffer up-front so a panic-mid-reload
1197 // from a previous attempt can't leave stale `Resolved` data
1198 // behind. Each `clear_buffer` is infallible (poison-recovering).
1199 for section in &self.sections {
1200 section.clear_buffer();
1201 }
1202
1203 // Phase 1: resolve every section into one shared bag, then
1204 // collapse it once. Non-empty bag → return early, no side-effects
1205 // committed (the all-or-nothing contract).
1206 let mut bag = ConfigDiagnostics::new();
1207 for section in &self.sections {
1208 section.resolve(&file, &mut bag);
1209 }
1210 // Drain before `into_result` consumes the bag. Emitting waits until
1211 // every gate that can abort the reload has passed (below), because an
1212 // aborted reload retains the previous values and its notices would
1213 // describe a config the node is not running.
1214 let notices = bag.take_notices();
1215 let problem_count = bag.problem_count();
1216 if let Err(err) = bag.into_result() {
1217 tracing::warn!(
1218 error = %err,
1219 problem_count,
1220 "config reload aborted; entire reload rolled back \
1221 (all-or-nothing) — all reloadable sections retained at \
1222 their previous values",
1223 );
1224 return Err(err);
1225 }
1226
1227 // Phase 2: fallible commits. The log-level section is the only
1228 // one that can fail here today; future sections may add more.
1229 // Rollback boundary: a section that already returned `Ok`
1230 // stays applied even if a later section fails. Surface the
1231 // first error and let the caller log/return.
1232 for section in &self.sections {
1233 if let Err(err) = section.fallible_commit() {
1234 // Sections that already committed in this loop stay
1235 // applied — that's the rollback boundary. We do *not*
1236 // run any infallible_swap to avoid driving sections
1237 // partway through a phase, so a setter failure returns
1238 // before any atomic swap (e.g. the rate swap) runs.
1239 tracing::warn!(
1240 error = %err,
1241 section = section.name(),
1242 "config reload fallible_commit failed; later sections skipped"
1243 );
1244 return Err(err);
1245 }
1246 }
1247
1248 // Past every gate that can abort: phase 1 collapsed clean, phase 2
1249 // committed, and phase 3 below cannot fail. A notice emitted earlier
1250 // would describe values a `fallible_commit` error left unapplied —
1251 // that path returns before any `infallible_swap`, which is where the
1252 // notice-bearing sections put their values.
1253 emit_config_notices(¬ices);
1254 // The same holds for the "requires restart" notices: one for each
1255 // non-reloadable section the file carries, and one for each
1256 // restart-required observability field whose resolved value differs
1257 // from startup. `warn_restart_required_changes` reads the observability
1258 // buffer, so it runs before phase 3 drains it.
1259 warn_restart_required_sections(&file);
1260 self.log_level.warn_restart_required_changes();
1261
1262 // Phase 3: infallible swaps. None can fail.
1263 for section in &self.sections {
1264 section.infallible_swap();
1265 }
1266
1267 // The single completion marker for the whole reload. The
1268 // per-section `config reload section applied` events carry
1269 // the structured fields; this one says every section landed.
1270 tracing::info!("config reload applied");
1271 Ok(())
1272 }
1273}
1274
1275/// Emit a single info line naming a non-reloadable field group.
1276fn warn_ignored(field: &'static str) {
1277 tracing::info!(
1278 field,
1279 "config reload: ignoring change to {field} (requires restart)"
1280 );
1281}
1282
1283/// Emit a "requires restart" notice for each non-reloadable field the
1284/// operator can't hot-apply. Fully non-reloadable sections (including
1285/// `payment`) warn whenever they are *present*; `cache` warns only when it
1286/// sets a field *outside* its reloadable subset, so the common
1287/// `cache.pinned_hashes`-only reload stays quiet. `security` is fully
1288/// reloadable and never warns. `observability` is not handled here: its
1289/// resolved values are diffed against startup by
1290/// [`LogLevelSection::warn_restart_required_changes`]. Best-effort operator
1291/// guidance, not a correctness gate — this does not diff against the previous
1292/// file, so a present-but-unchanged non-reloadable field still warns on every
1293/// reload.
1294fn warn_restart_required_sections(file: &decdn_common::config::FileConfig) {
1295 if file.identity.is_some() {
1296 warn_ignored("identity.* (data_dir, region)");
1297 }
1298 if file.network.is_some() {
1299 warn_ignored("network.* (bind_port, relay_urls, relay_url, discovery)");
1300 }
1301 if file.blockchain.is_some() {
1302 warn_ignored(
1303 "blockchain.* (rpc_url, eth_keystore, contract addresses, \
1304 pool_floor_signer_live_windows)",
1305 );
1306 }
1307 if file
1308 .cache
1309 .as_ref()
1310 .is_some_and(cache_has_restart_required_field)
1311 {
1312 warn_ignored("cache.* (cache_dir, sizes, origin, decompress, max_probe_holds)");
1313 }
1314 if file.payment.is_some() {
1315 // The whole `[payment]` section is restart-required. `rate_per_mb` is
1316 // the served price — an economic commitment on the wire — so it takes
1317 // effect only at startup; reprice by draining and restarting. The other
1318 // serve knobs are read once at handler construction.
1319 warn_ignored(
1320 "payment.* (rate_per_mb, credit_max, \
1321 credit_ramp_divisor, frame_target_bytes, voucher_commit_interval_ms)",
1322 );
1323 }
1324 if file.dht.is_some() {
1325 // `dht.*` (rate-limit caps) is not currently hot-reloadable — the
1326 // limiter is constructed once at startup.
1327 // Reloadability would follow the dispatch-limiter pattern
1328 // (`security.*`) once the limiter grows an ArcSwap on its inner
1329 // state.
1330 warn_ignored("dht.* (rate-limit)");
1331 }
1332 if file.probe.is_some() {
1333 // `probe.rate_limit` is read once at startup; changing it requires
1334 // a restart (same footing as `dht.*`).
1335 warn_ignored("probe.* (rate_limit)");
1336 }
1337 if file.receipts.is_some() {
1338 // `receipts.*` (rotation cap, retained backups) is read once when
1339 // `JsonlReceiptLog` is opened at bring-up; changing it requires a
1340 // restart.
1341 warn_ignored("receipts.* (max_file_bytes, retained_files)");
1342 }
1343 // `security.*` is fully reloadable — see `RuntimeReloadState::reload`'s
1344 // commit step. Invalid values reject the entire reload via
1345 // `resolve_security` upstream rather than landing here.
1346}
1347
1348/// Whether the file's `[cache]` section sets any field that a restart is
1349/// required to apply — i.e. anything other than the hot-reloadable
1350/// `pinned_hashes` (#276). Destructured exhaustively so that adding a new
1351/// `CacheConfig` field is a compile error here until it's classified
1352/// reloadable-or-not, rather than silently escaping the restart notice.
1353const fn cache_has_restart_required_field(c: &decdn_common::config::types::CacheConfig) -> bool {
1354 let decdn_common::config::types::CacheConfig {
1355 pinned_hashes: _, // the only hot-reloadable cache field
1356 cache_dir,
1357 cache_size_mb,
1358 disk_headroom_mb,
1359 max_blob_size_mb,
1360 max_rate_per_mb,
1361 origin,
1362 origins,
1363 origin_retry,
1364 circuit_breaker,
1365 user_agent,
1366 gc_interval_sec,
1367 fs_rescan_interval_sec,
1368 origin_probe_ttl_sec,
1369 origin_probe_negative_ttl_sec,
1370 origin_probe_fault_ttl_sec,
1371 origin_probe_timeout_ms,
1372 origin_probe_memo_capacity,
1373 eviction_high_water_pct,
1374 eviction_target_pct,
1375 eviction_per_sweep_budget,
1376 eviction_tick_secs,
1377 max_probe_holds,
1378 stake_lane_reserved_holds,
1379 node_to_node_pull_through_enabled,
1380 relay_foreign_namespaces,
1381 node_pull_probe_fanout,
1382 node_pull_timeout_sec,
1383 node_pull_stall_window_sec,
1384 node_pull_min_throughput_bps,
1385 eviction_policy,
1386 admission_policy,
1387 tinylfu,
1388 serve_economics,
1389 } = c;
1390 cache_dir.is_some()
1391 || cache_size_mb.is_some()
1392 // The disk-headroom clamp is read once into the eviction driver's params
1393 // at bring-up (#1930); changing it needs a restart.
1394 || disk_headroom_mb.is_some()
1395 || max_blob_size_mb.is_some()
1396 || max_rate_per_mb.is_some()
1397 || origin.is_some()
1398 || origins.is_some()
1399 || origin_retry.is_some()
1400 || circuit_breaker.is_some()
1401 || user_agent.is_some()
1402 || gc_interval_sec.is_some()
1403 // The rescan *cadence* needs a restart to rebuild the interval timer;
1404 // a reload still re-runs one rescan to pick up newly-added files.
1405 || fs_rescan_interval_sec.is_some()
1406 // Live-origin probe memo (#1130 pt3) is built once at bring-up, so any
1407 // of its knobs changing needs a restart.
1408 || origin_probe_ttl_sec.is_some()
1409 || origin_probe_negative_ttl_sec.is_some()
1410 || origin_probe_fault_ttl_sec.is_some()
1411 || origin_probe_timeout_ms.is_some()
1412 || origin_probe_memo_capacity.is_some()
1413 || eviction_high_water_pct.is_some()
1414 || eviction_target_pct.is_some()
1415 || eviction_per_sweep_budget.is_some()
1416 || eviction_tick_secs.is_some()
1417 || max_probe_holds.is_some()
1418 || stake_lane_reserved_holds.is_some()
1419 || node_to_node_pull_through_enabled.is_some()
1420 || relay_foreign_namespaces.is_some()
1421 || node_pull_probe_fanout.is_some()
1422 || node_pull_timeout_sec.is_some()
1423 || node_pull_stall_window_sec.is_some()
1424 || node_pull_min_throughput_bps.is_some()
1425 // Admission/eviction policy selection and its tuning knobs (ADR 040)
1426 // are wired once at bring-up — the estimator and policy objects are
1427 // constructed in `build_infra` and injected into the engine/driver;
1428 // changing them requires a restart.
1429 || eviction_policy.is_some()
1430 || admission_policy.is_some()
1431 || tinylfu.is_some()
1432 // Refuse-to-serve economics (ADR 041) is wired once at bring-up
1433 // alongside the admission/eviction policy objects; changing it
1434 // requires a restart.
1435 || serve_economics.is_some()
1436}
1437
1438#[cfg(test)]
1439#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1440mod tests;