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}