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}