dynamic_config/group.rs
1//! Reloading several configuration types as one step.
2//!
3//! Two structs over one file reload independently: for a moment after an edit,
4//! `ServerConfig` is new while `DbConfig` is still old. Usually nobody notices.
5//! Sometimes it matters — a TLS certificate path and the port it is served on,
6//! a queue name and the credentials for it — and then a half-applied
7//! configuration is worse than a late one.
8//!
9//! A group fixes that by splitting a reload in two. **Prepare** does everything
10//! that can fail: read, merge, deserialize, validate. **Commit** does the part
11//! that cannot: swap an `Arc`. Every member prepares before any member commits,
12//! so a failure anywhere leaves every member on its previous snapshot.
13//!
14//! ```text
15//! prepare A ─┐
16//! prepare B ─┼─ all succeeded? ─ yes ─→ commit A, commit B
17//! prepare C ─┘ no ─→ nothing moves
18//! ```
19//!
20//! The commits are not one atomic operation — nothing in `std` makes three
21//! `Arc` swaps simultaneous — but they happen with no fallible work between
22//! them, which is the part that actually goes wrong.
23
24use crate::error::Error;
25
26/// The second half of a reload: the part that cannot fail.
27pub type Commit = Box<dyn FnOnce() + Send>;
28
29/// A configuration type that a [`ReloadGroup`] can drive.
30///
31/// Implemented by `#[dynamic_config]`. Implementing it by hand is possible but
32/// rarely what you want — the contract is that `prepare` does *all* the
33/// fallible work, and nothing checks that for you.
34pub trait Reloadable: 'static {
35 /// Loads and validates, returning the swap to perform.
36 ///
37 /// # Errors
38 ///
39 /// Whatever a load of this type would report.
40 fn prepare() -> Result<Commit, Error>;
41
42 /// The type's name, for diagnostics.
43 fn name() -> &'static str;
44}
45
46/// Several configuration types that reload together or not at all.
47///
48/// ```no_run
49/// # use dynamic_config::ReloadGroup;
50/// # struct ServerConfig; struct DbConfig;
51/// # impl dynamic_config::Reloadable for ServerConfig {
52/// # fn prepare() -> Result<dynamic_config::Commit, dynamic_config::Error> { unimplemented!() }
53/// # fn name() -> &'static str { "ServerConfig" }
54/// # }
55/// # impl dynamic_config::Reloadable for DbConfig {
56/// # fn prepare() -> Result<dynamic_config::Commit, dynamic_config::Error> { unimplemented!() }
57/// # fn name() -> &'static str { "DbConfig" }
58/// # }
59/// let group = ReloadGroup::new()
60/// .with::<ServerConfig>()
61/// .with::<DbConfig>();
62///
63/// group.reload()?;
64/// # Ok::<(), dynamic_config::Error>(())
65/// ```
66#[derive(Default)]
67pub struct ReloadGroup {
68 members: Vec<Member>,
69 /// Serializes [`reload`](Self::reload); see there.
70 reloading: std::sync::Mutex<()>,
71}
72
73struct Member {
74 name: &'static str,
75 prepare: fn() -> Result<Commit, Error>,
76}
77
78impl ReloadGroup {
79 /// An empty group.
80 #[must_use]
81 pub const fn new() -> Self {
82 Self {
83 members: Vec::new(),
84 reloading: std::sync::Mutex::new(()),
85 }
86 }
87
88 /// Adds a configuration type.
89 ///
90 /// Order matters only for which failure is reported first; the outcome is
91 /// all-or-nothing either way.
92 #[must_use]
93 pub fn with<T: Reloadable>(mut self) -> Self {
94 self.members.push(Member {
95 name: T::name(),
96 prepare: T::prepare,
97 });
98
99 self
100 }
101
102 /// The types in this group, in the order they were added.
103 pub fn members(&self) -> impl Iterator<Item = &'static str> + '_ {
104 self.members.iter().map(|member| member.name)
105 }
106
107 /// Whether the group would do anything.
108 #[must_use]
109 pub fn is_empty(&self) -> bool {
110 self.members.is_empty()
111 }
112
113 /// Loads every member, then installs every member.
114 ///
115 /// # Errors
116 ///
117 /// The first failure, with the offending type's name prepended. Nothing has
118 /// been installed when this returns an error — not even the members that
119 /// loaded cleanly.
120 pub fn reload(&self) -> Result<(), Error> {
121 // Serialized: two concurrent reloads — the admin endpoint and the
122 // file watcher, say — could otherwise interleave their commit loops
123 // and leave member A on one thread's prepare and member B on the
124 // other's, which is the exact mixed state this type exists to
125 // prevent.
126 let _guard = self
127 .reloading
128 .lock()
129 .unwrap_or_else(std::sync::PoisonError::into_inner);
130
131 let mut commits = Vec::with_capacity(self.members.len());
132
133 for member in &self.members {
134 // Any failure here drops the commits collected so far, and dropping
135 // a commit is what *not* applying it means.
136 let commit = (member.prepare)().map_err(|error| error.prepend_key(member.name))?;
137
138 commits.push(commit);
139 }
140
141 for commit in commits {
142 // Caught per commit: a commit ends in `store`, which runs reload
143 // hooks, and a panicking hook must not stop the *other members'*
144 // snapshots from installing — a half-committed group is the
145 // failure this type promises against. (The hook itself is also
146 // caught in `dispatch`; this is the second belt for anything
147 // that unwinds out of a commit some other way.)
148 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(commit));
149
150 if outcome.is_err() {
151 crate::log::warning!(
152 "a commit's reload hook panicked; the remaining members \
153 were still committed"
154 );
155 }
156 }
157
158 Ok(())
159 }
160}
161
162impl std::fmt::Debug for ReloadGroup {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.debug_list().entries(self.members()).finish()
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use std::sync::atomic::{AtomicUsize, Ordering};
172
173 /// A counter per test rather than one shared by all of them: these run in
174 /// parallel, and a `static` they all reset is a race that passes until it
175 /// does not.
176 macro_rules! members {
177 ($counter:ident, $good:ident, $bad:ident) => {
178 static $counter: AtomicUsize = AtomicUsize::new(0);
179
180 struct $good;
181 // Not every test needs the failing half; the macro declares both so
182 // each test's types stay independent.
183 #[allow(dead_code)]
184 struct $bad;
185
186 impl Reloadable for $good {
187 fn prepare() -> Result<Commit, Error> {
188 Ok(Box::new(|| {
189 $counter.fetch_add(1, Ordering::SeqCst);
190 }))
191 }
192
193 fn name() -> &'static str {
194 stringify!($good)
195 }
196 }
197
198 impl Reloadable for $bad {
199 fn prepare() -> Result<Commit, Error> {
200 Err(Error::new(crate::ErrorKind::Missing, "nothing supplies it"))
201 }
202
203 fn name() -> &'static str {
204 stringify!($bad)
205 }
206 }
207 };
208 }
209
210 members!(ALL_COMMITTED, AllGood, AllBad);
211 members!(NONE_COMMITTED, NoneGood, NoneBad);
212 members!(ORDER_COMMITTED, OrderGood, OrderBad);
213
214 #[test]
215 fn every_member_commits_when_every_member_prepares() {
216 ReloadGroup::new()
217 .with::<AllGood>()
218 .with::<AllGood>()
219 .reload()
220 .expect("both prepare cleanly");
221
222 assert_eq!(ALL_COMMITTED.load(Ordering::SeqCst), 2);
223 }
224
225 #[test]
226 fn one_failure_stops_every_commit_including_the_ones_that_would_have_worked() {
227 let error = ReloadGroup::new()
228 .with::<NoneGood>()
229 .with::<NoneBad>()
230 .with::<NoneGood>()
231 .reload()
232 .expect_err("the middle member fails");
233
234 assert_eq!(
235 NONE_COMMITTED.load(Ordering::SeqCst),
236 0,
237 "the member that prepared before the failure must not have committed"
238 );
239 assert!(error.path().starts_with("NoneBad"), "{error}");
240 }
241
242 #[test]
243 fn an_empty_group_is_a_no_op() {
244 let group = ReloadGroup::new();
245
246 assert!(group.is_empty());
247 assert!(group.reload().is_ok());
248 }
249
250 #[test]
251 fn a_group_reports_its_members_in_order() {
252 let group = ReloadGroup::new().with::<OrderGood>().with::<OrderBad>();
253
254 assert_eq!(
255 group.members().collect::<Vec<_>>(),
256 ["OrderGood", "OrderBad"]
257 );
258 }
259}