Skip to main content

kunobi_daemon/
replacement.rs

1//! Replacement ordering shared by blocking clients and async servers.
2//!
3//! The caller holds the persistent upgrade lock throughout the transaction.
4//! Adapters perform one operation at a time; this module decides their order.
5use crate::ProcessLock;
6use std::time::{Duration, Instant};
7
8/// Whether the application permits old and new processes to coexist.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10#[cfg_attr(kani, derive(kani::Arbitrary))]
11pub enum Mode {
12    /// Verify and select the candidate before retiring the incumbent.
13    Overlap,
14    /// Drain and release the incumbent before starting the candidate.
15    Exclusive,
16}
17
18/// One operation supplied by an application adapter.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Step {
21    /// Re-read selection under the transaction lock. Unchanged skips replacement.
22    Recheck,
23    /// Validate the artifact and capture application preparation state.
24    Prepare,
25    /// Close incumbent admission and release exclusive resources.
26    Drain,
27    /// Launch the candidate through its process owner.
28    Start,
29    /// Obtain a live identity/readiness proof. Pending retries this step only.
30    Verify,
31    /// Revalidate selection and application preparation after the live proof.
32    Validate,
33    /// Atomically select the verified candidate; never return an error after committing.
34    Commit,
35    /// Start retiring the incumbent. Pending keeps its obligations alive.
36    Retire,
37}
38
39/// Result of one adapter operation.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41#[cfg_attr(kani, derive(kani::Arbitrary))]
42pub enum Progress {
43    /// The operation completed.
44    Done,
45    /// The proof/drain is not ready, or retirement remains in progress.
46    Pending,
47    /// Recheck found no replacement necessary or another updater already won.
48    Unchanged,
49}
50
51/// A replacement result never implies replay of application work.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum Outcome {
55    /// No replacement was performed.
56    Unchanged,
57    /// Selection committed and the incumbent has no remaining obligations.
58    Complete,
59    /// Selection committed; the incumbent must remain alive for its obligations.
60    RetirementPending,
61}
62
63/// Failure retains the phase and whether selection has already committed.
64#[derive(Debug)]
65#[non_exhaustive]
66pub struct Failure<E> {
67    /// Failed operation.
68    pub step: Step,
69    /// Whether the new selection is authoritative despite this failure.
70    pub committed: bool,
71    /// Why the operation failed.
72    pub reason: Reason<E>,
73}
74
75/// Cause of a replacement failure.
76#[derive(Debug)]
77#[non_exhaustive]
78pub enum Reason<E> {
79    /// Setup or the consumer's explicit drain budget expired.
80    Deadline,
81    /// Application or operating system operation failed.
82    Adapter(E),
83    /// The adapter returned a result that is not valid for the current step.
84    InvalidProgress,
85}
86
87impl<E: std::fmt::Display> std::fmt::Display for Failure<E> {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "replacement {:?} failed: ", self.step)?;
90        match &self.reason {
91            Reason::Deadline => f.write_str("deadline expired"),
92            Reason::Adapter(error) => error.fmt(f),
93            Reason::InvalidProgress => f.write_str("invalid adapter progress"),
94        }
95    }
96}
97impl<E: std::error::Error + 'static> std::error::Error for Failure<E> {
98    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99        match &self.reason {
100            Reason::Adapter(error) => Some(error),
101            Reason::Deadline | Reason::InvalidProgress => None,
102        }
103    }
104}
105
106/// Short setup budget and independent application-owned drain policy.
107#[derive(Clone, Copy, Debug)]
108pub struct Budgets {
109    /// Absolute budget shared by setup operations, restarted after exclusive drain.
110    pub setup: Duration,
111    /// None allows drain to take arbitrarily long. Expiry never kills work.
112    pub drain: Option<Duration>,
113}
114
115/// Blocking application operations; no runtime is introduced into the client.
116pub trait Driver {
117    /// Application error.
118    type Error;
119    /// Perform exactly the requested operation, bounding I/O by the given deadline.
120    fn perform(&mut self, step: Step, deadline: Option<Instant>) -> Result<Progress, Self::Error>;
121    /// Client shims to prefault after selection commits. Empty by default.
122    ///
123    /// Typical entries are the relay or compiler wrapper next to this daemon.
124    /// Prefault errors are discarded: publication has already succeeded.
125    fn warmup_paths(&self) -> Vec<std::path::PathBuf> {
126        Vec::new()
127    }
128}
129
130/// Async application operations.
131#[cfg(feature = "async")]
132pub trait AsyncDriver {
133    /// Application error.
134    type Error;
135    /// Perform exactly the requested operation. Cancellation must preserve ownership.
136    fn perform(
137        &mut self,
138        step: Step,
139        deadline: Option<tokio::time::Instant>,
140    ) -> impl std::future::Future<Output = Result<Progress, Self::Error>> + Send;
141    /// Client shims to prefault after selection commits. Empty by default.
142    fn warmup_paths(&self) -> Vec<std::path::PathBuf> {
143        Vec::new()
144    }
145}
146
147struct Machine {
148    mode: Mode,
149    step: Step,
150    committed: bool,
151}
152impl Machine {
153    fn new(mode: Mode) -> Self {
154        Self {
155            mode,
156            step: Step::Recheck,
157            committed: false,
158        }
159    }
160    fn failure<E>(&self, reason: Reason<E>) -> Failure<E> {
161        Failure {
162            step: self.step,
163            committed: self.committed,
164            reason,
165        }
166    }
167    fn advance<E>(&mut self, progress: Progress) -> Result<Option<Outcome>, Failure<E>> {
168        use Step::*;
169        match (self.step, progress) {
170            (Recheck, Progress::Unchanged) => return Ok(Some(Outcome::Unchanged)),
171            (Recheck | Verify | Drain, Progress::Pending) => return Ok(None),
172            (Retire, Progress::Pending) => return Ok(Some(Outcome::RetirementPending)),
173            (_, Progress::Done) => {}
174            _ => return Err(self.failure(Reason::InvalidProgress)),
175        }
176        self.step = match self.step {
177            Recheck => Prepare,
178            Prepare if self.mode == Mode::Exclusive => Drain,
179            Prepare | Drain => Start,
180            Start => Verify,
181            Verify => Validate,
182            Validate => Commit,
183            Commit => {
184                self.committed = true;
185                if self.mode == Mode::Exclusive {
186                    return Ok(Some(Outcome::Complete));
187                }
188                Retire
189            }
190            Retire => return Ok(Some(Outcome::Complete)),
191        };
192        Ok(None)
193    }
194}
195
196/// Execute a serialized replacement. The lock is retained by the caller on return.
197///
198/// The adapter must preserve a selected candidate when reporting post-commit
199/// retirement failures. No callback automatically replays requests or kills a daemon.
200pub fn run<D: Driver>(
201    _ownership: &ProcessLock,
202    mode: Mode,
203    budgets: Budgets,
204    driver: &mut D,
205) -> Result<Outcome, Failure<D::Error>> {
206    let mut machine = Machine::new(mode);
207    let mut deadline = Some(Instant::now() + budgets.setup);
208    loop {
209        if deadline.is_some_and(|limit| Instant::now() >= limit) {
210            return Err(machine.failure(Reason::Deadline));
211        }
212        let step = machine.step;
213        let progress = driver
214            .perform(step, deadline)
215            .map_err(|e| machine.failure(Reason::Adapter(e)))?;
216        // A committed result must be recorded even if a blocking commit ran late.
217        if step != Step::Commit && deadline.is_some_and(|limit| Instant::now() >= limit) {
218            return Err(machine.failure(Reason::Deadline));
219        }
220        let committed_before = machine.committed;
221        if let Some(outcome) = machine.advance(progress)? {
222            if machine.committed && !committed_before {
223                crate::warmup::prefault_all(driver.warmup_paths());
224            }
225            return Ok(outcome);
226        }
227        if machine.committed && !committed_before {
228            crate::warmup::prefault_all(driver.warmup_paths());
229        }
230        if machine.step != step {
231            if machine.step == Step::Drain || machine.step == Step::Retire {
232                deadline = budgets.drain.map(|budget| Instant::now() + budget);
233            } else if step == Step::Drain {
234                deadline = Some(Instant::now() + budgets.setup);
235            }
236        } else {
237            let wait = deadline.map_or(crate::readiness::POLL_INTERVAL, |limit| {
238                crate::readiness::POLL_INTERVAL.min(limit.saturating_duration_since(Instant::now()))
239            });
240            #[expect(
241                clippy::disallowed_methods,
242                reason = "The replacement driver returned Pending without a wake source; retry within the phase budget."
243            )]
244            std::thread::sleep(wait);
245        }
246    }
247}
248
249/// Execute the same transition machine on an async runtime.
250#[cfg(feature = "async")]
251pub async fn run_async<D: AsyncDriver>(
252    _ownership: &ProcessLock,
253    mode: Mode,
254    budgets: Budgets,
255    driver: &mut D,
256) -> Result<Outcome, Failure<D::Error>> {
257    let mut machine = Machine::new(mode);
258    let mut deadline = Some(tokio::time::Instant::now() + budgets.setup);
259    loop {
260        if deadline.is_some_and(|limit| tokio::time::Instant::now() >= limit) {
261            return Err(machine.failure(Reason::Deadline));
262        }
263        let step = machine.step;
264        // Commit is an atomic publication operation. Interrupting it could leave its
265        // result unknown, so its adapter must not await after publication.
266        let result = if let Some(limit) = deadline.filter(|_| step != Step::Commit) {
267            tokio::time::timeout_at(limit, driver.perform(step, deadline))
268                .await
269                .map_err(|_| machine.failure(Reason::Deadline))?
270        } else {
271            driver.perform(step, deadline).await
272        };
273        let progress = result.map_err(|e| machine.failure(Reason::Adapter(e)))?;
274        if step != Step::Commit
275            && deadline.is_some_and(|limit| tokio::time::Instant::now() >= limit)
276        {
277            return Err(machine.failure(Reason::Deadline));
278        }
279        let committed_before = machine.committed;
280        if let Some(outcome) = machine.advance(progress)? {
281            if machine.committed && !committed_before {
282                prefault_async(driver.warmup_paths()).await;
283            }
284            return Ok(outcome);
285        }
286        if machine.committed && !committed_before {
287            prefault_async(driver.warmup_paths()).await;
288        }
289        if machine.step != step {
290            if machine.step == Step::Drain || machine.step == Step::Retire {
291                deadline = budgets
292                    .drain
293                    .map(|budget| tokio::time::Instant::now() + budget);
294            } else if step == Step::Drain {
295                deadline = Some(tokio::time::Instant::now() + budgets.setup);
296            }
297        } else {
298            let wake = tokio::time::Instant::now() + crate::readiness::POLL_INTERVAL;
299            #[expect(
300                clippy::disallowed_methods,
301                reason = "The replacement driver returned Pending without a wake source; retry within the phase budget."
302            )]
303            tokio::time::sleep_until(deadline.map_or(wake, |limit| limit.min(wake))).await;
304        }
305    }
306}
307
308// Filesystem reads must not occupy a Tokio worker after a successful commit.
309// The paths are owned and bounded by the caller; failed warming is non-fatal.
310#[cfg(feature = "async")]
311async fn prefault_async(paths: Vec<std::path::PathBuf>) {
312    if !paths.is_empty() {
313        let _ = tokio::task::spawn_blocking(move || crate::warmup::prefault_all(paths)).await;
314    }
315}
316
317/// Properties of the transition machine for every adapter answer, not only the
318/// sequences a test author thought of. Each one restates a promise from this
319/// module's documentation.
320#[cfg(kani)]
321mod proofs {
322    use super::*;
323
324    /// Long enough to reach `Complete` in either mode with a `Pending` at every
325    /// step that allows one: Recheck, Prepare, Drain, Start, Verify, Validate,
326    /// Commit and Retire, each preceded by a retry.
327    const ANSWERS: usize = 17;
328
329    #[kani::proof]
330    #[kani::unwind(18)]
331    fn replacement_never_claims_what_it_has_not_done() {
332        let mode: Mode = kani::any();
333        let mut machine = Machine::new(mode);
334        let mut drained = false;
335        let mut verified = false;
336        let mut validated = false;
337
338        for _ in 0..ANSWERS {
339            let step = machine.step;
340            let was_committed = machine.committed;
341            let progress: Progress = kani::any();
342            let result = machine.advance::<()>(progress);
343
344            // Selection is never withdrawn once it has been made.
345            assert!(!was_committed || machine.committed);
346
347            if progress == Progress::Done {
348                drained |= step == Step::Drain;
349                verified |= step == Step::Verify;
350                validated |= step == Step::Validate;
351            }
352
353            // Overlap keeps the incumbent serving: it never drains it.
354            if mode == Mode::Overlap {
355                assert!(machine.step != Step::Drain);
356            }
357            // Exclusive releases the incumbent before starting the candidate.
358            if mode == Mode::Exclusive && machine.step == Step::Start {
359                assert!(drained);
360            }
361            // A candidate is selected only after a live proof and revalidation.
362            if machine.committed {
363                assert!(verified && validated);
364            }
365            // The incumbent is retired only after the new selection is committed.
366            if machine.step == Step::Retire {
367                assert!(machine.committed);
368            }
369
370            match result {
371                Ok(Some(Outcome::Unchanged)) => {
372                    // "Unchanged" is only ever the answer to Recheck, before
373                    // anything was replaced.
374                    assert!(step == Step::Recheck && !machine.committed);
375                    return;
376                }
377                Ok(Some(Outcome::Complete | Outcome::RetirementPending)) => {
378                    // Success is never reported for a candidate that was not
379                    // selected.
380                    assert!(machine.committed);
381                    return;
382                }
383                Ok(None) => {}
384                Err(failure) => {
385                    // A failure after commit says so, so the caller does not
386                    // treat an authoritative selection as rolled back.
387                    assert!(failure.committed == machine.committed);
388                    assert!(failure.step == step);
389                    return;
390                }
391            }
392        }
393    }
394}
395
396#[cfg(test)]
397mod error_tests {
398    use super::*;
399
400    #[test]
401    fn an_adapter_failure_preserves_its_source() {
402        let failure = Failure {
403            step: Step::Start,
404            committed: false,
405            reason: Reason::Adapter(std::io::Error::other("failed to spawn")),
406        };
407        assert_eq!(
408            std::error::Error::source(&failure).unwrap().to_string(),
409            "failed to spawn"
410        );
411        for reason in [Reason::Deadline, Reason::InvalidProgress] {
412            let failure: Failure<std::io::Error> = Failure {
413                step: Step::Start,
414                committed: false,
415                reason,
416            };
417            assert!(std::error::Error::source(&failure).is_none());
418        }
419    }
420}