Skip to main content

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;
23
24use super::error::SupervisionError;
25use super::executable::ManagedExecutable;
26use super::instance::{InstanceConfig, InstanceHandle, InstanceSnapshot};
27use super::policy::{SupervisionPolicy, UNCOMMISSIONED_REMEDY};
28use super::status::{ManagedWorkerReport, ManagedWorkerState, ManagedWorkerStatus};
29
30/// What an operator commissioned: how to supervise, and what to launch.
31///
32/// Both halves arrive together and neither can be swapped afterwards. They
33/// belong to the same decision — a restart discipline is meaningless without
34/// knowing what is being restarted — and holding them in one write-once cell
35/// is what makes "commissioned" a single, checkable fact.
36#[derive(Clone, Debug)]
37struct Commission {
38    policy: SupervisionPolicy,
39    executable: ManagedExecutable,
40}
41
42/// What [`WorkerSupervisor::shutdown`] observed, by name on both sides.
43#[derive(Debug, Default)]
44pub struct FleetShutdownReport {
45    /// Deployments proven stopped: each stop returned only after a
46    /// signal-zero probe found the worker's process group empty.
47    pub stopped: Vec<String>,
48    /// One entry per deployment that could NOT be proven stopped, carrying
49    /// the observation that contradicted it.
50    pub failures: Vec<SupervisionError>,
51}
52
53/// Server-owned supervision of the managed worker fleet.
54pub struct WorkerSupervisor {
55    store: Arc<dyn WorkerDeploymentStore>,
56    commission: OnceLock<Commission>,
57    instances: Mutex<BTreeMap<String, InstanceHandle>>,
58    publisher: ClusterEventPublisher,
59}
60
61impl WorkerSupervisor {
62    /// Build an UNCOMMISSIONED supervisor over the durable deployment store,
63    /// publishing desired-state changes onto the deployment-global cluster
64    /// channel.
65    ///
66    /// The publisher lives HERE — not in the transports — so every desired-state
67    /// write the supervisor makes ([`Self::start`], [`Self::stop`], and
68    /// [`Self::restart`] through them) reaches the console's live feed
69    /// identically whichever transport asked, exactly as the
70    /// worker-deployment desired-state endpoint publishes its own writes.
71    ///
72    /// Nothing is supervised until [`Self::commission`] installs an operator
73    /// policy: construction is not commissioning, so a server that boots
74    /// without the config section never spawns anything.
75    #[must_use]
76    pub fn new(store: Arc<dyn WorkerDeploymentStore>, publisher: ClusterEventPublisher) -> Self {
77        Self {
78            store,
79            commission: OnceLock::new(),
80            instances: Mutex::new(BTreeMap::new()),
81            publisher,
82        }
83    }
84
85    /// Install the operator's supervision policy and the executable a
86    /// `builtin` deployment means on this server.
87    ///
88    /// Returns false when a commission was already installed, in which case
89    /// the existing one stands: the restart discipline of a running fleet is
90    /// not something a later caller gets to swap out underneath it.
91    pub fn commission(&self, policy: SupervisionPolicy, executable: ManagedExecutable) -> bool {
92        self.commission
93            .set(Commission { policy, executable })
94            .is_ok()
95    }
96
97    /// The installed policy, if this server has one.
98    #[must_use]
99    pub fn policy(&self) -> Option<SupervisionPolicy> {
100        self.commission.get().map(|commission| commission.policy)
101    }
102
103    /// Whether an operator commission is installed.
104    #[must_use]
105    pub fn is_commissioned(&self) -> bool {
106        self.commission.get().is_some()
107    }
108
109    fn require_commission(&self) -> Result<&Commission, SupervisionError> {
110        self.commission
111            .get()
112            .ok_or(SupervisionError::NotCommissioned)
113    }
114
115    fn instances(
116        &self,
117    ) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, InstanceHandle>>, SupervisionError> {
118        self.instances
119            .lock()
120            .map_err(|poison| SupervisionError::StatePoisoned {
121                detail: poison.to_string(),
122            })
123    }
124
125    async fn record(&self, name: &str) -> Result<WorkerDeployment, SupervisionError> {
126        self.store
127            .get_worker_deployment(name)
128            .await
129            .map_err(|source| SupervisionError::Store { source })?
130            .ok_or_else(|| SupervisionError::UnknownDeployment {
131                name: name.to_owned(),
132            })
133    }
134
135    async fn listing(&self) -> Result<WorkerDeploymentListing, SupervisionError> {
136        self.store
137            .list_worker_deployments()
138            .await
139            .map_err(|source| SupervisionError::Store { source })
140    }
141
142    /// Start (or adopt) supervision of one deployment, recording the intent
143    /// durably first. A durable desired-state flip is published onto the
144    /// cluster channel, so the console's live feed sees it whichever transport
145    /// asked.
146    ///
147    /// Idempotent: a deployment already being supervised is reported as it is,
148    /// without a second process (and, already desiring `Running`, without a
149    /// second event).
150    ///
151    /// # Errors
152    ///
153    /// Returns [`SupervisionError::NotCommissioned`] when no policy is
154    /// installed — carrying the remedy — [`SupervisionError::UnknownDeployment`]
155    /// for a name with no durable record, and [`SupervisionError::Store`] when
156    /// the record cannot be read or the intent cannot be written.
157    pub async fn start(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
158        let commission = self.require_commission()?.clone();
159        let record = self.record(name).await?;
160        let record = if record.desired == DesiredState::Running {
161            record
162        } else {
163            let record = self
164                .store
165                .set_desired_state(name, DesiredState::Running)
166                .await
167                .map_err(|source| SupervisionError::Store { source })?
168                .ok_or_else(|| SupervisionError::UnknownDeployment {
169                    name: name.to_owned(),
170                })?;
171            self.publish_desired_state(&record);
172            record
173        };
174
175        let snapshot = {
176            let mut instances = self.instances()?;
177            let live = instances
178                .get(name)
179                .is_some_and(|handle| !handle.is_finished());
180            if !live {
181                let handle = InstanceHandle::start(self.instance_config(&record, &commission));
182                drop(instances.insert(record.name.clone(), handle));
183            }
184            instances
185                .get(name)
186                .map(InstanceHandle::snapshot)
187                .transpose()?
188        };
189        Ok(status_of(&record, snapshot, self.is_commissioned()))
190    }
191
192    /// Stop one deployment and record the intent durably. The durable
193    /// desired-state write is published onto the cluster channel, so the
194    /// console's live feed sees it whichever transport asked.
195    ///
196    /// The returned status is written only once the process group has been
197    /// probed empty; a group that survives the termination ladder produces
198    /// [`SupervisionError::StopIncomplete`] instead, so a caller can never read
199    /// "stopped" off bookkeeping alone.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`SupervisionError::UnknownDeployment`] for an absent record,
204    /// [`SupervisionError::Store`] when the intent cannot be written,
205    /// [`SupervisionError::StopIncomplete`] when the group cannot be proven
206    /// empty, and [`SupervisionError::TaskLost`] when the supervision task
207    /// ended abnormally.
208    pub async fn stop(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
209        let record = self
210            .store
211            .set_desired_state(name, DesiredState::Stopped)
212            .await
213            .map_err(|source| SupervisionError::Store { source })?
214            .ok_or_else(|| SupervisionError::UnknownDeployment {
215                name: name.to_owned(),
216            })?;
217        self.publish_desired_state(&record);
218        let handle = self.instances()?.remove(name);
219        if let Some(handle) = handle {
220            handle.stop(name).await?;
221        }
222        Ok(status_of(&record, None, self.is_commissioned()))
223    }
224
225    /// Stop and start one deployment, leaving desired state at `Running`.
226    ///
227    /// Publishes exactly what it durably writes: a restart of an
228    /// already-`Running` deployment changes no desired state and emits no
229    /// desired-state event; one that flips it inherits [`Self::start`]'s.
230    ///
231    /// # Errors
232    ///
233    /// Returns the same failures as [`Self::stop`] and [`Self::start`].
234    pub async fn restart(&self, name: &str) -> Result<ManagedWorkerStatus, SupervisionError> {
235        self.require_commission()?;
236        self.record(name).await?;
237        let handle = self.instances()?.remove(name);
238        if let Some(handle) = handle {
239            handle.stop(name).await?;
240        }
241        self.start(name).await
242    }
243
244    /// Bring the fleet to its durable desired state.
245    ///
246    /// Called at boot and safe to call again: deployments already supervised
247    /// are left alone. Returns the number of deployments now supervised.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`SupervisionError::NotCommissioned`] when no policy is
252    /// installed, and [`SupervisionError::Store`] when the durable listing
253    /// cannot be read.
254    pub async fn reconcile(&self) -> Result<usize, SupervisionError> {
255        let commission = self.require_commission()?.clone();
256        let listing = self.listing().await?;
257        let mut supervised = 0_usize;
258        for record in listing
259            .deployments
260            .iter()
261            .filter(|record| record.desired == DesiredState::Running)
262        {
263            let mut instances = self.instances()?;
264            let live = instances
265                .get(&record.name)
266                .is_some_and(|handle| !handle.is_finished());
267            if !live {
268                let handle = InstanceHandle::start(self.instance_config(record, &commission));
269                drop(instances.insert(record.name.clone(), handle));
270            }
271            supervised = supervised.saturating_add(1);
272        }
273        for poisoned in &listing.undecodable {
274            tracing::error!(
275                worker = poisoned.name.as_str(),
276                error = poisoned.error.as_str(),
277                "worker deployment record could not be decoded; it is not being supervised"
278            );
279        }
280        Ok(supervised)
281    }
282
283    /// Join durable intent with live supervision for every deployment.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`SupervisionError::Store`] when the durable listing cannot be
288    /// read, and [`SupervisionError::StatePoisoned`] when a status cell was
289    /// poisoned by a panicking holder.
290    pub async fn report(&self) -> Result<ManagedWorkerReport, SupervisionError> {
291        let listing = self.listing().await?;
292        let commissioned = self.is_commissioned();
293        let mut workers = Vec::with_capacity(listing.deployments.len());
294        {
295            let instances = self.instances()?;
296            for record in &listing.deployments {
297                let snapshot = instances
298                    .get(&record.name)
299                    .map(InstanceHandle::snapshot)
300                    .transpose()?;
301                workers.push(status_of(record, snapshot, commissioned));
302            }
303        }
304        Ok(ManagedWorkerReport {
305            commissioned,
306            remedy: (!commissioned).then(|| UNCOMMISSIONED_REMEDY.to_owned()),
307            workers,
308            undecodable: listing
309                .undecodable
310                .iter()
311                .map(|poisoned| poisoned.name.clone())
312                .collect(),
313        })
314    }
315
316    /// Stop every supervised instance, for server shutdown.
317    ///
318    /// Durable desired state is deliberately NOT changed: a server going down
319    /// is not an operator asking for the fleet to stay down, and the next boot
320    /// reconciles it back up. The report names both sides: every instance
321    /// proven stopped (its process group observed empty) and one failure per
322    /// instance that could not be — an empty failure list is the proof that
323    /// no worker was orphaned, and the stopped names let the shutdown
324    /// outcome record say WHICH workers went down rather than a count.
325    pub async fn shutdown(&self) -> FleetShutdownReport {
326        let handles = match self.instances() {
327            Ok(mut instances) => std::mem::take(&mut *instances),
328            Err(error) => {
329                return FleetShutdownReport {
330                    stopped: Vec::new(),
331                    failures: vec![error],
332                };
333            }
334        };
335        let mut report = FleetShutdownReport {
336            stopped: Vec::new(),
337            failures: Vec::new(),
338        };
339        for (name, handle) in handles {
340            match handle.stop(&name).await {
341                Ok(()) => report.stopped.push(name),
342                Err(error) => report.failures.push(error),
343            }
344        }
345        report
346    }
347
348    /// Publish one durable desired-state write onto the cluster channel — the
349    /// SAME event the worker-deployment desired-state endpoint publishes for
350    /// its writes, so the live feed cannot tell the transports apart.
351    ///
352    /// Called exactly where a write happened, never speculatively: an event
353    /// here is proof of a durable change. The emitted event's return value is
354    /// dropped deliberately — a channel with no subscribers is the calm state,
355    /// not a failure.
356    fn publish_desired_state(&self, record: &WorkerDeployment) {
357        let name = record.name.clone();
358        let desired_state = record.desired;
359        drop(
360            self.publisher
361                .emit(|meta| ClusterEvent::WorkerDeploymentDesiredStateChanged {
362                    meta,
363                    name,
364                    desired_state,
365                }),
366        );
367    }
368
369    fn instance_config(
370        &self,
371        record: &WorkerDeployment,
372        commission: &Commission,
373    ) -> InstanceConfig {
374        let WorkerArtifactRef::Builtin { verb } = &record.artifact;
375        InstanceConfig {
376            name: record.name.clone(),
377            verb: verb.clone(),
378            executable: commission.executable.clone(),
379            policy: commission.policy,
380            store: Arc::clone(&self.store),
381        }
382    }
383}
384
385/// Join one durable record with one live snapshot.
386fn status_of(
387    record: &WorkerDeployment,
388    snapshot: Option<InstanceSnapshot>,
389    commissioned: bool,
390) -> ManagedWorkerStatus {
391    // Three different absences, three different answers. An instance that
392    // exists but has not spoken yet is STARTING; a server that could not have
393    // supervised is UNSUPERVISED; only genuinely nothing-to-run is STOPPED.
394    // Collapsing any of them into another is how a status surface comes to
395    // report a terminal state about work that is under way.
396    let supervised = snapshot.is_some();
397    let snapshot = snapshot.unwrap_or_default();
398    let state = snapshot.state.unwrap_or({
399        if supervised {
400            ManagedWorkerState::Starting
401        } else if record.desired == DesiredState::Running && !commissioned {
402            ManagedWorkerState::Uncommissioned
403        } else {
404            ManagedWorkerState::Stopped
405        }
406    });
407    ManagedWorkerStatus {
408        name: record.name.clone(),
409        task_queue: record.task_queue.clone(),
410        desired: record.desired,
411        state,
412        pid: snapshot.pid,
413        process_group: snapshot.process_group,
414        restarts: snapshot.restarts,
415        last_exit: snapshot.last_exit,
416        last_error: snapshot.last_error,
417        deployed_binary: record.binary.clone(),
418        spawn_binary: snapshot.spawn_binary,
419    }
420}