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}