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}