Skip to main content

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}