1use aion_core::{ActivityId, Event, WorkflowId, WorkflowStatus, status_from_events};
66use aion_store::{
67 EventStore, OutboxRow, OutboxStore, RedriveMode, RedriveOutcome, RedriveRefusal, StoreError,
68};
69use chrono::Utc;
70use tracing::{info, warn};
71
72use super::outbox_settle::is_settle_terminal;
73
74#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
79pub enum RedriveRefused {
80 #[error(transparent)]
82 Row(#[from] RedriveRefusal),
83 #[error(
86 "workflow {workflow_id} is terminal ({status:?}); a terminal workflow's outbox rows are \
87 settled, never redriven"
88 )]
89 WorkflowTerminal {
90 workflow_id: WorkflowId,
92 status: WorkflowStatus,
94 },
95 #[error(
99 "workflow {workflow_id} already records a terminal outcome for activity {activity_id} \
100 (ordinal {ordinal}); redriving it would re-execute an activity whose outcome is recorded \
101 history"
102 )]
103 HistoryRecordsOutcome {
104 workflow_id: WorkflowId,
106 activity_id: ActivityId,
108 ordinal: u64,
110 },
111 #[error("redrive could not read durable state: {0}")]
113 Store(#[from] StoreError),
114}
115
116pub async fn list_dead_letters(
125 outbox_store: &dyn OutboxStore,
126 workflow_id: &WorkflowId,
127) -> Result<Vec<OutboxRow>, StoreError> {
128 outbox_store
129 .list_dead_lettered_outbox_rows(workflow_id)
130 .await
131}
132
133pub async fn redrive_dead_lettered_row(
145 event_store: &dyn EventStore,
146 outbox_store: &dyn OutboxStore,
147 workflow_id: &WorkflowId,
148 ordinal: u64,
149 mode: RedriveMode,
150) -> Result<OutboxRow, RedriveRefused> {
151 let dispatch_key = OutboxRow::dispatch_key_for(workflow_id, ordinal);
152 let history = event_store.read_history(workflow_id).await?;
153
154 let status = status_from_events(&history);
158 if is_settle_terminal(status) {
159 warn!(
160 workflow_id = %workflow_id,
161 ordinal,
162 projected_status = ?status,
163 "refusing outbox redrive: the owning workflow is terminal"
164 );
165 return Err(RedriveRefused::WorkflowTerminal {
166 workflow_id: workflow_id.clone(),
167 status,
168 });
169 }
170
171 let activity_id = ActivityId::from_sequence_position(ordinal);
175 if history_records_outcome(&history, &activity_id) {
176 if !mode.admits_judged() {
177 warn!(
178 workflow_id = %workflow_id,
179 ordinal,
180 activity_id = %activity_id,
181 "refusing outbox redrive: history already records a terminal outcome for this activity"
182 );
183 return Err(RedriveRefused::HistoryRecordsOutcome {
184 workflow_id: workflow_id.clone(),
185 activity_id,
186 ordinal,
187 });
188 }
189 warn!(
190 workflow_id = %workflow_id,
191 ordinal,
192 activity_id = %activity_id,
193 "FORCED outbox redrive of an activity whose terminal outcome is already recorded \
194 history: the activity will run again behind a recorded judgment"
195 );
196 }
197
198 match outbox_store
199 .redrive_outbox_row(&dispatch_key, Utc::now(), mode)
200 .await?
201 {
202 RedriveOutcome::Redriven { row, was_judged } => {
203 if was_judged {
204 warn!(
205 dispatch_key = %dispatch_key,
206 workflow_id = %workflow_id,
207 ordinal,
208 "FORCED outbox redrive of a dead letter whose failure was already delivered to \
209 the workflow"
210 );
211 }
212 info!(
213 dispatch_key = %dispatch_key,
214 workflow_id = %workflow_id,
215 ordinal,
216 mode = ?mode,
217 "outbox dead letter redriven to pending"
218 );
219 Ok(*row)
220 }
221 RedriveOutcome::Refused(refusal) => {
222 warn!(
223 dispatch_key = %dispatch_key,
224 workflow_id = %workflow_id,
225 ordinal,
226 refusal = %refusal,
227 "outbox redrive refused by the store"
228 );
229 Err(RedriveRefused::Row(refusal))
230 }
231 }
232}
233
234fn history_records_outcome(history: &[Event], activity_id: &ActivityId) -> bool {
240 let lease_start = history
241 .iter()
242 .rposition(|event| {
243 matches!(
244 event,
245 Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. }
246 )
247 })
248 .map_or(0, |index| index + 1);
249 history[lease_start..].iter().any(|event| {
250 matches!(
251 event,
252 Event::ActivityFailed { activity_id: id, .. }
253 | Event::ActivityCompleted { activity_id: id, .. }
254 | Event::ActivityCancelled { activity_id: id, .. }
255 if id == activity_id
256 )
257 })
258}
259
260#[cfg(test)]
261mod tests {
262 use std::collections::{BTreeMap, HashSet};
263 use std::sync::Arc;
264
265 use aion_core::{
266 ActivityError, ActivityErrorKind, ActivityId, ContentType, Event, EventEnvelope,
267 PackageVersion, Payload, RunId, WorkflowId,
268 };
269 use aion_store::{
270 ClaimScope, InMemoryStore, OutboxRow, OutboxStore, RedriveMode, StoreError,
271 WritableEventStore, WriteToken,
272 };
273 use chrono::{DateTime, Utc};
274
275 use super::{RedriveRefused, history_records_outcome, redrive_dead_lettered_row};
276
277 #[derive(Debug, Default)]
283 struct RefusingOutbox;
284
285 impl RefusingOutbox {
286 fn refusal<T>() -> Result<T, StoreError> {
287 Err(StoreError::Backend(String::from(
288 "the redrive gates must refuse before touching the outbox store",
289 )))
290 }
291 }
292
293 #[async_trait::async_trait]
294 impl OutboxStore for RefusingOutbox {
295 async fn append_outbox_batch(&self, _rows: &[OutboxRow]) -> Result<(), StoreError> {
296 Self::refusal()
297 }
298
299 async fn claim_outbox_rows(&self, _limit: u32) -> Result<Vec<OutboxRow>, StoreError> {
300 Self::refusal()
301 }
302
303 async fn claim_outbox_rows_scoped(
304 &self,
305 _scope: &ClaimScope,
306 _limit: u32,
307 ) -> Result<Vec<OutboxRow>, StoreError> {
308 Self::refusal()
309 }
310
311 async fn rearm_stale_claimed_outbox_rows(
312 &self,
313 _older_than: DateTime<Utc>,
314 _visible_after: DateTime<Utc>,
315 _limit: u32,
316 _excluded: &HashSet<String>,
317 ) -> Result<Vec<OutboxRow>, StoreError> {
318 Self::refusal()
319 }
320
321 async fn complete_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
322 Self::refusal()
323 }
324
325 async fn retry_outbox_row(
326 &self,
327 _dispatch_key: &str,
328 _next_attempt: u32,
329 _visible_after: DateTime<Utc>,
330 ) -> Result<(), StoreError> {
331 Self::refusal()
332 }
333
334 async fn fail_outbox_row(&self, _dispatch_key: &str) -> Result<(), StoreError> {
335 Self::refusal()
336 }
337
338 async fn count_inflight_outbox_rows(&self, _namespace: &str) -> Result<u64, StoreError> {
339 Self::refusal()
340 }
341
342 async fn count_claimed_outbox_rows(&self, _namespace: &str) -> Result<u64, StoreError> {
343 Self::refusal()
344 }
345
346 async fn count_claimed_outbox_rows_by_namespace(
347 &self,
348 _namespaces: &[&str],
349 ) -> Result<BTreeMap<String, u64>, StoreError> {
350 Self::refusal()
351 }
352
353 async fn pending_outbox_routes(&self) -> Result<Vec<ClaimScope>, StoreError> {
354 Self::refusal()
355 }
356 }
357
358 fn refused_expected(detail: &str) -> StoreError {
361 StoreError::Backend(format!("redrive contract violated: {detail}"))
362 }
363
364 fn envelope(workflow_id: &WorkflowId, seq: u64) -> EventEnvelope {
365 EventEnvelope {
366 seq,
367 recorded_at: Utc::now(),
368 workflow_id: workflow_id.clone(),
369 }
370 }
371
372 fn started(workflow_id: &WorkflowId, seq: u64) -> Event {
373 Event::WorkflowStarted {
374 envelope: envelope(workflow_id, seq),
375 workflow_type: String::from("charge"),
376 input: Payload::new(ContentType::Json, b"{}".to_vec()),
377 run_id: RunId::new_v4(),
378 parent_run_id: None,
379 parent_workflow_id: None,
380 package_version: PackageVersion::new("a".repeat(64)),
381 }
382 }
383
384 fn failed_activity(workflow_id: &WorkflowId, seq: u64, ordinal: u64) -> Event {
385 Event::ActivityFailed {
386 envelope: envelope(workflow_id, seq),
387 activity_id: ActivityId::from_sequence_position(ordinal),
388 error: ActivityError {
389 kind: ActivityErrorKind::Terminal,
390 message: String::from("infrastructure: delivery to worker failed"),
391 details: None,
392 },
393 attempt: 1,
394 }
395 }
396
397 fn completed_workflow(workflow_id: &WorkflowId, seq: u64) -> Event {
398 Event::WorkflowCompleted {
399 envelope: envelope(workflow_id, seq),
400 result: Payload::new(ContentType::Json, b"{}".to_vec()),
401 }
402 }
403
404 async fn store_with(events: Vec<Event>) -> Result<Arc<InMemoryStore>, StoreError> {
405 let store = Arc::new(InMemoryStore::default());
406 let Some(first) = events.first() else {
407 return Ok(store);
408 };
409 let workflow_id = first.workflow_id().clone();
410 store
411 .append(WriteToken::recorder(), &workflow_id, &events, 0)
412 .await?;
413 Ok(store)
414 }
415
416 #[tokio::test]
417 async fn a_terminal_workflow_is_refused_before_the_store_is_touched() -> Result<(), StoreError>
418 {
419 let workflow_id = WorkflowId::new_v4();
420 let store = store_with(vec![
421 started(&workflow_id, 1),
422 completed_workflow(&workflow_id, 2),
423 ])
424 .await?;
425
426 let outcome = redrive_dead_lettered_row(
427 store.as_ref(),
428 &RefusingOutbox,
429 &workflow_id,
430 0,
431 RedriveMode::Forced,
433 )
434 .await;
435 let Err(refusal) = outcome else {
436 return Err(refused_expected(
437 "a terminal workflow's dead letter must never redrive",
438 ));
439 };
440 assert!(
441 matches!(refusal, RedriveRefused::WorkflowTerminal { .. }),
442 "expected a WorkflowTerminal refusal, got {refusal:?}"
443 );
444 Ok(())
445 }
446
447 #[tokio::test]
448 async fn a_recorded_activity_outcome_is_refused_before_the_store_is_touched()
449 -> Result<(), StoreError> {
450 let workflow_id = WorkflowId::new_v4();
451 let store = store_with(vec![
452 started(&workflow_id, 1),
453 failed_activity(&workflow_id, 2, 0),
454 ])
455 .await?;
456
457 let outcome = redrive_dead_lettered_row(
458 store.as_ref(),
459 &RefusingOutbox,
460 &workflow_id,
461 0,
462 RedriveMode::Eligible,
463 )
464 .await;
465 let Err(refusal) = outcome else {
466 return Err(refused_expected(
467 "history that already records the activity's failure must refuse the redrive",
468 ));
469 };
470 assert!(
471 matches!(refusal, RedriveRefused::HistoryRecordsOutcome { .. }),
472 "expected a HistoryRecordsOutcome refusal, got {refusal:?}"
473 );
474 Ok(())
475 }
476
477 #[tokio::test]
478 async fn a_forced_redrive_passes_the_recorded_outcome_gate() -> Result<(), StoreError> {
479 let workflow_id = WorkflowId::new_v4();
480 let store = store_with(vec![
481 started(&workflow_id, 1),
482 failed_activity(&workflow_id, 2, 0),
483 ])
484 .await?;
485
486 let outcome = redrive_dead_lettered_row(
489 store.as_ref(),
490 &RefusingOutbox,
491 &workflow_id,
492 0,
493 RedriveMode::Forced,
494 )
495 .await;
496 let Err(refusal) = outcome else {
497 return Err(refused_expected(
498 "the refusing store must surface its error",
499 ));
500 };
501 assert!(
502 matches!(refusal, RedriveRefused::Store(_)),
503 "a forced redrive must reach the store, got {refusal:?}"
504 );
505 Ok(())
506 }
507
508 #[tokio::test]
509 async fn an_unknown_workflow_reaches_the_store_and_is_refused_there() -> Result<(), StoreError>
510 {
511 let store = store_with(Vec::new()).await?;
514 let outcome = redrive_dead_lettered_row(
515 store.as_ref(),
516 &RefusingOutbox,
517 &WorkflowId::new_v4(),
518 0,
519 RedriveMode::Eligible,
520 )
521 .await;
522 let Err(refusal) = outcome else {
523 return Err(refused_expected(
524 "the refusing store must surface its error",
525 ));
526 };
527 assert!(
528 matches!(refusal, RedriveRefused::Store(_)),
529 "expected the store to be consulted, got {refusal:?}"
530 );
531 Ok(())
532 }
533
534 #[test]
535 fn a_recorded_outcome_matches_only_the_row_s_own_activity() {
536 let workflow_id = WorkflowId::new_v4();
537 let history = vec![
538 started(&workflow_id, 1),
539 failed_activity(&workflow_id, 2, 3),
540 ];
541 assert!(history_records_outcome(
542 &history,
543 &ActivityId::from_sequence_position(3)
544 ));
545 assert!(!history_records_outcome(
546 &history,
547 &ActivityId::from_sequence_position(4)
548 ));
549 }
550
551 #[test]
552 fn a_prior_lease_s_outcome_never_judges_the_current_lease() {
553 let workflow_id = WorkflowId::new_v4();
554 let history = vec![
557 started(&workflow_id, 1),
558 failed_activity(&workflow_id, 2, 0),
559 started(&workflow_id, 3),
560 ];
561 assert!(!history_records_outcome(
562 &history,
563 &ActivityId::from_sequence_position(0)
564 ));
565 }
566
567 #[test]
568 fn a_completed_or_cancelled_activity_also_counts_as_judged() {
569 let workflow_id = WorkflowId::new_v4();
570 let activity_id = ActivityId::from_sequence_position(0);
571 for terminal in [
572 Event::ActivityCompleted {
573 envelope: envelope(&workflow_id, 2),
574 activity_id: activity_id.clone(),
575 result: Payload::new(ContentType::Json, b"{}".to_vec()),
576 attempt: 1,
577 },
578 Event::ActivityCancelled {
579 envelope: envelope(&workflow_id, 2),
580 activity_id: activity_id.clone(),
581 attempt: 1,
582 },
583 ] {
584 let history = vec![started(&workflow_id, 1), terminal];
585 assert!(history_records_outcome(&history, &activity_id));
586 }
587 }
588
589 #[test]
590 fn a_scheduled_but_unfinished_activity_is_not_judged() {
591 let workflow_id = WorkflowId::new_v4();
592 let activity_id = ActivityId::from_sequence_position(0);
593 let history = vec![
594 started(&workflow_id, 1),
595 Event::ActivityScheduled {
596 envelope: envelope(&workflow_id, 2),
597 activity_id: activity_id.clone(),
598 activity_type: String::from("charge"),
599 input: Payload::new(ContentType::Json, b"{}".to_vec()),
600 task_queue: String::from("default"),
601 node: None,
602 },
603 ];
604 assert!(!history_records_outcome(&history, &activity_id));
605 }
606}