Skip to main content

dynamic_config/remote/
mod.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//! ## Timeouts
33//!
34//! Every companion crate that takes a `with_timeout` means the same thing by
35//! it: **the deadline for a single fetch attempt, excluding retries the
36//! underlying client performs.** One sentence, seven stores, whatever each
37//! client happens to call the knob underneath.
38//!
39//! The exclusion is the part that surprises. Where a client retries beneath us
40//! — the AWS SDK does, by default — a fetch can take the timeout multiplied by
41//! the attempt count, and that store's README says so rather than quietly
42//! tuning the retries away.
43//!
44//! ## Two ways a fetch fails
45//!
46//! [`ErrorKind::Remote`] is the store being unreachable; [`ErrorKind::Auth`] is
47//! a credential it refused. The difference is exactly what a watch loop needs:
48//! the first may fix itself while the loop waits, the second will not. So a
49//! store crate reaches for [`Error::auth`] only where the store's own answer
50//! says so — a 401, a 403, a token that could not be replaced — and stays on
51//! [`Error::remote`] wherever a proxy could have been the one talking.
52//!
53//! ## What a fetch reports about itself
54//!
55//! [`Remote`] records a [`RemoteStatus`](crate::RemoteStatus) — how many documents have arrived,
56//! when the last one did, how long the last pull took, and how many fetches
57//! have returned nothing since one returned a document. It is the fetch half
58//! of the picture [`ConfigStatus`](crate::ConfigStatus) starts, in the same
59//! vocabulary rather than a second one: *did the store answer* here, *did the
60//! document install* there.
61//!
62//! Neither the document nor the store's description can reach it. A store's
63//! description is its URL and a store URL routinely embeds
64//! `user:password@host`, so nothing derived from
65//! [`describe`](crate::Remote::describe) is recorded, spanned or labelled — the name
66//! a metric carries is supplied by whoever renders it, exactly as it is for a
67//! `ConfigStatus`.
68//!
69//! With the `tracing` feature a pull is also a `dynamic_config.fetch` span
70//! around the round trip, with an event inside it carrying the outcome and,
71//! on a failure, the [`ErrorKind`]. Nothing is on the read path: `load()`
72//! reads [`Remote::document`], which none of this touches.
73//!
74//! ## Watching
75//!
76//! Polling a store on a timer works and is what [`Vault`] has to do, but three
77//! of the four can tell you the moment a value moves — etcd has a watch stream,
78//! NATS KV has one too, and Consul answers a blocking query. Each companion
79//! crate owns that loop, because a watch is long-lived and protocol-shaped in a
80//! way a single trait cannot honestly cover.
81//!
82//! What the loop pushes through is here: a document arrives, [`Remote::install`]
83//! puts it in the slot, and a [`RemoteSink`](crate::RemoteSink)'s `apply` reloads exactly the way
84//! a file change does — hooks, diffing, validation, the cache.
85//!
86//! The two halves are cancelled differently, and neither imposes a runtime:
87//!
88//! - **An async loop is a future.** Drop it and the watch stops. That is the
89//!   whole cancellation story, and it works on any executor.
90//! - **A blocking loop is a thread**, which cannot be dropped from outside, so
91//!   it takes a [`Watching`] and checks it between requests. The caller holds
92//!   the matching [`RemoteWatch`].
93//!
94//! [`Vault`]: https://docs.rs/dynamic-config-vault
95//!
96//! ## The files
97//!
98//! One concern each, and the split follows the vocabulary rather than the
99//! line count: `Fetched` and the two traits are what a *store* implements
100//! (`source`); `RemoteStatus` is what an operator reads (`status`);
101//! `Remote` is the slot a configuration type owns (here); `RemoteSink` is
102//! the door a watch loop pushes through (`sink`); and the blocking watch
103//! handle is `watch`. Every name below is re-exported at the crate root
104//! exactly where it was.
105
106mod sink;
107mod source;
108mod status;
109mod watch;
110
111pub use sink::RemoteSink;
112#[cfg(feature = "async")]
113pub use source::AsyncRemoteSource;
114pub use source::{Fetched, RemoteSource};
115pub use status::RemoteStatus;
116pub use watch::{RemoteWatch, Watching};
117
118use std::sync::Arc;
119
120use crate::sync::Mutex;
121use std::time::{Duration, Instant};
122
123use crate::error::{Error, ErrorKind};
124use crate::reload::FailureStatus;
125
126/// The remote source for one configuration type, and its last document.
127///
128/// `Remote::new()` is `const`, so this lives in a `static` — which is how
129/// `#[dynamic_config]` emits it.
130///
131/// # What it records about itself
132///
133/// Every fetch this type performs and every delivery it accepts is counted
134/// into a [`RemoteStatus`](crate::RemoteStatus), on the same terms `ConfigCell` records a
135/// [`ConfigStatus`](crate::ConfigStatus): recorded where it happens, read by
136/// an atomic-cheap [`status`](Self::status), and never on the read path —
137/// `load()` reads [`document`](Self::document), which this does not touch.
138/// The cost is one `Instant::now()` per fetch, beside a network round trip.
139#[derive(Default)]
140pub struct Remote {
141    /// One lock for the whole state, deliberately. Two separate locks — one
142    /// for the source, one for the document — allowed an interleaving where
143    /// a slow fetch from the *old* source committed its result after `set`
144    /// had installed a new one: new source, old store's document. The
145    /// generation counter is the fence that makes that impossible.
146    state: Mutex<State>,
147}
148
149#[derive(Default)]
150struct State {
151    source: Option<Kind>,
152    fetched: Option<Fetched>,
153    /// Bumped on every source change. A fetch snapshots it before the network
154    /// round trip and commits only if it has not moved — a result from a
155    /// source that is no longer installed is discarded, never stored.
156    ///
157    /// It is *source identity*, and that is the whole of it: a
158    /// [`RemoteSink`](crate::RemoteSink) holds one for the life of a watch loop, so anything
159    /// that moves this number ends that loop.
160    generation: u64,
161    /// Bumped by [`clear`](crate::Remote::clear), and by nothing else.
162    ///
163    /// A counter of its own rather than a bump of `generation`, because the
164    /// two questions differ: clearing drops the *document* and leaves the
165    /// source installed. Folding it into `generation` made every live
166    /// [`RemoteSink`](crate::RemoteSink) permanently stale — a watch loop whose store had not
167    /// changed and whose stream was still delivering would have every later
168    /// push refused for belonging to a source that had been "replaced". The
169    /// in-flight fetch a `clear` must still discard is fenced on this.
170    cleared: u64,
171    /// How the fetches have gone. Under the same lock as everything else
172    /// here, so a scrape cannot read a count that belongs to one source
173    /// beside a document that belongs to another.
174    status: RemoteStatus,
175}
176
177/// The state a fetch started under, in the two numbers that can invalidate
178/// its result: the source it was fetching from, and the document epoch it
179/// was fetching into.
180///
181/// Captured before the round trip and compared after it. Both halves are
182/// needed and neither is enough: a replaced source must discard the result,
183/// and so must a `clear` — but only the first ends a watch, which is why
184/// they are counted apart.
185#[derive(Clone, Copy, PartialEq, Eq)]
186struct Fence {
187    generation: u64,
188    cleared: u64,
189}
190
191impl Fence {
192    fn of(state: &State) -> Self {
193        Self {
194            generation: state.generation,
195            cleared: state.cleared,
196        }
197    }
198}
199
200/// `Arc` rather than `Box`: an async fetch borrows the source across an await
201/// point, and cloning the handle out of the lock first is what keeps a `std`
202/// mutex from being held across one.
203#[derive(Clone)]
204enum Kind {
205    Blocking(Arc<dyn RemoteSource>),
206    #[cfg(feature = "async")]
207    Asynchronous(Arc<dyn AsyncRemoteSource>),
208}
209
210impl Remote {
211    /// An empty slot: no source, no document.
212    #[must_use]
213    #[cfg(not(loom))]
214    pub const fn new() -> Self {
215        Self {
216            state: Mutex::new(State {
217                source: None,
218                fetched: None,
219                generation: 0,
220                cleared: 0,
221                status: RemoteStatus::empty(),
222            }),
223        }
224    }
225
226    /// The same, minus `const`: loom's constructors are not.
227    #[must_use]
228    #[cfg(loom)]
229    pub fn new() -> Self {
230        Self {
231            state: Mutex::new(State {
232                source: None,
233                fetched: None,
234                generation: 0,
235                cleared: 0,
236                status: RemoteStatus::empty(),
237            }),
238        }
239    }
240
241    /// Installs a blocking source, replacing any previous one.
242    ///
243    /// The document already fetched, if any, is dropped with it — a new source
244    /// answering with an old store's values would be a puzzle nobody needs.
245    /// A fetch from the previous source that is still in flight is discarded
246    /// when it lands, for the same reason.
247    ///
248    /// The recorded [`status`](Self::status) is dropped with the document:
249    /// `remote_up` for the *previous* store says nothing about this one, and
250    /// a stale `1` describing a store nobody is talking to any more is worse
251    /// than no sample at all.
252    pub fn set(&self, source: impl RemoteSource) {
253        let mut state = self.state();
254        state.source = Some(Kind::Blocking(Arc::new(source)));
255        state.fetched = None;
256        state.status = RemoteStatus::empty();
257        state.generation = state.generation.wrapping_add(1);
258    }
259
260    /// Installs an async source, replacing any previous one.
261    #[cfg(feature = "async")]
262    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
263    pub fn set_async(&self, source: impl AsyncRemoteSource) {
264        let mut state = self.state();
265        state.source = Some(Kind::Asynchronous(Arc::new(source)));
266        state.fetched = None;
267        state.status = RemoteStatus::empty();
268        state.generation = state.generation.wrapping_add(1);
269    }
270
271    /// Fetches, and keeps what came back.
272    ///
273    /// The network round trip happens with no lock held: a slow store cannot
274    /// make `load()` — which reads this state for provenance — wait for it.
275    /// If the source is replaced while the fetch is in flight, the result is
276    /// discarded and `Ok` is returned: the fetch *did* succeed, and the new
277    /// source's own refresh is the one that matters now.
278    ///
279    /// # Errors
280    ///
281    /// If no source is installed, if the installed one is async — use
282    /// [`refresh_async`](Self::refresh_async) — or if the fetch fails.
283    pub fn refresh(&self) -> Result<(), Error> {
284        let (source, fence) = {
285            let state = self.state();
286
287            match state.source.as_ref() {
288                Some(Kind::Blocking(source)) => (Arc::clone(source), Fence::of(&state)),
289
290                #[cfg(feature = "async")]
291                Some(Kind::Asynchronous(source)) => {
292                    return Err(Error::new(
293                        ErrorKind::Remote,
294                        format!(
295                            "`{}` is an async source; refresh it with `refresh_remote_async`",
296                            source.describe()
297                        ),
298                    ))
299                }
300
301                None => return Err(none_installed()),
302            }
303        };
304
305        // The span covers the round trip rather than following it, which is
306        // the only arrangement that gives a trace a duration to draw. It
307        // carries no name for the store: the one string a source has is its
308        // description, and a store URL routinely embeds `user:password@host`.
309        #[cfg(feature = "tracing")]
310        let span = crate::telemetry::fetching();
311
312        let started = Instant::now();
313
314        match source.fetch() {
315            Ok(fetched) => {
316                let elapsed = started.elapsed();
317
318                self.commit(fetched, fence);
319                self.record_fetch(Some(elapsed), fence.generation);
320
321                #[cfg(feature = "tracing")]
322                crate::telemetry::fetched(&span, elapsed);
323
324                Ok(())
325            }
326            Err(error) => {
327                self.record_fetch_failure(&error, fence.generation);
328
329                #[cfg(feature = "tracing")]
330                crate::telemetry::fetch_failed(&span, &error);
331
332                Err(error)
333            }
334        }
335    }
336
337    /// Fetches from an async source, and keeps what came back.
338    ///
339    /// A *blocking* source is not refused — swapping one implementation for
340    /// the other must not be a breaking change for the caller — but it is not
341    /// run on the executor either: it goes through
342    /// [`off_thread`](crate::off_thread), so an async caller's worker thread
343    /// never sits inside a blocking network call.
344    ///
345    /// The same replaced-mid-fetch rule as [`refresh`](Self::refresh)
346    /// applies, and matters more here: the unlocked window spans an await.
347    ///
348    /// # Errors
349    ///
350    /// If no source is installed, or the fetch fails.
351    #[cfg(feature = "async")]
352    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
353    pub async fn refresh_async(&self) -> Result<(), Error> {
354        // Cloned out of the lock before anything is awaited: holding a `std`
355        // mutex across an await point is how an executor deadlocks itself.
356        let (source, fence) = {
357            let state = self.state();
358
359            match state.source.as_ref() {
360                Some(source) => (source.clone(), Fence::of(&state)),
361                None => return Err(none_installed()),
362            }
363        };
364
365        // Not entered: this span is held across an await, and an
366        // `EnteredSpan` is `!Send`. `Span::in_scope` cannot wrap an await
367        // either, so what a subscriber gets here is the span's own timing
368        // and its fields rather than an ambient context — which is what a
369        // fetch has to report anyway, since nothing else runs inside it.
370        #[cfg(feature = "tracing")]
371        let span = crate::telemetry::fetching_async();
372
373        let started = Instant::now();
374
375        let outcome = match source {
376            Kind::Blocking(source) => crate::asynchronous::off_thread(move || source.fetch()).await,
377            Kind::Asynchronous(source) => source.fetch().await,
378        };
379
380        match outcome {
381            Ok(fetched) => {
382                let elapsed = started.elapsed();
383
384                self.commit(fetched, fence);
385                self.record_fetch(Some(elapsed), fence.generation);
386
387                #[cfg(feature = "tracing")]
388                crate::telemetry::fetched(&span, elapsed);
389
390                Ok(())
391            }
392            Err(error) => {
393                self.record_fetch_failure(&error, fence.generation);
394
395                #[cfg(feature = "tracing")]
396                crate::telemetry::fetch_failed(&span, &error);
397
398                Err(error)
399            }
400        }
401    }
402
403    /// The generation a sink created now would carry; see [`RemoteSink`](crate::RemoteSink).
404    pub(crate) fn generation(&self) -> u64 {
405        self.state().generation
406    }
407
408    /// Installs `document` if the source it came from is still the one
409    /// installed — the push-side twin of the fetch fence.
410    ///
411    /// # Errors
412    ///
413    /// When the source has been replaced since `generation` was captured:
414    /// the document belongs to a store nobody asked about any more, and
415    /// installing it would hand a stale watcher the last word.
416    pub(crate) fn install_if(&self, generation: u64, document: Fetched) -> Result<(), Error> {
417        let mut state = self.state();
418
419        if state.generation != generation {
420            return Err(Error::new(
421                crate::ErrorKind::Backend,
422                "the remote source this sink was created for has been \
423                 replaced; stop the old watch loop and take a fresh sink \
424                 from `remote_sink()`",
425            ));
426        }
427
428        state.fetched = Some(document);
429
430        // A push is a fetch somebody else performed: the store answered, and
431        // that is the whole question `RemoteStatus` reports on. Whether the
432        // document then *installs* is `ConfigStatus`'s business, and
433        // `RemoteSink::apply` records it there through the reload it runs.
434        state.status.fetches = state.status.fetches.saturating_add(1);
435        state.status.last_fetch = Some(Instant::now());
436        state.status.last_fetch_duration = None;
437        state.status.consecutive_failures = 0;
438
439        Ok(())
440    }
441
442    /// Not public API: the loom suite's door to the fence internals.
443    #[cfg(loom)]
444    #[doc(hidden)]
445    #[must_use]
446    pub fn generation_for_loom(&self) -> u64 {
447        self.generation()
448    }
449
450    /// Not public API: the loom suite's door to the fence internals.
451    ///
452    /// # Errors
453    ///
454    /// As `install_if`.
455    #[cfg(loom)]
456    #[doc(hidden)]
457    pub fn install_if_for_loom(&self, generation: u64, document: Fetched) -> Result<(), Error> {
458        self.install_if(generation, document)
459    }
460
461    /// The document last fetched, if any.
462    #[must_use]
463    pub fn document(&self) -> Option<Fetched> {
464        self.state().fetched.clone()
465    }
466
467    /// Whether a source is installed.
468    #[must_use]
469    pub fn is_configured(&self) -> bool {
470        self.state().source.is_some()
471    }
472
473    /// How the fetches from this source have gone.
474    ///
475    /// One lock and a clone, no I/O and no network: an exporter may call it
476    /// per scrape, which is the same contract
477    /// [`ConfigCell::status`](crate::ConfigCell::status) makes.
478    #[must_use]
479    pub fn status(&self) -> RemoteStatus {
480        self.state().status.clone()
481    }
482
483    /// Records a fetch that returned a document.
484    ///
485    /// Fenced on the source `generation` the fetch started under, and under
486    /// the one lock that reads it: [`set`](Self::set) empties the status
487    /// along with the document, so an old fetch landing afterwards would
488    /// otherwise report the *replacement* as fetched and healthy — a store
489    /// nothing has yet spoken to.
490    fn record_fetch(&self, elapsed: Option<Duration>, generation: u64) {
491        let mut state = self.state();
492
493        if state.generation != generation {
494            return;
495        }
496
497        state.status.fetches = state.status.fetches.saturating_add(1);
498        state.status.last_fetch = Some(Instant::now());
499        state.status.last_fetch_duration = elapsed;
500        state.status.consecutive_failures = 0;
501    }
502
503    /// Records a fetch that returned nothing.
504    ///
505    /// The document is untouched: a store that stopped answering leaves the
506    /// last one it did answer with in place, and the counter is what says
507    /// so. Only the failure's category and key path are kept — the same
508    /// [`FailureStatus`] a refused reload records, for the same reason.
509    ///
510    /// Fenced like [`record_fetch`](Self::record_fetch), and for the mirror
511    /// reason: an old fetch's failure must not report a store that has just
512    /// been installed as down.
513    fn record_fetch_failure(&self, error: &Error, generation: u64) {
514        let mut state = self.state();
515
516        if state.generation != generation {
517            return;
518        }
519
520        // Saturating rather than wrapping, as `ConfigCell` does: a counter
521        // that rolls over to zero reads as "healthy" at the worst moment.
522        state.status.consecutive_failures = state.status.consecutive_failures.saturating_add(1);
523        state.status.last_failure = Some(FailureStatus::of(error));
524    }
525
526    /// How the installed source names itself.
527    #[must_use]
528    pub fn describe(&self) -> Option<String> {
529        // The lock is held only for the clone: `describe()` on the source runs
530        // unlocked, so a source whose description does real work cannot stall
531        // readers.
532        let source = self.state().source.clone()?;
533
534        Some(match source {
535            Kind::Blocking(source) => source.describe(),
536            #[cfg(feature = "async")]
537            Kind::Asynchronous(source) => source.describe(),
538        })
539    }
540
541    /// Drops the document, so the next load sees no remote layer.
542    ///
543    /// A fetch that was already in flight is discarded when it lands, the
544    /// same way [`set`](Self::set) discards one: clearing is a state change
545    /// like any other, and a document a caller explicitly dropped must not
546    /// come back from a round trip that started before they dropped it.
547    ///
548    /// The *source* is left alone, and so is every [`RemoteSink`](crate::RemoteSink) taken from
549    /// it: a watch loop delivering from the same store keeps delivering, and
550    /// its next push installs normally. Dropping the document is not
551    /// replacing the store, and only replacing the store ends a watch.
552    pub fn clear(&self) {
553        let mut state = self.state();
554
555        state.fetched = None;
556        state.cleared = state.cleared.wrapping_add(1);
557    }
558
559    /// Stores a fetch result, unless the slot moved while it was in flight —
560    /// the source was replaced, and the result belongs to a store nobody
561    /// asked about any more, or the document was cleared and putting this
562    /// one back would undo that.
563    fn commit(&self, fetched: Fetched, fence: Fence) {
564        let mut state = self.state();
565
566        if Fence::of(&state) == fence {
567            state.fetched = Some(fetched);
568        }
569    }
570
571    fn state(&self) -> crate::sync::MutexGuard<'_, State> {
572        self.state
573            .lock()
574            .unwrap_or_else(std::sync::PoisonError::into_inner)
575    }
576}
577
578impl std::fmt::Debug for Remote {
579    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580        f.debug_struct("Remote")
581            .field("source", &self.describe())
582            .field("fetched", &self.document().is_some())
583            .finish()
584    }
585}
586
587fn none_installed() -> Error {
588    Error::new(
589        ErrorKind::Remote,
590        "no remote source is installed; call `set_remote` first",
591    )
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::source::Format;
598    use crate::sync::atomic::Ordering;
599
600    struct Fake(&'static str);
601
602    impl RemoteSource for Fake {
603        fn fetch(&self) -> Result<Fetched, Error> {
604            Ok(Fetched::new(self.0, Format::Json))
605        }
606
607        fn describe(&self) -> String {
608            "a fake store".to_owned()
609        }
610    }
611
612    struct Broken;
613
614    impl RemoteSource for Broken {
615        fn fetch(&self) -> Result<Fetched, Error> {
616            Err(Error::remote("the store is unreachable"))
617        }
618
619        fn describe(&self) -> String {
620            "a broken store".to_owned()
621        }
622    }
623
624    #[test]
625    fn nothing_is_fetched_until_it_is_asked_for() {
626        let remote = Remote::new();
627        remote.set(Fake(r#"{"db": {"host": "a"}}"#));
628
629        assert!(remote.is_configured());
630        assert!(
631            remote.document().is_none(),
632            "installing a source must not reach the network"
633        );
634
635        remote.refresh().unwrap();
636        assert!(remote.document().is_some());
637    }
638
639    /// Succeeds once, then fails — a store that answered and went away.
640    struct Flaky(std::sync::atomic::AtomicBool);
641
642    impl RemoteSource for Flaky {
643        fn fetch(&self) -> Result<Fetched, Error> {
644            if self.0.swap(true, Ordering::SeqCst) {
645                return Err(Error::remote("the store went away"));
646            }
647
648            Fake(r#"{"db": {"host": "a"}}"#).fetch()
649        }
650
651        fn describe(&self) -> String {
652            "a store that answers once".to_owned()
653        }
654    }
655
656    #[test]
657    fn a_failed_fetch_leaves_the_previous_document_alone() {
658        let remote = Remote::new();
659        remote.set(Flaky(std::sync::atomic::AtomicBool::new(false)));
660        remote.refresh().unwrap();
661
662        let before = remote.document();
663        assert!(before.is_some(), "the first fetch succeeds");
664
665        // The second fetch *fails*, and the failure must surface — while the
666        // document from the fetch that worked stays where it was.
667        let error = remote.refresh().unwrap_err();
668
669        assert!(error.to_string().contains("went away"), "{error}");
670        assert_eq!(remote.document(), before);
671    }
672
673    /// Blocks inside `fetch` on a pair of barriers, so a test can hold a
674    /// fetch mid-flight while it does something else to the `Remote`.
675    struct Parked {
676        started: std::sync::Arc<std::sync::Barrier>,
677        release: std::sync::Arc<std::sync::Barrier>,
678    }
679
680    impl RemoteSource for Parked {
681        fn fetch(&self) -> Result<Fetched, Error> {
682            self.started.wait();
683            self.release.wait();
684
685            Fake(r#"{"db": {"host": "stale"}}"#).fetch()
686        }
687
688        fn describe(&self) -> String {
689            "a parked store".to_owned()
690        }
691    }
692
693    /// The same, for the failing half of the fence: parked mid-fetch, and
694    /// what it finally returns is an error.
695    struct ParkedThenBroken {
696        started: std::sync::Arc<std::sync::Barrier>,
697        release: std::sync::Arc<std::sync::Barrier>,
698    }
699
700    impl RemoteSource for ParkedThenBroken {
701        fn fetch(&self) -> Result<Fetched, Error> {
702            self.started.wait();
703            self.release.wait();
704
705            Broken.fetch()
706        }
707
708        fn describe(&self) -> String {
709            "a parked store that then breaks".to_owned()
710        }
711    }
712
713    /// The race the generation fence exists for: a fetch from the *old*
714    /// source lands after `set` installed a new one. Its result must be
715    /// discarded — new source, old store's document is the state this
716    /// module's docs promise cannot happen.
717    #[test]
718    fn a_fetch_from_a_replaced_source_is_discarded() {
719        let started = std::sync::Arc::new(std::sync::Barrier::new(2));
720        let release = std::sync::Arc::new(std::sync::Barrier::new(2));
721
722        let remote = std::sync::Arc::new(Remote::new());
723        remote.set(Parked {
724            started: std::sync::Arc::clone(&started),
725            release: std::sync::Arc::clone(&release),
726        });
727
728        let refresher = {
729            let remote = std::sync::Arc::clone(&remote);
730            std::thread::spawn(move || remote.refresh())
731        };
732
733        // The fetch is provably in flight...
734        started.wait();
735
736        // ...when the source is replaced.
737        remote.set(Fake(r#"{"db": {"host": "fresh"}}"#));
738
739        release.wait();
740        refresher
741            .join()
742            .expect("the refresher must not panic")
743            .expect("the fetch itself succeeded");
744
745        assert_eq!(
746            remote.document(),
747            None,
748            "the old source's document landed after the replacement and must \
749             not be paired with the new source"
750        );
751
752        // And the new source works normally.
753        remote.refresh().unwrap();
754        assert!(remote.document().unwrap().text.contains("fresh"));
755    }
756
757    /// The same fence, from the other side: `clear()` is a state change too,
758    /// so a fetch that was in flight when a caller cleared the slot must not
759    /// put the document back. The barriers force the interleaving — the
760    /// fetch is provably parked when `clear` runs — so this is a proof
761    /// rather than a race the scheduler usually loses.
762    #[test]
763    fn a_fetch_in_flight_when_the_slot_is_cleared_is_discarded() {
764        let started = std::sync::Arc::new(std::sync::Barrier::new(2));
765        let release = std::sync::Arc::new(std::sync::Barrier::new(2));
766
767        let remote = std::sync::Arc::new(Remote::new());
768        remote.set(Parked {
769            started: std::sync::Arc::clone(&started),
770            release: std::sync::Arc::clone(&release),
771        });
772
773        let refresher = {
774            let remote = std::sync::Arc::clone(&remote);
775            std::thread::spawn(move || remote.refresh())
776        };
777
778        started.wait();
779
780        remote.clear();
781
782        release.wait();
783        refresher
784            .join()
785            .expect("the refresher must not panic")
786            .expect("the fetch itself succeeded");
787
788        assert_eq!(
789            remote.document(),
790            None,
791            "a document the caller cleared must not come back from a fetch \
792             that started before they cleared it"
793        );
794    }
795
796    /// Clearing the document must not end a watch. The source is untouched
797    /// by `clear()`, so a loop that took its sink before the call is still
798    /// serving the store it was created for, and its next delivery installs
799    /// like any other. The first shape of this fence counted both events on
800    /// one number and made every live sink permanently stale.
801    #[test]
802    fn clearing_the_document_leaves_a_watchs_sink_alive() {
803        let remote = Remote::new();
804        remote.set(Fake(r#"{"db": {"host": "a"}}"#));
805
806        // What `remote_sink()` captures, once, where a loop starts.
807        let generation = remote.generation();
808
809        remote.clear();
810
811        remote
812            .install_if(generation, Fetched::new("{}", crate::Format::Json))
813            .expect("clearing the document does not replace the source");
814        assert!(remote.document().is_some());
815    }
816
817    /// The status fence, from the side `set` opens: an old fetch that
818    /// succeeds after its source was replaced must not report the
819    /// replacement — which nothing has yet spoken to — as fetched and
820    /// healthy. `set` empties the status precisely so that it says nothing
821    /// about a store that is no longer installed.
822    #[test]
823    fn a_late_fetch_does_not_report_the_replacement_as_healthy() {
824        let started = std::sync::Arc::new(std::sync::Barrier::new(2));
825        let release = std::sync::Arc::new(std::sync::Barrier::new(2));
826
827        let remote = std::sync::Arc::new(Remote::new());
828        remote.set(Parked {
829            started: std::sync::Arc::clone(&started),
830            release: std::sync::Arc::clone(&release),
831        });
832
833        let refresher = {
834            let remote = std::sync::Arc::clone(&remote);
835            std::thread::spawn(move || remote.refresh())
836        };
837
838        started.wait();
839        remote.set(Fake(r#"{"db": {"host": "fresh"}}"#));
840        release.wait();
841
842        let _ = refresher.join().expect("the refresher must not panic");
843
844        let status = remote.status();
845        assert_eq!(
846            status.fetches, 0,
847            "the replacement has been fetched from nobody"
848        );
849        assert_eq!(status.last_fetch, None);
850        assert_eq!(status.reachable(), None);
851    }
852
853    /// The same fence for a failure. A store that was replaced while its
854    /// fetch was erroring must not leave the new one looking down.
855    #[test]
856    fn a_late_failure_does_not_report_the_replacement_as_down() {
857        let started = std::sync::Arc::new(std::sync::Barrier::new(2));
858        let release = std::sync::Arc::new(std::sync::Barrier::new(2));
859
860        let remote = std::sync::Arc::new(Remote::new());
861        remote.set(ParkedThenBroken {
862            started: std::sync::Arc::clone(&started),
863            release: std::sync::Arc::clone(&release),
864        });
865
866        let refresher = {
867            let remote = std::sync::Arc::clone(&remote);
868            std::thread::spawn(move || remote.refresh())
869        };
870
871        started.wait();
872        remote.set(Fake(r#"{"db": {"host": "fresh"}}"#));
873        release.wait();
874
875        let _ = refresher.join().expect("the refresher must not panic");
876
877        let status = remote.status();
878        assert_eq!(status.consecutive_failures, 0);
879        assert_eq!(
880            status.reachable(),
881            None,
882            "nothing has yet asked the replacement anything"
883        );
884    }
885
886    /// Readers must not wait for a slow store: `document()` and `describe()`
887    /// are on the `load()` path, and `load()` promises to touch no network.
888    #[test]
889    fn readers_are_not_blocked_by_a_fetch_in_flight() {
890        let started = std::sync::Arc::new(std::sync::Barrier::new(2));
891        let release = std::sync::Arc::new(std::sync::Barrier::new(2));
892
893        let remote = std::sync::Arc::new(Remote::new());
894        remote.set(Parked {
895            started: std::sync::Arc::clone(&started),
896            release: std::sync::Arc::clone(&release),
897        });
898
899        let refresher = {
900            let remote = std::sync::Arc::clone(&remote);
901            std::thread::spawn(move || remote.refresh())
902        };
903
904        started.wait();
905
906        // With the fetch parked, a reader thread must finish promptly. The
907        // old two-lock design held the source lock across the fetch, so
908        // `describe()` — and with it every `load()` — waited out the store's
909        // full timeout.
910        let (sender, receiver) = std::sync::mpsc::channel();
911        {
912            let remote = std::sync::Arc::clone(&remote);
913            std::thread::spawn(move || {
914                let described = remote.describe();
915                let document = remote.document();
916                let _ = sender.send((described, document));
917            });
918        }
919
920        let (described, document) = receiver
921            .recv_timeout(Duration::from_secs(2))
922            .expect("readers must not wait for the network");
923
924        assert_eq!(described.as_deref(), Some("a parked store"));
925        assert_eq!(document, None);
926
927        release.wait();
928        let _ = refresher.join();
929    }
930
931    /// `set` clears the document atomically with the source swap: no
932    /// interleaving may observe the new source paired with any document.
933    #[test]
934    fn replacing_the_source_and_dropping_the_document_is_one_step() {
935        let remote = Remote::new();
936        remote.set(Fake(r#"{"db": {"host": "a"}}"#));
937        remote.refresh().unwrap();
938        let generation = remote.generation();
939        remote
940            .install_if(generation, Fetched::new("{}", crate::Format::Json))
941            .expect("the source has not moved");
942
943        remote.set(Fake(r#"{"db": {"host": "b"}}"#));
944
945        assert_eq!(remote.document(), None);
946
947        // And the push-side fence itself: the pre-swap generation is now
948        // stale, so a late delivery bounces instead of landing.
949        remote
950            .install_if(generation, Fetched::new("{}", crate::Format::Json))
951            .expect_err("a replaced source's generation must be refused");
952        assert_eq!(remote.document(), None);
953    }
954
955    #[test]
956    fn a_broken_store_reports_rather_than_pretending() {
957        let remote = Remote::new();
958        remote.set(Broken);
959
960        let error = remote.refresh().unwrap_err();
961
962        assert_eq!(error.kind(), ErrorKind::Remote);
963        assert!(error.to_string().contains("unreachable"), "{error}");
964    }
965
966    #[test]
967    fn refreshing_with_no_source_says_so() {
968        let error = Remote::new().refresh().unwrap_err();
969
970        assert!(error.to_string().contains("set_remote"), "{error}");
971    }
972
973    #[test]
974    fn replacing_the_source_drops_the_old_document() {
975        let remote = Remote::new();
976        remote.set(Fake(r#"{"db": {"host": "a"}}"#));
977        remote.refresh().unwrap();
978
979        remote.set(Fake(r#"{"db": {"host": "b"}}"#));
980
981        assert!(
982            remote.document().is_none(),
983            "a new source answering with the old store's values would be a puzzle"
984        );
985    }
986}