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 package_version: PackageVersion::new("a".repeat(64)),
380 }
381 }
382
383 fn failed_activity(workflow_id: &WorkflowId, seq: u64, ordinal: u64) -> Event {
384 Event::ActivityFailed {
385 envelope: envelope(workflow_id, seq),
386 activity_id: ActivityId::from_sequence_position(ordinal),
387 error: ActivityError {
388 kind: ActivityErrorKind::Terminal,
389 message: String::from("infrastructure: delivery to worker failed"),
390 details: None,
391 },
392 attempt: 1,
393 }
394 }
395
396 fn completed_workflow(workflow_id: &WorkflowId, seq: u64) -> Event {
397 Event::WorkflowCompleted {
398 envelope: envelope(workflow_id, seq),
399 result: Payload::new(ContentType::Json, b"{}".to_vec()),
400 }
401 }
402
403 async fn store_with(events: Vec<Event>) -> Result<Arc<InMemoryStore>, StoreError> {
404 let store = Arc::new(InMemoryStore::default());
405 let Some(first) = events.first() else {
406 return Ok(store);
407 };
408 let workflow_id = first.workflow_id().clone();
409 store
410 .append(WriteToken::recorder(), &workflow_id, &events, 0)
411 .await?;
412 Ok(store)
413 }
414
415 #[tokio::test]
416 async fn a_terminal_workflow_is_refused_before_the_store_is_touched() -> Result<(), StoreError>
417 {
418 let workflow_id = WorkflowId::new_v4();
419 let store = store_with(vec![
420 started(&workflow_id, 1),
421 completed_workflow(&workflow_id, 2),
422 ])
423 .await?;
424
425 let outcome = redrive_dead_lettered_row(
426 store.as_ref(),
427 &RefusingOutbox,
428 &workflow_id,
429 0,
430 RedriveMode::Forced,
432 )
433 .await;
434 let Err(refusal) = outcome else {
435 return Err(refused_expected(
436 "a terminal workflow's dead letter must never redrive",
437 ));
438 };
439 assert!(
440 matches!(refusal, RedriveRefused::WorkflowTerminal { .. }),
441 "expected a WorkflowTerminal refusal, got {refusal:?}"
442 );
443 Ok(())
444 }
445
446 #[tokio::test]
447 async fn a_recorded_activity_outcome_is_refused_before_the_store_is_touched()
448 -> Result<(), StoreError> {
449 let workflow_id = WorkflowId::new_v4();
450 let store = store_with(vec![
451 started(&workflow_id, 1),
452 failed_activity(&workflow_id, 2, 0),
453 ])
454 .await?;
455
456 let outcome = redrive_dead_lettered_row(
457 store.as_ref(),
458 &RefusingOutbox,
459 &workflow_id,
460 0,
461 RedriveMode::Eligible,
462 )
463 .await;
464 let Err(refusal) = outcome else {
465 return Err(refused_expected(
466 "history that already records the activity's failure must refuse the redrive",
467 ));
468 };
469 assert!(
470 matches!(refusal, RedriveRefused::HistoryRecordsOutcome { .. }),
471 "expected a HistoryRecordsOutcome refusal, got {refusal:?}"
472 );
473 Ok(())
474 }
475
476 #[tokio::test]
477 async fn a_forced_redrive_passes_the_recorded_outcome_gate() -> Result<(), StoreError> {
478 let workflow_id = WorkflowId::new_v4();
479 let store = store_with(vec![
480 started(&workflow_id, 1),
481 failed_activity(&workflow_id, 2, 0),
482 ])
483 .await?;
484
485 let outcome = redrive_dead_lettered_row(
488 store.as_ref(),
489 &RefusingOutbox,
490 &workflow_id,
491 0,
492 RedriveMode::Forced,
493 )
494 .await;
495 let Err(refusal) = outcome else {
496 return Err(refused_expected(
497 "the refusing store must surface its error",
498 ));
499 };
500 assert!(
501 matches!(refusal, RedriveRefused::Store(_)),
502 "a forced redrive must reach the store, got {refusal:?}"
503 );
504 Ok(())
505 }
506
507 #[tokio::test]
508 async fn an_unknown_workflow_reaches_the_store_and_is_refused_there() -> Result<(), StoreError>
509 {
510 let store = store_with(Vec::new()).await?;
513 let outcome = redrive_dead_lettered_row(
514 store.as_ref(),
515 &RefusingOutbox,
516 &WorkflowId::new_v4(),
517 0,
518 RedriveMode::Eligible,
519 )
520 .await;
521 let Err(refusal) = outcome else {
522 return Err(refused_expected(
523 "the refusing store must surface its error",
524 ));
525 };
526 assert!(
527 matches!(refusal, RedriveRefused::Store(_)),
528 "expected the store to be consulted, got {refusal:?}"
529 );
530 Ok(())
531 }
532
533 #[test]
534 fn a_recorded_outcome_matches_only_the_row_s_own_activity() {
535 let workflow_id = WorkflowId::new_v4();
536 let history = vec![
537 started(&workflow_id, 1),
538 failed_activity(&workflow_id, 2, 3),
539 ];
540 assert!(history_records_outcome(
541 &history,
542 &ActivityId::from_sequence_position(3)
543 ));
544 assert!(!history_records_outcome(
545 &history,
546 &ActivityId::from_sequence_position(4)
547 ));
548 }
549
550 #[test]
551 fn a_prior_lease_s_outcome_never_judges_the_current_lease() {
552 let workflow_id = WorkflowId::new_v4();
553 let history = vec![
556 started(&workflow_id, 1),
557 failed_activity(&workflow_id, 2, 0),
558 started(&workflow_id, 3),
559 ];
560 assert!(!history_records_outcome(
561 &history,
562 &ActivityId::from_sequence_position(0)
563 ));
564 }
565
566 #[test]
567 fn a_completed_or_cancelled_activity_also_counts_as_judged() {
568 let workflow_id = WorkflowId::new_v4();
569 let activity_id = ActivityId::from_sequence_position(0);
570 for terminal in [
571 Event::ActivityCompleted {
572 envelope: envelope(&workflow_id, 2),
573 activity_id: activity_id.clone(),
574 result: Payload::new(ContentType::Json, b"{}".to_vec()),
575 attempt: 1,
576 },
577 Event::ActivityCancelled {
578 envelope: envelope(&workflow_id, 2),
579 activity_id: activity_id.clone(),
580 attempt: 1,
581 },
582 ] {
583 let history = vec![started(&workflow_id, 1), terminal];
584 assert!(history_records_outcome(&history, &activity_id));
585 }
586 }
587
588 #[test]
589 fn a_scheduled_but_unfinished_activity_is_not_judged() {
590 let workflow_id = WorkflowId::new_v4();
591 let activity_id = ActivityId::from_sequence_position(0);
592 let history = vec![
593 started(&workflow_id, 1),
594 Event::ActivityScheduled {
595 envelope: envelope(&workflow_id, 2),
596 activity_id: activity_id.clone(),
597 activity_type: String::from("charge"),
598 input: Payload::new(ContentType::Json, b"{}".to_vec()),
599 task_queue: String::from("default"),
600 node: None,
601 },
602 ];
603 assert!(!history_records_outcome(&history, &activity_id));
604 }
605}