Skip to main content

aion/runtime/handle/
delivery.rs

1//! Mailbox delivery surface of [`RuntimeHandle`]: wake markers, two-phase
2//! activity completion retention, and the retry-tolerant enqueue path.
3//!
4//! Markers are pure wakes — durable state lives in recorded history or the
5//! retained completion maps, never in the marker itself.
6
7use aion_core::{
8    ActivityError, ActivityErrorKind, ActivityId, ContentType, Payload, RunId, WorkflowId,
9};
10use beamr::atom::Atom;
11use beamr::process::ExitReason;
12
13use crate::error::EngineError;
14use crate::registry::Registry;
15
16use super::{Pid, RuntimeHandle, runtime_error};
17use crate::runtime::payload::term_to_payload;
18
19impl RuntimeHandle {
20    /// Block until an activity exits, then surface its success or failure to the parent.
21    ///
22    /// Normal returns become typed payload results queued for the workflow and
23    /// abnormal exits become typed activity errors that can be read alongside the
24    /// trapped EXIT message delivered by the runtime link.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`EngineError::Runtime`] when the parent is not live, the result
29    /// term cannot be converted to a payload, or mailbox delivery fails.
30    pub fn propagate_activity_outcome(
31        &self,
32        parent_pid: Pid,
33        activity_pid: Pid,
34    ) -> Result<(), EngineError> {
35        self.ensure_live_pid(parent_pid)?;
36        let (reason, owned_result) = self.scheduler.run_until_exit(activity_pid);
37        self.release_spawn_heaps(activity_pid);
38        if reason == ExitReason::Normal {
39            let payload = term_to_payload(owned_result.root(), &self.atom_table)?;
40            self.deliver_activity_result(parent_pid, activity_pid, payload)
41        } else {
42            let error = self
43                .activity_errors
44                .get(&(parent_pid, activity_pid))
45                .map_or_else(
46                    || ActivityError {
47                        kind: ActivityErrorKind::Terminal,
48                        message: format!("activity process {activity_pid} exited: {reason:?}"),
49                        details: None,
50                    },
51                    |entry| entry.clone(),
52                );
53            self.deliver_activity_error(parent_pid, activity_pid, error)
54        }
55    }
56
57    /// Deliver a recorded signal wake marker to the workflow mailbox surface.
58    ///
59    /// The marker is a pure wake: the signal payload was already durably
60    /// recorded by the signal router before delivery, and the awaiting NIF
61    /// resolves it from recorded history. Nothing is retained here.
62    ///
63    /// Blocking variant for synchronous callers (engine-seam trait impls and
64    /// scheduler-thread paths); async tasks use
65    /// [`Self::deliver_signal_received_async`] so their executor threads are
66    /// never parked in `std::thread::sleep`.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
71    /// mailbox marker cannot be queued.
72    pub fn deliver_signal_received(&self, workflow_pid: Pid) -> Result<(), EngineError> {
73        self.ensure_live_pid(workflow_pid)?;
74        self.wait_for_process_ready(workflow_pid)?;
75        let marker = self.atom_table.intern("aion_signal_received");
76        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
77    }
78
79    /// Async variant of [`Self::deliver_signal_received`] for runtime tasks:
80    /// the readiness wait and the enqueue retry yield to the executor
81    /// instead of blocking its worker thread.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
86    /// mailbox marker cannot be queued.
87    pub(crate) async fn deliver_signal_received_async(
88        &self,
89        workflow_pid: Pid,
90    ) -> Result<(), EngineError> {
91        self.ensure_live_pid(workflow_pid)?;
92        self.wait_for_process_ready_async(workflow_pid).await?;
93        let marker = self.atom_table.intern("aion_signal_received");
94        self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
95            .await
96    }
97
98    /// Deliver a pending-query wake marker to the workflow mailbox surface.
99    ///
100    /// The marker is a pure wake: the pending query (id and name) was already
101    /// queued in the engine NIF state by the query mailbox engine, and the
102    /// woken suspending await drains it through the query-pump entry check.
103    /// Nothing is retained here and nothing is recorded.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
108    /// mailbox marker cannot be queued.
109    pub(crate) fn deliver_query_request(&self, workflow_pid: Pid) -> Result<(), EngineError> {
110        self.ensure_live_pid(workflow_pid)?;
111        self.wait_for_process_ready(workflow_pid)?;
112        let marker = self.atom_table.intern("aion_query");
113        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
114    }
115
116    /// Deliver a recorded child-terminal wake marker to the parent workflow
117    /// mailbox surface.
118    ///
119    /// The marker is a pure wake: the child's terminal outcome was already
120    /// durably recorded into the parent's history (as
121    /// `ChildWorkflowCompleted`/`ChildWorkflowFailed`) by the child-terminal
122    /// watcher before delivery, and the awaiting NIF resolves it from
123    /// recorded history. Nothing is retained here.
124    ///
125    /// Async by contract: the only caller is the child-terminal watcher on
126    /// the single-worker child-task runtime, where a blocking readiness wait
127    /// would serialize every other watcher's delivery behind it (worst case
128    /// N × `ready_timeout` under fan-out).
129    ///
130    /// # Errors
131    ///
132    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
133    /// mailbox marker cannot be queued.
134    pub(crate) async fn deliver_child_terminal(
135        &self,
136        workflow_pid: Pid,
137    ) -> Result<(), EngineError> {
138        self.ensure_live_pid(workflow_pid)?;
139        self.wait_for_process_ready_async(workflow_pid).await?;
140        let marker = self.atom_table.intern("aion_child_terminal");
141        self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
142            .await
143    }
144
145    /// Deliver a two-phase activity completion marker to the workflow mailbox.
146    ///
147    /// The structured `{activity_complete, CorrelationId, Result}` payload is
148    /// retained in the runtime boundary, and an atom marker wakes any suspended
149    /// selective receive. The await NIF resolves the retained payload by
150    /// correlation id after consuming the marker.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
155    /// marker cannot be queued.
156    pub(crate) fn deliver_activity_completion_message(
157        &self,
158        workflow_pid: Pid,
159        correlation_id: &str,
160        result: String,
161    ) -> Result<(), EngineError> {
162        self.ensure_live_pid(workflow_pid)?;
163        let activity_id = correlation_to_activity_pid(correlation_id)?;
164        self.activity_results.insert(
165            (workflow_pid, activity_id),
166            Payload::new(ContentType::Json, result.into_bytes()),
167        );
168        let marker = self.atom_table.intern("activity_complete");
169        self.enqueue_activity_marker(workflow_pid, marker, correlation_id)
170    }
171
172    /// Deliver a two-phase activity failure marker to the workflow mailbox.
173    ///
174    /// # Errors
175    ///
176    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
177    /// marker cannot be queued.
178    pub(crate) fn deliver_activity_failure_message(
179        &self,
180        workflow_pid: Pid,
181        correlation_id: &str,
182        reason: String,
183    ) -> Result<(), EngineError> {
184        self.ensure_live_pid(workflow_pid)?;
185        let activity_id = correlation_to_activity_pid(correlation_id)?;
186        self.activity_errors
187            .insert((workflow_pid, activity_id), activity_failure(reason));
188        let marker = self.atom_table.intern("activity_failed");
189        self.enqueue_activity_marker(workflow_pid, marker, correlation_id)
190    }
191
192    /// Route an unmatched durable-outbox activity completion into the live
193    /// workflow's mailbox.
194    ///
195    /// Resolves `workflow_id` to its live pid through `registry` (the
196    /// [`RuntimeHandle`] does not hold the registry) and delegates to
197    /// [`Self::deliver_activity_completion_message`], whose retained payload
198    /// the engine's `take_and_record` later records as the terminal.
199    ///
200    /// Returns `Ok(true)` when delivered to a live workflow and `Ok(false)`
201    /// when no run for the workflow is currently live — the expected
202    /// stale-completion case after a crash or eviction, which recovery
203    /// re-arms. A `false` is not an error: the caller logs it at debug.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`EngineError::RegistryPoisoned`] if the registry index lock was
208    /// poisoned, or [`EngineError::Runtime`] if the resolved process is not
209    /// live or the mailbox marker cannot be queued.
210    pub fn deliver_outbox_completion(
211        &self,
212        registry: &Registry,
213        workflow_id: &WorkflowId,
214        activity_id: &ActivityId,
215        run_id: Option<&RunId>,
216        result: String,
217    ) -> Result<bool, EngineError> {
218        // Run-aware gate: a completion carrying a run_id is only delivered when
219        // that run is still the workflow's live run. After continue-as-new the
220        // prior run is superseded, and its late completion must NOT resolve the
221        // new run's reused ordinal (OBX-011). The recorder's
222        // `record_fan_out_completion` run check is the second enforcement layer.
223        let Some(pid) = outbox_delivery_pid(registry, workflow_id, run_id)? else {
224            return Ok(false);
225        };
226        self.deliver_activity_completion_message(pid, &activity_id.to_string(), result)?;
227        Ok(true)
228    }
229
230    /// Route an unmatched durable-outbox activity failure into the live
231    /// workflow's mailbox.
232    ///
233    /// Failure twin of [`Self::deliver_outbox_completion`]: same registry
234    /// resolution and the same not-live `Ok(false)` outcome, delegating to
235    /// [`Self::deliver_activity_failure_message`].
236    ///
237    /// # Errors
238    ///
239    /// Returns [`EngineError::RegistryPoisoned`] if the registry index lock was
240    /// poisoned, or [`EngineError::Runtime`] if the resolved process is not
241    /// live or the mailbox marker cannot be queued.
242    pub fn deliver_outbox_failure(
243        &self,
244        registry: &Registry,
245        workflow_id: &WorkflowId,
246        activity_id: &ActivityId,
247        run_id: Option<&RunId>,
248        reason: String,
249    ) -> Result<bool, EngineError> {
250        // Run-aware gate, identical to `deliver_outbox_completion`: a failure
251        // belonging to a superseded run (post continue-as-new) must not resolve
252        // the new run's reused ordinal (OBX-011).
253        let Some(pid) = outbox_delivery_pid(registry, workflow_id, run_id)? else {
254            return Ok(false);
255        };
256        self.deliver_activity_failure_message(pid, &activity_id.to_string(), reason)?;
257        Ok(true)
258    }
259
260    /// Deliver a successful activity result payload to the workflow mailbox surface.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
265    /// mailbox marker cannot be queued.
266    pub fn deliver_activity_result(
267        &self,
268        parent_pid: Pid,
269        activity_pid: Pid,
270        payload: Payload,
271    ) -> Result<(), EngineError> {
272        self.ensure_live_pid(parent_pid)?;
273        self.activity_results
274            .insert((parent_pid, activity_pid), payload);
275        let marker = self.atom_table.intern("aion_activity_result");
276        if self.scheduler.enqueue_atom_message(parent_pid, marker) {
277            self.confirm_marker_wake(parent_pid);
278            Ok(())
279        } else {
280            Err(runtime_error(format!(
281                "failed to deliver activity result from {activity_pid} to {parent_pid}"
282            )))
283        }
284    }
285
286    /// Wake a suspended workflow process so blocking awaits re-run their
287    /// two-phase resolution (a fired timer, an expired `with_timeout`
288    /// deadline, or any other recorded arrival).
289    ///
290    /// # Errors
291    ///
292    /// Returns [`EngineError::Runtime`] when the workflow process is not
293    /// live or the wake marker cannot be queued.
294    pub(crate) fn wake_workflow(&self, workflow_pid: Pid) -> Result<(), EngineError> {
295        self.ensure_live_pid(workflow_pid)?;
296        let marker = self.atom_table.intern("aion_timer_fired");
297        // Retry covers the transient just-spawned/executing windows where
298        // beamr's enqueue declines; a recovery-re-armed timer can fire
299        // before the recovered process slot is fully materialized.
300        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
301    }
302
303    fn enqueue_activity_marker(
304        &self,
305        workflow_pid: Pid,
306        marker: Atom,
307        correlation_id: &str,
308    ) -> Result<(), EngineError> {
309        if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
310            self.confirm_marker_wake(workflow_pid);
311            tracing::debug!(
312                workflow_pid,
313                correlation_id,
314                "delivered activity completion marker to workflow mailbox via scheduler queue"
315            );
316            Ok(())
317        } else {
318            Err(runtime_error(format!(
319                "failed to deliver activity completion marker {correlation_id} to {workflow_pid}"
320            )))
321        }
322    }
323
324    /// Store a typed activity error for a trapped activity EXIT signal.
325    ///
326    /// # Errors
327    ///
328    /// Returns [`EngineError::Runtime`] when the workflow process is not live.
329    pub fn deliver_activity_error(
330        &self,
331        parent_pid: Pid,
332        activity_pid: Pid,
333        error: ActivityError,
334    ) -> Result<(), EngineError> {
335        self.ensure_live_pid(parent_pid)?;
336        self.activity_errors
337            .insert((parent_pid, activity_pid), error);
338        Ok(())
339    }
340
341    /// Read a previously delivered activity result payload.
342    #[must_use]
343    pub fn activity_result(&self, parent_pid: Pid, activity_pid: Pid) -> Option<Payload> {
344        self.activity_results
345            .get(&(parent_pid, activity_pid))
346            .map(|entry| entry.clone())
347    }
348
349    /// Read a previously delivered activity error associated with a trapped exit.
350    #[must_use]
351    pub fn activity_error(&self, parent_pid: Pid, activity_pid: Pid) -> Option<ActivityError> {
352        self.activity_errors
353            .get(&(parent_pid, activity_pid))
354            .map(|entry| entry.clone())
355    }
356
357    pub(crate) fn take_activity_result(
358        &self,
359        parent_pid: Pid,
360        activity_sequence: Pid,
361    ) -> Option<Payload> {
362        self.activity_results
363            .remove(&(parent_pid, activity_sequence))
364            .map(|(_, payload)| payload)
365    }
366
367    pub(crate) fn take_activity_error(
368        &self,
369        parent_pid: Pid,
370        activity_sequence: Pid,
371    ) -> Option<ActivityError> {
372        self.activity_errors
373            .remove(&(parent_pid, activity_sequence))
374            .map(|(_, error)| error)
375    }
376
377    /// Drop every retained activity completion and failure for a workflow pid.
378    ///
379    /// Called from the workflow process monitor when the process exits: a
380    /// completion delivered after the workflow stopped awaiting it — a race
381    /// loser's late settle, or any delivery after exit — is never `take`n by
382    /// an await and would otherwise be retained forever (D5).
383    pub(crate) fn drain_activity_completions(&self, workflow_pid: Pid) {
384        self.activity_results
385            .retain(|(parent, _), _| *parent != workflow_pid);
386        self.activity_errors
387            .retain(|(parent, _), _| *parent != workflow_pid);
388    }
389
390    /// Number of retained two-phase activity completion entries (results
391    /// plus failures) across every workflow process.
392    ///
393    /// Diagnostic surface: after a workflow exits, the monitor drain must
394    /// leave nothing behind for its pid, so an engine with no live awaits
395    /// should report zero.
396    #[must_use]
397    pub fn retained_activity_completions(&self) -> usize {
398        self.activity_results.len() + self.activity_errors.len()
399    }
400
401    pub(crate) fn activity_complete_atom(&self) -> Atom {
402        self.atom_table.intern("activity_complete")
403    }
404
405    pub(crate) fn activity_failed_atom(&self) -> Atom {
406        self.atom_table.intern("activity_failed")
407    }
408
409    pub(crate) fn activity_result_atom(&self) -> Atom {
410        self.atom_table.intern("aion_activity_result")
411    }
412
413    pub(crate) fn signal_received_atom(&self) -> Atom {
414        self.atom_table.intern("aion_signal_received")
415    }
416
417    pub(crate) fn timer_fired_atom(&self) -> Atom {
418        self.atom_table.intern("aion_timer_fired")
419    }
420
421    pub(crate) fn query_marker_atom(&self) -> Atom {
422        self.atom_table.intern("aion_query")
423    }
424
425    pub(crate) fn child_terminal_atom(&self) -> Atom {
426        self.atom_table.intern("aion_child_terminal")
427    }
428
429    pub(crate) fn wait_for_process_ready(&self, pid: Pid) -> Result<(), EngineError> {
430        let deadline = std::time::Instant::now() + self.signal_delivery.ready_timeout;
431        while std::time::Instant::now() < deadline {
432            if self.scheduler.trap_exit(pid).is_some() {
433                return Ok(());
434            }
435            sleep_signal_delivery_backoff(self.signal_delivery.initial_backoff);
436        }
437        self.scheduler
438            .trap_exit(pid)
439            .map(|_| ())
440            .ok_or_else(|| runtime_error(format!("process {pid} is not ready")))
441    }
442
443    /// Async twin of [`Self::wait_for_process_ready`]: identical readiness
444    /// semantics, but the waits yield to the executor (`tokio::time::sleep`)
445    /// so one slow-to-materialize process never parks a worker thread other
446    /// deliveries share.
447    pub(crate) async fn wait_for_process_ready_async(&self, pid: Pid) -> Result<(), EngineError> {
448        let deadline = std::time::Instant::now() + self.signal_delivery.ready_timeout;
449        while std::time::Instant::now() < deadline {
450            if self.scheduler.trap_exit(pid).is_some() {
451                return Ok(());
452            }
453            yield_signal_delivery_backoff(self.signal_delivery.initial_backoff).await;
454        }
455        self.scheduler
456            .trap_exit(pid)
457            .map(|_| ())
458            .ok_or_else(|| runtime_error(format!("process {pid} is not ready")))
459    }
460
461    fn enqueue_signal_marker_with_retry(
462        &self,
463        workflow_pid: Pid,
464        marker: Atom,
465    ) -> Result<(), EngineError> {
466        let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
467        let mut backoff = self.signal_delivery.initial_backoff;
468        for attempt in 1..=attempts {
469            if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
470                self.confirm_marker_wake(workflow_pid);
471                return Ok(());
472            }
473
474            if self.scheduler.process_table().get(workflow_pid).is_none() {
475                return Err(runtime_error(format!(
476                    "failed to deliver signal to workflow process {workflow_pid}: process is not live"
477                )));
478            }
479
480            if attempt < attempts {
481                // beamr 0.3.15 normal spawn publishes the PID before a scheduler
482                // worker materializes the process body from its SpawnRequest. It
483                // also exposes an Executing slot while the process is running.
484                // enqueue_atom_message only accepts a Present slot, so an alive
485                // just-spawned or currently executing process can transiently
486                // return false even after the liveness/ready gate above.
487                sleep_signal_delivery_backoff(backoff);
488                backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
489            }
490        }
491
492        Err(runtime_error(format!(
493            "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
494        )))
495    }
496
497    /// Async twin of [`Self::enqueue_signal_marker_with_retry`]: identical
498    /// retry policy over the same just-spawned/executing windows, with the
499    /// backoff yielded to the executor instead of blocking its worker.
500    async fn enqueue_signal_marker_with_retry_async(
501        &self,
502        workflow_pid: Pid,
503        marker: Atom,
504    ) -> Result<(), EngineError> {
505        let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
506        let mut backoff = self.signal_delivery.initial_backoff;
507        for attempt in 1..=attempts {
508            if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
509                self.confirm_marker_wake(workflow_pid);
510                return Ok(());
511            }
512
513            if self.scheduler.process_table().get(workflow_pid).is_none() {
514                return Err(runtime_error(format!(
515                    "failed to deliver signal to workflow process {workflow_pid}: process is not live"
516                )));
517            }
518
519            if attempt < attempts {
520                // Same transient-window rationale as the blocking variant.
521                yield_signal_delivery_backoff(backoff).await;
522                backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
523            }
524        }
525
526        Err(runtime_error(format!(
527            "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
528        )))
529    }
530
531    /// Arm the consumption-gated wake ladder for a delivered marker.
532    ///
533    /// `enqueue_atom_message` stores the message and wakes the pid, but
534    /// beamr's `Wait`-arm gap can swallow that wake (the message is
535    /// stored after the parked process's mailbox re-check and the wake runs
536    /// before its wait-set insert), parking the process forever on a
537    /// one-shot delivery. Follow-up wakes land after the insert and drain
538    /// the already-stored message; the ladder stops once the target's
539    /// wake-observation epoch moves — a suspending-native entry or process
540    /// exit after this delivery — so it survives arbitrarily stretched gaps
541    /// (OS preemption) without waking healthy processes forever.
542    ///
543    /// NOTE: this workaround was written against beamr 0.4.9. The crate is now
544    /// pinned to beamr 0.6.4; the `Wait`-arm gap may have been fixed upstream,
545    /// so this ladder needs re-validation against 0.6.4 and may now be stale.
546    fn confirm_marker_wake(&self, workflow_pid: Pid) {
547        let state = std::sync::Arc::clone(self.nif_state());
548        let snapshot = state.wake_observation_epoch(workflow_pid);
549        self.wake_confirmer
550            .confirm(self.scheduler.wake_notifier(workflow_pid), move || {
551                state.wake_ladder_done(workflow_pid, snapshot)
552            });
553    }
554}
555
556/// Resolve the pid an unmatched outbox completion/failure should be delivered
557/// to, enforcing run scoping when a `run_id` is supplied.
558///
559/// When `run_id` is `Some(r)`, delivery is gated on the workflow's live run
560/// still being `r`: a completion for a superseded/dead run (e.g. a prior run
561/// after continue-as-new) resolves to `Ok(None)` and is dropped, so it can
562/// never resolve the new run's reused ordinal space (OBX-011).
563///
564/// When `run_id` is `None` (legacy/pre-CAN callers), this preserves the
565/// original run-agnostic behaviour: deliver to whatever run is live.
566///
567/// `Ok(None)` is the not-live / wrong-run outcome, never an error.
568fn outbox_delivery_pid(
569    registry: &Registry,
570    workflow_id: &WorkflowId,
571    run_id: Option<&RunId>,
572) -> Result<Option<u64>, EngineError> {
573    match run_id {
574        None => registry.live_pid(workflow_id),
575        Some(expected) => {
576            let Some((live_run, pid)) = registry.live_run_pid(workflow_id)? else {
577                return Ok(None);
578            };
579            if live_run == *expected {
580                Ok(Some(pid))
581            } else {
582                tracing::debug!(
583                    %workflow_id,
584                    %expected,
585                    live_run = %live_run,
586                    "dropping outbox delivery for superseded run"
587                );
588                Ok(None)
589            }
590        }
591    }
592}
593
594fn activity_failure(message: String) -> ActivityError {
595    ActivityError {
596        kind: ActivityErrorKind::Terminal,
597        message,
598        details: None,
599    }
600}
601
602fn correlation_to_activity_pid(correlation_id: &str) -> Result<Pid, EngineError> {
603    let Some(raw) = correlation_id.strip_prefix("activity:") else {
604        return Err(runtime_error(format!(
605            "invalid activity correlation id {correlation_id}"
606        )));
607    };
608    raw.parse::<Pid>().map_err(|error| {
609        runtime_error(format!(
610            "invalid activity correlation sequence {correlation_id}: {error}"
611        ))
612    })
613}
614
615fn next_signal_delivery_backoff(
616    current: std::time::Duration,
617    max: std::time::Duration,
618) -> std::time::Duration {
619    let doubled = current.saturating_mul(2);
620    if doubled > max { max } else { doubled }
621}
622
623fn sleep_signal_delivery_backoff(duration: std::time::Duration) {
624    if duration.is_zero() {
625        std::thread::yield_now();
626    } else {
627        std::thread::sleep(duration);
628    }
629}
630
631async fn yield_signal_delivery_backoff(duration: std::time::Duration) {
632    if duration.is_zero() {
633        tokio::task::yield_now().await;
634    } else {
635        tokio::time::sleep(duration).await;
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use std::sync::Arc;
642
643    use aion_core::{ActivityId, RunId, WorkflowId, WorkflowStatus};
644    use aion_package::ContentHash;
645
646    use crate::registry::Registry;
647    use crate::registry::handle::{
648        CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
649    };
650    use crate::runtime::config::RuntimeConfig;
651
652    use super::RuntimeHandle;
653
654    fn live_handle(workflow_id: &WorkflowId, run_id: &RunId, pid: u64) -> WorkflowHandle {
655        let store = Arc::new(aion_store::InMemoryStore::default());
656        let recorder = crate::durability::Recorder::new(workflow_id.clone(), store);
657        WorkflowHandle::new(WorkflowHandleParts {
658            workflow_id: workflow_id.clone(),
659            run_id: run_id.clone(),
660            pid,
661            workflow_type: "checkout".to_owned(),
662            namespace: String::from("default"),
663            loaded_version: ContentHash::from_bytes([1; 32]),
664            cached_status: WorkflowStatus::Running,
665            residency: HandleResidency::Resident,
666            recorder,
667            completion: CompletionNotifier::new(),
668        })
669    }
670
671    #[test]
672    fn outbox_completion_lands_where_take_reads_it() -> Result<(), Box<dyn std::error::Error>> {
673        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
674        let registry = Registry::default();
675        let workflow_id = WorkflowId::new_v4();
676        let run_id = RunId::new_v4();
677        // A live test process supplies the pid the registry resolves to.
678        let pid = runtime.spawn_test_process()?;
679        registry.insert(
680            (workflow_id.clone(), run_id.clone()),
681            live_handle(&workflow_id, &run_id, pid),
682        )?;
683
684        let ordinal = 3;
685        let activity_id = ActivityId::from_sequence_position(ordinal);
686        let delivered = runtime.deliver_outbox_completion(
687            &registry,
688            &workflow_id,
689            &activity_id,
690            None,
691            r#"{"ok":true}"#.to_owned(),
692        )?;
693
694        assert!(delivered, "delivery to a live workflow must report true");
695        let payload = runtime
696            .take_activity_result(pid, ordinal)
697            .ok_or("completion was not retained where take_activity_result reads it")?;
698        assert_eq!(payload.bytes(), br#"{"ok":true}"#);
699
700        // An unknown workflow id is the not-live outcome, never an error.
701        let unknown = runtime.deliver_outbox_completion(
702            &registry,
703            &WorkflowId::new_v4(),
704            &activity_id,
705            None,
706            "{}".to_owned(),
707        )?;
708        assert!(
709            !unknown,
710            "an unknown workflow must report not-live, not error"
711        );
712
713        runtime.shutdown()?;
714        Ok(())
715    }
716
717    #[test]
718    fn outbox_completion_is_run_scoped_across_continue_as_new()
719    -> Result<(), Box<dyn std::error::Error>> {
720        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
721        let registry = Registry::default();
722        let workflow_id = WorkflowId::new_v4();
723        // R1 is the prior run; R2 is the live run after a continue-as-new. The
724        // index tracks the newest run, so the workflow's live run is R2.
725        let r1 = RunId::new_v4();
726        let r2 = RunId::new_v4();
727        let pid = runtime.spawn_test_process()?;
728        registry.insert(
729            (workflow_id.clone(), r2.clone()),
730            live_handle(&workflow_id, &r2, pid),
731        )?;
732
733        // A reused ordinal that exists in both R1's and R2's ordinal space.
734        let ordinal = 3;
735        let activity_id = ActivityId::from_sequence_position(ordinal);
736
737        // A completion belonging to the superseded run R1 must NOT be delivered
738        // and must NOT resolve R2's reused ordinal.
739        let stale = runtime.deliver_outbox_completion(
740            &registry,
741            &workflow_id,
742            &activity_id,
743            Some(&r1),
744            r#"{"from":"r1"}"#.to_owned(),
745        )?;
746        assert!(
747            !stale,
748            "a completion for a superseded run must not be delivered"
749        );
750        assert!(
751            runtime.take_activity_result(pid, ordinal).is_none(),
752            "a superseded run's completion must not resolve the live run's reused ordinal"
753        );
754
755        // A completion for the live run R2 IS delivered and resolves the ordinal.
756        let live = runtime.deliver_outbox_completion(
757            &registry,
758            &workflow_id,
759            &activity_id,
760            Some(&r2),
761            r#"{"from":"r2"}"#.to_owned(),
762        )?;
763        assert!(live, "a completion for the live run must be delivered");
764        let payload = runtime
765            .take_activity_result(pid, ordinal)
766            .ok_or("live-run completion was not retained where take_activity_result reads it")?;
767        assert_eq!(payload.bytes(), br#"{"from":"r2"}"#);
768
769        runtime.shutdown()?;
770        Ok(())
771    }
772
773    #[test]
774    fn outbox_failure_is_run_scoped_across_continue_as_new()
775    -> Result<(), Box<dyn std::error::Error>> {
776        let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
777        let registry = Registry::default();
778        let workflow_id = WorkflowId::new_v4();
779        let r1 = RunId::new_v4();
780        let r2 = RunId::new_v4();
781        let pid = runtime.spawn_test_process()?;
782        registry.insert(
783            (workflow_id.clone(), r2.clone()),
784            live_handle(&workflow_id, &r2, pid),
785        )?;
786
787        let ordinal = 5;
788        let activity_id = ActivityId::from_sequence_position(ordinal);
789
790        let stale = runtime.deliver_outbox_failure(
791            &registry,
792            &workflow_id,
793            &activity_id,
794            Some(&r1),
795            "r1 failed".to_owned(),
796        )?;
797        assert!(
798            !stale,
799            "a failure for a superseded run must not be delivered"
800        );
801        assert!(
802            runtime.take_activity_error(pid, ordinal).is_none(),
803            "a superseded run's failure must not resolve the live run's reused ordinal"
804        );
805
806        let live = runtime.deliver_outbox_failure(
807            &registry,
808            &workflow_id,
809            &activity_id,
810            Some(&r2),
811            "r2 failed".to_owned(),
812        )?;
813        assert!(live, "a failure for the live run must be delivered");
814        assert!(
815            runtime.take_activity_error(pid, ordinal).is_some(),
816            "live-run failure must be retained where take_activity_error reads it"
817        );
818
819        runtime.shutdown()?;
820        Ok(())
821    }
822}