nixfleet_state_machine/rollout/transitions/
terminal.rs

1//! `Terminal` source state. All hosts Converged; channel-edges may
2//! release. Two exits: `→ Superseded` on `SuccessorOpened`,
3//! `→ Pruned` on `RetentionExpired`.
4
5use chrono::{DateTime, Utc};
6
7use super::{illegal, transition_effect};
8use crate::rollout::effect::RolloutEffect;
9use crate::rollout::error::RolloutTransitionError;
10use crate::rollout::event::RolloutEvent;
11use crate::rollout::state::{RolloutRecord, RolloutState};
12
13pub(super) fn step(
14    mut record: RolloutRecord,
15    event: RolloutEvent,
16    _now: DateTime<Utc>,
17) -> Result<(RolloutRecord, Vec<RolloutEffect>), RolloutTransitionError> {
18    match event {
19        RolloutEvent::SuccessorOpened { at, .. } => {
20            record.state = RolloutState::Superseded;
21            record.superseded_at = Some(at);
22            let effects = vec![transition_effect(
23                &record,
24                RolloutState::Terminal,
25                RolloutState::Superseded,
26                at,
27            )];
28            Ok((record, effects))
29        }
30        RolloutEvent::RetentionExpired { at, .. } => {
31            record.state = RolloutState::Pruned;
32            let effects = vec![transition_effect(
33                &record,
34                RolloutState::Terminal,
35                RolloutState::Pruned,
36                at,
37            )];
38            Ok((record, effects))
39        }
40        // Re-emitted terminal aggregation is a no-op (idempotency).
41        RolloutEvent::RolloutTerminal { .. } => Ok((record, Vec::new())),
42        // Host state changes after Terminal are stale ordering noise.
43        RolloutEvent::HostStateChanged { .. } | RolloutEvent::HostJoined { .. } => {
44            Ok((record, Vec::new()))
45        }
46        RolloutEvent::RolloutOpened { .. }
47        | RolloutEvent::WaveAdvanced { .. }
48        | RolloutEvent::OperatorClearance { .. } => Err(illegal(
49            RolloutState::Terminal,
50            &event,
51            record.rollout_id.clone(),
52        )),
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use chrono::TimeZone;
60
61    fn t0() -> DateTime<Utc> {
62        Utc.with_ymd_and_hms(2026, 5, 16, 1, 0, 0).unwrap()
63    }
64
65    fn terminal_record() -> RolloutRecord {
66        RolloutRecord {
67            rollout_id: "r1".into(),
68            channel: "stable".into(),
69            target_ref: "ref-1".into(),
70            state: RolloutState::Terminal,
71            current_wave: 0,
72            opened_event_log_seq: None,
73            last_transition_event_log_seq: None,
74            opened_at: t0(),
75            terminal_at: Some(t0()),
76            superseded_at: None,
77        }
78    }
79
80    #[test]
81    fn successor_opened_transitions_to_superseded() {
82        let event = RolloutEvent::SuccessorOpened {
83            superseded_rollout_id: "r1".into(),
84            successor_rollout_id: "r2".into(),
85            at: t0() + chrono::Duration::hours(1),
86        };
87        let (record, _) = step(terminal_record(), event, t0()).unwrap();
88        assert_eq!(record.state, RolloutState::Superseded);
89        assert!(record.superseded_at.is_some());
90    }
91
92    #[test]
93    fn retention_expired_transitions_to_pruned() {
94        let event = RolloutEvent::RetentionExpired {
95            rollout_id: "r1".into(),
96            at: t0() + chrono::Duration::hours(72),
97        };
98        let (record, _) = step(terminal_record(), event, t0()).unwrap();
99        assert_eq!(record.state, RolloutState::Pruned);
100    }
101
102    #[test]
103    fn idempotent_rollout_terminal_is_noop() {
104        let event = RolloutEvent::RolloutTerminal {
105            rollout_id: "r1".into(),
106            at: t0(),
107        };
108        let (record, effects) = step(terminal_record(), event, t0()).unwrap();
109        assert_eq!(record.state, RolloutState::Terminal);
110        assert!(effects.is_empty());
111    }
112}