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::{ActivityError, ActivityErrorKind, ContentType, Payload};
8use beamr::atom::Atom;
9use beamr::process::ExitReason;
10
11use crate::error::EngineError;
12
13use super::{Pid, RuntimeHandle, runtime_error};
14use crate::runtime::payload::term_to_payload;
15
16impl RuntimeHandle {
17    /// Block until an activity exits, then surface its success or failure to the parent.
18    ///
19    /// Normal returns become typed payload results queued for the workflow and
20    /// abnormal exits become typed activity errors that can be read alongside the
21    /// trapped EXIT message delivered by the runtime link.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`EngineError::Runtime`] when the parent is not live, the result
26    /// term cannot be converted to a payload, or mailbox delivery fails.
27    pub fn propagate_activity_outcome(
28        &self,
29        parent_pid: Pid,
30        activity_pid: Pid,
31    ) -> Result<(), EngineError> {
32        self.ensure_live_pid(parent_pid)?;
33        let (reason, owned_result) = self.scheduler.run_until_exit(activity_pid);
34        self.release_spawn_heaps(activity_pid);
35        if reason == ExitReason::Normal {
36            let payload = term_to_payload(owned_result.root(), &self.atom_table)?;
37            self.deliver_activity_result(parent_pid, activity_pid, payload)
38        } else {
39            let error = self
40                .activity_errors
41                .get(&(parent_pid, activity_pid))
42                .map_or_else(
43                    || ActivityError {
44                        kind: ActivityErrorKind::Terminal,
45                        message: format!("activity process {activity_pid} exited: {reason:?}"),
46                        details: None,
47                    },
48                    |entry| entry.clone(),
49                );
50            self.deliver_activity_error(parent_pid, activity_pid, error)
51        }
52    }
53
54    /// Deliver a recorded signal wake marker to the workflow mailbox surface.
55    ///
56    /// The marker is a pure wake: the signal payload was already durably
57    /// recorded by the signal router before delivery, and the awaiting NIF
58    /// resolves it from recorded history. Nothing is retained here.
59    ///
60    /// Blocking variant for synchronous callers (engine-seam trait impls and
61    /// scheduler-thread paths); async tasks use
62    /// [`Self::deliver_signal_received_async`] so their executor threads are
63    /// never parked in `std::thread::sleep`.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
68    /// mailbox marker cannot be queued.
69    pub fn deliver_signal_received(&self, workflow_pid: Pid) -> Result<(), EngineError> {
70        self.ensure_live_pid(workflow_pid)?;
71        self.wait_for_process_ready(workflow_pid)?;
72        let marker = self.atom_table.intern("aion_signal_received");
73        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
74    }
75
76    /// Async variant of [`Self::deliver_signal_received`] for runtime tasks:
77    /// the readiness wait and the enqueue retry yield to the executor
78    /// instead of blocking its worker thread.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
83    /// mailbox marker cannot be queued.
84    pub(crate) async fn deliver_signal_received_async(
85        &self,
86        workflow_pid: Pid,
87    ) -> Result<(), EngineError> {
88        self.ensure_live_pid(workflow_pid)?;
89        self.wait_for_process_ready_async(workflow_pid).await?;
90        let marker = self.atom_table.intern("aion_signal_received");
91        self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
92            .await
93    }
94
95    /// Deliver a pending-query wake marker to the workflow mailbox surface.
96    ///
97    /// The marker is a pure wake: the pending query (id and name) was already
98    /// queued in the engine NIF state by the query mailbox engine, and the
99    /// woken suspending await drains it through the query-pump entry check.
100    /// Nothing is retained here and nothing is recorded.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
105    /// mailbox marker cannot be queued.
106    pub(crate) fn deliver_query_request(&self, workflow_pid: Pid) -> Result<(), EngineError> {
107        self.ensure_live_pid(workflow_pid)?;
108        self.wait_for_process_ready(workflow_pid)?;
109        let marker = self.atom_table.intern("aion_query");
110        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
111    }
112
113    /// Deliver a recorded child-terminal wake marker to the parent workflow
114    /// mailbox surface.
115    ///
116    /// The marker is a pure wake: the child's terminal outcome was already
117    /// durably recorded into the parent's history (as
118    /// `ChildWorkflowCompleted`/`ChildWorkflowFailed`) by the child-terminal
119    /// watcher before delivery, and the awaiting NIF resolves it from
120    /// recorded history. Nothing is retained here.
121    ///
122    /// Async by contract: the only caller is the child-terminal watcher on
123    /// the single-worker child-task runtime, where a blocking readiness wait
124    /// would serialize every other watcher's delivery behind it (worst case
125    /// N × `ready_timeout` under fan-out).
126    ///
127    /// # Errors
128    ///
129    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
130    /// mailbox marker cannot be queued.
131    pub(crate) async fn deliver_child_terminal(
132        &self,
133        workflow_pid: Pid,
134    ) -> Result<(), EngineError> {
135        self.ensure_live_pid(workflow_pid)?;
136        self.wait_for_process_ready_async(workflow_pid).await?;
137        let marker = self.atom_table.intern("aion_child_terminal");
138        self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
139            .await
140    }
141
142    /// Deliver a two-phase activity completion marker to the workflow mailbox.
143    ///
144    /// The structured `{activity_complete, CorrelationId, Result}` payload is
145    /// retained in the runtime boundary, and an atom marker wakes any suspended
146    /// selective receive. The await NIF resolves the retained payload by
147    /// correlation id after consuming the marker.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
152    /// marker cannot be queued.
153    pub(crate) fn deliver_activity_completion_message(
154        &self,
155        workflow_pid: Pid,
156        correlation_id: &str,
157        result: String,
158    ) -> Result<(), EngineError> {
159        self.ensure_live_pid(workflow_pid)?;
160        let activity_id = correlation_to_activity_pid(correlation_id)?;
161        self.activity_results.insert(
162            (workflow_pid, activity_id),
163            Payload::new(ContentType::Json, result.into_bytes()),
164        );
165        let marker = self.atom_table.intern("activity_complete");
166        self.enqueue_activity_marker(workflow_pid, marker, correlation_id)
167    }
168
169    /// Deliver a two-phase activity failure marker to the workflow mailbox.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
174    /// marker cannot be queued.
175    pub(crate) fn deliver_activity_failure_message(
176        &self,
177        workflow_pid: Pid,
178        correlation_id: &str,
179        reason: String,
180    ) -> Result<(), EngineError> {
181        self.ensure_live_pid(workflow_pid)?;
182        let activity_id = correlation_to_activity_pid(correlation_id)?;
183        self.activity_errors
184            .insert((workflow_pid, activity_id), activity_failure(reason));
185        let marker = self.atom_table.intern("activity_failed");
186        self.enqueue_activity_marker(workflow_pid, marker, correlation_id)
187    }
188
189    /// Deliver a successful activity result payload to the workflow mailbox surface.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`EngineError::Runtime`] when the workflow is not live or the
194    /// mailbox marker cannot be queued.
195    pub fn deliver_activity_result(
196        &self,
197        parent_pid: Pid,
198        activity_pid: Pid,
199        payload: Payload,
200    ) -> Result<(), EngineError> {
201        self.ensure_live_pid(parent_pid)?;
202        self.activity_results
203            .insert((parent_pid, activity_pid), payload);
204        let marker = self.atom_table.intern("aion_activity_result");
205        if self.scheduler.enqueue_atom_message(parent_pid, marker) {
206            self.confirm_marker_wake(parent_pid);
207            Ok(())
208        } else {
209            Err(runtime_error(format!(
210                "failed to deliver activity result from {activity_pid} to {parent_pid}"
211            )))
212        }
213    }
214
215    /// Wake a suspended workflow process so blocking awaits re-run their
216    /// two-phase resolution (a fired timer, an expired `with_timeout`
217    /// deadline, or any other recorded arrival).
218    ///
219    /// # Errors
220    ///
221    /// Returns [`EngineError::Runtime`] when the workflow process is not
222    /// live or the wake marker cannot be queued.
223    pub(crate) fn wake_workflow(&self, workflow_pid: Pid) -> Result<(), EngineError> {
224        self.ensure_live_pid(workflow_pid)?;
225        let marker = self.atom_table.intern("aion_timer_fired");
226        // Retry covers the transient just-spawned/executing windows where
227        // beamr's enqueue declines; a recovery-re-armed timer can fire
228        // before the recovered process slot is fully materialized.
229        self.enqueue_signal_marker_with_retry(workflow_pid, marker)
230    }
231
232    fn enqueue_activity_marker(
233        &self,
234        workflow_pid: Pid,
235        marker: Atom,
236        correlation_id: &str,
237    ) -> Result<(), EngineError> {
238        if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
239            self.confirm_marker_wake(workflow_pid);
240            tracing::debug!(
241                workflow_pid,
242                correlation_id,
243                "delivered activity completion marker to workflow mailbox via scheduler queue"
244            );
245            Ok(())
246        } else {
247            Err(runtime_error(format!(
248                "failed to deliver activity completion marker {correlation_id} to {workflow_pid}"
249            )))
250        }
251    }
252
253    /// Store a typed activity error for a trapped activity EXIT signal.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`EngineError::Runtime`] when the workflow process is not live.
258    pub fn deliver_activity_error(
259        &self,
260        parent_pid: Pid,
261        activity_pid: Pid,
262        error: ActivityError,
263    ) -> Result<(), EngineError> {
264        self.ensure_live_pid(parent_pid)?;
265        self.activity_errors
266            .insert((parent_pid, activity_pid), error);
267        Ok(())
268    }
269
270    /// Read a previously delivered activity result payload.
271    #[must_use]
272    pub fn activity_result(&self, parent_pid: Pid, activity_pid: Pid) -> Option<Payload> {
273        self.activity_results
274            .get(&(parent_pid, activity_pid))
275            .map(|entry| entry.clone())
276    }
277
278    /// Read a previously delivered activity error associated with a trapped exit.
279    #[must_use]
280    pub fn activity_error(&self, parent_pid: Pid, activity_pid: Pid) -> Option<ActivityError> {
281        self.activity_errors
282            .get(&(parent_pid, activity_pid))
283            .map(|entry| entry.clone())
284    }
285
286    pub(crate) fn take_activity_result(
287        &self,
288        parent_pid: Pid,
289        activity_sequence: Pid,
290    ) -> Option<Payload> {
291        self.activity_results
292            .remove(&(parent_pid, activity_sequence))
293            .map(|(_, payload)| payload)
294    }
295
296    pub(crate) fn take_activity_error(
297        &self,
298        parent_pid: Pid,
299        activity_sequence: Pid,
300    ) -> Option<ActivityError> {
301        self.activity_errors
302            .remove(&(parent_pid, activity_sequence))
303            .map(|(_, error)| error)
304    }
305
306    /// Drop every retained activity completion and failure for a workflow pid.
307    ///
308    /// Called from the workflow process monitor when the process exits: a
309    /// completion delivered after the workflow stopped awaiting it — a race
310    /// loser's late settle, or any delivery after exit — is never `take`n by
311    /// an await and would otherwise be retained forever (D5).
312    pub(crate) fn drain_activity_completions(&self, workflow_pid: Pid) {
313        self.activity_results
314            .retain(|(parent, _), _| *parent != workflow_pid);
315        self.activity_errors
316            .retain(|(parent, _), _| *parent != workflow_pid);
317    }
318
319    /// Number of retained two-phase activity completion entries (results
320    /// plus failures) across every workflow process.
321    ///
322    /// Diagnostic surface: after a workflow exits, the monitor drain must
323    /// leave nothing behind for its pid, so an engine with no live awaits
324    /// should report zero.
325    #[must_use]
326    pub fn retained_activity_completions(&self) -> usize {
327        self.activity_results.len() + self.activity_errors.len()
328    }
329
330    pub(crate) fn activity_complete_atom(&self) -> Atom {
331        self.atom_table.intern("activity_complete")
332    }
333
334    pub(crate) fn activity_failed_atom(&self) -> Atom {
335        self.atom_table.intern("activity_failed")
336    }
337
338    pub(crate) fn activity_result_atom(&self) -> Atom {
339        self.atom_table.intern("aion_activity_result")
340    }
341
342    pub(crate) fn signal_received_atom(&self) -> Atom {
343        self.atom_table.intern("aion_signal_received")
344    }
345
346    pub(crate) fn timer_fired_atom(&self) -> Atom {
347        self.atom_table.intern("aion_timer_fired")
348    }
349
350    pub(crate) fn query_marker_atom(&self) -> Atom {
351        self.atom_table.intern("aion_query")
352    }
353
354    pub(crate) fn child_terminal_atom(&self) -> Atom {
355        self.atom_table.intern("aion_child_terminal")
356    }
357
358    pub(crate) fn wait_for_process_ready(&self, pid: Pid) -> Result<(), EngineError> {
359        let deadline = std::time::Instant::now() + self.signal_delivery.ready_timeout;
360        while std::time::Instant::now() < deadline {
361            if self.scheduler.trap_exit(pid).is_some() {
362                return Ok(());
363            }
364            sleep_signal_delivery_backoff(self.signal_delivery.initial_backoff);
365        }
366        self.scheduler
367            .trap_exit(pid)
368            .map(|_| ())
369            .ok_or_else(|| runtime_error(format!("process {pid} is not ready")))
370    }
371
372    /// Async twin of [`Self::wait_for_process_ready`]: identical readiness
373    /// semantics, but the waits yield to the executor (`tokio::time::sleep`)
374    /// so one slow-to-materialize process never parks a worker thread other
375    /// deliveries share.
376    pub(crate) async fn wait_for_process_ready_async(&self, pid: Pid) -> Result<(), EngineError> {
377        let deadline = std::time::Instant::now() + self.signal_delivery.ready_timeout;
378        while std::time::Instant::now() < deadline {
379            if self.scheduler.trap_exit(pid).is_some() {
380                return Ok(());
381            }
382            yield_signal_delivery_backoff(self.signal_delivery.initial_backoff).await;
383        }
384        self.scheduler
385            .trap_exit(pid)
386            .map(|_| ())
387            .ok_or_else(|| runtime_error(format!("process {pid} is not ready")))
388    }
389
390    fn enqueue_signal_marker_with_retry(
391        &self,
392        workflow_pid: Pid,
393        marker: Atom,
394    ) -> Result<(), EngineError> {
395        let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
396        let mut backoff = self.signal_delivery.initial_backoff;
397        for attempt in 1..=attempts {
398            if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
399                self.confirm_marker_wake(workflow_pid);
400                return Ok(());
401            }
402
403            if self.scheduler.process_table().get(workflow_pid).is_none() {
404                return Err(runtime_error(format!(
405                    "failed to deliver signal to workflow process {workflow_pid}: process is not live"
406                )));
407            }
408
409            if attempt < attempts {
410                // beamr 0.3.15 normal spawn publishes the PID before a scheduler
411                // worker materializes the process body from its SpawnRequest. It
412                // also exposes an Executing slot while the process is running.
413                // enqueue_atom_message only accepts a Present slot, so an alive
414                // just-spawned or currently executing process can transiently
415                // return false even after the liveness/ready gate above.
416                sleep_signal_delivery_backoff(backoff);
417                backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
418            }
419        }
420
421        Err(runtime_error(format!(
422            "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
423        )))
424    }
425
426    /// Async twin of [`Self::enqueue_signal_marker_with_retry`]: identical
427    /// retry policy over the same just-spawned/executing windows, with the
428    /// backoff yielded to the executor instead of blocking its worker.
429    async fn enqueue_signal_marker_with_retry_async(
430        &self,
431        workflow_pid: Pid,
432        marker: Atom,
433    ) -> Result<(), EngineError> {
434        let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
435        let mut backoff = self.signal_delivery.initial_backoff;
436        for attempt in 1..=attempts {
437            if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
438                self.confirm_marker_wake(workflow_pid);
439                return Ok(());
440            }
441
442            if self.scheduler.process_table().get(workflow_pid).is_none() {
443                return Err(runtime_error(format!(
444                    "failed to deliver signal to workflow process {workflow_pid}: process is not live"
445                )));
446            }
447
448            if attempt < attempts {
449                // Same transient-window rationale as the blocking variant.
450                yield_signal_delivery_backoff(backoff).await;
451                backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
452            }
453        }
454
455        Err(runtime_error(format!(
456            "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
457        )))
458    }
459
460    /// Arm the consumption-gated wake ladder for a delivered marker.
461    ///
462    /// `enqueue_atom_message` stores the message and wakes the pid, but
463    /// beamr 0.4.9's `Wait`-arm gap can swallow that wake (the message is
464    /// stored after the parked process's mailbox re-check and the wake runs
465    /// before its wait-set insert), parking the process forever on a
466    /// one-shot delivery. Follow-up wakes land after the insert and drain
467    /// the already-stored message; the ladder stops once the target's
468    /// wake-observation epoch moves — a suspending-native entry or process
469    /// exit after this delivery — so it survives arbitrarily stretched gaps
470    /// (OS preemption) without waking healthy processes forever.
471    fn confirm_marker_wake(&self, workflow_pid: Pid) {
472        let state = std::sync::Arc::clone(self.nif_state());
473        let snapshot = state.wake_observation_epoch(workflow_pid);
474        self.wake_confirmer
475            .confirm(self.scheduler.wake_notifier(workflow_pid), move || {
476                state.wake_ladder_done(workflow_pid, snapshot)
477            });
478    }
479}
480
481fn activity_failure(message: String) -> ActivityError {
482    ActivityError {
483        kind: ActivityErrorKind::Terminal,
484        message,
485        details: None,
486    }
487}
488
489fn correlation_to_activity_pid(correlation_id: &str) -> Result<Pid, EngineError> {
490    let Some(raw) = correlation_id.strip_prefix("activity:") else {
491        return Err(runtime_error(format!(
492            "invalid activity correlation id {correlation_id}"
493        )));
494    };
495    raw.parse::<Pid>().map_err(|error| {
496        runtime_error(format!(
497            "invalid activity correlation sequence {correlation_id}: {error}"
498        ))
499    })
500}
501
502fn next_signal_delivery_backoff(
503    current: std::time::Duration,
504    max: std::time::Duration,
505) -> std::time::Duration {
506    let doubled = current.saturating_mul(2);
507    if doubled > max { max } else { doubled }
508}
509
510fn sleep_signal_delivery_backoff(duration: std::time::Duration) {
511    if duration.is_zero() {
512        std::thread::yield_now();
513    } else {
514        std::thread::sleep(duration);
515    }
516}
517
518async fn yield_signal_delivery_backoff(duration: std::time::Duration) {
519    if duration.is_zero() {
520        tokio::task::yield_now().await;
521    } else {
522        tokio::time::sleep(duration).await;
523    }
524}