nixfleet_state_machine/rollout/transitions/
superseded.rs

1//! `Superseded` source state. A newer rollout for the same channel has
2//! opened. Only exit is `→ Pruned` on `RetentionExpired`.
3
4use chrono::{DateTime, Utc};
5
6use super::{illegal, transition_effect};
7use crate::rollout::effect::RolloutEffect;
8use crate::rollout::error::RolloutTransitionError;
9use crate::rollout::event::RolloutEvent;
10use crate::rollout::state::{RolloutRecord, RolloutState};
11
12pub(super) fn step(
13    mut record: RolloutRecord,
14    event: RolloutEvent,
15    _now: DateTime<Utc>,
16) -> Result<(RolloutRecord, Vec<RolloutEffect>), RolloutTransitionError> {
17    match event {
18        RolloutEvent::RetentionExpired { at, .. } => {
19            record.state = RolloutState::Pruned;
20            let effects = vec![transition_effect(
21                &record,
22                RolloutState::Superseded,
23                RolloutState::Pruned,
24                at,
25            )];
26            Ok((record, effects))
27        }
28        // Late host events and aggregations from a superseded rollout
29        // are noise (predecessor was already replaced).
30        RolloutEvent::HostStateChanged { .. }
31        | RolloutEvent::HostJoined { .. }
32        | RolloutEvent::WaveAdvanced { .. }
33        | RolloutEvent::RolloutTerminal { .. }
34        // Re-emitted SuccessorOpened (multiple successors landed) is
35        // idempotent — we're already Superseded.
36        | RolloutEvent::SuccessorOpened { .. } => Ok((record, Vec::new())),
37        RolloutEvent::RolloutOpened { .. } | RolloutEvent::OperatorClearance { .. } => Err(
38            illegal(RolloutState::Superseded, &event, record.rollout_id.clone()),
39        ),
40    }
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46    use chrono::TimeZone;
47
48    fn t0() -> DateTime<Utc> {
49        Utc.with_ymd_and_hms(2026, 5, 16, 1, 0, 0).unwrap()
50    }
51
52    fn superseded_record() -> RolloutRecord {
53        RolloutRecord {
54            rollout_id: "r1".into(),
55            channel: "stable".into(),
56            target_ref: "ref-1".into(),
57            state: RolloutState::Superseded,
58            current_wave: 0,
59            opened_event_log_seq: None,
60            last_transition_event_log_seq: None,
61            opened_at: t0(),
62            terminal_at: None,
63            superseded_at: Some(t0()),
64        }
65    }
66
67    #[test]
68    fn retention_expired_transitions_to_pruned() {
69        let event = RolloutEvent::RetentionExpired {
70            rollout_id: "r1".into(),
71            at: t0(),
72        };
73        let (record, _) = step(superseded_record(), event, t0()).unwrap();
74        assert_eq!(record.state, RolloutState::Pruned);
75    }
76
77    #[test]
78    fn re_successor_opened_is_idempotent() {
79        let event = RolloutEvent::SuccessorOpened {
80            superseded_rollout_id: "r1".into(),
81            successor_rollout_id: "r3".into(),
82            at: t0(),
83        };
84        let (record, effects) = step(superseded_record(), event, t0()).unwrap();
85        assert_eq!(record.state, RolloutState::Superseded);
86        assert!(effects.is_empty());
87    }
88}