aion_store/workloop.rs
1//! Durable workloop records and persistence contract (workloop brief Leg 2).
2//!
3//! Two record families:
4//!
5//! - **[`WorkloopRecord`]** — one per registered loop: the declared
6//! [`WorkloopSpec`], the engine's cadence bookkeeping (window sequence, next
7//! window, next check), and per-invariant health accounting. A sleeping loop
8//! IS this record plus its event history — no resident process, no scheduler
9//! presence (R13.3). The cadence sweeper drives itself off
10//! [`WorkloopStore::due_workloops`].
11//! - **[`InvariantStateRecord`]** — the current-state record of one invariant
12//! (R7): a store document of its declared type, type-erased here as a
13//! [`Payload`] plus the declared type name. Exactly one CURRENT record per
14//! invariant survives indefinitely; prior generations are retention-bounded
15//! on the loop's declared window (R8.1) and live in the same single-key slot
16//! so every mutation is one atomic value swap on any backend.
17
18use aion_core::{Payload, WorkflowId, WorkloopSpec};
19use async_trait::async_trait;
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23
24use crate::StoreError;
25
26/// Per-invariant health accounting the engine persists between sweeps.
27///
28/// The values are COMPUTED by the engine's health machinery (pure, in
29/// `aion-rs`); the store only carries them so tolerance accounting survives a
30/// restart — an engine that forgot how many windows a loop had already missed
31/// would silently re-grant the whole tolerance budget on every boot.
32#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct InvariantHealthState {
35 /// When the invariant was last confirmed, if ever.
36 pub last_confirmed_at: Option<DateTime<Utc>>,
37 /// Consecutive unconfirmed samples/missed windows since the last
38 /// confirmation.
39 pub consecutive_unconfirmed: u64,
40 /// The kind of unconfirmed evidence last accrued (`sample-red` or
41 /// `window-missed`), so a duration-form expiry can name the evidence it
42 /// sits on; `None` when the invariant has accrued no unconfirmed evidence
43 /// since its last confirmation — the `unconfirmed-unknown` case.
44 pub last_evidence: Option<aion_core::AlarmCause>,
45 /// Whether the tolerance-exceeded alarm has already been raised for the
46 /// current unconfirmed run (edge-triggered: reset on confirmation, so a
47 /// sustained breach alarms once, not once per sweep).
48 pub alarmed: bool,
49}
50
51/// One durable workloop registration: the declared spec plus the engine's
52/// cadence and health bookkeeping.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct WorkloopRecord {
56 /// The loop's workflow identity (primary key).
57 pub loop_id: WorkflowId,
58 /// Namespace the loop runs in.
59 pub namespace: String,
60 /// The declared spec — re-validated on decode by [`WorkloopSpec`]'s own
61 /// serde boundary, so a stored record cannot drift out of its declared
62 /// invariants.
63 pub spec: WorkloopSpec,
64 /// Sequence number of the last cadence window that fired (0 = none yet;
65 /// fired windows are one-based).
66 pub window_seq: u64,
67 /// When the next cadence window fires; `None` on a signal-only loop.
68 pub next_window_at: Option<DateTime<Utc>>,
69 /// When the sweeper must next evaluate this loop: the minimum of
70 /// `next_window_at` and the earliest duration-form tolerance deadline.
71 /// `None` = nothing left to check engine-side (a signal-only loop whose
72 /// alarms are all latched); sleeping loops with a future value cost the
73 /// sweeper nothing.
74 pub next_check_at: Option<DateTime<Utc>>,
75 /// The window sequence of the last recorded iteration close, if any —
76 /// what the dead-man switch compares against the fired window (R4.3).
77 pub last_iteration_closed_window: Option<u64>,
78 /// Per-invariant health accounting, keyed by invariant name.
79 pub invariant_health: BTreeMap<String, InvariantHealthState>,
80 /// When the loop was registered.
81 pub registered_at: DateTime<Utc>,
82 /// Most recent mutation instant.
83 pub updated_at: DateTime<Utc>,
84}
85
86impl WorkloopRecord {
87 /// Encode the stable backend-neutral representation.
88 ///
89 /// # Errors
90 ///
91 /// Returns [`StoreError::Serialization`] when serialization fails.
92 pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
93 serde_json::to_vec(self).map_err(|error| StoreError::Serialization(error.to_string()))
94 }
95
96 /// Decode and re-validate a stored representation (the spec's serde
97 /// boundary re-runs every declaration check).
98 ///
99 /// # Errors
100 ///
101 /// Returns [`StoreError::Serialization`] for malformed bytes or a spec
102 /// that no longer satisfies its declaration invariants.
103 pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
104 serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
105 }
106}
107
108/// The current-state record of one invariant (R7): a store document of the
109/// invariant's declared type, type-erased as payload bytes + type name.
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct InvariantStateRecord {
113 /// Loop the invariant belongs to.
114 pub loop_id: WorkflowId,
115 /// Invariant name.
116 pub invariant: String,
117 /// The record value, typed at the surface by `record_type` and carried
118 /// type-erased here (the load-bearing type-erasure invariant).
119 pub payload: Payload,
120 /// Declared AWL type name of the value — provenance, never a schema.
121 pub record_type: String,
122 /// Cadence window the value was produced in; `None` on signal-only loops.
123 pub window_seq: Option<u64>,
124 /// When the value was recorded.
125 pub recorded_at: DateTime<Utc>,
126}
127
128/// The single-key slot holding one invariant's CURRENT record plus its
129/// retention-bounded prior generations (oldest first).
130///
131/// One value per (loop, invariant) key keeps every mutation — rotation on a
132/// new current, pruning to the declared window — a single atomic value swap,
133/// which every backend (including the distributed CAS path) supports without
134/// multi-key transactions. Retention is what keeps the slot bounded: the
135/// declared window is mandatory, so unbounded growth is unrepresentable.
136#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct InvariantRecordSlot {
139 /// The current record — survives indefinitely, NEVER pruned (R8.1).
140 pub current: InvariantStateRecord,
141 /// Prior generations within the retention window, oldest first.
142 pub previous: Vec<InvariantStateRecord>,
143}
144
145impl InvariantRecordSlot {
146 /// Encode the stable backend-neutral representation.
147 ///
148 /// # Errors
149 ///
150 /// Returns [`StoreError::Serialization`] when serialization fails.
151 pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
152 serde_json::to_vec(self).map_err(|error| StoreError::Serialization(error.to_string()))
153 }
154
155 /// Decode a stored representation.
156 ///
157 /// # Errors
158 ///
159 /// Returns [`StoreError::Serialization`] for malformed bytes.
160 pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
161 serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
162 }
163
164 /// Rotate `record` in as the new current, pushing the old current onto the
165 /// generation list.
166 #[must_use]
167 pub fn rotated(mut self, record: InvariantStateRecord) -> Self {
168 self.previous.push(self.current);
169 self.current = record;
170 self
171 }
172
173 /// Drop prior generations recorded before `older_than`, returning how many
174 /// were removed. The current record is never touched (R8.1).
175 pub fn prune(&mut self, older_than: DateTime<Utc>) -> u64 {
176 let before = self.previous.len();
177 self.previous
178 .retain(|record| record.recorded_at >= older_than);
179 u64::try_from(before.saturating_sub(self.previous.len())).unwrap_or(u64::MAX)
180 }
181}
182
183/// The one field the due sweep filters on, decodable WITHOUT validating the
184/// whole record: a thousand sleeping loops must cost the sweeper a comparison
185/// each, not a full spec re-validation each (the R13.3 measurement is what
186/// caught the difference — ~20µs per full decode versus nanoseconds per
187/// comparison).
188#[derive(Clone, Copy, Debug, Deserialize)]
189pub struct WorkloopDueProbe {
190 /// Mirror of [`WorkloopRecord::next_check_at`].
191 pub next_check_at: Option<DateTime<Utc>>,
192}
193
194impl WorkloopDueProbe {
195 /// Probe-decode just the due instant from stored workloop bytes.
196 ///
197 /// # Errors
198 ///
199 /// Returns [`StoreError::Serialization`] for bytes that are not a JSON
200 /// object carrying the field — a poisoned row, which the sweep skips and
201 /// the listing surfaces.
202 pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
203 serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
204 }
205}
206
207/// A workloop row that was present but could not be decoded.
208#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
209pub struct UndecodableWorkloop {
210 /// Key under which the poisoned row is stored (the loop id's text form).
211 pub loop_id: String,
212 /// Decode failure rendered for operator diagnosis.
213 pub error: String,
214}
215
216/// Complete workloop listing, including poisoned-row visibility.
217#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
218pub struct WorkloopListing {
219 /// Successfully decoded records, ordered by loop id text.
220 pub workloops: Vec<WorkloopRecord>,
221 /// Present rows that could not be decoded, ordered by loop id text.
222 pub undecodable: Vec<UndecodableWorkloop>,
223}
224
225/// Durable workloop persistence contract.
226#[async_trait]
227pub trait WorkloopStore: Send + Sync + 'static {
228 /// Create or replace a loop's registration record.
229 async fn put_workloop(&self, record: WorkloopRecord) -> Result<(), StoreError>;
230
231 /// Look up one loop by id.
232 async fn get_workloop(
233 &self,
234 loop_id: &WorkflowId,
235 ) -> Result<Option<WorkloopRecord>, StoreError>;
236
237 /// List decodable loops and report every undecodable row, both ordered by
238 /// loop id text.
239 async fn list_workloops(&self) -> Result<WorkloopListing, StoreError>;
240
241 /// Loops whose `next_check_at` is `Some` and at or before `as_of` — the
242 /// sweeper's work set. A sleeping loop with a future (or absent) check
243 /// instant never appears, which is what makes a thousand sleeping loops
244 /// cost the sweeper nothing. Undecodable rows are excluded here (they
245 /// surface via [`WorkloopStore::list_workloops`], never silently in the
246 /// hot path).
247 async fn due_workloops(&self, as_of: DateTime<Utc>) -> Result<Vec<WorkloopRecord>, StoreError>;
248
249 /// Remove a loop's registration (retirement). Returns whether a row
250 /// existed. Invariant current-state records are NOT removed: the current
251 /// record survives indefinitely by declaration (R8.1).
252 async fn remove_workloop(&self, loop_id: &WorkflowId) -> Result<bool, StoreError>;
253
254 /// Install `record` as the invariant's current record — rotating any prior
255 /// current into the generation list — and prune prior generations recorded
256 /// before `prune_before`, in ONE read-modify-write. Returns how many prior
257 /// generations the prune removed. The current record is never pruned; it
258 /// survives indefinitely by declaration (R8.1).
259 ///
260 /// # 🔴 THE INSTALL AND THE PRUNE ARE ONE COMMIT, DELIBERATELY
261 ///
262 /// Both halves address the SAME key — one invariant's slot — and the close
263 /// path performs them back to back on every park. Two separate
264 /// read-modify-write commits made a park cost `2 + 2N` spine-linear
265 /// commits for `N` declared invariants; folding them makes it `2 + N`. The
266 /// second commit read back bytes the first had just written, paid a second
267 /// durable round trip for them, and could interleave with nothing useful:
268 /// a park between them would observe a slot whose retention window had not
269 /// yet been applied. One commit removes both the cost and that window.
270 ///
271 /// Retention is therefore not a separate verb that a caller could forget:
272 /// installing a record IS what prunes the ones it aged out, so declared
273 /// retention is retention that happens.
274 async fn put_invariant_record(
275 &self,
276 record: InvariantStateRecord,
277 prune_before: DateTime<Utc>,
278 ) -> Result<u64, StoreError>;
279
280 /// The invariant's current record, if one was ever produced.
281 async fn current_invariant_record(
282 &self,
283 loop_id: &WorkflowId,
284 invariant: &str,
285 ) -> Result<Option<InvariantStateRecord>, StoreError>;
286
287 /// The invariant's prior generations within retention, oldest first.
288 async fn invariant_record_generations(
289 &self,
290 loop_id: &WorkflowId,
291 invariant: &str,
292 ) -> Result<Vec<InvariantStateRecord>, StoreError>;
293}
294
295#[cfg(test)]
296mod tests {
297 use std::time::Duration;
298
299 use aion_core::{ContentType, InvariantSpec, ToleranceSpec, WorkloopArming};
300 use chrono::TimeZone;
301
302 use super::*;
303
304 fn instant(offset: i64) -> Result<DateTime<Utc>, &'static str> {
305 Utc.with_ymd_and_hms(2026, 8, 25, 1, 0, 0)
306 .single()
307 .map(|base| base + chrono::Duration::seconds(offset))
308 .ok_or("test instant must be valid")
309 }
310
311 fn spec() -> Result<WorkloopSpec, Box<dyn std::error::Error>> {
312 Ok(WorkloopSpec::new(
313 WorkloopArming::every(Duration::from_secs(1500))?,
314 vec![InvariantSpec {
315 name: String::from("serving"),
316 record_type: String::from("ServeState"),
317 tolerance: ToleranceSpec::both(3, Duration::from_secs(2700))?,
318 confirms: vec![String::from("sweep")],
319 }],
320 Duration::from_secs(14 * 86_400),
321 )?)
322 }
323
324 fn record() -> Result<WorkloopRecord, Box<dyn std::error::Error>> {
325 let registered_at = instant(0)?;
326 Ok(WorkloopRecord {
327 loop_id: WorkflowId::new(uuid::Uuid::from_u128(9)),
328 namespace: String::from("default"),
329 spec: spec()?,
330 window_seq: 4,
331 next_window_at: Some(instant(1500)?),
332 next_check_at: Some(instant(1500)?),
333 last_iteration_closed_window: Some(4),
334 invariant_health: BTreeMap::from([(
335 String::from("serving"),
336 InvariantHealthState {
337 last_confirmed_at: Some(instant(60)?),
338 consecutive_unconfirmed: 1,
339 last_evidence: Some(aion_core::AlarmCause::WindowMissed),
340 alarmed: false,
341 },
342 )]),
343 registered_at,
344 updated_at: instant(90)?,
345 })
346 }
347
348 fn state_record(offset: i64) -> Result<InvariantStateRecord, Box<dyn std::error::Error>> {
349 Ok(InvariantStateRecord {
350 loop_id: WorkflowId::new(uuid::Uuid::from_u128(9)),
351 invariant: String::from("serving"),
352 payload: Payload::new(ContentType::Json, b"{\"connected\":2}".to_vec()),
353 record_type: String::from("ServeState"),
354 window_seq: Some(4),
355 recorded_at: instant(offset)?,
356 })
357 }
358
359 #[test]
360 fn workloop_record_round_trips() -> Result<(), Box<dyn std::error::Error>> {
361 let expected = record()?;
362 assert_eq!(WorkloopRecord::decode(&expected.encode()?)?, expected);
363 Ok(())
364 }
365
366 #[test]
367 fn a_stored_record_with_an_invalid_spec_refuses_decode()
368 -> Result<(), Box<dyn std::error::Error>> {
369 let encoded = record()?.encode()?;
370 let mut value: serde_json::Value = serde_json::from_slice(&encoded)?;
371 // Strip the tolerance declaration in the stored bytes: decode must
372 // refuse rather than resurrect an undeclared-tolerance loop.
373 value["spec"]["invariants"][0]["tolerance"] = serde_json::json!({
374 "consecutive_windows": null,
375 "unconfirmed_for": null,
376 });
377 let error = WorkloopRecord::decode(&serde_json::to_vec(&value)?).err();
378 assert!(matches!(error, Some(StoreError::Serialization(_))));
379 Ok(())
380 }
381
382 #[test]
383 fn slot_rotation_keeps_current_and_orders_generations() -> Result<(), Box<dyn std::error::Error>>
384 {
385 let first = state_record(0)?;
386 let second = state_record(10)?;
387 let third = state_record(20)?;
388 let slot = InvariantRecordSlot {
389 current: first.clone(),
390 previous: Vec::new(),
391 }
392 .rotated(second.clone())
393 .rotated(third.clone());
394
395 assert_eq!(slot.current, third);
396 assert_eq!(slot.previous, vec![first, second]);
397 Ok(())
398 }
399
400 #[test]
401 fn prune_removes_only_out_of_window_generations_and_never_current()
402 -> Result<(), Box<dyn std::error::Error>> {
403 let stale_current = state_record(-100)?;
404 let mut slot = InvariantRecordSlot {
405 current: stale_current.clone(),
406 previous: vec![state_record(-50)?, state_record(-10)?, state_record(5)?],
407 };
408
409 let removed = slot.prune(instant(0)?);
410
411 assert_eq!(removed, 2);
412 assert_eq!(slot.previous, vec![state_record(5)?]);
413 // The current record is older than the cutoff and still survives:
414 // exactly one current record per invariant survives indefinitely.
415 assert_eq!(slot.current, stale_current);
416 Ok(())
417 }
418
419 #[test]
420 fn slot_round_trips() -> Result<(), Box<dyn std::error::Error>> {
421 let slot = InvariantRecordSlot {
422 current: state_record(0)?,
423 previous: vec![state_record(-10)?],
424 };
425 assert_eq!(InvariantRecordSlot::decode(&slot.encode()?)?, slot);
426 Ok(())
427 }
428}