lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

phase.rs (8519B)


      1 //! Service-neutral lifecycle phases and readiness state.
      2 
      3 use core::fmt;
      4 
      5 use serde::{Deserialize, Serialize};
      6 
      7 use super::ReasonCodes;
      8 
      9 /// Stable lifecycle phase shared by hardened services.
     10 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
     11 #[serde(rename_all = "snake_case")]
     12 pub enum ServicePhase {
     13     Starting,
     14     Ready,
     15     Degraded,
     16     Unready,
     17     Stopping,
     18     Failed,
     19 }
     20 
     21 impl ServicePhase {
     22     /// Returns whether moving from this phase to `next` is legal.
     23     #[must_use]
     24     pub const fn can_transition_to(self, next: Self) -> bool {
     25         if self as u8 == next as u8 {
     26             return true;
     27         }
     28         match self {
     29             Self::Starting => matches!(next, Self::Ready | Self::Degraded | Self::Failed),
     30             Self::Ready | Self::Degraded | Self::Unready => matches!(
     31                 next,
     32                 Self::Ready | Self::Degraded | Self::Unready | Self::Stopping | Self::Failed
     33             ),
     34             Self::Stopping => matches!(next, Self::Failed),
     35             Self::Failed => false,
     36         }
     37     }
     38 }
     39 
     40 /// Readiness is serialized as the exact boolean required by status contracts.
     41 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
     42 #[serde(transparent)]
     43 pub struct Readiness(bool);
     44 
     45 impl Readiness {
     46     pub const READY: Self = Self(true);
     47     pub const NOT_READY: Self = Self(false);
     48 
     49     #[must_use]
     50     pub const fn is_ready(self) -> bool {
     51         self.0
     52     }
     53 }
     54 
     55 /// One validated service-neutral operational observation.
     56 #[derive(Clone, Debug, PartialEq, Eq)]
     57 pub struct ServiceOperationalState {
     58     phase: ServicePhase,
     59     readiness: Readiness,
     60     reasons: ReasonCodes,
     61 }
     62 
     63 impl ServiceOperationalState {
     64     /// Constructs a phase/readiness pair, rejecting contradictory combinations.
     65     pub fn new(
     66         phase: ServicePhase,
     67         readiness: Readiness,
     68         reasons: ReasonCodes,
     69     ) -> Result<Self, StatusContractError> {
     70         validate_readiness(phase, readiness)?;
     71         Ok(Self {
     72             phase,
     73             readiness,
     74             reasons,
     75         })
     76     }
     77 
     78     #[must_use]
     79     pub const fn phase(&self) -> ServicePhase {
     80         self.phase
     81     }
     82 
     83     #[must_use]
     84     pub const fn readiness(&self) -> Readiness {
     85         self.readiness
     86     }
     87 
     88     #[must_use]
     89     pub const fn reasons(&self) -> &ReasonCodes {
     90         &self.reasons
     91     }
     92 
     93     /// Applies a legal transition while preserving the last valid state on failure.
     94     pub fn transition_to(
     95         &mut self,
     96         phase: ServicePhase,
     97         readiness: Readiness,
     98         reasons: ReasonCodes,
     99     ) -> Result<(), StatusContractError> {
    100         if !self.phase.can_transition_to(phase) {
    101             return Err(StatusContractError::IllegalTransition {
    102                 from: self.phase,
    103                 to: phase,
    104             });
    105         }
    106         validate_readiness(phase, readiness)?;
    107         self.phase = phase;
    108         self.readiness = readiness;
    109         self.reasons = reasons;
    110         Ok(())
    111     }
    112 }
    113 
    114 fn validate_readiness(
    115     phase: ServicePhase,
    116     readiness: Readiness,
    117 ) -> Result<(), StatusContractError> {
    118     let legal = match phase {
    119         ServicePhase::Ready => readiness.is_ready(),
    120         ServicePhase::Degraded => true,
    121         ServicePhase::Starting
    122         | ServicePhase::Unready
    123         | ServicePhase::Stopping
    124         | ServicePhase::Failed => !readiness.is_ready(),
    125     };
    126     if legal {
    127         Ok(())
    128     } else {
    129         Err(StatusContractError::InvalidReadiness { phase, readiness })
    130     }
    131 }
    132 
    133 /// Validation failure for common lifecycle and reason contracts.
    134 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    135 pub enum StatusContractError {
    136     InvalidReasonCode,
    137     TooManyReasonCodes {
    138         maximum: usize,
    139     },
    140     InvalidReadiness {
    141         phase: ServicePhase,
    142         readiness: Readiness,
    143     },
    144     IllegalTransition {
    145         from: ServicePhase,
    146         to: ServicePhase,
    147     },
    148 }
    149 
    150 impl fmt::Display for StatusContractError {
    151     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    152         match self {
    153             Self::InvalidReasonCode => formatter.write_str("status reason code is invalid"),
    154             Self::TooManyReasonCodes { maximum } => {
    155                 write!(formatter, "status exceeds its {maximum}-reason limit")
    156             }
    157             Self::InvalidReadiness { phase, readiness } => write!(
    158                 formatter,
    159                 "readiness {} is invalid for phase {phase:?}",
    160                 readiness.is_ready()
    161             ),
    162             Self::IllegalTransition { from, to } => {
    163                 write!(
    164                     formatter,
    165                     "service phase transition {from:?} -> {to:?} is illegal"
    166                 )
    167             }
    168         }
    169     }
    170 }
    171 
    172 impl std::error::Error for StatusContractError {}
    173 
    174 #[cfg(test)]
    175 mod tests {
    176     use super::*;
    177 
    178     #[test]
    179     fn legal_and_illegal_phase_transitions_are_explicit() {
    180         let phases = [
    181             ServicePhase::Starting,
    182             ServicePhase::Ready,
    183             ServicePhase::Degraded,
    184             ServicePhase::Unready,
    185             ServicePhase::Stopping,
    186             ServicePhase::Failed,
    187         ];
    188         let expected = [
    189             [true, true, true, false, false, true],
    190             [false, true, true, true, true, true],
    191             [false, true, true, true, true, true],
    192             [false, true, true, true, true, true],
    193             [false, false, false, false, true, true],
    194             [false, false, false, false, false, true],
    195         ];
    196         for (from_index, from) in phases.into_iter().enumerate() {
    197             for (to_index, to) in phases.into_iter().enumerate() {
    198                 assert_eq!(
    199                     from.can_transition_to(to),
    200                     expected[from_index][to_index],
    201                     "unexpected {from:?} -> {to:?} decision"
    202                 );
    203             }
    204         }
    205 
    206         let mut state = ServiceOperationalState::new(
    207             ServicePhase::Starting,
    208             Readiness::NOT_READY,
    209             ReasonCodes::empty(),
    210         )
    211         .expect("starting state");
    212         state
    213             .transition_to(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty())
    214             .expect("ready transition");
    215         state
    216             .transition_to(
    217                 ServicePhase::Stopping,
    218                 Readiness::NOT_READY,
    219                 ReasonCodes::empty(),
    220             )
    221             .expect("stopping transition");
    222 
    223         let before = state.clone();
    224         assert_eq!(
    225             state.transition_to(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty(),),
    226             Err(StatusContractError::IllegalTransition {
    227                 from: ServicePhase::Stopping,
    228                 to: ServicePhase::Ready,
    229             })
    230         );
    231         assert_eq!(state, before);
    232     }
    233 
    234     #[test]
    235     fn degraded_readiness_is_independent_but_other_phases_are_consistent() {
    236         for readiness in [Readiness::READY, Readiness::NOT_READY] {
    237             assert!(
    238                 ServiceOperationalState::new(
    239                     ServicePhase::Degraded,
    240                     readiness,
    241                     ReasonCodes::empty()
    242                 )
    243                 .is_ok()
    244             );
    245         }
    246         assert!(
    247             ServiceOperationalState::new(
    248                 ServicePhase::Ready,
    249                 Readiness::NOT_READY,
    250                 ReasonCodes::empty()
    251             )
    252             .is_err()
    253         );
    254         assert!(
    255             ServiceOperationalState::new(
    256                 ServicePhase::Failed,
    257                 Readiness::READY,
    258                 ReasonCodes::empty()
    259             )
    260             .is_err()
    261         );
    262     }
    263 
    264     #[test]
    265     fn phase_and_readiness_serde_names_match_frozen_contracts() {
    266         let phases = [
    267             (ServicePhase::Starting, "\"starting\""),
    268             (ServicePhase::Ready, "\"ready\""),
    269             (ServicePhase::Degraded, "\"degraded\""),
    270             (ServicePhase::Unready, "\"unready\""),
    271             (ServicePhase::Stopping, "\"stopping\""),
    272             (ServicePhase::Failed, "\"failed\""),
    273         ];
    274         for (phase, json) in phases {
    275             assert_eq!(serde_json::to_string(&phase).expect("phase"), json);
    276             assert_eq!(
    277                 serde_json::from_str::<ServicePhase>(json).expect("phase"),
    278                 phase
    279             );
    280         }
    281         assert_eq!(serde_json::to_string(&Readiness::READY).unwrap(), "true");
    282         assert_eq!(
    283             serde_json::to_string(&Readiness::NOT_READY).unwrap(),
    284             "false"
    285         );
    286     }
    287 }