Skip to main content

clawft_kernel/workload_ctl/
host_supervise.rs

1//! Node-local supervision of hosted instances (ADR-099 section 7): a
2//! periodic health heartbeat whose meaning each kind defines, and bounded
3//! restarts with backoff, every step chained.
4//!
5//! [`WorkloadHostService::supervise`] polls each instance that is meant to
6//! run, once per its kind's interval. A kind's verdict
7//! ([`crate::workload_kind::WorkloadKind::judge`]) counts a miss; enough
8//! misses in a row make the instance `Unhealthy`, and the restart policy
9//! then restarts it (stop, then start, both gated and chained by the
10//! adapter host) until its budget inside the window is spent, at which
11//! point it is `Failed` and held for the operator. An instance the
12//! operator stopped, one that was never started and a finished one-shot run
13//! are never restarted.
14
15use 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
26/// Grace given to a hung instance before it is killed for a restart.
27const RESTART_GRACE: Duration = Duration::from_secs(2);
28
29/// One thing a [`WorkloadHostService::supervise`] pass did.
30#[derive(Debug, Clone, PartialEq, Serialize)]
31pub struct Supervised {
32    /// Instance id on this node.
33    pub instance_id: String,
34    /// Workload name.
35    pub workload: String,
36    /// State before.
37    pub from: LifecycleState,
38    /// State after.
39    pub to: LifecycleState,
40    /// Why.
41    pub reason: String,
42}
43
44fn now_ms() -> u64 {
45    chrono::Utc::now().timestamp_millis().max(0) as u64
46}
47
48/// Move `life` to `to` if that step is legal (an operator verb racing the
49/// supervisor can make it illegal; the verb wins).
50pub(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    /// Use these kinds' health definitions (default: the built-in kinds).
70    pub fn with_kind_registry(mut self, kinds: crate::workload_kind::KindRegistry) -> Self {
71        self.kinds = kinds;
72        self
73    }
74
75    /// Opt in to the node-side lease: continuous instances stop once no
76    /// controller other than this node itself has been heard from for
77    /// `lease`, and only for instances another node placed. It must be
78    /// longer than the controller's tick (a lease shorter than a few ticks
79    /// would stop work for ordinary jitter) and, to stop the old copy before
80    /// a replacement starts, at most the controller's `dead_after`. Set it at or below the controller's `dead_after`, so the
81    /// copy on a partitioned node stops before the controller starts a
82    /// replacement. Off by default.
83    pub fn with_lease(mut self, lease: Duration) -> Self {
84        self.lease = Some(lease);
85        self
86    }
87
88    /// Replace the restart bounds.
89    pub fn with_restart_policy(mut self, p: RestartPolicy) -> Self {
90        self.restart = p;
91        self
92    }
93
94    /// Stop continuous instances when the controller lease is on and has
95    /// expired (opt-in, see [`Self::with_lease`]). Returns what it stopped.
96    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                // The token goes first, as for an operator stop.
119                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    /// Lifecycle state of a hosted instance.
162    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    /// Run the health heartbeat and the bounded restarts once, at `now_ms`.
178    /// Returns what changed. Instances are polled outside the instance map's
179    /// lock, so a slow adapter never holds up `status` or `place`.
180    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        // Decide under the lock (state only); act on the adapter outside it.
254        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; // an operator verb overtook this poll
260            }
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        // A failed instance is also taken off the CPU (best effort).
324        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            // Stop (a hung process), then start: both gated and chained by
330            // the adapter host. "Not started" / "already exited" on stop is
331            // fine; the start decides.
332            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                        // Back to unhealthy: the next poll retries within the budget.
344                        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    /// Run [`Self::supervise`] every `period` for as long as the runtime lives.
357    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}