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}