aion_server/worker/supervisor/fleet.rs
1//! The managed-worker supervisor: desired state in, running processes out.
2//!
3//! Desired state is the DURABLE record (`WorkerDeployment.desired`); actual
4//! state is a live [`InstanceHandle`]. This type is the only thing that reads
5//! one and writes the other, and every answer it gives is a join of the two —
6//! never one standing in for the other.
7//!
8//! Supervision is COMMISSIONED, not defaulted. A server whose operator has not
9//! written a `[worker_supervision]` policy supervises nothing and says so with
10//! the remedy attached; it does not pick a backoff on the operator's behalf
11//! (ADR-001).
12
13use std::collections::BTreeMap;
14use std::sync::{Arc, Mutex, OnceLock};
15
16use aion_core::ClusterEvent;
17use aion_store::{
18 DesiredState, WorkerArtifactRef, WorkerDeployment, WorkerDeploymentListing,
19 WorkerDeploymentStore,
20};
21
22use crate::cluster_publisher::ClusterEventPublisher;
23use crate::worker::auto_provision::AutoWorkerOutcome;
24
25use super::error::SupervisionError;
26use super::executable::ManagedExecutable;
27use super::instance::{InstanceConfig, InstanceHandle, InstanceSnapshot};
28use super::policy::{SupervisionPolicy, UNCOMMISSIONED_REMEDY};
29use super::status::{ManagedWorkerReport, ManagedWorkerState, ManagedWorkerStatus};
30
31/// What an operator commissioned: how to supervise, and what to launch.
32///
33/// Both halves arrive together and neither can be swapped afterwards. They
34/// belong to the same decision — a restart discipline is meaningless without
35/// knowing what is being restarted — and holding them in one write-once cell
36/// is what makes "commissioned" a single, checkable fact.
37#[derive(Clone, Debug)]
38struct Commission {
39 policy: SupervisionPolicy,
40 executable: ManagedExecutable,
41}
42
43/// How far a convergence may go when a live instance already exists.
44///
45/// The distinction is the staleness law: a supervised worker replays its
46/// record's argv VERBATIM, so a record whose argv was rewritten underneath a
47/// running instance is being served by a launch nobody deployed. Only the
48/// caller knows which of the two happened, so only the caller may choose.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum Convergence {
51 /// Leave an already-live instance running: it is serving this record's argv.
52 Idempotent,
53 /// Replace any live instance, because the record's argv was rewritten.
54 Replacing,
55}
56
57/// What [`WorkerSupervisor::shutdown`] observed, by name on both sides.
58#[derive(Debug, Default)]
59pub struct FleetShutdownReport {
60 /// Deployments proven stopped: each stop returned only after a
61 /// signal-zero probe found the worker's process group empty.
62 pub stopped: Vec<String>,
63 /// One entry per deployment that could NOT be proven stopped, carrying
64 /// the observation that contradicted it.
65 pub failures: Vec<SupervisionError>,
66}
67
68/// Server-owned supervision of the managed worker fleet.
69pub struct WorkerSupervisor {
70 store: Arc<dyn WorkerDeploymentStore>,
71 commission: OnceLock<Commission>,
72 instances: Mutex<BTreeMap<String, InstanceHandle>>,
73 publisher: ClusterEventPublisher,
74 /// The LATEST auto-provision decision per task queue, so the status
75 /// surface can say why a queue has no worker long after the deploy or the
76 /// boot that decided it. Keyed by queue and therefore bounded by what has
77 /// been deployed, never by how often.
78 auto_provision: Mutex<BTreeMap<String, AutoWorkerOutcome>>,
79 /// One async gate per deployment NAME, so every lifecycle verb's
80 /// read-record → tear-down → spawn sequence is a single critical section.
81 ///
82 /// The instances map alone is not enough. It makes two workers for one name
83 /// impossible, but it is released between the durable read and the spawn,
84 /// and two interleaved converges of one name could therefore leave the
85 /// EARLIER argv running: read(argv1) … read(argv2), replace→spawn(argv2),
86 /// replace→spawn(argv1). Nothing would notice — `reconcile` leaves live
87 /// instances alone — so the superseded launch survives until the next
88 /// restart, which is exactly the staleness `Convergence::Replacing` exists
89 /// to prevent.
90 ///
91 /// Keyed by name and therefore bounded by the deployment count. Entries
92 /// are never removed: a `tokio::sync::Mutex` is a few words, and reclaiming
93 /// them would need a second lock ordering to get wrong.
94 gates: Mutex<BTreeMap<String, Arc<tokio::sync::Mutex<()>>>>,
95}
96
97impl WorkerSupervisor {
98 /// Build an UNCOMMISSIONED supervisor over the durable deployment store,
99 /// publishing desired-state changes onto the deployment-global cluster
100 /// channel.
101 ///
102 /// The publisher lives HERE — not in the transports — so every desired-state
103 /// write the supervisor makes ([`Self::start`], [`Self::stop`], and
104 /// [`Self::restart`] through them) reaches the console's live feed
105 /// identically whichever transport asked, exactly as the
106 /// worker-deployment desired-state endpoint publishes its own writes.
107 ///
108 /// Nothing is supervised until [`Self::commission`] installs an operator
109 /// policy: construction is not commissioning, so a server that boots
110 /// without the config section never spawns anything.
111 #[must_use]
112 pub fn new(store: Arc<dyn WorkerDeploymentStore>, publisher: ClusterEventPublisher) -> Self {
113 Self {
114 store,
115 commission: OnceLock::new(),
116 instances: Mutex::new(BTreeMap::new()),
117 publisher,
118 auto_provision: Mutex::new(BTreeMap::new()),
119 gates: Mutex::new(BTreeMap::new()),
120 }
121 }
122
123 /// The per-name gate, created on first use.
124 ///
125 /// A poisoned gate map is a typed error rather than a silent free-for-all:
126 /// proceeding without the gate is exactly the interleaving it exists to
127 /// prevent, and doing that quietly would make the race intermittent instead
128 /// of impossible.
129 fn gate(&self, name: &str) -> Result<Arc<tokio::sync::Mutex<()>>, SupervisionError> {
130 let mut gates = self
131 .gates
132 .lock()
133 .map_err(|poison| SupervisionError::StatePoisoned {
134 detail: poison.to_string(),
135 })?;
136 Ok(Arc::clone(
137 gates
138 .entry(name.to_owned())
139 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
140 ))
141 }
142
143 /// Record what auto-provision decided about each task queue, replacing any
144 /// earlier decision for the same queue.
145 ///
146 /// A failure to take the lock is logged rather than propagated: losing the
147 /// REPORT of a provisioning decision must not turn a successful deploy into
148 /// a refusal, and the decision itself has already been logged and returned
149 /// to its caller by the time this is called.
150 pub fn record_auto_provision(&self, outcomes: &[AutoWorkerOutcome]) {
151 match self.auto_provision.lock() {
152 Ok(mut log) => {
153 for outcome in outcomes {
154 drop(log.insert(outcome.task_queue.clone(), outcome.clone()));
155 }
156 }
157 Err(poison) => tracing::error!(
158 detail = %poison,
159 "the auto-provision log is poisoned; this server's managed-worker report will \
160 not say why a queue has no built-in agent worker"
161 ),
162 }
163 }
164
165 /// The latest auto-provision decision per queue, ordered by queue.
166 ///
167 /// A poisoned log reports EMPTY rather than refusing the whole status read:
168 /// the fleet join is the operator's primary answer and must keep arriving.
169 /// The poison itself was already logged where it was found.
170 fn auto_provision_log(&self) -> Vec<AutoWorkerOutcome> {
171 self.auto_provision
172 .lock()
173 .map(|log| log.values().cloned().collect())
174 .unwrap_or_default()
175 }
176
177 /// Install the operator's supervision policy and the executable a
178 /// `builtin` deployment means on this server.
179 ///
180 /// Returns false when a commission was already installed, in which case
181 /// the existing one stands: the restart discipline of a running fleet is
182 /// not something a later caller gets to swap out underneath it.
183 pub fn commission(&self, policy: SupervisionPolicy, executable: ManagedExecutable) -> bool {
184 self.commission
185 .set(Commission { policy, executable })
186 .is_ok()
187 }
188
189 /// The installed policy, if this server has one.
190 #[must_use]
191 pub fn policy(&self) -> Option<SupervisionPolicy> {
192 self.commission.get().map(|commission| commission.policy)
193 }
194
195 /// Whether an operator commission is installed.
196 #[must_use]
197 pub fn is_commissioned(&self) -> bool {
198 self.commission.get().is_some()
199 }
200
201 fn require_commission(&self) -> Result<&Commission, SupervisionError> {
202 self.commission
203 .get()
204 .ok_or(SupervisionError::NotCommissioned)
205 }
206
207 fn instances(
208 &self,
209 ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, InstanceHandle>>, SupervisionError> {
210 self.instances
211 .lock()
212 .map_err(|poison| SupervisionError::StatePoisoned {
213 detail: poison.to_string(),
214 })
215 }
216
217 async fn record(&self, name: &str) -> Result<WorkerDeployment, SupervisionError> {
218 self.store
219 .get_worker_deployment(name)
220 .await
221 .map_err(|source| SupervisionError::Store { source })?
222 .ok_or_else(|| SupervisionError::UnknownDeployment {
223 name: name.to_owned(),
224 })
225 }
226
227 async fn listing(&self) -> Result<WorkerDeploymentListing, SupervisionError> {
228 self.store
229 .list_worker_deployments()
230 .await
231 .map_err(|source| SupervisionError::Store { source })
232 }
233
234 /// Start (or adopt) supervision of one deployment, recording the intent
235 /// durably first. A durable desired-state flip is published onto the
236 /// cluster channel, so the console's live feed sees it whichever transport
237 /// asked.
238 ///
239 /// Idempotent: a deployment already being supervised is reported as it is,
240 /// without a second process (and, already desiring `Running`, without a
241 /// second event).
242 ///
243 /// # Errors
244 ///
245 /// Returns [`SupervisionError::NotCommissioned`] when no policy is
246 /// installed — carrying the remedy — [`SupervisionError::UnknownDeployment`]
247 /// for a name with no durable record, and [`SupervisionError::Store`] when
248 /// the record cannot be read or the intent cannot be written.
249 pub async fn start(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
250 let gate = self.gate(name)?;
251 let held = gate.lock().await;
252 let started = self.start_gated(name).await;
253 drop(held);
254 started
255 }
256
257 /// [`Self::start`] with the per-name gate ALREADY held by the caller.
258 async fn start_gated(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
259 let commission = self.require_commission()?.clone();
260 let record = self.record(name).await?;
261 let record = if record.desired == DesiredState::Running {
262 record
263 } else {
264 let record = self
265 .store
266 .set_desired_state(name, DesiredState::Running)
267 .await
268 .map_err(|source| SupervisionError::Store { source })?
269 .ok_or_else(|| SupervisionError::UnknownDeployment {
270 name: name.to_owned(),
271 })?;
272 self.publish_desired_state(&record);
273 record
274 };
275
276 let snapshot = self.ensure_instance(&record, &commission)?;
277 Ok(status_of(&record, snapshot, self.is_commissioned()))
278 }
279
280 /// Bring ONE deployment's live state to its already-durable desired state,
281 /// writing no durable intent and publishing no desired-state event.
282 ///
283 /// This is what a transport calls immediately after it has written the
284 /// record itself. A durable `desired = running` is an INSTRUCTION, and the
285 /// node that accepted it acts on it now rather than at the next boot
286 /// reconcile — but the write and its event already happened at the
287 /// transport, so re-writing them here would double-publish a change that
288 /// occurred once. [`Self::start`] and [`Self::stop`] remain the verbs for a
289 /// caller that is asking for the intent to CHANGE.
290 ///
291 /// `mode` decides what an already-live instance means: [`Convergence::Idempotent`]
292 /// leaves it running, [`Convergence::Replacing`] tears it down first because
293 /// the record it is replaying was rewritten under it.
294 ///
295 /// # Errors
296 ///
297 /// Returns [`SupervisionError::UnknownDeployment`] for a name with no
298 /// durable record, [`SupervisionError::Store`] when the record cannot be
299 /// read, [`SupervisionError::NotCommissioned`] when the record wants to run
300 /// and no policy is installed, and [`SupervisionError::StopIncomplete`] when
301 /// a replaced or stopped instance cannot be proven gone.
302 pub async fn converge(
303 &self,
304 name: &str,
305 mode: Convergence,
306 ) -> Result<ManagedWorkerStatus, SupervisionError> {
307 let gate = self.gate(name)?;
308 let held = gate.lock().await;
309 let converged = self.converge_gated(name, mode).await;
310 drop(held);
311 converged
312 }
313
314 /// [`Self::converge`] with the per-name gate ALREADY held by the caller.
315 ///
316 /// Everything from the durable read to the spawn happens inside that gate:
317 /// two concurrent converges of one name are serialized, so the launch left
318 /// running is always the one the LAST durable write named.
319 async fn converge_gated(
320 &self,
321 name: &str,
322 mode: Convergence,
323 ) -> Result<ManagedWorkerStatus, SupervisionError> {
324 let record = self.record(name).await?;
325 if record.desired == DesiredState::Stopped {
326 self.tear_down(name).await?;
327 return Ok(status_of(&record, None, self.is_commissioned()));
328 }
329 // Commission is required only to RUN something. Checking it after the
330 // stopped arm above is deliberate: an uncommissioned server must still
331 // be able to converge a deployment DOWN.
332 let commission = self.require_commission()?.clone();
333 if mode == Convergence::Replacing {
334 self.tear_down(name).await?;
335 }
336 let snapshot = self.ensure_instance(&record, &commission)?;
337 Ok(status_of(&record, snapshot, self.is_commissioned()))
338 }
339
340 /// Drop one deployment out of supervision entirely, proving any live
341 /// instance stopped first, without touching durable state.
342 ///
343 /// For the transport that has just DELETED the record: a supervised process
344 /// whose record no longer exists is an orphan nothing can report on, stop,
345 /// or restart — `report()` joins the durable listing, so it would vanish
346 /// from every surface while still holding its port and its process group.
347 ///
348 /// # Errors
349 ///
350 /// Returns [`SupervisionError::StopIncomplete`] when the instance's process
351 /// group cannot be proven empty, [`SupervisionError::TaskLost`] when its
352 /// supervision task ended abnormally, and
353 /// [`SupervisionError::StatePoisoned`] when the instance map was poisoned.
354 pub async fn forget(&self, name: &str) -> Result<(), SupervisionError> {
355 let gate = self.gate(name)?;
356 let held = gate.lock().await;
357 let forgotten = self.tear_down(name).await;
358 drop(held);
359 forgotten
360 }
361
362 /// Remove any live instance for `name` and prove its process group empty.
363 /// Absent instances are a no-op, which is what makes every caller idempotent.
364 ///
365 /// The pid and process group are read BEFORE the stop, because the stop
366 /// consumes the handle: a failure to prove the group empty is the one
367 /// moment those two numbers matter, and they are gone by the time the
368 /// failure exists unless they are captured first.
369 async fn tear_down(&self, name: &str) -> Result<(), SupervisionError> {
370 let handle = self.instances()?.remove(name);
371 let Some(handle) = handle else {
372 return Ok(());
373 };
374 let identity = handle.snapshot().ok().map(|snapshot| ProcessIdentity {
375 pid: snapshot.pid,
376 process_group: snapshot.process_group,
377 });
378 handle
379 .stop(name)
380 .await
381 .map_err(|error| attach_process_identity(error, identity))
382 }
383
384 /// Ensure a live instance exists for `record`, returning its snapshot.
385 ///
386 /// The lock is held across the liveness test and the insert so two callers
387 /// cannot both observe "not live" and spawn two workers for one deployment.
388 fn ensure_instance(
389 &self,
390 record: &WorkerDeployment,
391 commission: &Commission,
392 ) -> Result<Option<InstanceSnapshot>, SupervisionError> {
393 let mut instances = self.instances()?;
394 let live = instances
395 .get(&record.name)
396 .is_some_and(|handle| !handle.is_finished());
397 if !live {
398 let handle = InstanceHandle::start(self.instance_config(record, commission));
399 drop(instances.insert(record.name.clone(), handle));
400 }
401 instances
402 .get(&record.name)
403 .map(InstanceHandle::snapshot)
404 .transpose()
405 }
406
407 /// Stop one deployment and record the intent durably. The durable
408 /// desired-state write is published onto the cluster channel, so the
409 /// console's live feed sees it whichever transport asked.
410 ///
411 /// The returned status is written only once the process group has been
412 /// probed empty; a group that survives the termination ladder produces
413 /// [`SupervisionError::StopIncomplete`] instead, so a caller can never read
414 /// "stopped" off bookkeeping alone.
415 ///
416 /// # Errors
417 ///
418 /// Returns [`SupervisionError::UnknownDeployment`] for an absent record,
419 /// [`SupervisionError::Store`] when the intent cannot be written,
420 /// [`SupervisionError::StopIncomplete`] when the group cannot be proven
421 /// empty, and [`SupervisionError::TaskLost`] when the supervision task
422 /// ended abnormally.
423 pub async fn stop(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
424 let gate = self.gate(name)?;
425 let held = gate.lock().await;
426 let stopped = self.stop_gated(name).await;
427 drop(held);
428 stopped
429 }
430
431 /// [`Self::stop`] with the per-name gate ALREADY held by the caller.
432 async fn stop_gated(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
433 let record = self
434 .store
435 .set_desired_state(name, DesiredState::Stopped)
436 .await
437 .map_err(|source| SupervisionError::Store { source })?
438 .ok_or_else(|| SupervisionError::UnknownDeployment {
439 name: name.to_owned(),
440 })?;
441 self.publish_desired_state(&record);
442 self.tear_down(name).await?;
443 Ok(status_of(&record, None, self.is_commissioned()))
444 }
445
446 /// Stop and start one deployment, leaving desired state at `Running`.
447 ///
448 /// Publishes exactly what it durably writes: a restart of an
449 /// already-`Running` deployment changes no desired state and emits no
450 /// desired-state event; one that flips it inherits [`Self::start`]'s.
451 ///
452 /// # Errors
453 ///
454 /// Returns the same failures as [`Self::stop`] and [`Self::start`].
455 pub async fn restart(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
456 // ONE gate for the whole stop-then-start, taken here and held across
457 // both halves: a restart that released it between them would let
458 // another caller's converge spawn into the gap and then be torn down
459 // by this restart's own start.
460 let gate = self.gate(name)?;
461 let held = gate.lock().await;
462 let restarted = self.restart_gated(name).await;
463 drop(held);
464 restarted
465 }
466
467 /// [`Self::restart`] with the per-name gate ALREADY held by the caller.
468 async fn restart_gated(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
469 self.require_commission()?;
470 self.record(name).await?;
471 self.tear_down(name).await?;
472 self.start_gated(name).await
473 }
474
475 /// Bring the fleet to its durable desired state.
476 ///
477 /// Called at boot and safe to call again: deployments already supervised
478 /// are left alone. Returns the number of deployments now supervised.
479 ///
480 /// Each record is brought up under its OWN per-name gate, and its record is
481 /// re-read inside it: a reconcile running beside a concurrent PUT must not
482 /// spawn the argv it happened to list before that write landed.
483 ///
484 /// # Errors
485 ///
486 /// Returns [`SupervisionError::NotCommissioned`] when no policy is
487 /// installed, and [`SupervisionError::Store`] when the durable listing
488 /// cannot be read.
489 pub async fn reconcile(&self) -> Result<usize, SupervisionError> {
490 drop(self.require_commission()?.clone());
491 let listing = self.listing().await?;
492 let mut supervised = 0_usize;
493 for record in listing
494 .deployments
495 .iter()
496 .filter(|record| record.desired == DesiredState::Running)
497 {
498 match self.converge(&record.name, Convergence::Idempotent).await {
499 Ok(_) => supervised = supervised.saturating_add(1),
500 // One record that cannot be converged must not abandon the
501 // rest of the fleet: a boot that stops at the first failure
502 // leaves every later deployment unsupervised and silent.
503 Err(error) => tracing::error!(
504 worker = record.name.as_str(),
505 %error,
506 "worker deployment could not be converged; it is not being supervised"
507 ),
508 }
509 }
510 for poisoned in &listing.undecodable {
511 tracing::error!(
512 worker = poisoned.name.as_str(),
513 error = poisoned.error.as_str(),
514 "worker deployment record could not be decoded; it is not being supervised"
515 );
516 }
517 Ok(supervised)
518 }
519
520 /// Join durable intent with live supervision for every deployment.
521 ///
522 /// # Errors
523 ///
524 /// Returns [`SupervisionError::Store`] when the durable listing cannot be
525 /// read, and [`SupervisionError::StatePoisoned`] when a status cell was
526 /// poisoned by a panicking holder.
527 pub async fn report(&self) -> Result<ManagedWorkerReport, SupervisionError> {
528 let listing = self.listing().await?;
529 let commissioned = self.is_commissioned();
530 let mut workers = Vec::with_capacity(listing.deployments.len());
531 {
532 let instances = self.instances()?;
533 for record in &listing.deployments {
534 let snapshot = instances
535 .get(&record.name)
536 .map(InstanceHandle::snapshot)
537 .transpose()?;
538 workers.push(status_of(record, snapshot, commissioned));
539 }
540 }
541 Ok(ManagedWorkerReport {
542 commissioned,
543 remedy: (!commissioned).then(|| UNCOMMISSIONED_REMEDY.to_owned()),
544 workers,
545 undecodable: listing
546 .undecodable
547 .iter()
548 .map(|poisoned| poisoned.name.clone())
549 .collect(),
550 auto_provision: self.auto_provision_log(),
551 })
552 }
553
554 /// Stop every supervised instance, for server shutdown.
555 ///
556 /// Durable desired state is deliberately NOT changed: a server going down
557 /// is not an operator asking for the fleet to stay down, and the next boot
558 /// reconciles it back up. The report names both sides: every instance
559 /// proven stopped (its process group observed empty) and one failure per
560 /// instance that could not be — an empty failure list is the proof that
561 /// no worker was orphaned, and the stopped names let the shutdown
562 /// outcome record say WHICH workers went down rather than a count.
563 pub async fn shutdown(&self) -> FleetShutdownReport {
564 let handles = match self.instances() {
565 Ok(mut instances) => std::mem::take(&mut *instances),
566 Err(error) => {
567 return FleetShutdownReport {
568 stopped: Vec::new(),
569 failures: vec![error],
570 };
571 }
572 };
573 let mut report = FleetShutdownReport {
574 stopped: Vec::new(),
575 failures: Vec::new(),
576 };
577 for (name, handle) in handles {
578 match handle.stop(&name).await {
579 Ok(()) => report.stopped.push(name),
580 Err(error) => report.failures.push(error),
581 }
582 }
583 report
584 }
585
586 /// Publish one durable desired-state write onto the cluster channel — the
587 /// SAME event the worker-deployment desired-state endpoint publishes for
588 /// its writes, so the live feed cannot tell the transports apart.
589 ///
590 /// Called exactly where a write happened, never speculatively: an event
591 /// here is proof of a durable change. The emitted event's return value is
592 /// dropped deliberately — a channel with no subscribers is the calm state,
593 /// not a failure.
594 fn publish_desired_state(&self, record: &WorkerDeployment) {
595 let name = record.name.clone();
596 let desired_state = record.desired;
597 drop(
598 self.publisher
599 .emit(|meta| ClusterEvent::WorkerDeploymentDesiredStateChanged {
600 meta,
601 name,
602 desired_state,
603 }),
604 );
605 }
606
607 fn instance_config(
608 &self,
609 record: &WorkerDeployment,
610 commission: &Commission,
611 ) -> InstanceConfig {
612 let WorkerArtifactRef::Builtin { verb } = &record.artifact;
613 InstanceConfig {
614 name: record.name.clone(),
615 verb: verb.clone(),
616 executable: commission.executable.clone(),
617 policy: commission.policy,
618 store: Arc::clone(&self.store),
619 }
620 }
621}
622
623/// The two numbers an operator needs to find a process the server could not
624/// prove stopped, captured before the stop consumes the handle that holds them.
625struct ProcessIdentity {
626 pid: Option<u32>,
627 process_group: Option<i32>,
628}
629
630/// Name the surviving process group in a stop failure.
631///
632/// Only [`SupervisionError::StopIncomplete`] is rewritten, and only when the
633/// identity was actually read: every other failure is about something else, and
634/// an unknown pid is reported as unknown rather than as a number nobody
635/// measured.
636fn attach_process_identity(
637 error: SupervisionError,
638 identity: Option<ProcessIdentity>,
639) -> SupervisionError {
640 match (error, identity) {
641 (SupervisionError::StopIncomplete { name, detail }, Some(identity)) => {
642 let pid = identity
643 .pid
644 .map_or_else(|| String::from("unknown"), |pid| pid.to_string());
645 let group = identity
646 .process_group
647 .map_or_else(|| String::from("unknown"), |group| group.to_string());
648 SupervisionError::StopIncomplete {
649 name,
650 detail: format!("{detail} (last observed pid {pid}, process group {group})"),
651 }
652 }
653 (error, _) => error,
654 }
655}
656
657/// Join one durable record with one live snapshot.
658fn status_of(
659 record: &WorkerDeployment,
660 snapshot: Option<InstanceSnapshot>,
661 commissioned: bool,
662) -> ManagedWorkerStatus {
663 // Three different absences, three different answers. An instance that
664 // exists but has not spoken yet is STARTING; a server that could not have
665 // supervised is UNSUPERVISED; only genuinely nothing-to-run is STOPPED.
666 // Collapsing any of them into another is how a status surface comes to
667 // report a terminal state about work that is under way.
668 let supervised = snapshot.is_some();
669 let snapshot = snapshot.unwrap_or_default();
670 let state = snapshot.state.unwrap_or({
671 if supervised {
672 ManagedWorkerState::Starting
673 } else if record.desired == DesiredState::Running && !commissioned {
674 ManagedWorkerState::Uncommissioned
675 } else {
676 ManagedWorkerState::Stopped
677 }
678 });
679 ManagedWorkerStatus {
680 name: record.name.clone(),
681 task_queue: record.task_queue.clone(),
682 desired: record.desired,
683 state,
684 pid: snapshot.pid,
685 process_group: snapshot.process_group,
686 restarts: snapshot.restarts,
687 last_exit: snapshot.last_exit,
688 last_error: snapshot.last_error,
689 deployed_binary: record.binary.clone(),
690 spawn_binary: snapshot.spawn_binary,
691 }
692}