dynamic_config/reload.rs
1//! Why a snapshot was installed, and how the installs since have gone.
2//!
3//! Three questions an operator asks that the crate could always answer and
4//! never did: *why did this change*, *did the last attempt work*, and *how
5//! many have failed since one did*. [`ReloadReason`] is the first,
6//! [`ReloadEvent`] carries it to a hook, and [`ConfigStatus`] is all three
7//! in one cheap struct.
8//!
9//! Nothing here is on the read path. A reason is recorded by the same store
10//! that publishes the snapshot, a failure by the same code that decided not
11//! to publish one — so reading any of it is a load, never a computation,
12//! and `current()` stays the single atomic load it has always been.
13//!
14//! **Values never appear here.** A reason names a *file*, a failure names a
15//! *key path* and an [`ErrorKind`], and a status is counts and timestamps.
16//! Every one of these types exists to be printed into a log, which is
17//! exactly how a configured value escapes, so none of them can hold one.
18
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::time::Instant;
22
23use crate::cell::SnapshotMeta;
24use crate::error::{Error, ErrorKind};
25
26/// What caused a snapshot to be installed.
27///
28/// Recorded at the call site that installs, because nothing downstream can
29/// reconstruct it: by the time a hook runs, a file change and a manual
30/// `reload()` have produced the identical swap.
31///
32/// `#[non_exhaustive]`: the set of things that can install a configuration
33/// grows with the crate, and matching on it must keep compiling when it
34/// does.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum ReloadReason {
38 /// A [`Builder::init`](crate::Builder::init) — the call that establishes
39 /// a configuration, whether or not one was already installed.
40 Initial,
41 /// A watched file changed. Carries the path whose event opened the
42 /// reload's debounce window.
43 ///
44 /// One path, not the set: the debounce collapses a flurry into a single
45 /// reload, and the window that a `..data` symlink swap or a two-file
46 /// edit produces genuinely covers several. The path names *what
47 /// triggered this reload*, which is the question a log line asks; the
48 /// keys that actually moved are
49 /// [`changed_paths`](crate::changed_paths)' job.
50 FileChanged(PathBuf),
51 /// A remote store pushed a document through
52 /// [`RemoteSink::apply`](crate::RemoteSink::apply).
53 RemoteChanged,
54 /// The program installed it: [`reload`](crate::Builder::reload), a
55 /// generated `replace`, [`ConfigCell::store`](crate::ConfigCell::store),
56 /// or a [`ReloadGroup`](crate::ReloadGroup) commit.
57 Manual,
58 /// The last-known-good cache, after the sources refused to load at
59 /// [`init`](crate::Builder::init).
60 Recovered,
61}
62
63impl ReloadReason {
64 /// A short, stable label — the category, without the path.
65 ///
66 /// For a metric dimension or a structured log field, where
67 /// [`FileChanged`](Self::FileChanged)'s path is unbounded cardinality
68 /// and the category is not.
69 #[must_use]
70 pub fn as_str(&self) -> &'static str {
71 match self {
72 Self::Initial => "initial",
73 Self::FileChanged(_) => "file-changed",
74 Self::RemoteChanged => "remote-changed",
75 Self::Manual => "manual",
76 Self::Recovered => "recovered",
77 }
78 }
79}
80
81/// One install, as an event.
82///
83/// What [`on_reload_with`](crate::ConfigCell::on_reload_with) hands a hook,
84/// and what the two-argument [`on_reload`](crate::ConfigCell::on_reload)
85/// cannot say: *why* the snapshot moved, *which* generation it became, and
86/// — through `previous` — that there was nothing before it.
87///
88/// The first install is an event with `previous: None`. The pair form has
89/// nowhere to put that and so does not fire for it at all; this form does,
90/// which is what makes [`ReloadReason::Initial`] reachable from a hook.
91///
92/// # A note on `Debug`
93///
94/// It prints the reason, the metadata and *whether* there was a previous
95/// snapshot — never the snapshots themselves, however `T` renders. An event
96/// is a diagnostic, a `{:?}` of one lands in a log, and a configuration
97/// holds passwords. Reach for the [`current`](Self::current) field when you
98/// want the values; that is a deliberate second step.
99#[non_exhaustive]
100pub struct ReloadEvent<T> {
101 /// The snapshot that was serving until now, or `None` when this install
102 /// is the first — there was no configuration before it.
103 pub previous: Option<Arc<T>>,
104 /// The snapshot now serving.
105 pub current: Arc<T>,
106 /// What caused this install.
107 pub reason: ReloadReason,
108 /// The generation this install became, and when it landed.
109 pub meta: SnapshotMeta,
110}
111
112impl<T> ReloadEvent<T> {
113 /// Not public API: built by the cell that dispatches it.
114 pub(crate) fn new(
115 previous: Option<Arc<T>>,
116 current: Arc<T>,
117 reason: ReloadReason,
118 meta: SnapshotMeta,
119 ) -> Self {
120 Self {
121 previous,
122 current,
123 reason,
124 meta,
125 }
126 }
127}
128
129// Hand-written: `#[derive(Clone)]` would demand `T: Clone`, and an `Arc<T>`
130// clones whatever `T` is.
131impl<T> Clone for ReloadEvent<T> {
132 fn clone(&self) -> Self {
133 Self {
134 previous: self.previous.clone(),
135 current: Arc::clone(&self.current),
136 reason: self.reason.clone(),
137 meta: self.meta,
138 }
139 }
140}
141
142impl<T> std::fmt::Debug for ReloadEvent<T> {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("ReloadEvent")
145 .field("reason", &self.reason)
146 .field("generation", &self.meta.generation)
147 // Presence, not content: see the type's documentation.
148 .field("had_previous", &self.previous.is_some())
149 .finish_non_exhaustive()
150 }
151}
152
153/// A reload that did not install anything.
154///
155/// The category and the key path, and deliberately not the message: an
156/// error's `Display` is value-free by policy and enforced by
157/// `tests/security.rs`, but a struct that *stores* free text is one careless
158/// `Error::new` away from carrying a value into every log that prints a
159/// status. What an operator needs to act — when, what kind, which key — is
160/// none of it free text.
161#[derive(Debug, Clone, PartialEq, Eq)]
162#[non_exhaustive]
163pub struct FailureStatus {
164 /// When the failure was recorded.
165 pub at: Instant,
166 /// The failure's category.
167 pub kind: ErrorKind,
168 /// The dotted key path it was reported at; empty when the failure
169 /// belongs to the load as a whole rather than to one key.
170 pub path: String,
171}
172
173impl FailureStatus {
174 /// Not public API: built where the failure is recorded.
175 pub(crate) fn of(error: &Error) -> Self {
176 Self {
177 at: Instant::now(),
178 kind: error.kind(),
179 path: error.path(),
180 }
181 }
182}
183
184/// What is true of a configuration right now, for an operator asking.
185///
186/// Every field is *recorded* where it happens rather than recomputed here,
187/// so building one is a handful of atomic loads: no I/O, no source is
188/// re-read, and nothing here can block. That is the constraint — an
189/// exporter calling this per scrape must cost nothing.
190///
191/// It is assembled from several loads, so a reload landing mid-call can
192/// leave one field an install ahead of another. The same trade
193/// [`SnapshotMeta`] makes, for the same reason: for operators, not for
194/// correctness.
195///
196/// # What it does not carry
197///
198/// **No values, by construction** — see [`FailureStatus`]. And no *source*
199/// list: which sources would be read is a question about the next load, and
200/// [`check`](crate::check) already answers it against the sources rather
201/// than from a cache of them that could go stale. Nor is there a
202/// `last_success`: an install *is* the success, so
203/// [`loaded_at`](Self::loaded_at) is when the last one was.
204///
205/// [`loaded_at`]: Self::loaded_at
206#[derive(Debug, Clone, PartialEq, Eq)]
207#[non_exhaustive]
208pub struct ConfigStatus {
209 /// Installs since the process started; zero before the first.
210 pub generation: u64,
211 /// When the serving snapshot was installed — which is also when the
212 /// last successful load was. `None` before the first install.
213 pub loaded_at: Option<Instant>,
214 /// Why the serving snapshot was installed. `None` before the first.
215 pub last_reason: Option<ReloadReason>,
216 /// The most recent reload that installed nothing, if there has been
217 /// one. Kept after a later success: it is history, and
218 /// [`consecutive_failures`](Self::consecutive_failures) is the health.
219 pub last_failure: Option<FailureStatus>,
220 /// Failures since the last install. **Zero means healthy.**
221 pub consecutive_failures: u32,
222}
223
224impl ConfigStatus {
225 /// Whether the last attempt to load installed something.
226 ///
227 /// Nothing more than `consecutive_failures == 0`, spelled the way the
228 /// question is asked. True before the first load too — nothing has
229 /// failed yet.
230 #[must_use]
231 pub fn is_healthy(&self) -> bool {
232 self.consecutive_failures == 0
233 }
234
235 /// How long ago the serving snapshot was installed.
236 ///
237 /// `None` before the first install. This is why the recorded instant is
238 /// monotonic: a wall clock going backwards under NTP would make a fresh
239 /// configuration look stale.
240 #[must_use]
241 pub fn stale_for(&self) -> Option<std::time::Duration> {
242 self.loaded_at.map(|at| at.elapsed())
243 }
244}