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}