clawft_kernel/workload_ctl/
lifecycle.rs1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum LifecycleState {
16 Requested,
18 Placed,
20 Fetching,
22 Verified,
24 Loaded,
26 Running,
28 Unhealthy,
30 Restarting,
32 Stopping,
34 Stopped,
36 Finished,
38 Failed,
41 Lost,
43 Rescheduled,
46 Revoked,
48 Unloaded,
50}
51
52impl LifecycleState {
53 pub fn as_str(self) -> &'static str {
55 match self {
56 Self::Requested => "requested",
57 Self::Placed => "placed",
58 Self::Fetching => "fetching",
59 Self::Verified => "verified",
60 Self::Loaded => "loaded",
61 Self::Running => "running",
62 Self::Unhealthy => "unhealthy",
63 Self::Restarting => "restarting",
64 Self::Stopping => "stopping",
65 Self::Stopped => "stopped",
66 Self::Finished => "finished",
67 Self::Failed => "failed",
68 Self::Lost => "lost",
69 Self::Rescheduled => "rescheduled",
70 Self::Revoked => "revoked",
71 Self::Unloaded => "unloaded",
72 }
73 }
74
75 pub fn is_terminal(self) -> bool {
77 self == Self::Unloaded
78 }
79
80 pub fn is_live(self) -> bool {
82 matches!(self, Self::Running | Self::Unhealthy | Self::Restarting)
83 }
84}
85
86pub fn can_transition(from: LifecycleState, to: LifecycleState) -> bool {
88 use LifecycleState::*;
89 if from == to || from.is_terminal() {
90 return false;
91 }
92 if from != Rescheduled && matches!(to, Revoked | Lost) {
95 return !(from == Revoked && to == Lost);
96 }
97 matches!(
98 (from, to),
99 (Requested, Placed | Failed)
100 | (Placed, Fetching | Loaded | Running | Failed)
101 | (Fetching, Verified | Failed)
102 | (Verified, Loaded | Failed)
103 | (Loaded, Running | Unloaded | Failed)
104 | (Running, Unhealthy | Stopping | Stopped | Finished | Failed | Unloaded)
105 | (Unhealthy, Running | Restarting | Stopping | Failed | Unloaded)
106 | (Restarting, Running | Unhealthy | Failed | Stopping)
107 | (Stopping, Stopped | Unloaded | Failed)
108 | (Stopped, Running | Unloaded | Failed)
109 | (Finished, Running | Unloaded)
110 | (Failed, Running | Unloaded)
111 | (Lost, Running | Rescheduled | Unloaded | Failed)
112 | (Rescheduled, Unloaded)
113 | (Revoked, Unloaded | Failed)
114 )
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
119#[error("illegal lifecycle step {from:?} -> {to:?}")]
120pub struct IllegalTransition {
121 pub from: LifecycleState,
123 pub to: LifecycleState,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130pub struct RestartPolicy {
131 pub max_restarts: u32,
133 pub window_ms: u64,
135 pub backoff_base_ms: u64,
137 pub backoff_max_ms: u64,
139}
140
141impl Default for RestartPolicy {
142 fn default() -> Self {
143 Self {
144 max_restarts: 3,
145 window_ms: 600_000,
146 backoff_base_ms: 1_000,
147 backoff_max_ms: 30_000,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum RestartDecision {
155 Restart {
157 attempt: u32,
159 },
160 Wait {
162 until_ms: u64,
164 },
165 Exhausted,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct InstanceLife {
172 pub state: LifecycleState,
174 pub since_ms: u64,
176 pub misses: u32,
178 pub restarts: Vec<u64>,
180 pub last_poll_ms: u64,
182}
183
184impl InstanceLife {
185 pub fn new(state: LifecycleState, now_ms: u64) -> Self {
187 Self {
188 state,
189 since_ms: now_ms,
190 misses: 0,
191 restarts: Vec::new(),
192 last_poll_ms: 0,
193 }
194 }
195
196 pub fn transition(
198 &mut self,
199 to: LifecycleState,
200 now_ms: u64,
201 ) -> Result<LifecycleState, IllegalTransition> {
202 let from = self.state;
203 if !can_transition(from, to) {
204 return Err(IllegalTransition { from, to });
205 }
206 self.state = to;
207 self.since_ms = now_ms;
208 if to == LifecycleState::Running {
209 self.misses = 0;
210 }
211 Ok(from)
212 }
213
214 pub fn restart_decision(&self, policy: &RestartPolicy, now_ms: u64) -> RestartDecision {
217 let recent: Vec<u64> = self
218 .restarts
219 .iter()
220 .copied()
221 .filter(|t| now_ms.saturating_sub(*t) < policy.window_ms)
222 .collect();
223 let n = recent.len() as u32;
224 if n >= policy.max_restarts {
225 return RestartDecision::Exhausted;
226 }
227 let shift = n.saturating_sub(1).min(20);
229 let backoff = policy
230 .backoff_base_ms
231 .saturating_mul(1u64 << shift)
232 .min(policy.backoff_max_ms);
233 match recent.last() {
234 Some(last) if now_ms < last.saturating_add(backoff) => RestartDecision::Wait {
235 until_ms: last.saturating_add(backoff),
236 },
237 _ => RestartDecision::Restart { attempt: n + 1 },
238 }
239 }
240
241 pub fn record_restart(&mut self, policy: &RestartPolicy, now_ms: u64) {
243 self.restarts
244 .retain(|t| now_ms.saturating_sub(*t) < policy.window_ms);
245 self.restarts.push(now_ms);
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251pub struct LifecyclePolicy {
252 pub migratable: bool,
255 pub max_reschedules: u32,
258}
259
260impl Default for LifecyclePolicy {
261 fn default() -> Self {
262 Self {
263 migratable: true,
264 max_reschedules: 5,
265 }
266 }
267}
268
269const HARDWARE_NAMESPACES: &[&str] = &["sensor", "device", "feed"];
275
276pub fn needs_attached_hardware(spec: &clawft_types::placement::engine::WorkloadSpec) -> bool {
282 use clawft_types::placement::IdSelector;
283 let reqs = spec
284 .requirements
285 .common
286 .iter()
287 .chain(spec.requirements.variants.iter().flat_map(|v| v.requirements.iter()));
288 reqs.into_iter().any(|r| {
289 let id = match &r.selector {
290 IdSelector::Exact(c) => c.as_str(),
291 IdSelector::Prefix(p) => p.as_str(),
292 };
293 let ns = id.split('.').next().unwrap_or(id);
294 HARDWARE_NAMESPACES.contains(&ns)
295 })
296}