Skip to main content

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: crate::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    #[cfg(not(loom))]
82    pub const fn new() -> Self {
83        Self {
84            members: Vec::new(),
85            reloading: crate::sync::Mutex::new(()),
86        }
87    }
88
89    /// The same, minus `const`: loom's constructors are not.
90    #[must_use]
91    #[cfg(loom)]
92    pub fn new() -> Self {
93        Self {
94            members: Vec::new(),
95            reloading: crate::sync::Mutex::new(()),
96        }
97    }
98
99    /// Adds a configuration type.
100    ///
101    /// Order matters only for which failure is reported first; the outcome is
102    /// all-or-nothing either way.
103    #[must_use]
104    pub fn with<T: Reloadable>(mut self) -> Self {
105        self.members.push(Member {
106            name: T::name(),
107            prepare: T::prepare,
108        });
109
110        self
111    }
112
113    /// The types in this group, in the order they were added.
114    pub fn members(&self) -> impl Iterator<Item = &'static str> + '_ {
115        self.members.iter().map(|member| member.name)
116    }
117
118    /// Whether the group would do anything.
119    #[must_use]
120    pub fn is_empty(&self) -> bool {
121        self.members.is_empty()
122    }
123
124    /// Loads every member, then installs every member.
125    ///
126    /// # Errors
127    ///
128    /// The first failure, with the offending type's name prepended. Nothing has
129    /// been installed when this returns an error — not even the members that
130    /// loaded cleanly.
131    pub fn reload(&self) -> Result<(), Error> {
132        // Serialized: two concurrent reloads — the admin endpoint and the
133        // file watcher, say — could otherwise interleave their commit loops
134        // and leave member A on one thread's prepare and member B on the
135        // other's, which is the exact mixed state this type exists to
136        // prevent.
137        let _guard = self
138            .reloading
139            .lock()
140            .unwrap_or_else(std::sync::PoisonError::into_inner);
141
142        let mut commits = Vec::with_capacity(self.members.len());
143
144        for member in &self.members {
145            // Any failure here drops the commits collected so far, and dropping
146            // a commit is what *not* applying it means.
147            let commit = (member.prepare)().map_err(|error| error.prepend_key(member.name))?;
148
149            commits.push(commit);
150        }
151
152        for commit in commits {
153            // Caught per commit: a commit ends in `store`, which runs reload
154            // hooks, and a panicking hook must not stop the *other members'*
155            // snapshots from installing — a half-committed group is the
156            // failure this type promises against. (The hook itself is also
157            // caught in `dispatch`; this is the second belt for anything
158            // that unwinds out of a commit some other way.)
159            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(commit));
160
161            if outcome.is_err() {
162                crate::log::warning!(
163                    "a commit's reload hook panicked; the remaining members \
164                     were still committed"
165                );
166            }
167        }
168
169        Ok(())
170    }
171}
172
173impl std::fmt::Debug for ReloadGroup {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.debug_list().entries(self.members()).finish()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use std::sync::atomic::{AtomicUsize, Ordering};
183
184    /// A counter per test rather than one shared by all of them: these run in
185    /// parallel, and a `static` they all reset is a race that passes until it
186    /// does not.
187    macro_rules! members {
188        ($counter:ident, $good:ident, $bad:ident) => {
189            static $counter: AtomicUsize = AtomicUsize::new(0);
190
191            struct $good;
192            // Not every test needs the failing half; the macro declares both so
193            // each test's types stay independent.
194            #[allow(dead_code)]
195            struct $bad;
196
197            impl Reloadable for $good {
198                fn prepare() -> Result<Commit, Error> {
199                    Ok(Box::new(|| {
200                        $counter.fetch_add(1, Ordering::SeqCst);
201                    }))
202                }
203
204                fn name() -> &'static str {
205                    stringify!($good)
206                }
207            }
208
209            impl Reloadable for $bad {
210                fn prepare() -> Result<Commit, Error> {
211                    Err(Error::new(crate::ErrorKind::Missing, "nothing supplies it"))
212                }
213
214                fn name() -> &'static str {
215                    stringify!($bad)
216                }
217            }
218        };
219    }
220
221    members!(ALL_COMMITTED, AllGood, AllBad);
222    members!(NONE_COMMITTED, NoneGood, NoneBad);
223    members!(ORDER_COMMITTED, OrderGood, OrderBad);
224
225    #[test]
226    fn every_member_commits_when_every_member_prepares() {
227        ReloadGroup::new()
228            .with::<AllGood>()
229            .with::<AllGood>()
230            .reload()
231            .expect("both prepare cleanly");
232
233        assert_eq!(ALL_COMMITTED.load(Ordering::SeqCst), 2);
234    }
235
236    #[test]
237    fn one_failure_stops_every_commit_including_the_ones_that_would_have_worked() {
238        let error = ReloadGroup::new()
239            .with::<NoneGood>()
240            .with::<NoneBad>()
241            .with::<NoneGood>()
242            .reload()
243            .expect_err("the middle member fails");
244
245        assert_eq!(
246            NONE_COMMITTED.load(Ordering::SeqCst),
247            0,
248            "the member that prepared before the failure must not have committed"
249        );
250        assert!(error.path().starts_with("NoneBad"), "{error}");
251    }
252
253    #[test]
254    fn an_empty_group_is_a_no_op() {
255        let group = ReloadGroup::new();
256
257        assert!(group.is_empty());
258        assert!(group.reload().is_ok());
259    }
260
261    #[test]
262    fn a_group_reports_its_members_in_order() {
263        let group = ReloadGroup::new().with::<OrderGood>().with::<OrderBad>();
264
265        assert_eq!(
266            group.members().collect::<Vec<_>>(),
267            ["OrderGood", "OrderBad"]
268        );
269    }
270}