use aion_core::{Payload, WorkflowId, WorkloopSpec};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::StoreError;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InvariantHealthState {
pub last_confirmed_at: Option<DateTime<Utc>>,
pub consecutive_unconfirmed: u64,
pub last_evidence: Option<aion_core::AlarmCause>,
pub alarmed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkloopRecord {
pub loop_id: WorkflowId,
pub namespace: String,
pub spec: WorkloopSpec,
pub window_seq: u64,
pub next_window_at: Option<DateTime<Utc>>,
pub next_check_at: Option<DateTime<Utc>>,
pub last_iteration_closed_window: Option<u64>,
pub invariant_health: BTreeMap<String, InvariantHealthState>,
pub registered_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl WorkloopRecord {
pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
serde_json::to_vec(self).map_err(|error| StoreError::Serialization(error.to_string()))
}
pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InvariantStateRecord {
pub loop_id: WorkflowId,
pub invariant: String,
pub payload: Payload,
pub record_type: String,
pub window_seq: Option<u64>,
pub recorded_at: DateTime<Utc>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InvariantRecordSlot {
pub current: InvariantStateRecord,
pub previous: Vec<InvariantStateRecord>,
}
impl InvariantRecordSlot {
pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
serde_json::to_vec(self).map_err(|error| StoreError::Serialization(error.to_string()))
}
pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
}
#[must_use]
pub fn rotated(mut self, record: InvariantStateRecord) -> Self {
self.previous.push(self.current);
self.current = record;
self
}
pub fn prune(&mut self, older_than: DateTime<Utc>) -> u64 {
let before = self.previous.len();
self.previous
.retain(|record| record.recorded_at >= older_than);
u64::try_from(before.saturating_sub(self.previous.len())).unwrap_or(u64::MAX)
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct WorkloopDueProbe {
pub next_check_at: Option<DateTime<Utc>>,
}
impl WorkloopDueProbe {
pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
serde_json::from_slice(bytes).map_err(|error| StoreError::Serialization(error.to_string()))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UndecodableWorkloop {
pub loop_id: String,
pub error: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkloopListing {
pub workloops: Vec<WorkloopRecord>,
pub undecodable: Vec<UndecodableWorkloop>,
}
#[async_trait]
pub trait WorkloopStore: Send + Sync + 'static {
async fn put_workloop(&self, record: WorkloopRecord) -> Result<(), StoreError>;
async fn get_workloop(
&self,
loop_id: &WorkflowId,
) -> Result<Option<WorkloopRecord>, StoreError>;
async fn list_workloops(&self) -> Result<WorkloopListing, StoreError>;
async fn due_workloops(&self, as_of: DateTime<Utc>) -> Result<Vec<WorkloopRecord>, StoreError>;
async fn remove_workloop(&self, loop_id: &WorkflowId) -> Result<bool, StoreError>;
async fn put_invariant_record(
&self,
record: InvariantStateRecord,
prune_before: DateTime<Utc>,
) -> Result<u64, StoreError>;
async fn current_invariant_record(
&self,
loop_id: &WorkflowId,
invariant: &str,
) -> Result<Option<InvariantStateRecord>, StoreError>;
async fn invariant_record_generations(
&self,
loop_id: &WorkflowId,
invariant: &str,
) -> Result<Vec<InvariantStateRecord>, StoreError>;
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use aion_core::{ContentType, InvariantSpec, ToleranceSpec, WorkloopArming};
use chrono::TimeZone;
use super::*;
fn instant(offset: i64) -> Result<DateTime<Utc>, &'static str> {
Utc.with_ymd_and_hms(2026, 8, 25, 1, 0, 0)
.single()
.map(|base| base + chrono::Duration::seconds(offset))
.ok_or("test instant must be valid")
}
fn spec() -> Result<WorkloopSpec, Box<dyn std::error::Error>> {
Ok(WorkloopSpec::new(
WorkloopArming::every(Duration::from_secs(1500))?,
vec![InvariantSpec {
name: String::from("serving"),
record_type: String::from("ServeState"),
tolerance: ToleranceSpec::both(3, Duration::from_secs(2700))?,
confirms: vec![String::from("sweep")],
}],
Duration::from_secs(14 * 86_400),
)?)
}
fn record() -> Result<WorkloopRecord, Box<dyn std::error::Error>> {
let registered_at = instant(0)?;
Ok(WorkloopRecord {
loop_id: WorkflowId::new(uuid::Uuid::from_u128(9)),
namespace: String::from("default"),
spec: spec()?,
window_seq: 4,
next_window_at: Some(instant(1500)?),
next_check_at: Some(instant(1500)?),
last_iteration_closed_window: Some(4),
invariant_health: BTreeMap::from([(
String::from("serving"),
InvariantHealthState {
last_confirmed_at: Some(instant(60)?),
consecutive_unconfirmed: 1,
last_evidence: Some(aion_core::AlarmCause::WindowMissed),
alarmed: false,
},
)]),
registered_at,
updated_at: instant(90)?,
})
}
fn state_record(offset: i64) -> Result<InvariantStateRecord, Box<dyn std::error::Error>> {
Ok(InvariantStateRecord {
loop_id: WorkflowId::new(uuid::Uuid::from_u128(9)),
invariant: String::from("serving"),
payload: Payload::new(ContentType::Json, b"{\"connected\":2}".to_vec()),
record_type: String::from("ServeState"),
window_seq: Some(4),
recorded_at: instant(offset)?,
})
}
#[test]
fn workloop_record_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let expected = record()?;
assert_eq!(WorkloopRecord::decode(&expected.encode()?)?, expected);
Ok(())
}
#[test]
fn a_stored_record_with_an_invalid_spec_refuses_decode()
-> Result<(), Box<dyn std::error::Error>> {
let encoded = record()?.encode()?;
let mut value: serde_json::Value = serde_json::from_slice(&encoded)?;
value["spec"]["invariants"][0]["tolerance"] = serde_json::json!({
"consecutive_windows": null,
"unconfirmed_for": null,
});
let error = WorkloopRecord::decode(&serde_json::to_vec(&value)?).err();
assert!(matches!(error, Some(StoreError::Serialization(_))));
Ok(())
}
#[test]
fn slot_rotation_keeps_current_and_orders_generations() -> Result<(), Box<dyn std::error::Error>>
{
let first = state_record(0)?;
let second = state_record(10)?;
let third = state_record(20)?;
let slot = InvariantRecordSlot {
current: first.clone(),
previous: Vec::new(),
}
.rotated(second.clone())
.rotated(third.clone());
assert_eq!(slot.current, third);
assert_eq!(slot.previous, vec![first, second]);
Ok(())
}
#[test]
fn prune_removes_only_out_of_window_generations_and_never_current()
-> Result<(), Box<dyn std::error::Error>> {
let stale_current = state_record(-100)?;
let mut slot = InvariantRecordSlot {
current: stale_current.clone(),
previous: vec![state_record(-50)?, state_record(-10)?, state_record(5)?],
};
let removed = slot.prune(instant(0)?);
assert_eq!(removed, 2);
assert_eq!(slot.previous, vec![state_record(5)?]);
assert_eq!(slot.current, stale_current);
Ok(())
}
#[test]
fn slot_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let slot = InvariantRecordSlot {
current: state_record(0)?,
previous: vec![state_record(-10)?],
};
assert_eq!(InvariantRecordSlot::decode(&slot.encode()?)?, slot);
Ok(())
}
}