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}