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