Skip to main content

aion_server/worker/
outbox_redrive.rs

1//! Operator-driven redrive of dead-lettered outbox rows.
2//!
3//! # Why a dead letter is not simply retryable
4//!
5//! A dead letter is a row whose dispatch spent its whole retry budget on infrastructure failures.
6//! Two of them can look identical in the table and mean opposite things:
7//!
8//! - the failure was **delivered** to the owning workflow, which reacted under its own
9//!   retry/failure semantics — that reaction is recorded history, judgment has passed, and
10//!   re-running the activity would re-execute possibly non-idempotent work behind a recorded
11//!   verdict;
12//! - the failure was **never delivered** — no live workflow accepted it, the delivery errored, or
13//!   no callback was installed — so the workflow is still waiting on an activity that will never
14//!   arrive. Nothing was judged. This is the row redrive exists for.
15//!
16//! The durable marker [`OutboxRow::failure_delivered`] records which is which at dead-letter time
17//! (see [`outbox_dead_letter`](super::outbox_dead_letter)), and the store's own guarded transition
18//! enforces it. This module adds the two checks that need workflow HISTORY, which the store cannot
19//! see:
20//!
21//! 1. **Liveness.** A terminal workflow's rows are settled, never re-armed — the same rule the
22//!    boot/adoption sweep in [`outbox_settle`](super::outbox_settle) enforces, projected from the
23//!    same [`status_from_events`] projection. Redriving a dead letter for a workflow that has
24//!    already Completed/Failed/Cancelled/TimedOut would dispatch a zombie round for a dead
25//!    workflow, which is precisely the incident that sweep exists to prevent.
26//! 2. **Recorded outcome.** The judgment marker is written one store round-trip AFTER the failure
27//!    is handed to the workflow, so a crash in that window can leave a judged row marked
28//!    unjudged. History is the durable authority that closes it: if the current lease already
29//!    records ANY terminal outcome for the row's activity, judgment has passed regardless of what
30//!    the marker says.
31//!
32//! Both checks refuse by default and are overridable only by an explicit
33//! [`RedriveMode::Forced`], which is logged at `warn` with the full reason it overrode.
34//!
35//! # Which gate is atomic, and which is not
36//!
37//! Stated exactly, because a gate believed stronger than it is is worse than a gate known to be
38//! advisory.
39//!
40//! **The judgment marker is ATOMIC and it is the control.** `failure_delivered` is never read
41//! here and acted on there: the store's `redrive_outbox_row` applies `status = 'failed'` and (in
42//! eligible mode) `failure_delivered = 0` as guards INSIDE the same transition that performs the
43//! write — one `IMMEDIATE` transaction on libSQL, one read-modify-write whose refusal writes
44//! nothing on haematite. A concurrent dead-letter, re-arm, or second redrive cannot interleave
45//! between the test and the write, and the `was_judged` an override reports comes from the
46//! store's own pre-state rather than from anything this module observed earlier.
47//!
48//! **The two history gates above are check-then-act, and structurally cannot be otherwise.** The
49//! workflow's event history and the outbox row are separate stores with no shared transaction, so
50//! nothing can read history and transition a row under one lock. They are advisory pre-checks
51//! that narrow a window, not controls that close it. The residual races, named rather than
52//! implied:
53//!
54//! - a workflow that goes terminal between the history read and the transition leaves a row
55//!   re-armed to Pending for a workflow that can never consume it — a zombie row, cleaned up by
56//!   the next [`outbox_settle`](super::outbox_settle) sweep on boot or shard adoption, and inert
57//!   until then because delivery into a terminal workflow is refused at the delivery seam;
58//! - an outcome recorded between the history read and the transition means a redrive proceeds for
59//!   an activity judged microseconds ago.
60//!
61//! Both windows are bounded by one operator-initiated call and neither can corrupt a row: the
62//! atomic guard still decides what the row BECOMES. The history gates exist to catch the crash
63//! window described in check 2, which the marker alone cannot see — not to be a second lock.
64
65use 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/// Why a redrive did not happen, or could not be attempted.
75///
76/// Every variant is an explicit, typed refusal: a redrive request never resolves to a silent
77/// no-op, so an operator is never told "done" about work that was not re-queued.
78#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
79pub enum RedriveRefused {
80    /// The store refused the transition (absent row, not a dead letter, or already judged).
81    #[error(transparent)]
82    Row(#[from] RedriveRefusal),
83    /// The owning workflow already reached a hard terminal, so its rows must be settled, never
84    /// re-armed — re-dispatching one would serve a zombie round for a dead workflow.
85    #[error(
86        "workflow {workflow_id} is terminal ({status:?}); a terminal workflow's outbox rows are \
87         settled, never redriven"
88    )]
89    WorkflowTerminal {
90        /// The workflow that owns the dead letter.
91        workflow_id: WorkflowId,
92        /// The status projected from its recorded history.
93        status: WorkflowStatus,
94    },
95    /// The workflow's recorded history already carries a terminal outcome for this activity, so
96    /// judgment has passed even if the row's judgment marker says otherwise (the crash window
97    /// between delivering a failure and recording that it was delivered).
98    #[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        /// The workflow that owns the dead letter.
105        workflow_id: WorkflowId,
106        /// The activity the row dispatches.
107        activity_id: ActivityId,
108        /// The row's fan-out ordinal.
109        ordinal: u64,
110    },
111    /// The store or history read failed; nothing was redriven.
112    #[error("redrive could not read durable state: {0}")]
113    Store(#[from] StoreError),
114}
115
116/// List every dead-lettered outbox row of `workflow_id` — the operator's discovery surface.
117///
118/// Each row carries [`OutboxRow::failure_delivered`], so an operator can see which dead letters are
119/// redrivable and which were already judged before choosing to act.
120///
121/// # Errors
122///
123/// Returns [`StoreError`] when the enumeration fails at the backend boundary.
124pub 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
133/// Return the dead-lettered row for `(workflow_id, ordinal)` to the pending claim path.
134///
135/// Applies, in order: the workflow-liveness gate, the recorded-outcome gate, and then the store's
136/// own status-guarded transition (which enforces the dead-letter status and the judgment marker
137/// inside its own atomic operation). Every refusal is typed and logged; a successful redrive is
138/// logged with the workflow, ordinal, and mode.
139///
140/// # Errors
141///
142/// Returns [`RedriveRefused`] when any gate refuses, when the store refuses the transition, or when
143/// durable state could not be read. Nothing is written unless the row actually moves.
144pub 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    // Gate 1 — liveness. Identical rule and projection to the terminal-workflow settlement sweep:
155    // a terminal workflow's rows are retired, never re-armed. Not overridable: forcing a dispatch
156    // for a workflow that can never consume it is not an operator decision, it is a zombie round.
157    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    // Gate 2 — recorded outcome. History is the durable authority on whether judgment passed, and
172    // it closes the crash window between delivering a dead letter's failure and recording that the
173    // delivery landed.
174    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
234/// Whether the workflow's CURRENT lease already records a terminal outcome for `activity_id`.
235///
236/// The lease is everything after the last `WorkflowStarted`/`WorkflowReopened` — the same scoping
237/// the failed-step projection uses — because a continue-as-new or reopened chain reuses ordinals,
238/// so a prior lease's recorded outcome says nothing about this row.
239fn 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    /// An outbox store that refuses EVERY call.
278    ///
279    /// The gates in this module must decide before the store is ever touched, so a test that
280    /// expects a gate refusal proves it by handing the redrive a store that cannot be used without
281    /// the failure showing up in the result.
282    #[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    /// A redrive that was supposed to refuse but did not: report it as a typed failure rather
359    /// than unwrapping.
360    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            // Even a FORCED redrive may not serve a zombie round for a dead workflow.
431            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        // The refusing store proves the call reached it: the force overrode the history gate, and
486        // the ONLY way to observe a store error here is to have passed that gate.
487        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        // No history at all: nothing is terminal and nothing is recorded, so both gates pass and
511        // the store's own status guard is the authority (here, the refusing double).
512        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        // Ordinals are reused across leases, so a failure recorded BEFORE the current lease's
554        // WorkflowStarted says nothing about this row.
555        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}