nixfleet_state_machine/rollout/
state.rs

1//! Rollout-level state (RFC-0008 §3). Eight states, all CP-internal.
2//!
3//! Parallel to the per-host `HostRolloutState` (RFC-0005 §3): same pure-
4//! reducer shape, but operates on a `(channel, rollout_id)` aggregate
5//! rather than a `(rollout_id, hostname)` per-host slot.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10// `RolloutId` re-exported from `nixfleet-proto` (RFC-0008 §6.3);
11// kept here for ergonomic `crate::rollout::state::RolloutId` access
12// in the rollout reducer's callsites.
13pub use nixfleet_proto::{ChannelRef, RolloutId};
14
15pub type ChannelId = String;
16pub type ClosureHash = String;
17
18/// The eight rollout-level states per RFC-0008 §3.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub enum RolloutState {
21    /// Channel-refs poll detected new ref; rollout opened; no hosts
22    /// dispatched yet.
23    Opening,
24    /// At least one host is in-flight
25    /// (`Pending` / `Activating` / `Soaking` per RFC-0005).
26    Active,
27    /// All dispatched hosts reached `Soaked`; later waves remain to
28    /// dispatch.
29    Converging,
30    /// All hosts in all waves are `Converged`; channel-edges may release.
31    Terminal,
32    /// Any host reached `Reverted` via the `rollback-and-halt` policy.
33    Reverted,
34    /// Any host stuck in `Failed` state without rollback
35    /// (e.g., `halt-only` policy).
36    Failed,
37    /// A newer rollout for the same channel opened.
38    Superseded,
39    /// Retention timeout elapsed; in-memory state-machine instance is
40    /// freed but the DB row persists (table remains re-derivable from
41    /// `event_log`). Physical row deletion deferred to v0.3 retention-
42    /// compaction (RFC-0008 §3 + §13).
43    Pruned,
44}
45
46/// The full row shape the reducer operates on. Mirrors the on-disk
47/// `rollouts` table (RFC-0008 §6.3) plus per-rollout in-memory aggregates
48/// the reducer needs to decide transitions.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct RolloutRecord {
51    pub rollout_id: RolloutId,
52    pub channel: ChannelId,
53    pub target_ref: ChannelRef,
54    pub state: RolloutState,
55    pub current_wave: u32,
56    /// `event_log.seq` of the `RolloutOpened` event that opened this
57    /// rollout. NULL-able under the v0.2.1 baseline (RFC-0008 §6.1 item 3 +
58    /// `.claude/plans/v0.2.1-followups.md` #1); tightens to NOT NULL when
59    /// the event_log writer gains synchronous seq return.
60    pub opened_event_log_seq: Option<i64>,
61    /// `event_log.seq` of the most recent `rollout_event` that mutated
62    /// this rollout. Same NULL-able caveat as `opened_event_log_seq`.
63    pub last_transition_event_log_seq: Option<i64>,
64    pub opened_at: DateTime<Utc>,
65    pub terminal_at: Option<DateTime<Utc>>,
66    pub superseded_at: Option<DateTime<Utc>>,
67}
68
69impl RolloutState {
70    pub fn as_db_str(&self) -> &'static str {
71        match self {
72            RolloutState::Opening => "Opening",
73            RolloutState::Active => "Active",
74            RolloutState::Converging => "Converging",
75            RolloutState::Terminal => "Terminal",
76            RolloutState::Reverted => "Reverted",
77            RolloutState::Failed => "Failed",
78            RolloutState::Superseded => "Superseded",
79            RolloutState::Pruned => "Pruned",
80        }
81    }
82
83    /// Parse the SQLite stored string. Returns `None` on unknown values; the
84    /// schema CHECK constraint should prevent that, but callers handle
85    /// gracefully rather than crashing.
86    pub fn from_db_str(s: &str) -> Option<Self> {
87        Some(match s {
88            "Opening" => RolloutState::Opening,
89            "Active" => RolloutState::Active,
90            "Converging" => RolloutState::Converging,
91            "Terminal" => RolloutState::Terminal,
92            "Reverted" => RolloutState::Reverted,
93            "Failed" => RolloutState::Failed,
94            "Superseded" => RolloutState::Superseded,
95            "Pruned" => RolloutState::Pruned,
96            _ => return None,
97        })
98    }
99
100    /// Channel-edges treat `Terminal` and `Superseded` as equivalent
101    /// release signals (RFC-0008 §3 invariant: "Superseded is terminal-for-
102    /// ordering but not terminal-for-pruning").
103    pub fn is_terminal_for_ordering(&self) -> bool {
104        matches!(self, RolloutState::Terminal | RolloutState::Superseded)
105    }
106}