clawft_kernel/workload_ctl/
host_supervise.rs1use std::time::Duration;
16
17use serde::Serialize;
18use serde_json::json;
19
20use super::host_service::WorkloadHostService;
21use super::lifecycle::{InstanceLife, LifecycleState, RestartDecision, RestartPolicy};
22use crate::chain;
23use crate::workload_kind::{Health, HealthSample, HealthSpec, SampleState, judge_process};
24use crate::workload_runtime::{InstanceState, InstanceStatus};
25
26const RESTART_GRACE: Duration = Duration::from_secs(2);
28
29#[derive(Debug, Clone, PartialEq, Serialize)]
31pub struct Supervised {
32 pub instance_id: String,
34 pub workload: String,
36 pub from: LifecycleState,
38 pub to: LifecycleState,
40 pub reason: String,
42}
43
44fn now_ms() -> u64 {
45 chrono::Utc::now().timestamp_millis().max(0) as u64
46}
47
48pub(super) fn enter(life: &mut InstanceLife, to: LifecycleState) {
51 let _ = life.transition(to, now_ms());
52}
53
54fn sample(st: &InstanceStatus, continuous: bool) -> HealthSample {
55 HealthSample {
56 state: match st.state {
57 InstanceState::Loaded => SampleState::Loaded,
58 InstanceState::Running => SampleState::Running,
59 InstanceState::Exited => SampleState::Exited,
60 InstanceState::Degraded => SampleState::Degraded,
61 InstanceState::Unknown => SampleState::Unknown,
62 },
63 exit_code: st.exit_code,
64 continuous,
65 }
66}
67
68impl WorkloadHostService {
69 pub fn with_kind_registry(mut self, kinds: crate::workload_kind::KindRegistry) -> Self {
71 self.kinds = kinds;
72 self
73 }
74
75 pub fn with_lease(mut self, lease: Duration) -> Self {
84 self.lease = Some(lease);
85 self
86 }
87
88 pub fn with_restart_policy(mut self, p: RestartPolicy) -> Self {
90 self.restart = p;
91 self
92 }
93
94 async fn expire_lease(&self, now_ms: u64) -> Vec<Supervised> {
97 let Some(lease) = self.lease else {
98 return Vec::new();
99 };
100 let silent = now_ms.saturating_sub(self.last_contact_ms.load(std::sync::atomic::Ordering::Relaxed));
101 if silent <= lease.as_millis() as u64 {
102 return Vec::new();
103 }
104 let mut targets = Vec::new();
105 {
106 let mut map = self.instances.lock().await;
107 for (id, p) in map.iter_mut() {
108 if !p.continuous
109 || !p.desired_running
110 || !p.life.state.is_live()
111 || p.requester == self.node_id
112 {
113 continue;
114 }
115 let Some(host) = self.routes.get(&p.route).cloned() else {
116 continue;
117 };
118 if let (Some(hk), Some(l)) = (&self.ingest, &p.ingest) {
120 hk.deactivate(l);
121 }
122 p.desired_running = false;
123 targets.push((id.clone(), p.handle.clone(), host, p.life.state));
124 }
125 }
126 let mut out = Vec::new();
127 for (id, handle, host, from) in targets {
128 let name = host.workload_for(&handle).await.map(|w| w.id).unwrap_or_default();
129 let r = host.stop(&handle, RESTART_GRACE).await;
130 let mut map = self.instances.lock().await;
131 if let Some(p) = map.get_mut(&id) {
132 p.lease_stopped = r.is_ok();
133 if let Ok(ev) = r {
134 p.last = Some(ev);
135 }
136 enter(&mut p.life, LifecycleState::Stopped);
137 }
138 let s = Supervised {
139 instance_id: id,
140 workload: name,
141 from,
142 to: LifecycleState::Stopped,
143 reason: format!("controller lease expired: no controller heard for {silent} ms"),
144 };
145 self.chain_life(&s, &self.node_id, json!({ "phase": "lease" }));
146 out.push(s);
147 }
148 out
149 }
150
151 fn health_spec(&self, kind: &str) -> HealthSpec {
152 self.kinds.get(kind).map_or_else(HealthSpec::default, |k| k.health())
153 }
154
155 fn judge(&self, kind: &str, s: &HealthSample) -> Health {
156 self.kinds
157 .get(kind)
158 .map_or_else(|| judge_process(s), |k| k.judge(s))
159 }
160
161 pub async fn lifecycle_of(&self, instance_id: &str) -> Option<LifecycleState> {
163 self.instances.lock().await.get(instance_id).map(|p| p.life.state)
164 }
165
166 fn chain_life(&self, s: &Supervised, node: &str, extra: serde_json::Value) {
167 let mut payload = json!({
168 "node": node, "instance_id": s.instance_id, "workload": s.workload,
169 "from": s.from, "to": s.to, "reason": s.reason,
170 });
171 if let (Some(p), Some(e)) = (payload.as_object_mut(), extra.as_object()) {
172 p.extend(e.clone());
173 }
174 self.record(chain::EVENT_KIND_WORKLOAD_LIFECYCLE, payload);
175 }
176
177 pub async fn supervise(&self, now_ms: u64) -> Vec<Supervised> {
181 struct Due {
182 id: String,
183 handle: crate::workload_runtime::InstanceHandle,
184 host: std::sync::Arc<crate::workload_runtime::WorkloadHost>,
185 name: String,
186 kind: String,
187 continuous: bool,
188 }
189 let mut out = self.expire_lease(now_ms).await;
190 let mut due = Vec::new();
191 {
192 let mut map = self.instances.lock().await;
193 for (id, p) in map.iter_mut() {
194 let skip = !p.desired_running
195 || !matches!(
196 p.life.state,
197 LifecycleState::Running | LifecycleState::Unhealthy | LifecycleState::Restarting
198 );
199 let Some(host) = self.routes.get(&p.route).cloned() else {
200 continue;
201 };
202 if skip {
203 continue;
204 }
205 let Some(w) = host.workload_for(&p.handle).await else {
206 continue;
207 };
208 let spec = self.health_spec(&w.kind);
209 if now_ms.saturating_sub(p.life.last_poll_ms) < spec.interval_ms {
210 continue;
211 }
212 p.life.last_poll_ms = now_ms;
213 due.push(Due {
214 id: id.clone(),
215 handle: p.handle.clone(),
216 host,
217 name: w.id.clone(),
218 kind: w.kind.clone(),
219 continuous: p.continuous,
220 });
221 }
222 }
223 for d in due {
224 let st = d.host.status(&d.handle).await;
225 let verdict = self.judge(&d.kind, &sample(&st, d.continuous));
226 if let Some(s) = self.apply_verdict(&d.id, &d.name, &d.kind, &d.handle, &d.host, verdict, now_ms).await {
227 out.extend(s);
228 }
229 }
230 out
231 }
232
233 #[allow(clippy::too_many_arguments)]
234 async fn apply_verdict(
235 &self,
236 id: &str,
237 name: &str,
238 kind: &str,
239 handle: &crate::workload_runtime::InstanceHandle,
240 host: &crate::workload_runtime::WorkloadHost,
241 verdict: Health,
242 now_ms: u64,
243 ) -> Option<Vec<Supervised>> {
244 let limit = self.health_spec(kind).miss_limit.max(1);
245 let mut steps: Vec<Supervised> = Vec::new();
246 let step = |from: LifecycleState, to: LifecycleState, reason: String| Supervised {
247 instance_id: id.to_string(),
248 workload: name.to_string(),
249 from,
250 to,
251 reason,
252 };
253 let restart_attempt;
255 {
256 let mut map = self.instances.lock().await;
257 let p = map.get_mut(id)?;
258 if !p.desired_running {
259 return None; }
261 match verdict {
262 Health::Healthy => {
263 p.life.misses = 0;
264 let from = p.life.state;
265 if matches!(from, LifecycleState::Unhealthy | LifecycleState::Restarting) {
266 enter(&mut p.life, LifecycleState::Running);
267 steps.push(step(from, LifecycleState::Running, "health recovered".into()));
268 }
269 restart_attempt = None;
270 }
271 Health::Finished => {
272 let from = p.life.state;
273 p.desired_running = false;
274 enter(&mut p.life, LifecycleState::Finished);
275 steps.push(step(from, LifecycleState::Finished, "one-shot run ended cleanly".into()));
276 restart_attempt = None;
277 }
278 Health::Unhealthy(why) => {
279 p.life.misses += 1;
280 let from = p.life.state;
281 if p.life.misses < limit {
282 return None;
283 }
284 if from == LifecycleState::Running {
285 enter(&mut p.life, LifecycleState::Unhealthy);
286 steps.push(step(
287 from,
288 LifecycleState::Unhealthy,
289 format!("{} missed heartbeats: {why}", p.life.misses),
290 ));
291 }
292 match p.life.restart_decision(&self.restart, now_ms) {
293 RestartDecision::Wait { .. } => restart_attempt = None,
294 RestartDecision::Exhausted => {
295 let f = p.life.state;
296 p.desired_running = false;
297 enter(&mut p.life, LifecycleState::Failed);
298 steps.push(step(
299 f,
300 LifecycleState::Failed,
301 format!(
302 "restart budget spent ({} in {} ms): {why}",
303 self.restart.max_restarts, self.restart.window_ms
304 ),
305 ));
306 restart_attempt = None;
307 }
308 RestartDecision::Restart { attempt } => {
309 let f = p.life.state;
310 enter(&mut p.life, LifecycleState::Restarting);
311 p.life.record_restart(&self.restart, now_ms);
312 steps.push(step(
313 f,
314 LifecycleState::Restarting,
315 format!("restart {attempt}/{}: {why}", self.restart.max_restarts),
316 ));
317 restart_attempt = Some(attempt);
318 }
319 }
320 }
321 }
322 }
323 let failed = steps.iter().any(|s| s.to == LifecycleState::Failed);
325 if failed {
326 let _ = host.stop(handle, RESTART_GRACE).await;
327 }
328 if let Some(attempt) = restart_attempt {
329 let _ = host.stop(handle, RESTART_GRACE).await;
333 let started = host.start(handle).await;
334 let mut map = self.instances.lock().await;
335 if let Some(p) = map.get_mut(id) {
336 let from = p.life.state;
337 match &started {
338 Ok(()) => {
339 enter(&mut p.life, LifecycleState::Running);
340 steps.push(step(from, LifecycleState::Running, format!("restarted (attempt {attempt})")));
341 }
342 Err(e) => {
343 enter(&mut p.life, LifecycleState::Unhealthy);
345 steps.push(step(from, LifecycleState::Unhealthy, format!("restart failed: {e}")));
346 }
347 }
348 }
349 }
350 for s in &steps {
351 self.chain_life(s, &self.node_id, json!({ "phase": "supervise" }));
352 }
353 (!steps.is_empty()).then_some(steps)
354 }
355
356 pub fn spawn_supervisor(
358 self: &std::sync::Arc<Self>,
359 period: Duration,
360 ) -> tokio::task::JoinHandle<()> {
361 let me = std::sync::Arc::clone(self);
362 tokio::spawn(async move {
363 let mut t = tokio::time::interval(period);
364 t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
365 loop {
366 t.tick().await;
367 let acted = me.supervise(now_ms()).await;
368 if !acted.is_empty() {
369 tracing::info!(n = acted.len(), "instance supervision");
370 }
371 }
372 })
373 }
374}