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