Skip to main content

kunobi_daemon/
selection.rs

1//! Wait for a replacement that another process drives.
2//!
3//! [`crate::replacement`] is for the process holding the upgrade lock. Its
4//! observers need a different answer: whether the candidate it launched, or is
5//! waiting for, is serving now. They have two kinds of evidence:
6//!
7//! - the published selection record, which says the candidate was chosen but
8//!   is not a readiness proof;
9//! - a fresh probe of the service, which is.
10//!
11//! Pairing them carelessly is a race. If the candidate commits while the
12//! observer is still probing the incumbent, the observer holds a stale "not the
13//! candidate" probe and a new "candidate committed" record. It then reports an
14//! unproven commit although the candidate is already serving.
15//!
16//! This module fixes the order: every round reads the record first, then
17//! probes. [`Selection::Current`] comes only from a fresh accepted probe. The
18//! record only decides what to report when no proof arrives:
19//! [`Selection::Committed`] (authoritative: never roll back underneath it) or
20//! [`Selection::NotCommitted`] (the candidate may be discarded). This is the
21//! boundary [`crate::replacement::Failure::committed`] draws for the driver.
22//!
23//! Polling is the default wait between rounds. A source that can be woken
24//! overrides [`Evidence::wait`], or `AsyncEvidence::wait` with the `async`
25//! feature, without changing the decision rules.
26//! [`crate::readiness::channel::Signaled`] is one: it wakes on a launched
27//! daemon's readiness channel.
28use std::time::{Duration, Instant};
29
30use crate::readiness::POLL_INTERVAL;
31
32/// What an observer may report about a candidate it does not drive.
33#[derive(Clone, Debug, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum Selection<P> {
36    /// A fresh probe proved the candidate is serving.
37    Current(P),
38    /// The selection names the candidate, but no fresh probe proved it within
39    /// [`Budget::proof`]. The candidate is authoritative: do not roll back,
40    /// restart or select anything around it.
41    Committed,
42    /// [`Budget::commit`] passed without the selection naming the candidate.
43    NotCommitted,
44}
45
46/// A startup bound and a separate proof bound beginning at the observed commit.
47#[derive(Clone, Copy, Debug)]
48pub struct Budget {
49    /// Longest wait for the selection to name the candidate at all. This covers
50    /// the candidate's startup, which is the slow part.
51    pub commit: Duration,
52    /// Once the selection names the candidate, how long to keep probing for a
53    /// fresh proof before reporting [`Selection::Committed`]. It starts when a
54    /// round reads the commit, before that round's probe, and it also bounds
55    /// that probe, so make it cover at least one probe round trip. A driver
56    /// commits only after verifying the candidate, so a proof normally arrives
57    /// on the first probe after the commit. Zero reports `Committed` as soon as
58    /// the commit is read.
59    pub proof: Duration,
60}
61
62/// Evidence about a candidate, gathered without an async runtime.
63pub trait Evidence {
64    /// A verified response from the candidate.
65    type Proof;
66    /// Application, transport or record failure. It ends the wait.
67    type Error;
68
69    /// Whether the published selection names the candidate. Read before each
70    /// probe; a record is never treated as a proof.
71    fn committed(&mut self) -> Result<bool, Self::Error>;
72
73    /// A fresh proof from the candidate, or `None`. Bound blocking I/O by
74    /// `deadline`; a proof returned after it is rejected.
75    fn probe(&mut self, deadline: Instant) -> Result<Option<Self::Proof>, Self::Error>;
76
77    /// Block until the next round is worth making, but not past `until`.
78    ///
79    /// The default sleeps for [`POLL_INTERVAL`]. A source that can be woken
80    /// returns as soon as it has news.
81    fn wait(&mut self, until: Instant) {
82        #[expect(
83            clippy::disallowed_methods,
84            reason = "Default evidence has no wake source; Signaled overrides this wait for channel events."
85        )]
86        std::thread::sleep(POLL_INTERVAL.min(until.saturating_duration_since(Instant::now())));
87    }
88}
89
90/// Wait for a candidate that another process selects.
91///
92/// Returns the first fresh proof, or, when none arrives in time, whether the
93/// candidate was committed. Errors from either source end the wait.
94pub fn await_selection<E: Evidence>(
95    budget: Budget,
96    evidence: &mut E,
97) -> Result<Selection<E::Proof>, E::Error> {
98    let start = Instant::now();
99    let mut watch = Watch::new(budget);
100    loop {
101        let committed = evidence.committed()?;
102        watch.record(start.elapsed(), committed);
103        let until = start + watch.until();
104        let proof = evidence.probe(until)?.filter(|_| Instant::now() < until);
105        let proven = proof.is_some();
106        match watch.decide(start.elapsed(), proven) {
107            Decision::Current => return Ok(Selection::Current(proof.expect("proven above"))),
108            Decision::Committed => return Ok(Selection::Committed),
109            Decision::NotCommitted => return Ok(Selection::NotCommitted),
110            Decision::Wait => evidence.wait(start + watch.until()),
111        }
112    }
113}
114
115/// Evidence about a candidate, gathered on a Tokio runtime.
116#[cfg(feature = "async")]
117pub trait AsyncEvidence {
118    /// A verified response from the candidate.
119    type Proof;
120    /// Application, transport or record failure. It ends the wait.
121    type Error;
122
123    /// Whether the published selection names the candidate. Read before each
124    /// probe; a record is never treated as a proof.
125    fn committed(&mut self) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
126
127    /// A fresh proof from the candidate, or `None`. The caller also enforces the
128    /// round's deadline with a timeout, so pending I/O must tolerate cancellation.
129    fn probe(
130        &mut self,
131    ) -> impl std::future::Future<Output = Result<Option<Self::Proof>, Self::Error>> + Send;
132
133    /// Wait until the next round is worth making, but not past `until`.
134    ///
135    /// The default sleeps for [`POLL_INTERVAL`]. A source that can be woken
136    /// completes as soon as it has news.
137    #[expect(
138        clippy::disallowed_methods,
139        reason = "Default evidence has no wake source; Signaled overrides this wait for channel events."
140    )]
141    fn wait(
142        &mut self,
143        until: tokio::time::Instant,
144    ) -> impl std::future::Future<Output = ()> + Send {
145        tokio::time::sleep_until((tokio::time::Instant::now() + POLL_INTERVAL).min(until))
146    }
147}
148
149/// Async counterpart of [`await_selection`]. Each probe is also bounded by the
150/// round's deadline; a probe that times out counts as no proof.
151#[cfg(feature = "async")]
152pub async fn await_selection_async<E: AsyncEvidence>(
153    budget: Budget,
154    evidence: &mut E,
155) -> Result<Selection<E::Proof>, E::Error> {
156    let start = tokio::time::Instant::now();
157    let mut watch = Watch::new(budget);
158    loop {
159        let committed = evidence.committed().await?;
160        watch.record(start.elapsed(), committed);
161        let until = start + watch.until();
162        let proof = match tokio::time::timeout_at(until, evidence.probe()).await {
163            Ok(proof) => proof?.filter(|_| tokio::time::Instant::now() < until),
164            Err(_) => None,
165        };
166        let proven = proof.is_some();
167        match watch.decide(start.elapsed(), proven) {
168            Decision::Current => return Ok(Selection::Current(proof.expect("proven above"))),
169            Decision::Committed => return Ok(Selection::Committed),
170            Decision::NotCommitted => return Ok(Selection::NotCommitted),
171            Decision::Wait => evidence.wait(start + watch.until()).await,
172        }
173    }
174}
175
176/// The decision after one round, separated from I/O so it can be model-checked.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178enum Decision {
179    Current,
180    Committed,
181    NotCommitted,
182    Wait,
183}
184
185struct Watch {
186    budget: Budget,
187    /// When a round first read a committed selection. Selection is never
188    /// withdrawn, so a later round that reads an older record keeps this.
189    committed_at: Option<Duration>,
190}
191
192impl Watch {
193    fn new(budget: Budget) -> Self {
194        Self {
195            budget,
196            committed_at: None,
197        }
198    }
199
200    /// Time from the start by which this wait must end.
201    fn until(&self) -> Duration {
202        match self.committed_at {
203            Some(at) => at.saturating_add(self.budget.proof).min(self.budget.commit),
204            None => self.budget.commit,
205        }
206    }
207
208    /// Note the selection as read at `elapsed`, before this round's probe. The
209    /// proof budget runs from here, so it also bounds that probe.
210    fn record(&mut self, elapsed: Duration, committed: bool) {
211        if committed && self.committed_at.is_none() {
212            self.committed_at = Some(elapsed);
213        }
214    }
215
216    /// Decide after the round's probe finished at `elapsed`.
217    fn decide(&self, elapsed: Duration, proven: bool) -> Decision {
218        if proven {
219            return Decision::Current;
220        }
221        let expired = elapsed >= self.until();
222        match (self.committed_at, expired) {
223            (_, false) => Decision::Wait,
224            (Some(_), true) => Decision::Committed,
225            (None, true) => Decision::NotCommitted,
226        }
227    }
228}
229
230/// Properties of the decision rules for every sequence of evidence, not only
231/// the ones a test author thought of. Each restates a promise from the module
232/// documentation.
233#[cfg(kani)]
234mod proofs {
235    use super::*;
236
237    /// Rounds explored per harness. Durations are small so every ordering of
238    /// commit, grace and deadline is reachable within them.
239    const ROUNDS: usize = 6;
240
241    fn small() -> Duration {
242        Duration::from_millis(u64::from(kani::any::<u8>() % 8))
243    }
244
245    #[kani::proof]
246    #[kani::unwind(7)]
247    fn an_observer_never_claims_what_its_evidence_does_not_show() {
248        let budget = Budget {
249            commit: small(),
250            proof: small(),
251        };
252        let mut watch = Watch::new(budget);
253        let mut elapsed = Duration::ZERO;
254        let mut seen_commit = false;
255
256        for _ in 0..ROUNDS {
257            // Time never runs backwards: the record is read, then the probe
258            // runs for some time, then the next round starts.
259            elapsed += small();
260            let committed: bool = kani::any();
261            watch.record(elapsed, committed);
262            seen_commit |= committed;
263            // A committed read bounds this round's probe by the proof budget.
264            if committed {
265                assert!(watch.until() <= elapsed + budget.proof);
266            }
267            elapsed += small();
268            let proven: bool = kani::any();
269            let decision = watch.decide(elapsed, proven);
270
271            // Only a fresh proof in this very round reports the candidate current.
272            assert_eq!(decision == Decision::Current, proven);
273            // Committed needs a record read before an unproven probe.
274            if decision == Decision::Committed {
275                assert!(seen_commit && !proven);
276            }
277            // A commit once read is never reported as NotCommitted.
278            if decision == Decision::NotCommitted {
279                assert!(!seen_commit && elapsed >= budget.commit);
280            }
281            // The wait always ends by the commit budget, and by the proof
282            // budget once a commit has been read.
283            if elapsed >= budget.commit {
284                assert!(decision != Decision::Wait);
285            }
286            if let Some(at) = watch.committed_at {
287                if elapsed >= at + budget.proof {
288                    assert!(decision != Decision::Wait);
289                }
290            }
291            if decision != Decision::Wait {
292                return;
293            }
294        }
295    }
296}
297
298/// The promises of the Kani harness above, which explores six rounds of
299/// durations under 8 ms, over long budgets and many rounds.
300#[cfg(test)]
301mod properties {
302    use super::*;
303    use proptest::prelude::*;
304
305    fn budget() -> impl Strategy<Value = Duration> {
306        prop_oneof![
307            Just(Duration::ZERO),
308            (0u64..10_000_000).prop_map(Duration::from_micros)
309        ]
310    }
311
312    /// Mostly short steps, so a wait often runs for many rounds.
313    fn step() -> impl Strategy<Value = Duration> {
314        prop_oneof![
315            4 => (0u64..200_000).prop_map(Duration::from_micros),
316            1 => (0u64..5_000_000).prop_map(Duration::from_micros),
317        ]
318    }
319
320    proptest! {
321        #![proptest_config(ProptestConfig {
322            cases: 512,
323            failure_persistence: None,
324            ..ProptestConfig::default()
325        })]
326
327        #[test]
328        fn an_observer_never_claims_what_its_evidence_does_not_show(
329            commit in budget(),
330            proof in budget(),
331            rounds in proptest::collection::vec(
332                (step(), any::<bool>(), step(), any::<bool>()),
333                1..128,
334            ),
335        ) {
336            let budget = Budget { commit, proof };
337            let mut watch = Watch::new(budget);
338            let mut elapsed = Duration::ZERO;
339            let mut seen_commit = false;
340
341            for (before_read, committed, probing, proven) in rounds {
342                elapsed += before_read;
343                watch.record(elapsed, committed);
344                seen_commit |= committed;
345                if committed {
346                    prop_assert!(watch.until() <= elapsed + budget.proof);
347                }
348                elapsed += probing;
349                let decision = watch.decide(elapsed, proven);
350
351                prop_assert_eq!(decision == Decision::Current, proven);
352                if decision == Decision::Committed {
353                    prop_assert!(seen_commit && !proven);
354                }
355                if decision == Decision::NotCommitted {
356                    prop_assert!(!seen_commit && elapsed >= budget.commit);
357                }
358                if elapsed >= budget.commit {
359                    prop_assert_ne!(decision, Decision::Wait);
360                }
361                if let Some(at) = watch.committed_at
362                    && elapsed >= at + budget.proof
363                {
364                    prop_assert_ne!(decision, Decision::Wait);
365                }
366                if decision != Decision::Wait {
367                    break;
368                }
369            }
370        }
371    }
372}