nixfleet_state_machine/rollout/transitions/
terminal.rs1use 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 RolloutEvent::RolloutTerminal { .. } => Ok((record, Vec::new())),
42 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}