Skip to main content

dynamic_config_store_core/
attempts.rs

1//! Reporting a watch loop's *failed* attempts to reach its store.
2//!
3//! A watch loop is the half of a store `dynamic-config` cannot see.
4//! [`RemoteSink::apply`] records a delivery, so a working watch keeps
5//! [`RemoteStatus`] current — but a loop whose stream broke, whose blocking
6//! query is erroring or whose credential was refused delivers nothing, and
7//! without this says nothing: `dynamic_config_remote_up` would report the
8//! last *delivery* rather than the last *attempt*, and a store that stopped
9//! answering an hour ago would look healthy until something called
10//! `refresh_remote`.
11//!
12//! # Why a type rather than an `Option<RemoteSink>` in seven crates
13//!
14//! Because the seven watch loops do not agree on anything else. Their
15//! signatures already differ — blocking against async, a `Watching` token or
16//! a cancelled future — so a second `watch_reporting_to` method in each crate
17//! would be seven new methods with seven doc comments saying the same thing.
18//! What they *do* agree on is that a failure site is one line, and that the
19//! line must be impossible to get wrong: [`Attempts::failed`] is infallible,
20//! is a no-op when nobody asked for reporting, and cannot be given anything
21//! but an error.
22//!
23//! # Which attempts report, in all seven crates
24//!
25//! Three rules, and each store crate's documentation carries the table its
26//! own loop makes of them:
27//!
28//! 1. **A failure the loop survives by retrying reports.** The stream is
29//!    down, the last delivery is old, and nothing else would say so.
30//! 2. **A recovery that worked stays silent.** Only a delivery or a fetch
31//!    clears the streak, so reporting a token that turned over on a healthy
32//!    cluster would drive `remote_up` to zero and leave it there.
33//! 3. **A refusal that never asked the store reports nowhere.** No format, a
34//!    key shape that cannot be watched, TLS material that will not build a
35//!    client: [`RemoteStatus::reachable`] is *whether the store answered the
36//!    last time it was asked*, and none of those ask. They are returned to
37//!    the caller, who is the one holding the mistake.
38//!
39//! Rule 3 is the one 0.6.1's audit settled. Two crates reported such a
40//! refusal and two did not, each with a test asserting its half; what decided
41//! it is that a status carries a kind and a path and **no message**, so a
42//! `remote_up = 0` for a source typo is an alert about the store that nothing
43//! downstream can correct.
44//!
45//! # What it deliberately does not do
46//!
47//! It does not touch the document, the fetch count or the clock. A failed
48//! attempt moves the failure streak and the last failure and nothing else,
49//! so `dynamic_config_remote_last_fetch_seconds` keeps *ageing* while
50//! `dynamic_config_remote_up` goes to zero — which is the pair an alert
51//! wants. A failure that reset the staleness clock would hide the half of
52//! the story that says how long the served document has been stale.
53//!
54//! [`RemoteSink::apply`]: dynamic_config::RemoteSink::apply
55//! [`RemoteStatus`]: dynamic_config::RemoteStatus
56//! [`RemoteStatus::reachable`]: dynamic_config::RemoteStatus::reachable
57
58use dynamic_config::{Error, RemoteSink};
59
60/// Where a watch loop reports an attempt that came back with nothing.
61///
62/// Default is *nobody asked*, which is what a source built without
63/// `reporting_to` carries and what makes [`failed`](Self::failed) free.
64#[derive(Clone, Copy, Debug, Default)]
65pub struct Attempts(Option<RemoteSink>);
66
67impl Attempts {
68    /// Reports to `sink`.
69    ///
70    /// A sink is `Copy` and captures its source's generation when it is
71    /// taken, which is what fences a stale loop's reports away from a
72    /// replacement source. Take it where the watch is wired, once.
73    #[must_use]
74    pub fn to(sink: RemoteSink) -> Self {
75        Self(Some(sink))
76    }
77
78    /// An attempt to reach the store came back with nothing.
79    ///
80    /// Infallible and silent by design: a loop must never have to handle a
81    /// failure to report a failure, and a loop nobody asked to report is not
82    /// paying for a branch it did not want.
83    pub fn failed(&self, error: &Error) {
84        if let Some(sink) = &self.0 {
85            sink.failed(error);
86        }
87    }
88
89    /// Whether anything is listening.
90    ///
91    /// For a store that would otherwise build a description or clone an
92    /// error only to hand it to nobody.
93    #[must_use]
94    pub fn is_reporting(&self) -> bool {
95        self.0.is_some()
96    }
97}
98
99impl From<RemoteSink> for Attempts {
100    fn from(sink: RemoteSink) -> Self {
101        Self::to(sink)
102    }
103}
104
105impl From<Option<RemoteSink>> for Attempts {
106    fn from(sink: Option<RemoteSink>) -> Self {
107        Self(sink)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// The shape every store's default carries: a source nobody wired a sink
116    /// into reports nowhere, and calling it is not a mistake.
117    #[test]
118    fn reporting_to_nobody_is_a_no_op_rather_than_a_refusal() {
119        let attempts = Attempts::default();
120
121        assert!(!attempts.is_reporting());
122
123        // Infallible, and there is nothing to unwrap or ignore.
124        attempts.failed(&Error::remote("the subscription dropped"));
125    }
126}