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 }