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 a [`RemoteSink`]'s `apply` 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::{Arc, Weak};
55
56use crate::sync::atomic::{AtomicBool, Ordering};
57use crate::sync::Mutex;
58use std::time::Duration;
59
60use crate::error::{Error, ErrorKind};
61use crate::source::Format;
62
63/// A document a remote store handed back.
64#[derive(Clone, PartialEq, Eq)]
65pub struct Fetched {
66 /// The document text, in `format`.
67 pub text: String,
68 /// How to parse it.
69 pub format: Format,
70}
71
72impl Fetched {
73 /// A document and the format it is written in.
74 #[must_use]
75 pub fn new(text: impl Into<String>, format: Format) -> Self {
76 Self {
77 text: text.into(),
78 format,
79 }
80 }
81}
82
83// The document is the one thing a `Debug` of this type must never print:
84// a remote store's flagship use case is serving secrets, and `Fetched` is
85// what every watch callback receives — one `tracing::debug!(?document)` away
86// from a log. The length is enough to debug with.
87impl std::fmt::Debug for Fetched {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("Fetched")
90 .field("format", &self.format)
91 .field("bytes", &self.text.len())
92 .finish()
93 }
94}
95
96/// A remote store that can be read without an async runtime.
97///
98/// The right trait for anything with a plain HTTP API — Consul and Vault both
99/// are — because implementing it needs no runtime and using it needs no
100/// runtime either. `fetch` may block; it is called from
101/// `refresh_remote()`, never from `load()`.
102pub trait RemoteSource: Send + Sync + 'static {
103 /// Reads the current document.
104 ///
105 /// # Errors
106 ///
107 /// Whatever going wrong looks like for this store. Use
108 /// [`Error::remote`](crate::Error::remote) so the failure is categorised
109 /// consistently.
110 fn fetch(&self) -> Result<Fetched, Error>;
111
112 /// How to name this source in an error or a report.
113 fn describe(&self) -> String;
114}
115
116/// A remote store that is read asynchronously.
117///
118/// The right trait for a client that is async to begin with — etcd speaks gRPC
119/// and NATS is a streaming protocol, so both are. Used through
120/// `refresh_remote_async().await`.
121///
122/// The lifetime-bound boxed future rather than `async fn`: this trait is
123/// object-safe on purpose, so a configuration type can hold one without being
124/// generic over it.
125#[cfg(feature = "async")]
126#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
127pub trait AsyncRemoteSource: Send + Sync + 'static {
128 /// Reads the current document.
129 ///
130 /// # Errors
131 ///
132 /// As [`RemoteSource::fetch`].
133 fn fetch(
134 &self,
135 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Fetched, Error>> + Send + '_>>;
136
137 /// How to name this source in an error or a report.
138 fn describe(&self) -> String;
139}
140
141/// The remote source for one configuration type, and its last document.
142///
143/// `Remote::new()` is `const`, so this lives in a `static` — which is how
144/// `#[dynamic_config]` emits it.
145#[derive(Default)]
146pub struct Remote {
147 /// One lock for the whole state, deliberately. Two separate locks — one
148 /// for the source, one for the document — allowed an interleaving where
149 /// a slow fetch from the *old* source committed its result after `set`
150 /// had installed a new one: new source, old store's document. The
151 /// generation counter is the fence that makes that impossible.
152 state: Mutex<State>,
153}
154
155#[derive(Default)]
156struct State {
157 source: Option<Kind>,
158 fetched: Option<Fetched>,
159 /// Bumped on every source change. A fetch snapshots it before the network
160 /// round trip and commits only if it has not moved — a result from a
161 /// source that is no longer installed is discarded, never stored.
162 generation: u64,
163}
164
165/// `Arc` rather than `Box`: an async fetch borrows the source across an await
166/// point, and cloning the handle out of the lock first is what keeps a `std`
167/// mutex from being held across one.
168#[derive(Clone)]
169enum Kind {
170 Blocking(Arc<dyn RemoteSource>),
171 #[cfg(feature = "async")]
172 Asynchronous(Arc<dyn AsyncRemoteSource>),
173}
174
175impl Remote {
176 /// An empty slot: no source, no document.
177 #[must_use]
178 #[cfg(not(loom))]
179 pub const fn new() -> Self {
180 Self {
181 state: Mutex::new(State {
182 source: None,
183 fetched: None,
184 generation: 0,
185 }),
186 }
187 }
188
189 /// The same, minus `const`: loom's constructors are not.
190 #[must_use]
191 #[cfg(loom)]
192 pub fn new() -> Self {
193 Self {
194 state: Mutex::new(State {
195 source: None,
196 fetched: None,
197 generation: 0,
198 }),
199 }
200 }
201
202 /// Installs a blocking source, replacing any previous one.
203 ///
204 /// The document already fetched, if any, is dropped with it — a new source
205 /// answering with an old store's values would be a puzzle nobody needs.
206 /// A fetch from the previous source that is still in flight is discarded
207 /// when it lands, for the same reason.
208 pub fn set(&self, source: impl RemoteSource) {
209 let mut state = self.state();
210 state.source = Some(Kind::Blocking(Arc::new(source)));
211 state.fetched = None;
212 state.generation = state.generation.wrapping_add(1);
213 }
214
215 /// Installs an async source, replacing any previous one.
216 #[cfg(feature = "async")]
217 #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
218 pub fn set_async(&self, source: impl AsyncRemoteSource) {
219 let mut state = self.state();
220 state.source = Some(Kind::Asynchronous(Arc::new(source)));
221 state.fetched = None;
222 state.generation = state.generation.wrapping_add(1);
223 }
224
225 /// Fetches, and keeps what came back.
226 ///
227 /// The network round trip happens with no lock held: a slow store cannot
228 /// make `load()` — which reads this state for provenance — wait for it.
229 /// If the source is replaced while the fetch is in flight, the result is
230 /// discarded and `Ok` is returned: the fetch *did* succeed, and the new
231 /// source's own refresh is the one that matters now.
232 ///
233 /// # Errors
234 ///
235 /// If no source is installed, if the installed one is async — use
236 /// [`refresh_async`](Self::refresh_async) — or if the fetch fails.
237 pub fn refresh(&self) -> Result<(), Error> {
238 let (source, generation) = {
239 let state = self.state();
240
241 match state.source.as_ref() {
242 Some(Kind::Blocking(source)) => (Arc::clone(source), state.generation),
243
244 #[cfg(feature = "async")]
245 Some(Kind::Asynchronous(source)) => {
246 return Err(Error::new(
247 ErrorKind::Remote,
248 format!(
249 "`{}` is an async source; refresh it with `refresh_remote_async`",
250 source.describe()
251 ),
252 ))
253 }
254
255 None => return Err(none_installed()),
256 }
257 };
258
259 let fetched = source.fetch()?;
260
261 self.commit(fetched, generation);
262
263 Ok(())
264 }
265
266 /// Fetches from an async source, and keeps what came back.
267 ///
268 /// A *blocking* source is not refused — swapping one implementation for
269 /// the other must not be a breaking change for the caller — but it is not
270 /// run on the executor either: it goes through
271 /// [`off_thread`](crate::off_thread), so an async caller's worker thread
272 /// never sits inside a blocking network call.
273 ///
274 /// The same replaced-mid-fetch rule as [`refresh`](Self::refresh)
275 /// applies, and matters more here: the unlocked window spans an await.
276 ///
277 /// # Errors
278 ///
279 /// If no source is installed, or the fetch fails.
280 #[cfg(feature = "async")]
281 #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
282 pub async fn refresh_async(&self) -> Result<(), Error> {
283 // Cloned out of the lock before anything is awaited: holding a `std`
284 // mutex across an await point is how an executor deadlocks itself.
285 let (source, generation) = {
286 let state = self.state();
287
288 match state.source.as_ref() {
289 Some(source) => (source.clone(), state.generation),
290 None => return Err(none_installed()),
291 }
292 };
293
294 let fetched = match source {
295 Kind::Blocking(source) => {
296 crate::asynchronous::off_thread(move || source.fetch()).await?
297 }
298 Kind::Asynchronous(source) => source.fetch().await?,
299 };
300
301 self.commit(fetched, generation);
302
303 Ok(())
304 }
305
306 /// The generation a sink created now would carry; see [`RemoteSink`].
307 pub(crate) fn generation(&self) -> u64 {
308 self.state().generation
309 }
310
311 /// Installs `document` if the source it came from is still the one
312 /// installed — the push-side twin of the fetch fence.
313 ///
314 /// # Errors
315 ///
316 /// When the source has been replaced since `generation` was captured:
317 /// the document belongs to a store nobody asked about any more, and
318 /// installing it would hand a stale watcher the last word.
319 pub(crate) fn install_if(&self, generation: u64, document: Fetched) -> Result<(), Error> {
320 let mut state = self.state();
321
322 if state.generation != generation {
323 return Err(Error::new(
324 crate::ErrorKind::Backend,
325 "the remote source this sink was created for has been \
326 replaced; stop the old watch loop and take a fresh sink \
327 from `remote_sink()`",
328 ));
329 }
330
331 state.fetched = Some(document);
332
333 Ok(())
334 }
335
336 /// Not public API: the loom suite's door to the fence internals.
337 #[cfg(loom)]
338 #[doc(hidden)]
339 #[must_use]
340 pub fn generation_for_loom(&self) -> u64 {
341 self.generation()
342 }
343
344 /// Not public API: the loom suite's door to the fence internals.
345 ///
346 /// # Errors
347 ///
348 /// As `install_if`.
349 #[cfg(loom)]
350 #[doc(hidden)]
351 pub fn install_if_for_loom(&self, generation: u64, document: Fetched) -> Result<(), Error> {
352 self.install_if(generation, document)
353 }
354
355 /// The document last fetched, if any.
356 #[must_use]
357 pub fn document(&self) -> Option<Fetched> {
358 self.state().fetched.clone()
359 }
360
361 /// Whether a source is installed.
362 #[must_use]
363 pub fn is_configured(&self) -> bool {
364 self.state().source.is_some()
365 }
366
367 /// How the installed source names itself.
368 #[must_use]
369 pub fn describe(&self) -> Option<String> {
370 // The lock is held only for the clone: `describe()` on the source runs
371 // unlocked, so a source whose description does real work cannot stall
372 // readers.
373 let source = self.state().source.clone()?;
374
375 Some(match source {
376 Kind::Blocking(source) => source.describe(),
377 #[cfg(feature = "async")]
378 Kind::Asynchronous(source) => source.describe(),
379 })
380 }
381
382 /// Drops the document, so the next load sees no remote layer.
383 pub fn clear(&self) {
384 self.state().fetched = None;
385 }
386
387 /// Stores a fetch result, unless the source changed while it was in
388 /// flight — then the result belongs to a store nobody asked about any
389 /// more, and storing it would pair the new source with the old store's
390 /// values.
391 fn commit(&self, fetched: Fetched, generation: u64) {
392 let mut state = self.state();
393
394 if state.generation == generation {
395 state.fetched = Some(fetched);
396 }
397 }
398
399 fn state(&self) -> crate::sync::MutexGuard<'_, State> {
400 self.state
401 .lock()
402 .unwrap_or_else(std::sync::PoisonError::into_inner)
403 }
404}
405
406impl std::fmt::Debug for Remote {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 f.debug_struct("Remote")
409 .field("source", &self.describe())
410 .field("fetched", &self.document().is_some())
411 .finish()
412 }
413}
414
415fn none_installed() -> Error {
416 Error::new(
417 ErrorKind::Remote,
418 "no remote source is installed; call `set_remote` first",
419 )
420}
421
422// ---------------------------------------------------------------------------
423// Stopping a blocking watch
424// ---------------------------------------------------------------------------
425
426/// A running blocking watch, from the caller's side.
427///
428/// Dropping it stops the loop — the same contract the file watcher's
429/// `WatchHandle` has, for the same reason: a watch nobody owns is a leak nobody
430/// asked for. [`detach`](Self::detach) is the way to say *this one really should
431/// run forever*.
432///
433/// Only blocking loops need this. An async watch is a future: drop it and it is
434/// cancelled, on any executor.
435///
436/// ```no_run
437/// # use dynamic_config::RemoteWatch;
438/// # struct Consul;
439/// # impl Consul {
440/// # fn watch(&self, _: dynamic_config::Watching, _: fn(dynamic_config::Fetched) -> Result<(), dynamic_config::Error>) -> Result<(), dynamic_config::Error> { Ok(()) }
441/// # }
442/// # fn example(consul: Consul) {
443/// # fn apply(_: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
444/// let watch = RemoteWatch::new();
445/// let watching = watch.watching();
446///
447/// std::thread::spawn(move || consul.watch(watching, apply));
448///
449/// // ... and later, or by dropping `watch`:
450/// watch.stop();
451/// # }
452/// ```
453#[must_use = "dropping the handle stops the watch; bind it, or call `.detach()` \
454 to watch for the rest of the process"]
455#[derive(Debug)]
456pub struct RemoteWatch {
457 running: Arc<AtomicBool>,
458}
459
460impl RemoteWatch {
461 /// A handle for a watch that has not been handed to a loop yet.
462 pub fn new() -> Self {
463 Self {
464 running: Arc::new(AtomicBool::new(true)),
465 }
466 }
467
468 /// The loop's half of this handle.
469 ///
470 /// Hand it to the watch; keep the `RemoteWatch` yourself.
471 #[must_use]
472 pub fn watching(&self) -> Watching {
473 Watching {
474 running: Arc::downgrade(&self.running),
475 }
476 }
477
478 /// Stops the loop at its next check.
479 ///
480 /// *At its next check* is the whole caveat, and it is not small: a loop
481 /// parked in a blocking query does not return until the store answers or
482 /// the wait expires, so the store's wait time is the worst-case delay. Each
483 /// companion crate documents its own.
484 pub fn stop(&self) {
485 self.running.store(false, Ordering::Release);
486 }
487
488 /// Whether the loop has been told to stop.
489 #[must_use]
490 pub fn is_stopped(&self) -> bool {
491 !self.running.load(Ordering::Acquire)
492 }
493
494 /// Watches for the remainder of the process.
495 ///
496 /// Leaks the handle on purpose, exactly as the file watcher's
497 /// `WatchHandle::detach` does: a watch that must never stop has no owner to
498 /// hold it, and pretending otherwise is how it ends up stopped at the end of
499 /// `main`'s first statement.
500 pub fn detach(self) {
501 std::mem::forget(self);
502 }
503}
504
505impl Default for RemoteWatch {
506 fn default() -> Self {
507 Self::new()
508 }
509}
510
511impl Drop for RemoteWatch {
512 fn drop(&mut self) {
513 self.stop();
514 }
515}
516
517/// The loop's half of a [`RemoteWatch`].
518///
519/// A `Weak`, so a handle that is dropped without anyone remembering to call
520/// `stop` still ends the loop: the upgrade fails and
521/// [`keep_going`](Self::keep_going) answers `false`.
522#[derive(Debug, Clone)]
523pub struct Watching {
524 running: Weak<AtomicBool>,
525}
526
527impl Watching {
528 /// Whether the loop should go round again.
529 ///
530 /// `false` once the caller called [`RemoteWatch::stop`] or dropped the
531 /// handle. Check it before every request, not only after one: a loop that
532 /// checks only on the way out issues one more query than it was asked to.
533 #[must_use]
534 pub fn keep_going(&self) -> bool {
535 self.running
536 .upgrade()
537 .is_some_and(|running| running.load(Ordering::Acquire))
538 }
539
540 /// Sleeps for `total`, waking early if the watch is stopped.
541 ///
542 /// The polling loop every blocking store crate writes: sleep a slice,
543 /// check [`keep_going`](Self::keep_going), repeat — so a stopped watch
544 /// ends within a quarter second instead of at the end of its interval.
545 /// Here once, rather than once per store crate.
546 pub fn sleep_for(&self, total: Duration) {
547 const SLICE: Duration = Duration::from_millis(250);
548
549 let mut slept = Duration::ZERO;
550
551 while slept < total && self.keep_going() {
552 std::thread::sleep(SLICE.min(total - slept));
553 slept += SLICE;
554 }
555 }
556
557 /// A token for a watch that should never stop.
558 ///
559 /// For a loop the caller genuinely wants to outlive everything, so there is
560 /// no handle to hold. Prefer [`RemoteWatch::detach`], which says the same
561 /// thing at the point where somebody decided it.
562 #[must_use]
563 pub fn forever() -> Self {
564 // A `Weak` that can never upgrade would stop the loop immediately, so
565 // this leaks one live flag — one allocation, once, for the life of the
566 // process.
567 let running = Box::leak(Box::new(Arc::new(AtomicBool::new(true))));
568
569 Self {
570 running: Arc::downgrade(running),
571 }
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 struct Fake(&'static str);
580
581 impl RemoteSource for Fake {
582 fn fetch(&self) -> Result<Fetched, Error> {
583 Ok(Fetched::new(self.0, Format::Json))
584 }
585
586 fn describe(&self) -> String {
587 "a fake store".to_owned()
588 }
589 }
590
591 struct Broken;
592
593 impl RemoteSource for Broken {
594 fn fetch(&self) -> Result<Fetched, Error> {
595 Err(Error::remote("the store is unreachable"))
596 }
597
598 fn describe(&self) -> String {
599 "a broken store".to_owned()
600 }
601 }
602
603 #[test]
604 fn nothing_is_fetched_until_it_is_asked_for() {
605 let remote = Remote::new();
606 remote.set(Fake(r#"{"db": {"host": "a"}}"#));
607
608 assert!(remote.is_configured());
609 assert!(
610 remote.document().is_none(),
611 "installing a source must not reach the network"
612 );
613
614 remote.refresh().unwrap();
615 assert!(remote.document().is_some());
616 }
617
618 /// Succeeds once, then fails — a store that answered and went away.
619 struct Flaky(std::sync::atomic::AtomicBool);
620
621 impl RemoteSource for Flaky {
622 fn fetch(&self) -> Result<Fetched, Error> {
623 if self.0.swap(true, Ordering::SeqCst) {
624 return Err(Error::remote("the store went away"));
625 }
626
627 Fake(r#"{"db": {"host": "a"}}"#).fetch()
628 }
629
630 fn describe(&self) -> String {
631 "a store that answers once".to_owned()
632 }
633 }
634
635 #[test]
636 fn a_failed_fetch_leaves_the_previous_document_alone() {
637 let remote = Remote::new();
638 remote.set(Flaky(std::sync::atomic::AtomicBool::new(false)));
639 remote.refresh().unwrap();
640
641 let before = remote.document();
642 assert!(before.is_some(), "the first fetch succeeds");
643
644 // The second fetch *fails*, and the failure must surface — while the
645 // document from the fetch that worked stays where it was.
646 let error = remote.refresh().unwrap_err();
647
648 assert!(error.to_string().contains("went away"), "{error}");
649 assert_eq!(remote.document(), before);
650 }
651
652 /// Blocks inside `fetch` on a pair of barriers, so a test can hold a
653 /// fetch mid-flight while it does something else to the `Remote`.
654 struct Parked {
655 started: std::sync::Arc<std::sync::Barrier>,
656 release: std::sync::Arc<std::sync::Barrier>,
657 }
658
659 impl RemoteSource for Parked {
660 fn fetch(&self) -> Result<Fetched, Error> {
661 self.started.wait();
662 self.release.wait();
663
664 Fake(r#"{"db": {"host": "stale"}}"#).fetch()
665 }
666
667 fn describe(&self) -> String {
668 "a parked store".to_owned()
669 }
670 }
671
672 /// The race the generation fence exists for: a fetch from the *old*
673 /// source lands after `set` installed a new one. Its result must be
674 /// discarded — new source, old store's document is the state this
675 /// module's docs promise cannot happen.
676 #[test]
677 fn a_fetch_from_a_replaced_source_is_discarded() {
678 let started = std::sync::Arc::new(std::sync::Barrier::new(2));
679 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
680
681 let remote = std::sync::Arc::new(Remote::new());
682 remote.set(Parked {
683 started: std::sync::Arc::clone(&started),
684 release: std::sync::Arc::clone(&release),
685 });
686
687 let refresher = {
688 let remote = std::sync::Arc::clone(&remote);
689 std::thread::spawn(move || remote.refresh())
690 };
691
692 // The fetch is provably in flight...
693 started.wait();
694
695 // ...when the source is replaced.
696 remote.set(Fake(r#"{"db": {"host": "fresh"}}"#));
697
698 release.wait();
699 refresher
700 .join()
701 .expect("the refresher must not panic")
702 .expect("the fetch itself succeeded");
703
704 assert_eq!(
705 remote.document(),
706 None,
707 "the old source's document landed after the replacement and must \
708 not be paired with the new source"
709 );
710
711 // And the new source works normally.
712 remote.refresh().unwrap();
713 assert!(remote.document().unwrap().text.contains("fresh"));
714 }
715
716 /// Readers must not wait for a slow store: `document()` and `describe()`
717 /// are on the `load()` path, and `load()` promises to touch no network.
718 #[test]
719 fn readers_are_not_blocked_by_a_fetch_in_flight() {
720 let started = std::sync::Arc::new(std::sync::Barrier::new(2));
721 let release = std::sync::Arc::new(std::sync::Barrier::new(2));
722
723 let remote = std::sync::Arc::new(Remote::new());
724 remote.set(Parked {
725 started: std::sync::Arc::clone(&started),
726 release: std::sync::Arc::clone(&release),
727 });
728
729 let refresher = {
730 let remote = std::sync::Arc::clone(&remote);
731 std::thread::spawn(move || remote.refresh())
732 };
733
734 started.wait();
735
736 // With the fetch parked, a reader thread must finish promptly. The
737 // old two-lock design held the source lock across the fetch, so
738 // `describe()` — and with it every `load()` — waited out the store's
739 // full timeout.
740 let (sender, receiver) = std::sync::mpsc::channel();
741 {
742 let remote = std::sync::Arc::clone(&remote);
743 std::thread::spawn(move || {
744 let described = remote.describe();
745 let document = remote.document();
746 let _ = sender.send((described, document));
747 });
748 }
749
750 let (described, document) = receiver
751 .recv_timeout(Duration::from_secs(2))
752 .expect("readers must not wait for the network");
753
754 assert_eq!(described.as_deref(), Some("a parked store"));
755 assert_eq!(document, None);
756
757 release.wait();
758 let _ = refresher.join();
759 }
760
761 /// `set` clears the document atomically with the source swap: no
762 /// interleaving may observe the new source paired with any document.
763 #[test]
764 fn replacing_the_source_and_dropping_the_document_is_one_step() {
765 let remote = Remote::new();
766 remote.set(Fake(r#"{"db": {"host": "a"}}"#));
767 remote.refresh().unwrap();
768 let generation = remote.generation();
769 remote
770 .install_if(generation, Fetched::new("{}", crate::Format::Json))
771 .expect("the source has not moved");
772
773 remote.set(Fake(r#"{"db": {"host": "b"}}"#));
774
775 assert_eq!(remote.document(), None);
776
777 // And the push-side fence itself: the pre-swap generation is now
778 // stale, so a late delivery bounces instead of landing.
779 remote
780 .install_if(generation, Fetched::new("{}", crate::Format::Json))
781 .expect_err("a replaced source's generation must be refused");
782 assert_eq!(remote.document(), None);
783 }
784
785 #[test]
786 fn a_broken_store_reports_rather_than_pretending() {
787 let remote = Remote::new();
788 remote.set(Broken);
789
790 let error = remote.refresh().unwrap_err();
791
792 assert_eq!(error.kind(), ErrorKind::Remote);
793 assert!(error.to_string().contains("unreachable"), "{error}");
794 }
795
796 #[test]
797 fn refreshing_with_no_source_says_so() {
798 let error = Remote::new().refresh().unwrap_err();
799
800 assert!(error.to_string().contains("set_remote"), "{error}");
801 }
802
803 #[test]
804 fn replacing_the_source_drops_the_old_document() {
805 let remote = Remote::new();
806 remote.set(Fake(r#"{"db": {"host": "a"}}"#));
807 remote.refresh().unwrap();
808
809 remote.set(Fake(r#"{"db": {"host": "b"}}"#));
810
811 assert!(
812 remote.document().is_none(),
813 "a new source answering with the old store's values would be a puzzle"
814 );
815 }
816}
817
818/// A fenced door for a remote watch loop's pushes.
819///
820/// Created by the generated `remote_sink()` *after* the source is
821/// installed, it remembers which source that was. [`apply`](Self::apply)
822/// installs the document and reloads — unless the source has since been
823/// replaced, in which case it refuses: a watch loop serving yesterday's
824/// store cannot overwrite today's, by construction rather than by the old
825/// documentation's request to please stop the loop first.
826///
827/// Cheap to clone; each wiring of a watch loop should take its own —
828/// **once, where the loop starts**. A sink taken per delivery reads the
829/// generation of that moment and fences nothing.
830#[derive(Clone, Copy)]
831pub struct RemoteSink {
832 remote: &'static Remote,
833 generation: u64,
834 reload: fn() -> Result<(), Error>,
835 name: &'static str,
836}
837
838impl RemoteSink {
839 /// Not public API: called by the generated `remote_sink()`.
840 #[doc(hidden)]
841 #[must_use]
842 pub fn new(
843 remote: &'static Remote,
844 reload: fn() -> Result<(), Error>,
845 name: &'static str,
846 ) -> Self {
847 Self {
848 remote,
849 generation: remote.generation(),
850 reload,
851 name,
852 }
853 }
854
855 /// Installs a document the watch pushed, and reloads.
856 ///
857 /// Everything a file change would do happens here too — validation,
858 /// the reload hooks, the cache — because it is the same code path,
859 /// reached with a document instead of a filesystem event. A failure
860 /// leaves the previous snapshot serving.
861 ///
862 /// # Errors
863 ///
864 /// If the source has been replaced since this sink was created —
865 /// checked before the reload *and again after it*, because a
866 /// replacement can land while the reload runs — or if the resulting
867 /// configuration does not load or validate.
868 pub fn apply(&self, document: Fetched) -> Result<(), Error> {
869 self.remote.install_if(self.generation, document)?;
870
871 let outcome = (self.reload)();
872
873 // The reload read the slot as it stood while it ran. If the source
874 // was replaced mid-flight — after `install_if` said yes — what just
875 // installed may derive from this sink's document even though the
876 // fence now belongs to the replacement. Reload once more against
877 // the slot as it stands, so the replacement's state has the last
878 // word, then refuse like any other stale push.
879 if self.remote.generation() != self.generation {
880 let _ = (self.reload)();
881
882 let error = Error::new(
883 crate::ErrorKind::Backend,
884 "the remote source this sink was created for was replaced \
885 while its delivery reloaded; the replacement's state was \
886 restored — stop the old watch loop and take a fresh sink \
887 from `remote_sink()`",
888 );
889 crate::__log_remote_failure(self.name, &error);
890
891 return Err(error);
892 }
893
894 match outcome {
895 Ok(()) => {
896 crate::__log_remote_reload(self.name, None);
897
898 Ok(())
899 }
900 Err(error) => {
901 crate::__log_remote_failure(self.name, &error);
902
903 Err(error)
904 }
905 }
906 }
907}
908
909impl std::fmt::Debug for RemoteSink {
910 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
911 f.debug_struct("RemoteSink")
912 .field("config", &self.name)
913 .field("generation", &self.generation)
914 .finish_non_exhaustive()
915 }
916}