nodedb_cluster/
lifecycle_state.rs1use std::sync::{Arc, RwLock};
24
25use serde::{Deserialize, Serialize};
26use tracing::info;
27
28use crate::readiness;
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(tag = "phase", rename_all = "snake_case")]
33pub enum ClusterLifecycleState {
34 Starting,
36 Restarting,
38 Bootstrapping,
40 Joining {
42 attempt: u32,
45 },
46 Ready {
49 nodes: usize,
51 },
52 Failed {
56 reason: String,
58 },
59}
60
61impl ClusterLifecycleState {
62 pub fn label(&self) -> &'static str {
66 match self {
67 Self::Starting => "starting",
68 Self::Restarting => "restarting",
69 Self::Bootstrapping => "bootstrapping",
70 Self::Joining { .. } => "joining",
71 Self::Ready { .. } => "ready",
72 Self::Failed { .. } => "failed",
73 }
74 }
75
76 pub fn is_ready(&self) -> bool {
78 matches!(self, Self::Ready { .. })
79 }
80
81 pub fn all_labels() -> &'static [&'static str] {
84 &[
85 "starting",
86 "restarting",
87 "bootstrapping",
88 "joining",
89 "ready",
90 "failed",
91 ]
92 }
93}
94
95#[derive(Debug, Clone)]
111pub struct ClusterLifecycleTracker {
112 inner: Arc<RwLock<ClusterLifecycleState>>,
113}
114
115impl ClusterLifecycleTracker {
116 pub fn new() -> Self {
118 Self {
119 inner: Arc::new(RwLock::new(ClusterLifecycleState::Starting)),
120 }
121 }
122
123 pub fn current(&self) -> ClusterLifecycleState {
126 self.inner.read().unwrap_or_else(|p| p.into_inner()).clone()
127 }
128
129 pub fn is_ready(&self) -> bool {
131 self.current().is_ready()
132 }
133
134 pub fn to_restarting(&self) {
135 self.transition(ClusterLifecycleState::Restarting, "restart");
136 }
137
138 pub fn to_bootstrapping(&self) {
139 self.transition(
140 ClusterLifecycleState::Bootstrapping,
141 "bootstrapping new cluster",
142 );
143 }
144
145 pub fn to_joining(&self, attempt: u32) {
146 let detail = format!("joining cluster (attempt {attempt})");
147 self.transition(ClusterLifecycleState::Joining { attempt }, &detail);
148 }
149
150 pub fn to_ready(&self, nodes: usize) {
151 let detail = format!("ready ({nodes} nodes)");
152 self.transition(ClusterLifecycleState::Ready { nodes }, &detail);
153 }
154
155 pub fn to_failed(&self, reason: impl Into<String>) {
156 let reason = reason.into();
157 let detail = format!("failed: {reason}");
158 self.transition(ClusterLifecycleState::Failed { reason }, &detail);
159 }
160
161 fn transition(&self, new: ClusterLifecycleState, human: &str) {
164 let prev = {
165 let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner());
166 std::mem::replace(&mut *guard, new.clone())
167 };
168 info!(
169 prev = prev.label(),
170 new = new.label(),
171 detail = human,
172 "cluster lifecycle transition"
173 );
174 readiness::notify_status(human);
175 }
176}
177
178impl Default for ClusterLifecycleTracker {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn initial_state_is_starting() {
190 let t = ClusterLifecycleTracker::new();
191 assert_eq!(t.current(), ClusterLifecycleState::Starting);
192 assert!(!t.is_ready());
193 }
194
195 #[test]
196 fn transition_sequence_logs_and_updates() {
197 let t = ClusterLifecycleTracker::new();
198 t.to_joining(0);
199 assert_eq!(t.current(), ClusterLifecycleState::Joining { attempt: 0 });
200 t.to_joining(1);
201 assert_eq!(t.current(), ClusterLifecycleState::Joining { attempt: 1 });
202 t.to_ready(3);
203 assert_eq!(t.current(), ClusterLifecycleState::Ready { nodes: 3 });
204 assert!(t.is_ready());
205 }
206
207 #[test]
208 fn bootstrapping_then_ready() {
209 let t = ClusterLifecycleTracker::new();
210 t.to_bootstrapping();
211 assert_eq!(t.current(), ClusterLifecycleState::Bootstrapping);
212 t.to_ready(1);
213 assert!(t.is_ready());
214 }
215
216 #[test]
217 fn restarting_path() {
218 let t = ClusterLifecycleTracker::new();
219 t.to_restarting();
220 assert_eq!(t.current(), ClusterLifecycleState::Restarting);
221 t.to_ready(3);
222 assert!(t.is_ready());
223 }
224
225 #[test]
226 fn failed_is_not_terminal_by_contract() {
227 let t = ClusterLifecycleTracker::new();
232 t.to_joining(5);
233 t.to_failed("timeout");
234 assert!(matches!(t.current(), ClusterLifecycleState::Failed { .. }));
235 t.to_ready(3);
236 assert_eq!(t.current(), ClusterLifecycleState::Ready { nodes: 3 });
237 }
238
239 #[test]
240 fn labels_are_stable() {
241 assert_eq!(ClusterLifecycleState::Starting.label(), "starting");
242 assert_eq!(ClusterLifecycleState::Restarting.label(), "restarting");
243 assert_eq!(
244 ClusterLifecycleState::Bootstrapping.label(),
245 "bootstrapping"
246 );
247 assert_eq!(
248 ClusterLifecycleState::Joining { attempt: 0 }.label(),
249 "joining"
250 );
251 assert_eq!(ClusterLifecycleState::Ready { nodes: 3 }.label(), "ready");
252 assert_eq!(
253 ClusterLifecycleState::Failed { reason: "x".into() }.label(),
254 "failed"
255 );
256 }
257
258 #[test]
259 fn all_labels_matches_variants() {
260 for variant in [
263 ClusterLifecycleState::Starting,
264 ClusterLifecycleState::Restarting,
265 ClusterLifecycleState::Bootstrapping,
266 ClusterLifecycleState::Joining { attempt: 0 },
267 ClusterLifecycleState::Ready { nodes: 0 },
268 ClusterLifecycleState::Failed { reason: "x".into() },
269 ] {
270 assert!(
271 ClusterLifecycleState::all_labels().contains(&variant.label()),
272 "label {} missing from all_labels()",
273 variant.label()
274 );
275 }
276 }
277
278 #[test]
279 fn tracker_is_cheap_to_clone() {
280 let a = ClusterLifecycleTracker::new();
281 let b = a.clone();
282 a.to_bootstrapping();
283 assert_eq!(b.current(), ClusterLifecycleState::Bootstrapping);
285 }
286}