use aion_core::{ActivityId, Event, WorkflowId, WorkflowStatus, status_from_events};
use aion_store::{
EventStore, OutboxRow, OutboxStore, RedriveMode, RedriveOutcome, RedriveRefusal, StoreError,
};
use chrono::Utc;
use tracing::{info, warn};
use super::outbox_settle::is_settle_terminal;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum RedriveRefused {
#[error(transparent)]
Row(#[from] RedriveRefusal),
#[error(
"workflow {workflow_id} is terminal ({status:?}); a terminal workflow's outbox rows are \
settled, never redriven"
)]
WorkflowTerminal {
workflow_id: WorkflowId,
status: WorkflowStatus,
},
#[error(
"workflow {workflow_id} already records a terminal outcome for activity {activity_id} \
(ordinal {ordinal}); redriving it would re-execute an activity whose outcome is recorded \
history"
)]
HistoryRecordsOutcome {
workflow_id: WorkflowId,
activity_id: ActivityId,
ordinal: u64,
},
#[error("redrive could not read durable state: {0}")]
Store(#[from] StoreError),
}
pub async fn list_dead_letters(
outbox_store: &dyn OutboxStore,
workflow_id: &WorkflowId,
) -> Result<Vec<OutboxRow>, StoreError> {
outbox_store
.list_dead_lettered_outbox_rows(workflow_id)
.await
}
pub async fn redrive_dead_lettered_row(
event_store: &dyn EventStore,
outbox_store: &dyn OutboxStore,
workflow_id: &WorkflowId,
ordinal: u64,
mode: RedriveMode,
) -> Result<OutboxRow, RedriveRefused> {
let dispatch_key = OutboxRow::dispatch_key_for(workflow_id, ordinal);
let history = event_store.read_history(workflow_id).await?;
let status = status_from_events(&history);
if is_settle_terminal(status) {
warn!(
workflow_id = %workflow_id,
ordinal,
projected_status = ?status,
"refusing outbox redrive: the owning workflow is terminal"
);
return Err(RedriveRefused::WorkflowTerminal {
workflow_id: workflow_id.clone(),
status,
});
}
let activity_id = ActivityId::from_sequence_position(ordinal);
if history_records_outcome(&history, &activity_id) {
if !mode.admits_judged() {
warn!(
workflow_id = %workflow_id,
ordinal,
activity_id = %activity_id,
"refusing outbox redrive: history already records a terminal outcome for this activity"
);
return Err(RedriveRefused::HistoryRecordsOutcome {
workflow_id: workflow_id.clone(),
activity_id,
ordinal,
});
}
warn!(
workflow_id = %workflow_id,
ordinal,
activity_id = %activity_id,
"FORCED outbox redrive of an activity whose terminal outcome is already recorded \
history: the activity will run again behind a recorded judgment"
);
}
match outbox_store
.redrive_outbox_row(&dispatch_key, Utc::now(), mode)
.await?
{
RedriveOutcome::Redriven { row, was_judged } => {
if was_judged {
warn!(
dispatch_key = %dispatch_key,
workflow_id = %workflow_id,
ordinal,
"FORCED outbox redrive of a dead letter whose failure was already delivered to \
the workflow"
);
}
info!(
dispatch_key = %dispatch_key,
workflow_id = %workflow_id,
ordinal,
mode = ?mode,
"outbox dead letter redriven to pending"
);
Ok(*row)
}
RedriveOutcome::Refused(refusal) => {
warn!(
dispatch_key = %dispatch_key,
workflow_id = %workflow_id,
ordinal,
refusal = %refusal,
"outbox redrive refused by the store"
);
Err(RedriveRefused::Row(refusal))
}
}
}
fn history_records_outcome(history: &[Event], activity_id: &ActivityId) -> bool {
let lease_start = history
.iter()
.rposition(|event| {
matches!(
event,
Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. }
)
})
.map_or(0, |index| index + 1);
history[lease_start..].iter().any(|event| {
matches!(
event,
Event::ActivityFailed { activity_id: id, .. }
| Event::ActivityCompleted { activity_id: id, .. }
| Event::ActivityCancelled { activity_id: id, .. }
if id == activity_id
)
})
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use aion_core::{
ActivityError, ActivityErrorKind, ActivityId, ContentType, Event, EventEnvelope,
PackageVersion, Payload, RunId, WorkflowId,
};
use aion_store::{
ClaimScope, InMemoryStore, OutboxRow, OutboxStore, RedriveMode, StoreError,
WritableEventStore, WriteToken,
};
use chrono::{DateTime, Utc};
use super::{RedriveRefused, history_records_outcome, redrive_dead_lettered_row};
#[derive(Debug, Default)]
struct RefusingOutbox;
impl RefusingOutbox {
fn refusal<T>() -> Result<T, StoreError> {
Err(StoreError::Backend(String::from(
"the redrive gates must refuse before touching the outbox store",
)))
}
}
#[async_trait::async_trait]
impl OutboxStore for RefusingOutbox {
async fn append_outbox_batch(&self, _rows: &[OutboxRow]) -> Result<(), StoreError> {
Self::refusal()
}
async fn claim_outbox_rows(&self, _limit: u32) -> Result<Vec<OutboxRow>, StoreError> {
Self::refusal()
}
async fn claim_outbox_rows_scoped(
&self,
_scope: &ClaimScope,
_limit: u32,
) -> Result<Vec<OutboxRow>, StoreError> {
Self::refusal()
}
async fn rearm_stale_claimed_outbox_rows(
&self,
_older_than: DateTime<Utc>,
_visible_after: DateTime<Utc>,
_limit: u32,
_excluded: &HashSet<String>,
) -> Result<Vec<OutboxRow>, StoreError> {
Self::refusal()
}
async fn complete_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
Self::refusal()
}
async fn retry_outbox_row(
&self,
_dispatch_key: &str,
_next_attempt: u32,
_visible_after: DateTime<Utc>,
) -> Result<(), StoreError> {
Self::refusal()
}
async fn fail_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
Self::refusal()
}
async fn count_inflight_outbox_rows(&self, _namespace: &str) -> Result<u64, StoreError> {
Self::refusal()
}
async fn count_claimed_outbox_rows(&self, _namespace: &str) -> Result<u64, StoreError> {
Self::refusal()
}
async fn count_claimed_outbox_rows_by_namespace(
&self,
_namespaces: &[&str],
) -> Result<BTreeMap<String, u64>, StoreError> {
Self::refusal()
}
async fn pending_outbox_routes(&self) -> Result<Vec<ClaimScope>, StoreError> {
Self::refusal()
}
}
fn refused_expected(detail: &str) -> StoreError {
StoreError::Backend(format!("redrive contract violated: {detail}"))
}
fn envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: Utc::now(),
workflow_id: workflow_id.clone(),
}
}
fn started(workflow_id: &WorkflowId, seq: u64) -> Event {
Event::WorkflowStarted {
envelope: envelope(workflow_id, seq),
workflow_type: String::from("charge"),
input: Payload::new(ContentType::Json, b"{}".to_vec()),
run_id: RunId::new_v4(),
parent_run_id: None,
package_version: PackageVersion::new("a".repeat(64)),
}
}
fn failed_activity(workflow_id: &WorkflowId, seq: u64, ordinal: u64) -> Event {
Event::ActivityFailed {
envelope: envelope(workflow_id, seq),
activity_id: ActivityId::from_sequence_position(ordinal),
error: ActivityError {
kind: ActivityErrorKind::Terminal,
message: String::from("infrastructure: delivery to worker failed"),
details: None,
},
attempt: 1,
}
}
fn completed_workflow(workflow_id: &WorkflowId, seq: u64) -> Event {
Event::WorkflowCompleted {
envelope: envelope(workflow_id, seq),
result: Payload::new(ContentType::Json, b"{}".to_vec()),
}
}
async fn store_with(events: Vec<Event>) -> Result<Arc<InMemoryStore>, StoreError> {
let store = Arc::new(InMemoryStore::default());
let Some(first) = events.first() else {
return Ok(store);
};
let workflow_id = first.workflow_id().clone();
store
.append(WriteToken::recorder(), &workflow_id, &events, 0)
.await?;
Ok(store)
}
#[tokio::test]
async fn a_terminal_workflow_is_refused_before_the_store_is_touched() -> Result<(), StoreError>
{
let workflow_id = WorkflowId::new_v4();
let store = store_with(vec![
started(&workflow_id, 1),
completed_workflow(&workflow_id, 2),
])
.await?;
let outcome = redrive_dead_lettered_row(
store.as_ref(),
&RefusingOutbox,
&workflow_id,
0,
RedriveMode::Forced,
)
.await;
let Err(refusal) = outcome else {
return Err(refused_expected(
"a terminal workflow's dead letter must never redrive",
));
};
assert!(
matches!(refusal, RedriveRefused::WorkflowTerminal { .. }),
"expected a WorkflowTerminal refusal, got {refusal:?}"
);
Ok(())
}
#[tokio::test]
async fn a_recorded_activity_outcome_is_refused_before_the_store_is_touched()
-> Result<(), StoreError> {
let workflow_id = WorkflowId::new_v4();
let store = store_with(vec![
started(&workflow_id, 1),
failed_activity(&workflow_id, 2, 0),
])
.await?;
let outcome = redrive_dead_lettered_row(
store.as_ref(),
&RefusingOutbox,
&workflow_id,
0,
RedriveMode::Eligible,
)
.await;
let Err(refusal) = outcome else {
return Err(refused_expected(
"history that already records the activity's failure must refuse the redrive",
));
};
assert!(
matches!(refusal, RedriveRefused::HistoryRecordsOutcome { .. }),
"expected a HistoryRecordsOutcome refusal, got {refusal:?}"
);
Ok(())
}
#[tokio::test]
async fn a_forced_redrive_passes_the_recorded_outcome_gate() -> Result<(), StoreError> {
let workflow_id = WorkflowId::new_v4();
let store = store_with(vec![
started(&workflow_id, 1),
failed_activity(&workflow_id, 2, 0),
])
.await?;
let outcome = redrive_dead_lettered_row(
store.as_ref(),
&RefusingOutbox,
&workflow_id,
0,
RedriveMode::Forced,
)
.await;
let Err(refusal) = outcome else {
return Err(refused_expected(
"the refusing store must surface its error",
));
};
assert!(
matches!(refusal, RedriveRefused::Store(_)),
"a forced redrive must reach the store, got {refusal:?}"
);
Ok(())
}
#[tokio::test]
async fn an_unknown_workflow_reaches_the_store_and_is_refused_there() -> Result<(), StoreError>
{
let store = store_with(Vec::new()).await?;
let outcome = redrive_dead_lettered_row(
store.as_ref(),
&RefusingOutbox,
&WorkflowId::new_v4(),
0,
RedriveMode::Eligible,
)
.await;
let Err(refusal) = outcome else {
return Err(refused_expected(
"the refusing store must surface its error",
));
};
assert!(
matches!(refusal, RedriveRefused::Store(_)),
"expected the store to be consulted, got {refusal:?}"
);
Ok(())
}
#[test]
fn a_recorded_outcome_matches_only_the_row_s_own_activity() {
let workflow_id = WorkflowId::new_v4();
let history = vec![
started(&workflow_id, 1),
failed_activity(&workflow_id, 2, 3),
];
assert!(history_records_outcome(
&history,
&ActivityId::from_sequence_position(3)
));
assert!(!history_records_outcome(
&history,
&ActivityId::from_sequence_position(4)
));
}
#[test]
fn a_prior_lease_s_outcome_never_judges_the_current_lease() {
let workflow_id = WorkflowId::new_v4();
let history = vec![
started(&workflow_id, 1),
failed_activity(&workflow_id, 2, 0),
started(&workflow_id, 3),
];
assert!(!history_records_outcome(
&history,
&ActivityId::from_sequence_position(0)
));
}
#[test]
fn a_completed_or_cancelled_activity_also_counts_as_judged() {
let workflow_id = WorkflowId::new_v4();
let activity_id = ActivityId::from_sequence_position(0);
for terminal in [
Event::ActivityCompleted {
envelope: envelope(&workflow_id, 2),
activity_id: activity_id.clone(),
result: Payload::new(ContentType::Json, b"{}".to_vec()),
attempt: 1,
},
Event::ActivityCancelled {
envelope: envelope(&workflow_id, 2),
activity_id: activity_id.clone(),
attempt: 1,
},
] {
let history = vec![started(&workflow_id, 1), terminal];
assert!(history_records_outcome(&history, &activity_id));
}
}
#[test]
fn a_scheduled_but_unfinished_activity_is_not_judged() {
let workflow_id = WorkflowId::new_v4();
let activity_id = ActivityId::from_sequence_position(0);
let history = vec![
started(&workflow_id, 1),
Event::ActivityScheduled {
envelope: envelope(&workflow_id, 2),
activity_id: activity_id.clone(),
activity_type: String::from("charge"),
input: Payload::new(ContentType::Json, b"{}".to_vec()),
task_queue: String::from("default"),
node: None,
},
];
assert!(!history_records_outcome(&history, &activity_id));
}
}