Skip to main content

dynamic_config/
remote.rs

1//! Configuration served from somewhere other than this machine.
2//!
3//! etcd, Consul, NATS, Vault — a document fetched over a network and merged
4//! like a file. The companion crates implement one of the two traits here;
5//! this module is the part that never changes.
6//!
7//! ## Fetching is explicit
8//!
9//! A remote source is **not** read on every `load()`. Configuration is read on
10//! nearly every request; a network round trip there would be indefensible, and
11//! it is also what forces every async question to become a blocking one.
12//!
13//! ```text
14//! refresh_remote()          →  fetch, keep the document
15//! load()                    →  merge the kept document, no I/O
16//! ```
17//!
18//! That one decision is what lets a blocking source and an async source sit
19//! side by side without `block_on` anywhere, and without the crate caring which
20//! runtime — if any — the program is built on.
21//!
22//! ## Where it sits
23//!
24//! ```text
25//! defaults < files < remote < environment < flags < overrides
26//! ```
27//!
28//! Above the files, because centrally distributed configuration should beat
29//! what a package shipped. Below the environment, because a machine's own
30//! settings should beat what a central store thinks it wants.
31//!
32//! ## Watching
33//!
34//! Polling a store on a timer works and is what [`Vault`] has to do, but three
35//! of the four can tell you the moment a value moves — etcd has a watch stream,
36//! NATS KV has one too, and Consul answers a blocking query. Each companion
37//! crate owns that loop, because a watch is long-lived and protocol-shaped in a
38//! way a single trait cannot honestly cover.
39//!
40//! What the loop pushes through is here: a document arrives, [`Remote::install`]
41//! puts it in the slot, and the generated `apply_remote` reloads exactly the way
42//! a file change does — hooks, diffing, validation, the cache.
43//!
44//! The two halves are cancelled differently, and neither imposes a runtime:
45//!
46//! - **An async loop is a future.** Drop it and the watch stops. That is the
47//!   whole cancellation story, and it works on any executor.
48//! - **A blocking loop is a thread**, which cannot be dropped from outside, so
49//!   it takes a [`Watching`] and checks it between requests. The caller holds
50//!   the matching [`RemoteWatch`].
51//!
52//! [`Vault`]: https://docs.rs/dynamic-config-vault
53
54use std::sync::atomic::{AtomicBool, Ordering};
55use std::sync::{Arc, Mutex, Weak};
56use std::time::Duration;
57
58use crate::error::{Error, ErrorKind};
59use crate::source::Format;
60
61/// A document a remote store handed back.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Fetched {
64    /// The document text, in `format`.
65    pub text: String,
66    /// How to parse it.
67    pub format: Format,
68}
69
70impl Fetched {
71    /// A document and the format it is written in.
72    pub fn new(text: impl Into<String>, format: Format) -> Self {
73        Self {
74            text: text.into(),
75            format,
76        }
77    }
78}
79
80/// A remote store that can be read without an async runtime.
81///
82/// The right trait for anything with a plain HTTP API — Consul and Vault both
83/// are — because implementing it needs no runtime and using it needs no
84/// runtime either. `fetch` may block; it is called from
85/// `refresh_remote()`, never from `load()`.
86pub trait RemoteSource: Send + Sync + 'static {
87    /// Reads the current document.
88    ///
89    /// # Errors
90    ///
91    /// Whatever going wrong looks like for this store. Use
92    /// [`Error::remote`](crate::Error::remote) so the failure is categorised
93    /// consistently.
94    fn fetch(&self) -> Result<Fetched, Error>;
95
96    /// How to name this source in an error or a report.
97    fn describe(&self) -> String;
98}
99
100/// A remote store that is read asynchronously.
101///
102/// The right trait for a client that is async to begin with — etcd speaks gRPC
103/// and NATS is a streaming protocol, so both are. Used through
104/// `refresh_remote_async().await`.
105///
106/// The lifetime-bound boxed future rather than `async fn`: this trait is
107/// object-safe on purpose, so a configuration type can hold one without being
108/// generic over it.
109#[cfg(feature = "async")]
110#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
111pub trait AsyncRemoteSource: Send + Sync + 'static {
112    /// Reads the current document.
113    ///
114    /// # Errors
115    ///
116    /// As [`RemoteSource::fetch`].
117    fn fetch(
118        &self,
119    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Fetched, Error>> + Send + '_>>;
120
121    /// How to name this source in an error or a report.
122    fn describe(&self) -> String;
123}
124
125/// The remote source for one configuration type, and its last document.
126///
127/// `Remote::new()` is `const`, so this lives in a `static` — which is how
128/// `#[dynamic_config]` emits it.
129#[derive(Default)]
130pub struct Remote {
131    source: Mutex<Option<Kind>>,
132    fetched: Mutex<Option<Fetched>>,
133}
134
135/// `Arc` rather than `Box`: an async fetch borrows the source across an await
136/// point, and cloning the handle out of the lock first is what keeps a `std`
137/// mutex from being held across one.
138#[derive(Clone)]
139enum Kind {
140    Blocking(Arc<dyn RemoteSource>),
141    #[cfg(feature = "async")]
142    Asynchronous(Arc<dyn AsyncRemoteSource>),
143}
144
145impl Remote {
146    /// An empty slot: no source, no document.
147    #[must_use]
148    pub const fn new() -> Self {
149        Self {
150            source: Mutex::new(None),
151            fetched: Mutex::new(None),
152        }
153    }
154
155    /// Installs a blocking source, replacing any previous one.
156    ///
157    /// The document already fetched, if any, is dropped with it — a new source
158    /// answering with an old store's values would be a puzzle nobody needs.
159    pub fn set(&self, source: impl RemoteSource) {
160        *self.source_slot() = Some(Kind::Blocking(Arc::new(source)));
161        *self.fetched_slot() = None;
162    }
163
164    /// Installs an async source, replacing any previous one.
165    #[cfg(feature = "async")]
166    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
167    pub fn set_async(&self, source: impl AsyncRemoteSource) {
168        *self.source_slot() = Some(Kind::Asynchronous(Arc::new(source)));
169        *self.fetched_slot() = None;
170    }
171
172    /// Fetches, and keeps what came back.
173    ///
174    /// # Errors
175    ///
176    /// If no source is installed, if the installed one is async — use
177    /// [`refresh_async`](Self::refresh_async) — or if the fetch fails.
178    pub fn refresh(&self) -> Result<(), Error> {
179        let fetched = {
180            let source = self.source_slot();
181
182            match source.as_ref() {
183                Some(Kind::Blocking(source)) => source.fetch()?,
184
185                #[cfg(feature = "async")]
186                Some(Kind::Asynchronous(source)) => {
187                    return Err(Error::new(
188                        ErrorKind::Remote,
189                        format!(
190                            "`{}` is an async source; refresh it with `refresh_remote_async`",
191                            source.describe()
192                        ),
193                    ))
194                }
195
196                None => return Err(none_installed()),
197            }
198        };
199
200        *self.fetched_slot() = Some(fetched);
201
202        Ok(())
203    }
204
205    /// Fetches from an async source, and keeps what came back.
206    ///
207    /// # Errors
208    ///
209    /// If no source is installed, or the fetch fails. A *blocking* source is
210    /// run inline here rather than refused: it is already allowed to block, and
211    /// refusing would make swapping one implementation for the other a breaking
212    /// change for the caller.
213    #[cfg(feature = "async")]
214    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
215    pub async fn refresh_async(&self) -> Result<(), Error> {
216        // Cloned out of the lock before anything is awaited: holding a `std`
217        // mutex across an await point is how an executor deadlocks itself.
218        let source = self.source_slot().clone();
219
220        let fetched = match source {
221            Some(Kind::Blocking(source)) => source.fetch()?,
222            Some(Kind::Asynchronous(source)) => source.fetch().await?,
223            None => return Err(none_installed()),
224        };
225
226        *self.fetched_slot() = Some(fetched);
227
228        Ok(())
229    }
230
231    /// Puts a document in the slot without fetching one.
232    ///
233    /// What a watch loop calls: the document already arrived, pushed by the
234    /// store, and re-fetching it to learn what it just said would be silly.
235    ///
236    /// No source need be installed for this to work — a program that only ever
237    /// watches never has to configure one.
238    pub fn install(&self, document: Fetched) {
239        *self.fetched_slot() = Some(document);
240    }
241
242    /// The document last fetched, if any.
243    pub fn document(&self) -> Option<Fetched> {
244        self.fetched_slot().clone()
245    }
246
247    /// Whether a source is installed.
248    pub fn is_configured(&self) -> bool {
249        self.source_slot().is_some()
250    }
251
252    /// How the installed source names itself.
253    pub fn describe(&self) -> Option<String> {
254        self.source_slot().as_ref().map(|source| match source {
255            Kind::Blocking(source) => source.describe(),
256            #[cfg(feature = "async")]
257            Kind::Asynchronous(source) => source.describe(),
258        })
259    }
260
261    /// Drops the document, so the next load sees no remote layer.
262    pub fn clear(&self) {
263        *self.fetched_slot() = None;
264    }
265
266    fn source_slot(&self) -> std::sync::MutexGuard<'_, Option<Kind>> {
267        self.source
268            .lock()
269            .unwrap_or_else(std::sync::PoisonError::into_inner)
270    }
271
272    fn fetched_slot(&self) -> std::sync::MutexGuard<'_, Option<Fetched>> {
273        self.fetched
274            .lock()
275            .unwrap_or_else(std::sync::PoisonError::into_inner)
276    }
277}
278
279impl std::fmt::Debug for Remote {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        f.debug_struct("Remote")
282            .field("source", &self.describe())
283            .field("fetched", &self.document().is_some())
284            .finish()
285    }
286}
287
288fn none_installed() -> Error {
289    Error::new(
290        ErrorKind::Remote,
291        "no remote source is installed; call `set_remote` first",
292    )
293}
294
295// ---------------------------------------------------------------------------
296// Stopping a blocking watch
297// ---------------------------------------------------------------------------
298
299/// A running blocking watch, from the caller's side.
300///
301/// Dropping it stops the loop — the same contract the file watcher's
302/// `WatchHandle` has, for the same reason: a watch nobody owns is a leak nobody
303/// asked for. [`detach`](Self::detach) is the way to say *this one really should
304/// run forever*.
305///
306/// Only blocking loops need this. An async watch is a future: drop it and it is
307/// cancelled, on any executor.
308///
309/// ```no_run
310/// # use dynamic_config::RemoteWatch;
311/// # struct Consul;
312/// # impl Consul {
313/// #     fn watch(&self, _: dynamic_config::Watching, _: fn(dynamic_config::Fetched) -> Result<(), dynamic_config::Error>) -> Result<(), dynamic_config::Error> { Ok(()) }
314/// # }
315/// # fn example(consul: Consul) {
316/// # fn apply(_: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
317/// let watch = RemoteWatch::new();
318/// let watching = watch.watching();
319///
320/// std::thread::spawn(move || consul.watch(watching, apply));
321///
322/// // ... and later, or by dropping `watch`:
323/// watch.stop();
324/// # }
325/// ```
326#[must_use = "dropping the handle stops the watch; bind it, or call `.detach()` \
327              to watch for the rest of the process"]
328#[derive(Debug)]
329pub struct RemoteWatch {
330    running: Arc<AtomicBool>,
331}
332
333impl RemoteWatch {
334    /// A handle for a watch that has not been handed to a loop yet.
335    pub fn new() -> Self {
336        Self {
337            running: Arc::new(AtomicBool::new(true)),
338        }
339    }
340
341    /// The loop's half of this handle.
342    ///
343    /// Hand it to the watch; keep the `RemoteWatch` yourself.
344    #[must_use]
345    pub fn watching(&self) -> Watching {
346        Watching {
347            running: Arc::downgrade(&self.running),
348        }
349    }
350
351    /// Stops the loop at its next check.
352    ///
353    /// *At its next check* is the whole caveat, and it is not small: a loop
354    /// parked in a blocking query does not return until the store answers or
355    /// the wait expires, so the store's wait time is the worst-case delay. Each
356    /// companion crate documents its own.
357    pub fn stop(&self) {
358        self.running.store(false, Ordering::Release);
359    }
360
361    /// Whether the loop has been told to stop.
362    #[must_use]
363    pub fn is_stopped(&self) -> bool {
364        !self.running.load(Ordering::Acquire)
365    }
366
367    /// Watches for the remainder of the process.
368    ///
369    /// Leaks the handle on purpose, exactly as the file watcher's
370    /// `WatchHandle::detach` does: a watch that must never stop has no owner to
371    /// hold it, and pretending otherwise is how it ends up stopped at the end of
372    /// `main`'s first statement.
373    pub fn detach(self) {
374        std::mem::forget(self);
375    }
376}
377
378impl Default for RemoteWatch {
379    fn default() -> Self {
380        Self::new()
381    }
382}
383
384impl Drop for RemoteWatch {
385    fn drop(&mut self) {
386        self.stop();
387    }
388}
389
390/// The loop's half of a [`RemoteWatch`].
391///
392/// A `Weak`, so a handle that is dropped without anyone remembering to call
393/// `stop` still ends the loop: the upgrade fails and
394/// [`keep_going`](Self::keep_going) answers `false`.
395#[derive(Debug, Clone)]
396pub struct Watching {
397    running: Weak<AtomicBool>,
398}
399
400impl Watching {
401    /// Whether the loop should go round again.
402    ///
403    /// `false` once the caller called [`RemoteWatch::stop`] or dropped the
404    /// handle. Check it before every request, not only after one: a loop that
405    /// checks only on the way out issues one more query than it was asked to.
406    #[must_use]
407    pub fn keep_going(&self) -> bool {
408        self.running
409            .upgrade()
410            .is_some_and(|running| running.load(Ordering::Acquire))
411    }
412
413    /// Sleeps for `total`, waking early if the watch is stopped.
414    ///
415    /// The polling loop every blocking store crate writes: sleep a slice,
416    /// check [`keep_going`](Self::keep_going), repeat — so a stopped watch
417    /// ends within a quarter second instead of at the end of its interval.
418    /// Here once, rather than once per store crate.
419    pub fn sleep_for(&self, total: Duration) {
420        const SLICE: Duration = Duration::from_millis(250);
421
422        let mut slept = Duration::ZERO;
423
424        while slept < total && self.keep_going() {
425            std::thread::sleep(SLICE.min(total - slept));
426            slept += SLICE;
427        }
428    }
429
430    /// A token for a watch that should never stop.
431    ///
432    /// For a loop the caller genuinely wants to outlive everything, so there is
433    /// no handle to hold. Prefer [`RemoteWatch::detach`], which says the same
434    /// thing at the point where somebody decided it.
435    #[must_use]
436    pub fn forever() -> Self {
437        // A `Weak` that can never upgrade would stop the loop immediately, so
438        // this leaks one live flag — one allocation, once, for the life of the
439        // process.
440        let running = Box::leak(Box::new(Arc::new(AtomicBool::new(true))));
441
442        Self {
443            running: Arc::downgrade(running),
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    struct Fake(&'static str);
453
454    impl RemoteSource for Fake {
455        fn fetch(&self) -> Result<Fetched, Error> {
456            Ok(Fetched::new(self.0, Format::Json))
457        }
458
459        fn describe(&self) -> String {
460            "a fake store".to_owned()
461        }
462    }
463
464    struct Broken;
465
466    impl RemoteSource for Broken {
467        fn fetch(&self) -> Result<Fetched, Error> {
468            Err(Error::remote("the store is unreachable"))
469        }
470
471        fn describe(&self) -> String {
472            "a broken store".to_owned()
473        }
474    }
475
476    #[test]
477    fn nothing_is_fetched_until_it_is_asked_for() {
478        let remote = Remote::new();
479        remote.set(Fake(r#"{"db": {"host": "a"}}"#));
480
481        assert!(remote.is_configured());
482        assert!(
483            remote.document().is_none(),
484            "installing a source must not reach the network"
485        );
486
487        remote.refresh().unwrap();
488        assert!(remote.document().is_some());
489    }
490
491    /// Succeeds once, then fails — a store that answered and went away.
492    struct Flaky(std::sync::atomic::AtomicBool);
493
494    impl RemoteSource for Flaky {
495        fn fetch(&self) -> Result<Fetched, Error> {
496            if self.0.swap(true, Ordering::SeqCst) {
497                return Err(Error::remote("the store went away"));
498            }
499
500            Fake(r#"{"db": {"host": "a"}}"#).fetch()
501        }
502
503        fn describe(&self) -> String {
504            "a store that answers once".to_owned()
505        }
506    }
507
508    #[test]
509    fn a_failed_fetch_leaves_the_previous_document_alone() {
510        let remote = Remote::new();
511        remote.set(Flaky(std::sync::atomic::AtomicBool::new(false)));
512        remote.refresh().unwrap();
513
514        let before = remote.document();
515        assert!(before.is_some(), "the first fetch succeeds");
516
517        // The second fetch *fails*, and the failure must surface — while the
518        // document from the fetch that worked stays where it was.
519        let error = remote.refresh().unwrap_err();
520
521        assert!(error.to_string().contains("went away"), "{error}");
522        assert_eq!(remote.document(), before);
523    }
524
525    #[test]
526    fn a_broken_store_reports_rather_than_pretending() {
527        let remote = Remote::new();
528        remote.set(Broken);
529
530        let error = remote.refresh().unwrap_err();
531
532        assert_eq!(error.kind(), ErrorKind::Remote);
533        assert!(error.to_string().contains("unreachable"), "{error}");
534    }
535
536    #[test]
537    fn refreshing_with_no_source_says_so() {
538        let error = Remote::new().refresh().unwrap_err();
539
540        assert!(error.to_string().contains("set_remote"), "{error}");
541    }
542
543    #[test]
544    fn replacing_the_source_drops_the_old_document() {
545        let remote = Remote::new();
546        remote.set(Fake(r#"{"db": {"host": "a"}}"#));
547        remote.refresh().unwrap();
548
549        remote.set(Fake(r#"{"db": {"host": "b"}}"#));
550
551        assert!(
552            remote.document().is_none(),
553            "a new source answering with the old store's values would be a puzzle"
554        );
555    }
556}