aion-rs 0.11.0

Transport-agnostic Aion workflow engine with durability, replay, timers, and supervision.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! Remote activity completion delivery and durable retry execution.

use std::sync::Arc;

use crate::activity::bridge::{ActivityDispatch, ActivityDispatcher};
use crate::durability::Recorder;

/// Spawn the completion task for one dispatched activity.
///
/// The task drives the dispatch to its FINAL outcome before waking the
/// workflow: a retryable-class failure (`retryable:` reason prefix — the
/// string form of the wire's structured `ActivityErrorKind`, see
/// [`super::nif_activity_retry`]) with budget left under the SDK-declared
/// retry policy is recorded durably as a non-terminal `ActivityFailed`
/// (kind `Retryable`), backed off, and re-dispatched with the SAME ordinal
/// and routing at the incremented attempt. Non-retryable failures, absent
/// policies (`"retry": null` — the SDK's run-exactly-once contract), and an
/// exhausted budget deliver to the workflow exactly as before, with the last
/// reason verbatim.
///
/// Every durable retry record is guarded against the settle races the
/// workflow thread can win mid-loop (a `with_timeout` expiry recording the
/// ordinal's terminal, a workflow terminal): the guard re-reads history under
/// the recorder lock and aborts the loop once the decision was made elsewhere.
/// The backoff sleep itself is task-local, not a durable timer: an engine
/// crash mid-backoff recovers through replay, whose dangling retryable
/// failure re-dispatches the activity live at the next attempt.
pub(super) fn spawn_completion_task(
    tokio_handle: &tokio::runtime::Handle,
    runtime: Arc<crate::RuntimeHandle>,
    dispatcher: Arc<dyn ActivityDispatcher>,
    seam: RetryRecorderSeam,
    workflow_pid: u64,
    correlation_id: String,
    request: ActivityDispatch,
) {
    let future = async move {
        let outcome = dispatch_with_retries(&dispatcher, &seam, &request).await;
        let attempt = outcome.attempt;
        match outcome.terminal {
            RetryLoopTerminal::Completed(payload) => {
                if let Err(error) = runtime.deliver_activity_completion_message_with_attempt(
                    workflow_pid,
                    &correlation_id,
                    payload,
                    Some(attempt),
                ) {
                    tracing::warn!(%error, workflow_pid, correlation_id, "activity completion delivery failed");
                }
            }
            RetryLoopTerminal::Failed(reason) => {
                if let Err(error) = runtime.deliver_activity_failure_message_with_attempt(
                    workflow_pid,
                    &correlation_id,
                    reason,
                    Some(attempt),
                ) {
                    tracing::warn!(%error, workflow_pid, correlation_id, "activity failure delivery failed");
                }
            }
            RetryLoopTerminal::SettledElsewhere => {
                // The awaited ordinal (or the whole workflow) reached a
                // recorded terminal while the loop ran — deliver nothing; the
                // workflow already took that branch.
                tracing::debug!(
                    workflow_id = %request.workflow_id,
                    activity_id = %request.activity_id,
                    attempt,
                    "activity retry loop stopped: the activity settled through another path"
                );
            }
            RetryLoopTerminal::Parked => {
                // The server parked this dispatch for restart recovery
                // (graceful drain, #207): record nothing, deliver nothing. The
                // durable log ends at the dangling scheduled/started trail —
                // byte-equivalent to a kill -9 — so post-restart replay
                // re-dispatches the activity live (cursor Exhausted →
                // ResumeLive), exactly the SettledElsewhere stand-down shape.
                tracing::debug!(
                    workflow_id = %request.workflow_id,
                    activity_id = %request.activity_id,
                    attempt,
                    "activity dispatch parked for restart recovery; retry loop stood down"
                );
            }
        }
    };
    tokio_handle.spawn(future);
}

/// The durable seam one completion task records retry attempts through: the
/// workflow's single-writer recorder plus the run the dispatch belongs to
/// (settlement is a per-run question — see [`record_retry_event`]).
pub(super) struct RetryRecorderSeam {
    /// The workflow's single-writer recorder, shared with the NIF contexts.
    pub(super) recorder: Arc<tokio::sync::Mutex<Recorder>>,
    /// The run this dispatch was issued by.
    pub(super) run_id: aion_core::RunId,
}

/// The retry loop's final disposition, carrying the attempt that produced it.
pub(super) struct RetryLoopOutcome {
    pub(super) attempt: u32,
    pub(super) terminal: RetryLoopTerminal,
}

pub(super) enum RetryLoopTerminal {
    /// The encoded output of the successful attempt.
    Completed(String),
    /// The last failure reason, verbatim (prefix included).
    Failed(String),
    /// A terminal for this ordinal/workflow was recorded by another path
    /// mid-loop; nothing may be delivered or recorded for it anymore.
    SettledElsewhere,
    /// The server parked the dispatch for restart recovery during a graceful
    /// drain (#207): nothing may be delivered or recorded — the workflow stays
    /// suspended and post-restart replay re-dispatches the dangling ordinal.
    Parked,
}

/// Classify a failed dispatch BEFORE any durable retry record: the parked
/// sentinel stands the loop down (park beats retry, #207 — nothing recorded,
/// nothing delivered, no budget consumed; restart recovery re-dispatches the
/// dangling ordinal); a non-retryable class, an absent policy (`"retry":
/// null`), or an exhausted budget fails with the reason verbatim. `None`
/// means the loop retries under the policy.
fn failure_stand_down(
    policy: Option<&super::nif_activity_retry::RetryPolicy>,
    reason: &str,
    attempt: u32,
) -> Option<RetryLoopTerminal> {
    use super::nif_activity_retry::{is_parked_reason, is_retryable_reason};

    if is_parked_reason(reason) {
        return Some(RetryLoopTerminal::Parked);
    }
    match policy {
        Some(policy) if is_retryable_reason(reason) && attempt < policy.max_attempts => None,
        _ => Some(RetryLoopTerminal::Failed(reason.to_owned())),
    }
}

/// Drive one activity dispatch to its final outcome under the SDK-declared
/// retry policy carried in the dispatch config (#197).
pub(super) async fn dispatch_with_retries(
    dispatcher: &Arc<dyn ActivityDispatcher>,
    seam: &RetryRecorderSeam,
    request: &ActivityDispatch,
) -> RetryLoopOutcome {
    use super::nif_activity_retry::retry_policy_from_config;

    let policy = retry_policy_from_config(&request.config);
    let mut attempt = request.attempt;
    loop {
        let mut delivery = request.clone();
        delivery.attempt = attempt;
        let reason = match Arc::clone(dispatcher).dispatch_async(delivery).await {
            Ok(payload) => {
                return RetryLoopOutcome {
                    attempt,
                    terminal: RetryLoopTerminal::Completed(payload),
                };
            }
            Err(reason) => reason,
        };
        // TRANSPORT-domain worker loss: the activity never executed to a result,
        // so it is re-dispatched at the SAME attempt with NOTHING recorded —
        // attempt-neutral. The prior behaviour delivered this as a terminal
        // failure whenever the activity carried no authored retry policy (the
        // SDK's run-exactly-once default), so every infrastructure death read to
        // the operator as a red action.
        //
        // The loop cannot spin: the re-dispatch goes through the same
        // unserved-queue park a first dispatch does, so it waits for a worker
        // rather than failing fast, and the server stops sending this class once
        // its transport-loss budget is spent (a `transport-exhausted:` reason
        // that no arm here recognises, so it falls through to the terminal
        // below). A terminal recorded elsewhere mid-loop still wins — checked
        // before every re-dispatch, because this arm records nothing and would
        // otherwise never notice.
        if super::nif_activity_retry::is_worker_lost_reason(&reason) {
            match worker_loss_stand_down(seam, request, &reason, attempt).await {
                Some(terminal) => return RetryLoopOutcome { attempt, terminal },
                None => continue,
            }
        }
        if let Some(terminal) = failure_stand_down(policy.as_ref(), &reason, attempt) {
            if matches!(terminal, RetryLoopTerminal::Failed(_)) {
                record_advisory_exhaustion(seam, request, &reason, attempt).await;
            }
            return RetryLoopOutcome { attempt, terminal };
        }
        // The stand-down above returns `Failed` whenever no policy is present,
        // so a `None` here is structurally unreachable — kept as the honest
        // failure terminal rather than an unwrap.
        let Some(policy) = policy.as_ref() else {
            return RetryLoopOutcome {
                attempt,
                terminal: RetryLoopTerminal::Failed(reason),
            };
        };
        // Record the failed attempt durably as a NON-terminal (Retryable)
        // `ActivityFailed` — the observable retry record the history cursor
        // walks past to the eventual terminal. Recording failures abort the
        // loop into an honest terminal failure: an unrecorded retry is a
        // silent retry.
        match record_retry_event(
            seam,
            request,
            RetryRecord::AttemptFailed {
                attempt,
                reason: reason.clone(),
            },
        )
        .await
        {
            RetryRecordOutcome::Recorded => {}
            RetryRecordOutcome::Settled => {
                return RetryLoopOutcome {
                    attempt,
                    terminal: RetryLoopTerminal::SettledElsewhere,
                };
            }
            RetryRecordOutcome::RecordFailed(record_error) => {
                tracing::warn!(
                    workflow_id = %request.workflow_id,
                    activity_id = %request.activity_id,
                    attempt,
                    error = %record_error,
                    "failed to record a retryable activity failure; failing the activity instead \
                     of retrying unrecorded"
                );
                return RetryLoopOutcome {
                    attempt,
                    terminal: RetryLoopTerminal::Failed(reason),
                };
            }
        }
        announce_and_back_off(policy, request, &reason, attempt).await;
        attempt += 1;
        // Record the retry delivery's `ActivityStarted` before it goes on the
        // wire, so history and the worker wire agree on the attempt (NOI-0) —
        // re-guarded, because the backoff sleep is a settle-race window.
        match record_retry_event(seam, request, RetryRecord::AttemptStarted { attempt }).await {
            RetryRecordOutcome::Recorded => {}
            RetryRecordOutcome::Settled => {
                return RetryLoopOutcome {
                    attempt,
                    terminal: RetryLoopTerminal::SettledElsewhere,
                };
            }
            RetryRecordOutcome::RecordFailed(record_error) => {
                tracing::warn!(
                    workflow_id = %request.workflow_id,
                    activity_id = %request.activity_id,
                    attempt,
                    error = %record_error,
                    "failed to record a retry attempt start; failing the activity instead of \
                     dispatching unrecorded"
                );
                return RetryLoopOutcome {
                    attempt,
                    terminal: RetryLoopTerminal::Failed(reason),
                };
            }
        }
    }
}

/// Announce a retryable action failure and sleep out its backoff before the next
/// attempt goes on the wire.
///
/// Split out of the loop body purely for size; the sleep is deliberately
/// task-local rather than a durable timer (an engine crash mid-backoff recovers
/// through replay, whose dangling retryable failure re-dispatches live).
async fn announce_and_back_off(
    policy: &super::nif_activity_retry::RetryPolicy,
    request: &ActivityDispatch,
    reason: &str,
    attempt: u32,
) {
    let delay = policy.backoff.delay_after(attempt);
    tracing::warn!(
        workflow_id = %request.workflow_id,
        activity_id = %request.activity_id,
        activity_type = %request.name,
        attempt,
        max_attempts = policy.max_attempts,
        retry_in_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
        reason = %reason,
        "activity attempt failed with a retryable error; re-dispatching"
    );
    tokio::time::sleep(delay).await;
}

/// Decide what an attempt-neutral worker-loss re-dispatch does: stand the loop
/// down when the ordinal was settled elsewhere, or `None` to re-dispatch the
/// SAME attempt, recording nothing.
///
/// The settlement read is load-bearing precisely BECAUSE this path records
/// nothing: every other arm of the loop rides `record_retry_event`'s settlement
/// check, and without this one a re-dispatch could outlive a `with_timeout`
/// expiry (or a workflow terminal) that already decided the ordinal.
async fn worker_loss_stand_down(
    seam: &RetryRecorderSeam,
    request: &ActivityDispatch,
    reason: &str,
    attempt: u32,
) -> Option<RetryLoopTerminal> {
    if activity_settled_elsewhere(seam, request).await {
        return Some(RetryLoopTerminal::SettledElsewhere);
    }
    tracing::warn!(
        workflow_id = %request.workflow_id,
        activity_id = %request.activity_id,
        activity_type = %request.name,
        attempt,
        reason = %reason,
        "activity's worker was lost before it reported a result; re-dispatching the same \
         attempt (transport loss consumes no authored retry budget)"
    );
    None
}

/// Whether this ordinal (within its run) already carries a recorded terminal.
///
/// The attempt-neutral worker-loss re-dispatch records NOTHING, so it has no
/// `record_retry_event` settle check to ride on; without this read it could
/// re-dispatch an ordinal a `with_timeout` expiry (or a workflow terminal)
/// already settled. A read failure answers `false` — the loop then re-dispatches
/// and the recorder's own guards still refuse a post-terminal append, which is
/// strictly safer than standing an activity down on an unreadable history.
async fn activity_settled_elsewhere(seam: &RetryRecorderSeam, request: &ActivityDispatch) -> bool {
    let recorder = seam.recorder.lock().await;
    let Ok(history) = recorder.read_history().await else {
        return false;
    };
    let Ok(history) = crate::durability::current_run_segment(history, &seam.run_id) else {
        return false;
    };
    super::nif_activity_retry::activity_settled(&history, &request.activity_id)
}

/// Record the R5 warning when an ADVISORY activity's attempt budget is spent
/// (RUNTIME-OPERATIONS.md R5).
///
/// "Retry exhaustion" is read as the attempt budget being SPENT, whatever its
/// size: an advisory action with no declared retry has a budget of one, and its
/// single failure exhausts it exactly as a declared `retry 5` exhausts five.
/// The warning therefore fires on every terminal failure of an advisory
/// dispatch, and never on a park or a settled-elsewhere stand-down (neither is
/// a failure, and both must record nothing).
///
/// Ordering: the warning lands JUST BEFORE the failure is delivered, so it
/// precedes the terminal `ActivityFailed` the workflow thread records on
/// receipt. Both are in history; the warning never replaces the failure.
///
/// A recording failure here is logged and swallowed — deliberately. The
/// activity's own honest terminal is what the workflow acts on; losing the
/// warning must not also change the outcome the run gets.
async fn record_advisory_exhaustion(
    seam: &RetryRecorderSeam,
    request: &ActivityDispatch,
    reason: &str,
    attempt: u32,
) {
    if !request.advisory {
        return;
    }
    match record_retry_event(
        seam,
        request,
        RetryRecord::AdvisoryExhausted {
            attempt,
            reason: reason.to_owned(),
        },
    )
    .await
    {
        RetryRecordOutcome::Recorded | RetryRecordOutcome::Settled => {}
        RetryRecordOutcome::RecordFailed(record_error) => {
            tracing::warn!(
                workflow_id = %request.workflow_id,
                activity_id = %request.activity_id,
                attempt,
                error = %record_error,
                "failed to record the advisory-exhaustion warning; the activity's terminal \
                 failure still stands"
            );
        }
    }
}

/// One durable retry record the loop appends between attempts.
enum RetryRecord {
    /// The just-failed attempt's non-terminal `ActivityFailed`.
    AttemptFailed { attempt: u32, reason: String },
    /// The next delivery's `ActivityStarted`.
    AttemptStarted { attempt: u32 },
    /// The R5 warning that an ADVISORY activity spent its attempt budget.
    AdvisoryExhausted { attempt: u32, reason: String },
}

enum RetryRecordOutcome {
    Recorded,
    /// The ordinal (or workflow) already has a recorded terminal; the loop
    /// must stop without recording or delivering anything further.
    Settled,
    RecordFailed(crate::durability::DurabilityError),
}

/// Append one retry record under the recorder lock, re-checking settlement
/// first so the append can never land after a terminal recorded by the
/// workflow thread (`with_timeout` expiry, workflow terminal).
async fn record_retry_event(
    seam: &RetryRecorderSeam,
    request: &ActivityDispatch,
    record: RetryRecord,
) -> RetryRecordOutcome {
    let mut recorder = seam.recorder.lock().await;
    let history = match recorder.read_history().await {
        Ok(history) => history,
        Err(error) => return RetryRecordOutcome::RecordFailed(error),
    };
    // Settlement is a per-run question: scope to the current run's segment so
    // a prior run's terminal (continue-as-new) never aborts this run's loop.
    let history = match crate::durability::current_run_segment(history, &seam.run_id) {
        Ok(history) => history,
        Err(error) => return RetryRecordOutcome::RecordFailed(error),
    };
    if super::nif_activity_retry::activity_settled(&history, &request.activity_id) {
        return RetryRecordOutcome::Settled;
    }
    let append_result = match record {
        RetryRecord::AttemptFailed { attempt, reason } => {
            recorder
                .record_activity_failed(
                    chrono::Utc::now(),
                    request.activity_id.clone(),
                    aion_core::ActivityError {
                        kind: aion_core::ActivityErrorKind::Retryable,
                        message: reason,
                        details: None,
                    },
                    attempt,
                )
                .await
        }
        RetryRecord::AttemptStarted { attempt } => {
            recorder
                .record_activity_started(chrono::Utc::now(), request.activity_id.clone(), attempt)
                .await
        }
        RetryRecord::AdvisoryExhausted { attempt, reason } => {
            recorder
                .record_activity_advisory_exhausted(
                    chrono::Utc::now(),
                    request.activity_id.clone(),
                    request.name.clone(),
                    reason,
                    attempt,
                )
                .await
        }
    };
    match append_result {
        Ok(()) => RetryRecordOutcome::Recorded,
        Err(error) => RetryRecordOutcome::RecordFailed(error),
    }
}