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: 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 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 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 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 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 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
556fn 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 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 ®istry,
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 let unknown = runtime.deliver_outbox_completion(
702 ®istry,
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 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 let ordinal = 3;
735 let activity_id = ActivityId::from_sequence_position(ordinal);
736
737 let stale = runtime.deliver_outbox_completion(
740 ®istry,
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 let live = runtime.deliver_outbox_completion(
757 ®istry,
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 ®istry,
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 ®istry,
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}