use aion_core::{Event, InvariantAlarm, RunId, WorkflowId};
use aion_store::EventStore;
use aion_store::visibility::VisibilityStore;
use async_trait::async_trait;
use chrono::Utc;
use std::sync::Arc;
use super::error::WorkloopError;
use super::service::LoopEventSink;
use crate::durability::Recorder;
use crate::engine_seam::RecordOutcome;
use crate::registry::Registry;
pub struct EngineLoopEventSink {
registry: Arc<Registry>,
store: Arc<dyn EventStore>,
visibility_store: Arc<dyn VisibilityStore>,
}
impl EngineLoopEventSink {
#[must_use]
pub fn new(
registry: Arc<Registry>,
store: Arc<dyn EventStore>,
visibility_store: Arc<dyn VisibilityStore>,
) -> Self {
Self {
registry,
store,
visibility_store,
}
}
fn registered_recorder(
&self,
loop_id: &WorkflowId,
) -> Result<Option<crate::registry::WorkflowHandle>, WorkloopError> {
self.registry
.sole_handle(loop_id)
.map_err(|error| WorkloopError::Engine {
reason: error.to_string(),
})
}
async fn record_with_recorder<F>(
&self,
loop_id: &WorkflowId,
refuse_terminal: bool,
record: F,
) -> Result<RecordOutcome, WorkloopError>
where
F: for<'a> AsyncFnOnce(&'a mut Recorder) -> Result<(), crate::durability::DurabilityError>,
{
if let Some(handle) = self.registered_recorder(loop_id)? {
let recorder = handle.recorder();
let mut recorder = recorder.lock().await;
let history = self.store.read_history(loop_id).await?;
if refuse_terminal && let Some(refusal) = terminal_refusal(&history) {
return Ok(refusal);
}
record(&mut recorder).await?;
return Ok(RecordOutcome::Recorded);
}
let history = self.store.read_history(loop_id).await?;
if history.is_empty() {
return Err(WorkloopError::Engine {
reason: format!(
"workloop {loop_id} is on the sweep set but its workflow has no recorded \
history, so there is nothing to fire at. This is either the width of an \
in-flight `start_workloop` — the registration precedes the start by \
design — or a registration whose start never landed, which engine boot \
reconciliation withdraws"
),
});
}
if refuse_terminal && let Some(refusal) = terminal_refusal(&history) {
return Ok(refusal);
}
let head = history.iter().map(Event::seq).max().unwrap_or_default();
let mut recorder = Recorder::resume_at(loop_id.clone(), Arc::clone(&self.store), head);
if let Some(run_id) = active_run_id(&history) {
recorder = recorder.with_visibility(run_id, Arc::clone(&self.visibility_store));
}
record(&mut recorder).await?;
Ok(RecordOutcome::Recorded)
}
}
#[async_trait]
impl LoopEventSink for EngineLoopEventSink {
async fn record_cadence_fired(
&self,
loop_id: &WorkflowId,
window_seq: u64,
) -> Result<RecordOutcome, WorkloopError> {
self.record_with_recorder(loop_id, true, async move |recorder: &mut Recorder| {
recorder.record_cadence_fired(Utc::now(), window_seq).await
})
.await
}
async fn record_invariant_unconfirmed(
&self,
loop_id: &WorkflowId,
alarm: InvariantAlarm,
) -> Result<(), WorkloopError> {
self.record_with_recorder(loop_id, false, async move |recorder: &mut Recorder| {
recorder
.record_invariant_unconfirmed(Utc::now(), alarm)
.await
})
.await
.map(|_| ())
}
}
fn terminal_refusal(history: &[Event]) -> Option<RecordOutcome> {
let run_id = active_run_id(history)?;
crate::lifecycle::completion::terminal_outcome_from_history(history, &run_id)?;
let retired = aion_core::run_segment(history, &run_id)
.iter()
.any(|event| matches!(event, Event::LoopRetired { .. }));
Some(if retired {
RecordOutcome::RefusedRetired
} else {
RecordOutcome::RefusedTerminal
})
}
fn active_run_id(history: &[Event]) -> Option<RunId> {
history.iter().rev().find_map(|event| match event {
Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
_ => None,
})
}
#[cfg(test)]
mod tests {
use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, WorkflowId};
use super::{Event, RecordOutcome, RunId, terminal_refusal};
fn envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: chrono::Utc::now(),
workflow_id: workflow_id.clone(),
}
}
fn started(workflow_id: &WorkflowId, run_id: &RunId, seq: u64) -> Event {
Event::WorkflowStarted {
envelope: envelope(workflow_id, seq),
workflow_type: String::from("queue_watch"),
input: Payload::new(ContentType::Json, b"{}".to_vec()),
run_id: run_id.clone(),
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new("a".repeat(64)),
}
}
fn retired(workflow_id: &WorkflowId, seq: u64) -> Event {
Event::LoopRetired {
envelope: envelope(workflow_id, seq),
reason: String::from("decommissioned"),
}
}
fn completed(workflow_id: &WorkflowId, seq: u64) -> Event {
Event::WorkflowCompleted {
envelope: envelope(workflow_id, seq),
result: Payload::new(ContentType::Json, b"{}".to_vec()),
}
}
#[test]
fn a_live_run_refuses_no_cadence_fire() {
let workflow_id = WorkflowId::new_v4();
let run = RunId::new_v4();
assert!(terminal_refusal(&[started(&workflow_id, &run, 1)]).is_none());
}
#[test]
fn a_terminal_without_a_retirement_is_a_death_and_with_one_is_not() {
let workflow_id = WorkflowId::new_v4();
let run = RunId::new_v4();
let died = vec![started(&workflow_id, &run, 1), completed(&workflow_id, 2)];
assert!(matches!(
terminal_refusal(&died),
Some(RecordOutcome::RefusedTerminal)
));
let was_retired = vec![
started(&workflow_id, &run, 1),
retired(&workflow_id, 2),
completed(&workflow_id, 3),
];
assert!(matches!(
terminal_refusal(&was_retired),
Some(RecordOutcome::RefusedRetired)
));
}
#[test]
fn a_prior_generations_retirement_does_not_excuse_this_generations_death() {
let workflow_id = WorkflowId::new_v4();
let old_run = RunId::new_v4();
let current_run = RunId::new_v4();
let history = vec![
started(&workflow_id, &old_run, 1),
retired(&workflow_id, 2),
started(&workflow_id, ¤t_run, 3),
completed(&workflow_id, 4),
];
assert!(
history
.iter()
.any(|event| matches!(event, Event::LoopRetired { .. })),
"fixture control: the history must carry a LoopRetired for this to prove scoping"
);
assert!(
matches!(
terminal_refusal(&history),
Some(RecordOutcome::RefusedTerminal)
),
"a retirement in an earlier generation must not excuse this one's death"
);
}
}