aion/workloop/service.rs
1//! The engine-side cadence service: dead-man switch, tolerance sweep, wake.
2//!
3//! ONE service task sweeps every registered loop off the store's
4//! `due_workloops` set — a sleeping loop is store bytes plus this row's
5//! `next_check_at`, with no resident process, no timer-wheel entry, and no
6//! per-loop task (R13.3). Missed-window evaluation happens HERE, engine-side,
7//! never inside the loop's own fault domain (R4.3): a loop crash is the
8//! simultaneous non-confirmation of every invariant it carried, which is what
9//! makes the single alarm path total (R4.2).
10
11use std::sync::Arc;
12use std::time::Duration;
13
14use aion_core::{HealthSample, HealthStatus, InvariantAlarm, WorkflowId, WorkloopSpec};
15use aion_store::workloop::{WorkloopRecord, WorkloopStore};
16use async_trait::async_trait;
17use chrono::{DateTime, Utc};
18
19use super::error::WorkloopError;
20use super::health::{
21 UnconfirmedEvidence, latch_alarm, observe_confirmed, observe_unconfirmed, peek_alarm,
22};
23use super::windows::{advance_window, initial_window, next_check_at};
24use crate::engine_seam::RecordOutcome;
25
26/// Durable append seam for engine-raised workloop events. Both paths MUST go
27/// through the target loop's single Recorder (the one-writer law).
28#[async_trait]
29pub trait LoopEventSink: Send + Sync {
30 /// Record `CadenceFired { window_seq }` in the loop's history.
31 ///
32 /// Returns [`RecordOutcome::RefusedTerminal`] when the loop's active run
33 /// already holds a terminal — a dead loop has no windows, and the refusal
34 /// is the engine's positive evidence of death.
35 ///
36 /// # Errors
37 ///
38 /// Returns [`WorkloopError`] when the append fails outright.
39 async fn record_cadence_fired(
40 &self,
41 loop_id: &WorkflowId,
42 window_seq: u64,
43 ) -> Result<RecordOutcome, WorkloopError>;
44
45 /// Record `InvariantUnconfirmed` (the one alarm path) in the loop's
46 /// history. UNLIKE cadence fires, this append is honoured even after the
47 /// run's terminal: the alarm that reports a loop's death must not be
48 /// silenced by the very death it reports (R5.3's premise). The event is
49 /// status-invisible, so the terminal projection is untouched.
50 ///
51 /// # Errors
52 ///
53 /// Returns [`WorkloopError`] when the append fails outright.
54 async fn record_invariant_unconfirmed(
55 &self,
56 loop_id: &WorkflowId,
57 alarm: InvariantAlarm,
58 ) -> Result<(), WorkloopError>;
59}
60
61/// Wake seam: bring a suspended loop's current generation to a live process so
62/// the fired iteration can run (wake = load carry, run iteration, suspend —
63/// R13.3). Called only after the fire is durably recorded.
64#[async_trait]
65pub trait WorkloopWaker: Send + Sync {
66 /// Wake the loop's current generation.
67 ///
68 /// # Errors
69 ///
70 /// Returns [`WorkloopError`] when the generation cannot be woken; the
71 /// sweep reports the fault and the recorded fire stands (the next sweep's
72 /// dead-man evaluation counts the unrun window — a failed wake degrades to
73 /// a missed window, never to silence).
74 async fn wake(&self, loop_id: &WorkflowId) -> Result<(), WorkloopError>;
75}
76
77/// What one sweep pass did — the service's own observability surface.
78#[derive(Debug, Default)]
79pub struct SweepReport {
80 /// Loops evaluated this pass.
81 pub swept: usize,
82 /// Cadence fires recorded: (loop, window sequence).
83 pub fired: Vec<(WorkflowId, u64)>,
84 /// Alarms raised on the one path: (loop, alarm).
85 pub alarms: Vec<(WorkflowId, InvariantAlarm)>,
86 /// Loops found dead (cadence fire refused on a terminal run) and
87 /// deregistered after their loop-dead alarms were raised.
88 pub dead: Vec<WorkflowId>,
89 /// Loops found RETIRED (cadence fire refused on a run carrying
90 /// `LoopRetired`) and withdrawn from the sweep set with no alarms — a
91 /// declared stop, reported separately from a death so an operator reading
92 /// this report is never told a decommission was an incident.
93 pub retired: Vec<WorkflowId>,
94 /// Per-loop faults that did not stop the sweep, with their loop.
95 pub faults: Vec<(WorkflowId, String)>,
96}
97
98/// The engine-side cadence service. One instance, one sweep task, N loops.
99pub struct WorkloopService {
100 store: Arc<dyn WorkloopStore>,
101 sink: Arc<dyn LoopEventSink>,
102 waker: Arc<dyn WorkloopWaker>,
103 sweep_interval: Duration,
104 now: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>,
105}
106
107impl WorkloopService {
108 /// Creates the service. `sweep_interval` is the operator-declared sweep
109 /// cadence — REQUIRED, never defaulted, and it bounds dead-man detection
110 /// latency.
111 ///
112 /// # Errors
113 ///
114 /// Refuses a zero interval ([`WorkloopError::ZeroSweepInterval`]).
115 pub fn new(
116 store: Arc<dyn WorkloopStore>,
117 sink: Arc<dyn LoopEventSink>,
118 waker: Arc<dyn WorkloopWaker>,
119 sweep_interval: Duration,
120 ) -> Result<Self, WorkloopError> {
121 Self::with_clock(store, sink, waker, sweep_interval, Utc::now)
122 }
123
124 /// [`WorkloopService::new`] with an injected clock, for deterministic
125 /// tests.
126 ///
127 /// # Errors
128 ///
129 /// Refuses a zero interval ([`WorkloopError::ZeroSweepInterval`]).
130 pub fn with_clock(
131 store: Arc<dyn WorkloopStore>,
132 sink: Arc<dyn LoopEventSink>,
133 waker: Arc<dyn WorkloopWaker>,
134 sweep_interval: Duration,
135 now: impl Fn() -> DateTime<Utc> + Send + Sync + 'static,
136 ) -> Result<Self, WorkloopError> {
137 if sweep_interval.is_zero() {
138 return Err(WorkloopError::ZeroSweepInterval);
139 }
140 Ok(Self {
141 store,
142 sink,
143 waker,
144 sweep_interval,
145 now: Arc::new(now),
146 })
147 }
148
149 /// The declared sweep interval.
150 #[must_use]
151 pub const fn sweep_interval(&self) -> Duration {
152 self.sweep_interval
153 }
154
155 /// Registers a loop: persists its declared spec and arms the first cadence
156 /// window (and/or duration deadlines) on the sweep set.
157 ///
158 /// # Errors
159 ///
160 /// Refuses a duplicate registration and propagates store failures.
161 pub async fn register(
162 &self,
163 loop_id: WorkflowId,
164 namespace: String,
165 spec: WorkloopSpec,
166 ) -> Result<WorkloopRecord, WorkloopError> {
167 if self.store.get_workloop(&loop_id).await?.is_some() {
168 return Err(WorkloopError::AlreadyRegistered { loop_id });
169 }
170 let now = (self.now)();
171 let next_window_at = match spec.arming().cadence_period() {
172 Some(period) => Some(initial_window(now, period)?),
173 None => None,
174 };
175 let mut record = WorkloopRecord {
176 loop_id,
177 namespace,
178 spec,
179 window_seq: 0,
180 next_window_at,
181 next_check_at: None,
182 last_iteration_closed_window: None,
183 invariant_health: std::collections::BTreeMap::new(),
184 registered_at: now,
185 updated_at: now,
186 };
187 record.next_check_at = next_check_at(&record);
188 self.store.put_workloop(record.clone()).await?;
189 Ok(record)
190 }
191
192 /// Wakes a loop's current generation immediately — the signal-armed fire
193 /// (R2.4): a declared signal arrived, so the iteration runs now, no
194 /// window involved.
195 ///
196 /// # Errors
197 ///
198 /// Propagates the waker's failure.
199 pub async fn wake_now(&self, loop_id: &WorkflowId) -> Result<(), WorkloopError> {
200 self.waker.wake(loop_id).await
201 }
202
203 /// Deregisters a loop (retirement or death). Invariant current-state
204 /// records are untouched — the current record survives indefinitely
205 /// (R8.1).
206 ///
207 /// # Errors
208 ///
209 /// Propagates store failures.
210 pub async fn deregister(&self, loop_id: &WorkflowId) -> Result<bool, WorkloopError> {
211 Ok(self.store.remove_workloop(loop_id).await?)
212 }
213
214 /// Feed an iteration close into health accounting (R3.3): every declared
215 /// invariant is sampled on the same tick — `Confirmed` resets its
216 /// accounting, `Unconfirmed` accrues red-sample evidence and may exceed
217 /// the count-form tolerance immediately. Returns the alarms raised.
218 ///
219 /// # Errors
220 ///
221 /// Refuses samples naming undeclared invariants; propagates store and
222 /// append failures.
223 pub async fn note_iteration_closed(
224 &self,
225 loop_id: &WorkflowId,
226 samples: &[HealthSample],
227 ) -> Result<Vec<InvariantAlarm>, WorkloopError> {
228 let mut record = self.store.get_workloop(loop_id).await?.ok_or_else(|| {
229 WorkloopError::NotRegistered {
230 loop_id: loop_id.clone(),
231 }
232 })?;
233 for sample in samples {
234 if !record
235 .spec
236 .invariants()
237 .iter()
238 .any(|invariant| invariant.name == sample.invariant)
239 {
240 return Err(WorkloopError::UndeclaredInvariant {
241 loop_id: loop_id.clone(),
242 invariant: sample.invariant.clone(),
243 });
244 }
245 }
246 let now = (self.now)();
247 for sample in samples {
248 let state = record.invariant_health.entry(sample.invariant.clone());
249 let state = state.or_default();
250 match sample.status {
251 HealthStatus::Confirmed => observe_confirmed(state, now),
252 HealthStatus::Unconfirmed => {
253 observe_unconfirmed(state, UnconfirmedEvidence::SampleRed);
254 }
255 }
256 }
257 record.last_iteration_closed_window = Some(record.window_seq);
258
259 let mut alarms = Vec::new();
260 let window_ctx = window_context(&record);
261 let anchor = record.registered_at;
262 for invariant in record.spec.invariants().to_vec() {
263 let Some(state) = record.invariant_health.get_mut(&invariant.name) else {
264 continue;
265 };
266 if let Some(alarm) = peek_alarm(
267 &invariant.name,
268 &invariant.tolerance,
269 state,
270 anchor,
271 now,
272 window_ctx,
273 ) {
274 self.sink
275 .record_invariant_unconfirmed(loop_id, alarm.clone())
276 .await?;
277 latch_alarm(state);
278 alarms.push(alarm);
279 }
280 }
281
282 record.next_check_at = next_check_at(&record);
283 record.updated_at = now;
284 self.store.put_workloop(record).await?;
285 Ok(alarms)
286 }
287
288 /// One sweep pass over every due loop: fire elapsed cadence windows
289 /// (recording `CadenceFired` through the one Recorder, then waking the
290 /// generation), evaluate the dead-man switch (an iteration with no
291 /// terminal by its next window is a missed window — R3.3a — counted
292 /// against every invariant), and evaluate every declared tolerance,
293 /// raising `InvariantUnconfirmed` with its cause on the one alarm path.
294 ///
295 /// # Errors
296 ///
297 /// Returns an error only when the due-set itself cannot be read; per-loop
298 /// faults are carried in the report so one sick loop cannot silence the
299 /// sweep for the rest.
300 pub async fn tick(&self) -> Result<SweepReport, WorkloopError> {
301 let now = (self.now)();
302 let mut report = SweepReport::default();
303 for record in self.store.due_workloops(now).await? {
304 let loop_id = record.loop_id.clone();
305 if let Err(error) = self.sweep_loop(record, now, &mut report).await {
306 report.faults.push((loop_id, error.to_string()));
307 }
308 report.swept += 1;
309 }
310 Ok(report)
311 }
312
313 async fn sweep_loop(
314 &self,
315 mut record: WorkloopRecord,
316 now: DateTime<Utc>,
317 report: &mut SweepReport,
318 ) -> Result<(), WorkloopError> {
319 let loop_id = record.loop_id.clone();
320 let mut wake_pending = false;
321
322 // --- cadence half: fire the elapsed window (R4.3). ---
323 if let (Some(period), Some(window_at)) =
324 (record.spec.arming().cadence_period(), record.next_window_at)
325 && window_at <= now
326 {
327 // Dead-man first: did the PREVIOUS fired window's iteration close?
328 // An iteration that produced no terminal by its next window is a
329 // missed window, evaluated engine-side, never waited on (R3.3a).
330 if record.window_seq >= 1
331 && record.last_iteration_closed_window != Some(record.window_seq)
332 {
333 for invariant in record.spec.invariants().to_vec() {
334 let state = record
335 .invariant_health
336 .entry(invariant.name.clone())
337 .or_default();
338 observe_unconfirmed(state, UnconfirmedEvidence::WindowMissed);
339 }
340 }
341
342 let window_seq = record.window_seq.saturating_add(1);
343 match self.sink.record_cadence_fired(&loop_id, window_seq).await? {
344 // AlreadyRecorded is an acknowledgement-lost duplicate of OUR
345 // own append (single writer): the fire stands and the owed
346 // wake must still follow, exactly as for Recorded.
347 RecordOutcome::Recorded | RecordOutcome::AlreadyRecorded => {
348 record.window_seq = window_seq;
349 record.next_window_at = Some(advance_window(window_at, period, now)?);
350 report.fired.push((loop_id.clone(), window_seq));
351 wake_pending = true;
352 }
353 RecordOutcome::RefusedTerminal => {
354 // The loop's run is terminal without a declared retirement:
355 // the engine positively knows the loop cannot run. Loop
356 // death is the simultaneous non-confirmation of every
357 // invariant it carried — the missed-window event fanned out
358 // with cause loop-dead (R4.3), recorded even though the run
359 // is terminal.
360 return self.declare_loop_dead(record, report).await;
361 }
362 RecordOutcome::RefusedRetired => {
363 // 🔴 A RETIREMENT IS NOT A DEATH.
364 //
365 // Retirement records `LoopRetired` + its terminal and THEN
366 // withdraws the sweep-set row, so a sweep landing between
367 // those two steps finds a registered loop whose run is
368 // terminal — the same observation a dead loop produces.
369 // Answering it with `declare_loop_dead` wrote an
370 // `AlarmCause::LoopDead` against every invariant of a loop
371 // that was decommissioned on purpose, and those alarms are
372 // durable and permanent.
373 //
374 // The row is withdrawn here instead. Doing so is not a
375 // race with the retirement's own `deregister`: removal is
376 // idempotent and reports whether a row existed, and the
377 // retirement logs when it finds the row already gone.
378 let removed = self.store.remove_workloop(&loop_id).await?;
379 tracing::info!(
380 %loop_id,
381 row_existed = removed,
382 "sweep found a retired workloop still on the sweep set and withdrew it; \
383 a declared retirement raises no alarms"
384 );
385 report.retired.push(loop_id);
386 return Ok(());
387 }
388 }
389 }
390
391 // --- evaluation half: every declared tolerance, one alarm path. ---
392 let window_ctx = window_context(&record);
393 let anchor = record.registered_at;
394 for invariant in record.spec.invariants().to_vec() {
395 let state = record
396 .invariant_health
397 .entry(invariant.name.clone())
398 .or_default();
399 if let Some(alarm) = peek_alarm(
400 &invariant.name,
401 &invariant.tolerance,
402 state,
403 anchor,
404 now,
405 window_ctx,
406 ) {
407 self.sink
408 .record_invariant_unconfirmed(&loop_id, alarm.clone())
409 .await?;
410 latch_alarm(state);
411 report.alarms.push((loop_id.clone(), alarm));
412 }
413 }
414
415 record.next_check_at = next_check_at(&record);
416 record.updated_at = now;
417 self.store.put_workloop(record).await?;
418
419 if wake_pending && let Err(error) = self.waker.wake(&loop_id).await {
420 // The fire is durably recorded; a failed wake degrades to a
421 // missed window at the next sweep, never to silence.
422 report
423 .faults
424 .push((loop_id, format!("wake failed: {error}")));
425 }
426 Ok(())
427 }
428
429 async fn declare_loop_dead(
430 &self,
431 record: WorkloopRecord,
432 report: &mut SweepReport,
433 ) -> Result<(), WorkloopError> {
434 let loop_id = record.loop_id.clone();
435 let window_ctx = window_context(&record);
436 for invariant in record.spec.invariants() {
437 let state = record
438 .invariant_health
439 .get(&invariant.name)
440 .cloned()
441 .unwrap_or_default();
442 let alarm = InvariantAlarm {
443 invariant: invariant.name.clone(),
444 cause: aion_core::AlarmCause::LoopDead,
445 window_seq: window_ctx,
446 last_confirmed_at: state.last_confirmed_at,
447 consecutive_unconfirmed: state.consecutive_unconfirmed,
448 };
449 self.sink
450 .record_invariant_unconfirmed(&loop_id, alarm.clone())
451 .await?;
452 report.alarms.push((loop_id.clone(), alarm));
453 }
454 // Deregister: a dead loop has no windows to sweep. Its alarms are
455 // durably recorded, its history keeps the un-retired terminal, and
456 // its invariant current-state records survive indefinitely (R8.1).
457 self.store.remove_workloop(&loop_id).await?;
458 report.dead.push(loop_id);
459 Ok(())
460 }
461
462 /// Runs the sweep loop until `shutdown` flips true. One task for every
463 /// loop in the store — never a task per loop.
464 pub async fn run(self: Arc<Self>, mut shutdown: tokio::sync::watch::Receiver<bool>) {
465 let mut interval = tokio::time::interval(self.sweep_interval);
466 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
467 loop {
468 tokio::select! {
469 _ = interval.tick() => {
470 match self.tick().await {
471 Ok(report) => {
472 for (loop_id, fault) in &report.faults {
473 tracing::warn!(%loop_id, fault, "workloop sweep fault");
474 }
475 }
476 Err(error) => {
477 tracing::error!(%error, "workloop sweep pass failed");
478 }
479 }
480 }
481 changed = shutdown.changed() => {
482 if changed.is_err() || *shutdown.borrow() {
483 break;
484 }
485 }
486 }
487 }
488 }
489}
490
491/// Withdraws every registration whose workflow has NO recorded history — the
492/// boot half of `start_workloop`'s non-atomic register-then-start. Returns the
493/// loops withdrawn.
494///
495/// # 🔴 WHY THIS RUNS AT BOOT, BEFORE THE SWEEP TASK EXISTS, AND NOWHERE ELSE
496///
497/// `Engine::start_workloop` writes the sweep-set row BEFORE the workflow
498/// exists, and must: the reverse order lets generation 1 reach
499/// `close_iteration` before its own registration lands and fail the run. The
500/// price is a window in which a crash strands a row for a workflow that has no
501/// history and never will.
502///
503/// Nothing in a RUNNING process can tell that stranded row from the width of
504/// an in-flight start — they are the same observation — so a sweep that
505/// withdrew empty-history rows would race the very birth window the ordering
506/// exists to protect, and a loop would lose its registration between its start
507/// and its first close. A timeout would only make the race longer, and this
508/// codebase does not assume durations.
509///
510/// Boot has the fact the sweep lacks: no start is in flight across a process
511/// boundary. A registration whose workflow has no history at the instant this
512/// engine comes up is therefore provably orphaned, with no clock involved.
513/// It is a FREE function, called before the service and its sweep task are
514/// assembled, so the ordering is a property of the call site rather than a
515/// hope about which of two tasks ticks first.
516///
517/// # Errors
518///
519/// Propagates store failures; a row that cannot be decoded is left in place
520/// and reported, never silently withdrawn — an unreadable row is not evidence
521/// that its loop never started.
522pub async fn withdraw_unstarted_registrations(
523 workloop_store: &Arc<dyn WorkloopStore>,
524 store: &Arc<dyn aion_store::EventStore>,
525) -> Result<Vec<WorkflowId>, WorkloopError> {
526 let listing = workloop_store.list_workloops().await?;
527 let mut withdrawn = Vec::new();
528 for record in listing.workloops {
529 let loop_id = record.loop_id;
530 if !store.read_history(&loop_id).await?.is_empty() {
531 continue;
532 }
533 let existed = workloop_store.remove_workloop(&loop_id).await?;
534 tracing::warn!(
535 %loop_id,
536 row_existed = existed,
537 "withdrawing a workloop registration whose workflow has no recorded history: its \
538 start never landed, so the sweep set carried a row for a loop that never ran and \
539 the dead-man switch would have alarmed on it forever"
540 );
541 withdrawn.push(loop_id);
542 }
543 for row in listing.undecodable {
544 tracing::error!(
545 loop_id = %row.loop_id,
546 "workloop registration row does not decode; it is left in place rather than \
547 withdrawn, because an unreadable row is not evidence that its loop never started"
548 );
549 }
550 Ok(withdrawn)
551}
552
553/// The window context alarms carry: the current fired window on a cadenced
554/// loop that has fired at least once, `None` otherwise (signal-only loops
555/// have no windows; a loop that never fired has none yet).
556pub(crate) fn window_context(record: &WorkloopRecord) -> Option<u64> {
557 (record.spec.arming().cadence_period().is_some() && record.window_seq >= 1)
558 .then_some(record.window_seq)
559}