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 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}