1use 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 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: activity_exit_message(activity_pid, reason),
49 details: None,
50 },
51 |entry| entry.clone(),
52 );
53 self.deliver_activity_error(parent_pid, activity_pid, error)
54 }
55 }
56
57 pub(crate) fn in_vm_child_outcome(&self, child_pid: Pid) -> InVmChildOutcome {
73 let (reason, owned_result) = self.scheduler.run_until_exit(child_pid);
74 self.release_spawn_heaps(child_pid);
75 if reason == ExitReason::Normal {
76 decode_in_vm_result(owned_result.root()).unwrap_or_else(|| {
77 InVmChildOutcome::Failed(format!(
78 "terminal:activity process {child_pid} returned an unexpected result shape"
79 ))
80 })
81 } else {
82 InVmChildOutcome::Failed(format!(
83 "terminal:{}",
84 activity_exit_message(child_pid, reason)
85 ))
86 }
87 }
88
89 pub fn deliver_signal_received(&self, workflow_pid: Pid) -> Result<(), EngineError> {
105 self.ensure_live_pid(workflow_pid)?;
106 self.wait_for_process_ready(workflow_pid)?;
107 let marker = self.atom_table.intern("aion_signal_received");
108 self.enqueue_signal_marker_with_retry(workflow_pid, marker)
109 }
110
111 pub(crate) async fn deliver_signal_received_async(
120 &self,
121 workflow_pid: Pid,
122 ) -> Result<(), EngineError> {
123 self.ensure_live_pid(workflow_pid)?;
124 self.wait_for_process_ready_async(workflow_pid).await?;
125 let marker = self.atom_table.intern("aion_signal_received");
126 self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
127 .await
128 }
129
130 pub(crate) fn deliver_query_request(&self, workflow_pid: Pid) -> Result<(), EngineError> {
142 self.ensure_live_pid(workflow_pid)?;
143 self.wait_for_process_ready(workflow_pid)?;
144 let marker = self.atom_table.intern("aion_query");
145 self.enqueue_signal_marker_with_retry(workflow_pid, marker)
146 }
147
148 pub(crate) async fn deliver_child_terminal(
167 &self,
168 workflow_pid: Pid,
169 ) -> Result<(), EngineError> {
170 self.ensure_live_pid(workflow_pid)?;
171 self.wait_for_process_ready_async(workflow_pid).await?;
172 let marker = self.atom_table.intern("aion_child_terminal");
173 self.enqueue_signal_marker_with_retry_async(workflow_pid, marker)
174 .await
175 }
176
177 pub(crate) fn deliver_activity_completion_message(
189 &self,
190 workflow_pid: Pid,
191 correlation_id: &str,
192 result: String,
193 ) -> Result<(), EngineError> {
194 self.ensure_live_pid(workflow_pid)?;
195 let activity_id = correlation_to_activity_pid(correlation_id)?;
196 self.activity_results.insert(
197 (workflow_pid, activity_id),
198 Payload::new(ContentType::Json, result.into_bytes()),
199 );
200 let marker = self.atom_table.intern("activity_complete");
201 self.enqueue_activity_marker(workflow_pid, marker, correlation_id)
202 }
203
204 pub(crate) fn deliver_activity_failure_message(
211 &self,
212 workflow_pid: Pid,
213 correlation_id: &str,
214 reason: String,
215 ) -> Result<(), EngineError> {
216 self.ensure_live_pid(workflow_pid)?;
217 let activity_id = correlation_to_activity_pid(correlation_id)?;
218 self.activity_errors
219 .insert((workflow_pid, activity_id), activity_failure(reason));
220 let marker = self.atom_table.intern("activity_failed");
221 self.enqueue_activity_marker(workflow_pid, marker, correlation_id)
222 }
223
224 pub fn deliver_outbox_completion(
243 &self,
244 registry: &Registry,
245 workflow_id: &WorkflowId,
246 activity_id: &ActivityId,
247 run_id: Option<&RunId>,
248 result: String,
249 ) -> Result<bool, EngineError> {
250 let Some(pid) = outbox_delivery_pid(registry, workflow_id, run_id)? else {
256 return Ok(false);
257 };
258 self.deliver_activity_completion_message(pid, &activity_id.to_string(), result)?;
259 Ok(true)
260 }
261
262 pub fn deliver_outbox_failure(
275 &self,
276 registry: &Registry,
277 workflow_id: &WorkflowId,
278 activity_id: &ActivityId,
279 run_id: Option<&RunId>,
280 reason: String,
281 ) -> Result<bool, EngineError> {
282 let Some(pid) = outbox_delivery_pid(registry, workflow_id, run_id)? else {
286 return Ok(false);
287 };
288 self.deliver_activity_failure_message(pid, &activity_id.to_string(), reason)?;
289 Ok(true)
290 }
291
292 pub fn deliver_activity_result(
299 &self,
300 parent_pid: Pid,
301 activity_pid: Pid,
302 payload: Payload,
303 ) -> Result<(), EngineError> {
304 self.ensure_live_pid(parent_pid)?;
305 self.activity_results
306 .insert((parent_pid, activity_pid), payload);
307 let marker = self.atom_table.intern("aion_activity_result");
308 if self.scheduler.enqueue_atom_message(parent_pid, marker) {
309 self.confirm_marker_wake(parent_pid);
310 Ok(())
311 } else {
312 Err(runtime_error(format!(
313 "failed to deliver activity result from {activity_pid} to {parent_pid}"
314 )))
315 }
316 }
317
318 pub(crate) fn wake_workflow(&self, workflow_pid: Pid) -> Result<(), EngineError> {
327 self.ensure_live_pid(workflow_pid)?;
328 let marker = self.atom_table.intern("aion_timer_fired");
329 self.enqueue_signal_marker_with_retry(workflow_pid, marker)
333 }
334
335 fn enqueue_activity_marker(
336 &self,
337 workflow_pid: Pid,
338 marker: Atom,
339 correlation_id: &str,
340 ) -> Result<(), EngineError> {
341 if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
342 self.confirm_marker_wake(workflow_pid);
343 tracing::debug!(
344 workflow_pid,
345 correlation_id,
346 "delivered activity completion marker to workflow mailbox via scheduler queue"
347 );
348 Ok(())
349 } else {
350 Err(runtime_error(format!(
351 "failed to deliver activity completion marker {correlation_id} to {workflow_pid}"
352 )))
353 }
354 }
355
356 pub fn deliver_activity_error(
362 &self,
363 parent_pid: Pid,
364 activity_pid: Pid,
365 error: ActivityError,
366 ) -> Result<(), EngineError> {
367 self.ensure_live_pid(parent_pid)?;
368 self.activity_errors
369 .insert((parent_pid, activity_pid), error);
370 Ok(())
371 }
372
373 #[must_use]
375 pub fn activity_result(&self, parent_pid: Pid, activity_pid: Pid) -> Option<Payload> {
376 self.activity_results
377 .get(&(parent_pid, activity_pid))
378 .map(|entry| entry.clone())
379 }
380
381 #[must_use]
383 pub fn activity_error(&self, parent_pid: Pid, activity_pid: Pid) -> Option<ActivityError> {
384 self.activity_errors
385 .get(&(parent_pid, activity_pid))
386 .map(|entry| entry.clone())
387 }
388
389 pub(crate) fn take_activity_result(
390 &self,
391 parent_pid: Pid,
392 activity_sequence: Pid,
393 ) -> Option<Payload> {
394 self.activity_results
395 .remove(&(parent_pid, activity_sequence))
396 .map(|(_, payload)| payload)
397 }
398
399 pub(crate) fn take_activity_error(
400 &self,
401 parent_pid: Pid,
402 activity_sequence: Pid,
403 ) -> Option<ActivityError> {
404 self.activity_errors
405 .remove(&(parent_pid, activity_sequence))
406 .map(|(_, error)| error)
407 }
408
409 pub(crate) fn note_delivery_attempt(
416 &self,
417 parent_pid: Pid,
418 activity_sequence: Pid,
419 attempt: u32,
420 ) {
421 self.activity_delivery_attempts
422 .insert((parent_pid, activity_sequence), attempt);
423 }
424
425 pub(crate) fn take_delivery_attempt(
430 &self,
431 parent_pid: Pid,
432 activity_sequence: Pid,
433 ) -> Option<u32> {
434 self.activity_delivery_attempts
435 .remove(&(parent_pid, activity_sequence))
436 .map(|(_, attempt)| attempt)
437 }
438
439 pub(crate) fn drain_activity_completions(&self, workflow_pid: Pid) {
446 self.activity_results
447 .retain(|(parent, _), _| *parent != workflow_pid);
448 self.activity_errors
449 .retain(|(parent, _), _| *parent != workflow_pid);
450 self.activity_delivery_attempts
451 .retain(|(parent, _), _| *parent != workflow_pid);
452 }
453
454 #[must_use]
461 pub fn retained_activity_completions(&self) -> usize {
462 self.activity_results.len() + self.activity_errors.len()
463 }
464
465 pub(crate) fn activity_complete_atom(&self) -> Atom {
466 self.atom_table.intern("activity_complete")
467 }
468
469 pub(crate) fn activity_failed_atom(&self) -> Atom {
470 self.atom_table.intern("activity_failed")
471 }
472
473 pub(crate) fn activity_result_atom(&self) -> Atom {
474 self.atom_table.intern("aion_activity_result")
475 }
476
477 pub(crate) fn signal_received_atom(&self) -> Atom {
478 self.atom_table.intern("aion_signal_received")
479 }
480
481 pub(crate) fn timer_fired_atom(&self) -> Atom {
482 self.atom_table.intern("aion_timer_fired")
483 }
484
485 pub(crate) fn query_marker_atom(&self) -> Atom {
486 self.atom_table.intern("aion_query")
487 }
488
489 pub(crate) fn child_terminal_atom(&self) -> Atom {
490 self.atom_table.intern("aion_child_terminal")
491 }
492
493 pub(crate) fn wait_for_process_ready(&self, pid: Pid) -> Result<(), EngineError> {
494 let deadline = std::time::Instant::now() + self.signal_delivery.ready_timeout;
495 while std::time::Instant::now() < deadline {
496 if self.scheduler.trap_exit(pid).is_some() {
497 return Ok(());
498 }
499 sleep_signal_delivery_backoff(self.signal_delivery.initial_backoff);
500 }
501 self.scheduler
502 .trap_exit(pid)
503 .map(|_| ())
504 .ok_or_else(|| runtime_error(format!("process {pid} is not ready")))
505 }
506
507 pub(crate) async fn wait_for_process_ready_async(&self, pid: Pid) -> Result<(), EngineError> {
512 let deadline = std::time::Instant::now() + self.signal_delivery.ready_timeout;
513 while std::time::Instant::now() < deadline {
514 if self.scheduler.trap_exit(pid).is_some() {
515 return Ok(());
516 }
517 yield_signal_delivery_backoff(self.signal_delivery.initial_backoff).await;
518 }
519 self.scheduler
520 .trap_exit(pid)
521 .map(|_| ())
522 .ok_or_else(|| runtime_error(format!("process {pid} is not ready")))
523 }
524
525 fn enqueue_signal_marker_with_retry(
526 &self,
527 workflow_pid: Pid,
528 marker: Atom,
529 ) -> Result<(), EngineError> {
530 let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
531 let mut backoff = self.signal_delivery.initial_backoff;
532 for attempt in 1..=attempts {
533 if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
534 self.confirm_marker_wake(workflow_pid);
535 return Ok(());
536 }
537
538 if self.scheduler.process_table().get(workflow_pid).is_none() {
539 return Err(runtime_error(format!(
540 "failed to deliver signal to workflow process {workflow_pid}: process is not live"
541 )));
542 }
543
544 if attempt < attempts {
545 sleep_signal_delivery_backoff(backoff);
552 backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
553 }
554 }
555
556 Err(runtime_error(format!(
557 "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
558 )))
559 }
560
561 async fn enqueue_signal_marker_with_retry_async(
565 &self,
566 workflow_pid: Pid,
567 marker: Atom,
568 ) -> Result<(), EngineError> {
569 let attempts = self.signal_delivery.max_enqueue_attempts.max(1);
570 let mut backoff = self.signal_delivery.initial_backoff;
571 for attempt in 1..=attempts {
572 if self.scheduler.enqueue_atom_message(workflow_pid, marker) {
573 self.confirm_marker_wake(workflow_pid);
574 return Ok(());
575 }
576
577 if self.scheduler.process_table().get(workflow_pid).is_none() {
578 return Err(runtime_error(format!(
579 "failed to deliver signal to workflow process {workflow_pid}: process is not live"
580 )));
581 }
582
583 if attempt < attempts {
584 yield_signal_delivery_backoff(backoff).await;
586 backoff = next_signal_delivery_backoff(backoff, self.signal_delivery.max_backoff);
587 }
588 }
589
590 Err(runtime_error(format!(
591 "failed to deliver signal to workflow process {workflow_pid} after {attempts} attempts"
592 )))
593 }
594
595 fn confirm_marker_wake(&self, workflow_pid: Pid) {
611 let state = std::sync::Arc::clone(self.nif_state());
612 let snapshot = state.wake_observation_epoch(workflow_pid);
613 self.wake_confirmer
614 .confirm(self.scheduler.wake_notifier(workflow_pid), move || {
615 state.wake_ladder_done(workflow_pid, snapshot)
616 });
617 }
618}
619
620fn outbox_delivery_pid(
633 registry: &Registry,
634 workflow_id: &WorkflowId,
635 run_id: Option<&RunId>,
636) -> Result<Option<u64>, EngineError> {
637 match run_id {
638 None => registry.live_pid(workflow_id),
639 Some(expected) => {
640 let Some((live_run, pid)) = registry.live_run_pid(workflow_id)? else {
641 return Ok(None);
642 };
643 if live_run == *expected {
644 Ok(Some(pid))
645 } else {
646 tracing::debug!(
647 %workflow_id,
648 %expected,
649 live_run = %live_run,
650 "dropping outbox delivery for superseded run"
651 );
652 Ok(None)
653 }
654 }
655 }
656}
657
658fn activity_failure(message: String) -> ActivityError {
659 ActivityError {
660 kind: ActivityErrorKind::Terminal,
661 message,
662 details: None,
663 }
664}
665
666fn activity_exit_message(activity_pid: Pid, reason: ExitReason) -> String {
669 format!("activity process {activity_pid} exited: {reason:?}")
670}
671
672#[derive(Debug, PartialEq, Eq)]
678pub(crate) enum InVmChildOutcome {
679 Completed(String),
681 Failed(String),
684}
685
686fn decode_in_vm_result(term: beamr::term::Term) -> Option<InVmChildOutcome> {
691 let tuple = beamr::term::boxed::Tuple::new(term)?;
692 if tuple.arity() != 2 {
693 return None;
694 }
695 let tag = tuple.get(0)?;
696 let value = tuple.get(1)?;
697 let bin = beamr::term::binary_ref::BinaryRef::new(value)?;
698 let text = String::from_utf8(bin.as_bytes().to_vec()).ok()?;
699 if tag == beamr::term::Term::atom(Atom::OK) {
700 Some(InVmChildOutcome::Completed(text))
701 } else if tag == beamr::term::Term::atom(Atom::ERROR) {
702 Some(InVmChildOutcome::Failed(text))
703 } else {
704 None
705 }
706}
707
708fn correlation_to_activity_pid(correlation_id: &str) -> Result<Pid, EngineError> {
709 let Some(raw) = correlation_id.strip_prefix("activity:") else {
710 return Err(runtime_error(format!(
711 "invalid activity correlation id {correlation_id}"
712 )));
713 };
714 raw.parse::<Pid>().map_err(|error| {
715 runtime_error(format!(
716 "invalid activity correlation sequence {correlation_id}: {error}"
717 ))
718 })
719}
720
721fn next_signal_delivery_backoff(
722 current: std::time::Duration,
723 max: std::time::Duration,
724) -> std::time::Duration {
725 let doubled = current.saturating_mul(2);
726 if doubled > max { max } else { doubled }
727}
728
729fn sleep_signal_delivery_backoff(duration: std::time::Duration) {
730 if duration.is_zero() {
731 std::thread::yield_now();
732 } else {
733 std::thread::sleep(duration);
734 }
735}
736
737async fn yield_signal_delivery_backoff(duration: std::time::Duration) {
738 if duration.is_zero() {
739 tokio::task::yield_now().await;
740 } else {
741 tokio::time::sleep(duration).await;
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use std::sync::Arc;
748
749 use aion_core::{ActivityId, RunId, WorkflowId, WorkflowStatus};
750 use aion_package::ContentHash;
751
752 use crate::registry::Registry;
753 use crate::registry::handle::{
754 CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
755 };
756 use crate::runtime::config::RuntimeConfig;
757
758 use super::RuntimeHandle;
759
760 fn live_handle(workflow_id: &WorkflowId, run_id: &RunId, pid: u64) -> WorkflowHandle {
761 let store = Arc::new(aion_store::InMemoryStore::default());
762 let recorder = crate::durability::Recorder::new(workflow_id.clone(), store);
763 WorkflowHandle::new(WorkflowHandleParts {
764 workflow_id: workflow_id.clone(),
765 run_id: run_id.clone(),
766 pid,
767 workflow_type: "checkout".to_owned(),
768 namespace: String::from("default"),
769 loaded_version: ContentHash::from_bytes([1; 32]),
770 cached_status: WorkflowStatus::Running,
771 residency: HandleResidency::Resident,
772 recorder,
773 completion: CompletionNotifier::new(),
774 })
775 }
776
777 #[test]
778 fn outbox_completion_lands_where_take_reads_it() -> Result<(), Box<dyn std::error::Error>> {
779 let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
780 let registry = Registry::default();
781 let workflow_id = WorkflowId::new_v4();
782 let run_id = RunId::new_v4();
783 let pid = runtime.spawn_test_process()?;
785 registry.insert(
786 (workflow_id.clone(), run_id.clone()),
787 live_handle(&workflow_id, &run_id, pid),
788 )?;
789
790 let ordinal = 3;
791 let activity_id = ActivityId::from_sequence_position(ordinal);
792 let delivered = runtime.deliver_outbox_completion(
793 ®istry,
794 &workflow_id,
795 &activity_id,
796 None,
797 r#"{"ok":true}"#.to_owned(),
798 )?;
799
800 assert!(delivered, "delivery to a live workflow must report true");
801 let payload = runtime
802 .take_activity_result(pid, ordinal)
803 .ok_or("completion was not retained where take_activity_result reads it")?;
804 assert_eq!(payload.bytes(), br#"{"ok":true}"#);
805
806 let unknown = runtime.deliver_outbox_completion(
808 ®istry,
809 &WorkflowId::new_v4(),
810 &activity_id,
811 None,
812 "{}".to_owned(),
813 )?;
814 assert!(
815 !unknown,
816 "an unknown workflow must report not-live, not error"
817 );
818
819 runtime.shutdown()?;
820 Ok(())
821 }
822
823 #[test]
824 fn outbox_completion_is_run_scoped_across_continue_as_new()
825 -> Result<(), Box<dyn std::error::Error>> {
826 let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
827 let registry = Registry::default();
828 let workflow_id = WorkflowId::new_v4();
829 let r1 = RunId::new_v4();
832 let r2 = RunId::new_v4();
833 let pid = runtime.spawn_test_process()?;
834 registry.insert(
835 (workflow_id.clone(), r2.clone()),
836 live_handle(&workflow_id, &r2, pid),
837 )?;
838
839 let ordinal = 3;
841 let activity_id = ActivityId::from_sequence_position(ordinal);
842
843 let stale = runtime.deliver_outbox_completion(
846 ®istry,
847 &workflow_id,
848 &activity_id,
849 Some(&r1),
850 r#"{"from":"r1"}"#.to_owned(),
851 )?;
852 assert!(
853 !stale,
854 "a completion for a superseded run must not be delivered"
855 );
856 assert!(
857 runtime.take_activity_result(pid, ordinal).is_none(),
858 "a superseded run's completion must not resolve the live run's reused ordinal"
859 );
860
861 let live = runtime.deliver_outbox_completion(
863 ®istry,
864 &workflow_id,
865 &activity_id,
866 Some(&r2),
867 r#"{"from":"r2"}"#.to_owned(),
868 )?;
869 assert!(live, "a completion for the live run must be delivered");
870 let payload = runtime
871 .take_activity_result(pid, ordinal)
872 .ok_or("live-run completion was not retained where take_activity_result reads it")?;
873 assert_eq!(payload.bytes(), br#"{"from":"r2"}"#);
874
875 runtime.shutdown()?;
876 Ok(())
877 }
878
879 #[test]
880 fn outbox_failure_is_run_scoped_across_continue_as_new()
881 -> Result<(), Box<dyn std::error::Error>> {
882 let runtime = RuntimeHandle::new(RuntimeConfig::new(None))?;
883 let registry = Registry::default();
884 let workflow_id = WorkflowId::new_v4();
885 let r1 = RunId::new_v4();
886 let r2 = RunId::new_v4();
887 let pid = runtime.spawn_test_process()?;
888 registry.insert(
889 (workflow_id.clone(), r2.clone()),
890 live_handle(&workflow_id, &r2, pid),
891 )?;
892
893 let ordinal = 5;
894 let activity_id = ActivityId::from_sequence_position(ordinal);
895
896 let stale = runtime.deliver_outbox_failure(
897 ®istry,
898 &workflow_id,
899 &activity_id,
900 Some(&r1),
901 "r1 failed".to_owned(),
902 )?;
903 assert!(
904 !stale,
905 "a failure for a superseded run must not be delivered"
906 );
907 assert!(
908 runtime.take_activity_error(pid, ordinal).is_none(),
909 "a superseded run's failure must not resolve the live run's reused ordinal"
910 );
911
912 let live = runtime.deliver_outbox_failure(
913 ®istry,
914 &workflow_id,
915 &activity_id,
916 Some(&r2),
917 "r2 failed".to_owned(),
918 )?;
919 assert!(live, "a failure for the live run must be delivered");
920 assert!(
921 runtime.take_activity_error(pid, ordinal).is_some(),
922 "live-run failure must be retained where take_activity_error reads it"
923 );
924
925 runtime.shutdown()?;
926 Ok(())
927 }
928}