Skip to main content

aion/
engine_seam.rs

1//! Engine-facing seam for time, signal, query, child, and concurrency services.
2//!
3//! AE implements [`EngineHandle`] for the real engine. This AT cluster consumes the seam to resolve
4//! workflow residency, deliver already-recorded observations to mailboxes, request linked child
5//! workflow starts, arm timer-wheel entries, and route asynchronous-arrival events through the
6//! target workflow's single AD Recorder. AT does not manage workflow process lifecycle,
7//! supervision, or module loading directly.
8
9use aion_core::{Event, Payload, TimerId, WorkflowError, WorkflowId};
10
11use crate::Pid;
12use chrono::{DateTime, Utc};
13use tokio::sync::oneshot;
14
15/// Narrow live-process handle used by AT services after AE resolves workflow residency.
16///
17/// The wrapper intentionally exposes only an opaque process identifier. Real AE implementations can
18/// adapt their concrete BEAM process handle into this type without giving AT lifecycle ownership.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
20pub struct WorkflowProcessHandle {
21    pid: u64,
22}
23
24impl WorkflowProcessHandle {
25    /// Creates a workflow process handle from an opaque process identifier.
26    #[must_use]
27    pub const fn new(pid: u64) -> Self {
28        Self { pid }
29    }
30
31    /// Returns the opaque process identifier backing this handle.
32    #[must_use]
33    pub const fn pid(self) -> u64 {
34        self.pid
35    }
36}
37
38/// AE's answer when AT resolves a logical workflow to a live process.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum WorkflowResidency {
41    /// The workflow is currently resident and can receive mailbox messages.
42    Resident(WorkflowProcessHandle),
43    /// The workflow exists durably but has no live process at the moment.
44    NonResident,
45    /// The workflow is terminal and should not receive live interactions.
46    Terminal,
47    /// AE has no durable or live workflow for the requested identifier.
48    Unknown,
49}
50
51/// One-shot reply path carried by read-only query mailbox messages.
52///
53/// Workflow processes answer query messages at yield points from registered read-only handlers.
54/// Queries are distinct from signals, do not mutate deterministic workflow state, and never record
55/// events; the reply sender carries either the handler payload or a typed query error.
56pub type QueryReplySender = oneshot::Sender<crate::query::service::QueryResult>;
57
58/// Message kinds AT may ask AE to deliver to a workflow process mailbox.
59#[derive(Debug)]
60pub enum WorkflowMailboxMessage {
61    /// A durable timer fired and has been recorded.
62    TimerFired {
63        /// Timer that fired.
64        timer_id: TimerId,
65        /// Deterministic fire timestamp carried for service/replay correlation.
66        fire_at: DateTime<Utc>,
67    },
68    /// A durable signal arrived and has been recorded.
69    SignalReceived {
70        /// Signal name selected by the sender.
71        name: String,
72        /// Opaque signal payload.
73        payload: Payload,
74    },
75    /// A read-only query request. Query dispatch records no event.
76    Query {
77        /// Query name selected by the caller.
78        name: String,
79        /// Opaque query input payload.
80        payload: Payload,
81        /// One-shot channel for the workflow query handler's reply.
82        reply_to: QueryReplySender,
83    },
84    /// A linked child workflow completed successfully and has been recorded.
85    ChildWorkflowCompleted {
86        /// Child workflow that produced the result.
87        child_workflow_id: WorkflowId,
88        /// Spawn correlation token used by collectors.
89        correlation: u64,
90        /// Opaque child result payload.
91        result: Payload,
92    },
93    /// A linked child workflow failed terminally and has been recorded.
94    ChildWorkflowFailed {
95        /// Child workflow that failed.
96        child_workflow_id: WorkflowId,
97        /// Spawn correlation token used by collectors.
98        correlation: u64,
99        /// Terminal child workflow failure.
100        error: WorkflowError,
101    },
102    /// A linked child workflow was cancelled and has been recorded.
103    ChildWorkflowCancelled {
104        /// Child workflow that was cancelled.
105        child_workflow_id: WorkflowId,
106        /// Spawn correlation token used by collectors.
107        correlation: u64,
108    },
109}
110
111/// Request from AT to AE to spawn a child workflow under a parent process.
112///
113/// The child workflow identifier is pre-allocated by the parent and durably
114/// recorded as `ChildWorkflowStarted` in the parent's history *before* this
115/// request is issued (record-then-spawn), so a crash between the record and
116/// the start leaves a recoverable record instead of an unrecorded orphan.
117/// AE must start the child under exactly this identifier. Children are not
118/// process-linked to their parents: parent death leaves children running,
119/// and awaited terminals are observed through the child-terminal watcher.
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub struct ChildWorkflowSpawnRequest {
122    /// Parent workflow requesting the child execution.
123    pub parent_workflow_id: WorkflowId,
124    /// Pre-allocated child workflow identifier already recorded by the parent.
125    pub child_workflow_id: WorkflowId,
126    /// Child workflow type selected by the parent workflow.
127    pub workflow_type: String,
128    /// Opaque child workflow input payload.
129    pub input: Payload,
130    /// Package version resolved for the child at record time.
131    pub package_version: aion_core::PackageVersion,
132}
133
134/// AE's result after starting a linked child workflow execution.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct ChildWorkflowSpawnResult {
137    /// Logical child workflow identifier.
138    pub child_workflow_id: WorkflowId,
139    /// Live process handle for the linked child execution.
140    pub child_process: WorkflowProcessHandle,
141}
142
143/// Timer-wheel entry requested by AT for a live workflow process.
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct TimerWheelEntry {
146    /// Workflow process that should receive the timer fire path.
147    pub process: WorkflowProcessHandle,
148    /// Timer selected by workflow code or assigned by the engine.
149    pub timer_id: TimerId,
150    /// UTC timestamp at which the wheel should fire.
151    pub fire_at: DateTime<Utc>,
152}
153
154/// Errors returned by the engine seam.
155#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
156pub enum EngineSeamError {
157    /// The target workflow has no current live process.
158    #[error("workflow {workflow_id} is not resident")]
159    NonResident {
160        /// Workflow that had no current live process.
161        workflow_id: WorkflowId,
162    },
163
164    /// The target workflow is terminal.
165    #[error("workflow {workflow_id} is terminal")]
166    Terminal {
167        /// Terminal workflow identifier.
168        workflow_id: WorkflowId,
169    },
170
171    /// The target workflow is unknown to AE.
172    #[error("workflow {workflow_id} is unknown")]
173    UnknownWorkflow {
174        /// Unknown workflow identifier.
175        workflow_id: WorkflowId,
176    },
177
178    /// AE could not deliver a mailbox message.
179    #[error("mailbox delivery failed: {reason}")]
180    Delivery {
181        /// Human-readable delivery failure reason.
182        reason: String,
183    },
184
185    /// AE could not spawn a linked child workflow.
186    #[error("child workflow spawn failed: {reason}")]
187    ChildSpawn {
188        /// Human-readable child-spawn failure reason.
189        reason: String,
190    },
191
192    /// A timer-wheel operation was refused: arming, disarming, or — since the
193    /// wheel gained a stand-down latch — the durable append that a fire or a
194    /// cancellation would otherwise have made after this engine's wheel was torn
195    /// down.
196    ///
197    /// That third case is not an arm or a disarm and is the reason this doc no
198    /// longer says it is. Callers that need to tell an orderly stand-down from a
199    /// genuine wheel fault classify on this variant
200    /// (`crate::runtime::nif_timer_fire::is_wheel_teardown`); the `reason`
201    /// string says which of the three happened, in words that are true of that
202    /// one — a refused cancel does not claim anything fired.
203    #[error("timer wheel operation failed: {reason}")]
204    TimerWheel {
205        /// Human-readable timer-wheel failure reason.
206        reason: String,
207    },
208
209    /// AE could not terminate a linked child process.
210    #[error("linked child termination failed: {reason}")]
211    ChildTermination {
212        /// Human-readable child termination failure reason.
213        reason: String,
214    },
215
216    /// AD's single Recorder path could not record the event.
217    #[error("workflow recorder failed: {reason}")]
218    Recorder {
219        /// Human-readable recorder failure reason.
220        reason: String,
221    },
222}
223
224/// Whether a recorder seam durably appended a timer event, or refused it because
225/// the workflow's active run already reached a terminal.
226///
227/// A refusal is a benign, expected outcome — a late timer fire or cancel that
228/// lands after the run terminated — not an error. It exists as an explicit value
229/// so the timer service can distinguish "appended" from "refused" and deliver a
230/// mailbox wake ONLY for a genuine append: a refused post-terminal fire must
231/// never wake (and thereby reschedule) a workflow that has already terminated.
232#[derive(Clone, Copy, Debug, Eq, PartialEq)]
233pub enum RecordOutcome {
234    /// The event was durably appended through the target run's Recorder.
235    Recorded,
236    /// The append was refused because the active run already recorded a terminal;
237    /// no history was mutated and no wake must follow.
238    RefusedTerminal,
239}
240
241/// Engine-facing capabilities consumed by AT services and implemented by AE.
242///
243/// This trait deliberately does not expose operations that start, supervise, tear down, or load
244/// top-level workflow processes. Child spawning, residency resolution, and recording are requests
245/// into AE/AD-owned infrastructure. In particular, [`EngineHandle::record_workflow_event`] must
246/// route asynchronous-arrival events through the target workflow's single Recorder; AT services must
247/// not append directly to the event store.
248pub trait EngineHandle: Send + Sync {
249    /// Resolves a workflow identifier to its current residency state.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`EngineSeamError`] when AE cannot inspect residency for the requested workflow.
254    fn resolve_workflow(
255        &self,
256        workflow_id: &WorkflowId,
257    ) -> Result<WorkflowResidency, EngineSeamError>;
258
259    /// Delivers a message to a resident workflow process mailbox.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`EngineSeamError`] when AE cannot enqueue the message on the target mailbox.
264    fn deliver_workflow_message(
265        &self,
266        process: WorkflowProcessHandle,
267        message: WorkflowMailboxMessage,
268    ) -> Result<(), EngineSeamError>;
269
270    /// Requests AE to spawn a child workflow execution linked to the parent process.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`EngineSeamError`] when AE rejects or fails the linked child-spawn request.
275    fn spawn_child_workflow(
276        &self,
277        request: ChildWorkflowSpawnRequest,
278    ) -> Result<ChildWorkflowSpawnResult, EngineSeamError>;
279
280    /// Terminates a linked child workflow process through AE's process-link boundary.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`EngineSeamError`] when AE cannot send the cancellation exit to the linked child.
285    fn terminate_linked_child_workflow(
286        &self,
287        parent_workflow_id: &WorkflowId,
288        child_process: WorkflowProcessHandle,
289        correlation: u64,
290    ) -> Result<(), EngineSeamError>;
291
292    /// Terminates a linked in-VM activity process through AE's process-link boundary.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`EngineSeamError`] when AE cannot send the cancellation exit to the linked child.
297    fn terminate_linked_activity(
298        &self,
299        parent_workflow_id: &WorkflowId,
300        activity_process: Pid,
301        correlation: u64,
302    ) -> Result<(), EngineSeamError>;
303
304    /// Arms a timer-wheel entry for a resident workflow process.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`EngineSeamError`] when AE cannot register the timer with the live wheel.
309    fn arm_timer(&self, entry: TimerWheelEntry) -> Result<(), EngineSeamError>;
310
311    /// Disarms a timer-wheel entry for a resident workflow process.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`EngineSeamError`] when AE cannot remove the timer from the live wheel.
316    fn disarm_timer(
317        &self,
318        process: WorkflowProcessHandle,
319        timer_id: &TimerId,
320    ) -> Result<(), EngineSeamError>;
321
322    /// Records an event through the target workflow's single AD Recorder.
323    ///
324    /// Returns [`RecordOutcome::Recorded`] when the event was durably appended, or
325    /// [`RecordOutcome::RefusedTerminal`] when the append was declined because the
326    /// active run already recorded a terminal (a benign late arrival). Callers
327    /// that follow a recorded fire with a mailbox wake MUST gate the wake on
328    /// `Recorded` — a `RefusedTerminal` fire must not reschedule a terminated
329    /// workflow.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`EngineSeamError`] when the target workflow's Recorder cannot append the event.
334    fn record_workflow_event(
335        &self,
336        workflow_id: &WorkflowId,
337        event: Event,
338    ) -> Result<RecordOutcome, EngineSeamError>;
339}
340
341#[cfg(test)]
342pub(crate) mod test_support {
343    use std::collections::{HashMap, VecDeque};
344    use std::sync::Arc;
345    use std::sync::{Mutex, MutexGuard};
346
347    use aion_store::{WritableEventStore, WriteToken};
348
349    use super::*;
350
351    /// Operation captured by [`FakeEngineHandle`] in observed order.
352    #[derive(Clone, Debug, PartialEq)]
353    pub enum FakeEngineOperation {
354        /// A mailbox message was delivered.
355        Delivered {
356            /// Target process handle.
357            process: WorkflowProcessHandle,
358            /// Delivered message projection.
359            message: DeliveredWorkflowMessage,
360        },
361        /// A child spawn was requested.
362        ChildSpawnRequested(ChildWorkflowSpawnRequest),
363        /// A timer-wheel entry was armed.
364        TimerArmed(TimerWheelEntry),
365        /// A timer-wheel entry was disarmed.
366        TimerDisarmed {
367            /// Target process handle.
368            process: WorkflowProcessHandle,
369            /// Timer that was disarmed.
370            timer_id: TimerId,
371        },
372        /// A linked child workflow process was terminated.
373        LinkedChildWorkflowTerminated {
374            /// Parent workflow owning the link.
375            parent_workflow_id: WorkflowId,
376            /// Linked child workflow process.
377            child_process: WorkflowProcessHandle,
378            /// Spawn correlation token.
379            correlation: u64,
380        },
381        /// A linked activity process was terminated.
382        LinkedActivityTerminated {
383            /// Parent workflow owning the link.
384            parent_workflow_id: WorkflowId,
385            /// Linked activity process.
386            activity_process: Pid,
387            /// Spawn correlation token.
388            correlation: u64,
389        },
390        /// An event was recorded through the recorder seam.
391        EventRecorded {
392            /// Workflow whose recorder received the event.
393            workflow_id: WorkflowId,
394            /// Recorded event.
395            event: Event,
396        },
397    }
398
399    #[derive(Default)]
400    struct FakeEngineState {
401        residency: HashMap<WorkflowId, WorkflowResidency>,
402        delivered: Vec<(WorkflowProcessHandle, DeliveredWorkflowMessage)>,
403        delivery_responses: VecDeque<Result<(), EngineSeamError>>,
404        child_spawn_responses: VecDeque<Result<ChildWorkflowSpawnResult, EngineSeamError>>,
405        armed_timers: Vec<TimerWheelEntry>,
406        disarmed_timers: Vec<(WorkflowProcessHandle, TimerId)>,
407        recorded_events: Vec<(WorkflowId, Event)>,
408        operations: Vec<FakeEngineOperation>,
409        recorder_store: Option<Arc<dyn WritableEventStore>>,
410        record_responses: VecDeque<Result<(), EngineSeamError>>,
411        /// When true, the next `record_workflow_event` refuses as a terminal
412        /// late-arrival (returns [`RecordOutcome::RefusedTerminal`] without
413        /// appending), letting a test drive the deliver-only-on-`Recorded` gate.
414        refuse_next_record_as_terminal: bool,
415    }
416
417    /// Cloneable projection of delivered mailbox messages for seam tests.
418    #[derive(Clone, Debug, PartialEq, Eq)]
419    pub enum DeliveredWorkflowMessage {
420        /// A timer-fired delivery was observed.
421        TimerFired {
422            timer_id: TimerId,
423            fire_at: DateTime<Utc>,
424        },
425        /// A signal delivery was observed.
426        SignalReceived { name: String, payload: Payload },
427        /// A query delivery was observed; the one-shot sender is intentionally not retained.
428        Query { name: String, payload: Payload },
429        /// A child completion delivery was observed.
430        ChildWorkflowCompleted {
431            child_workflow_id: WorkflowId,
432            correlation: u64,
433            result: Payload,
434        },
435        /// A child failure delivery was observed.
436        ChildWorkflowFailed {
437            child_workflow_id: WorkflowId,
438            correlation: u64,
439            error: WorkflowError,
440        },
441        /// A child cancellation delivery was observed.
442        ChildWorkflowCancelled {
443            child_workflow_id: WorkflowId,
444            correlation: u64,
445        },
446    }
447
448    impl DeliveredWorkflowMessage {
449        pub(crate) fn from_message(message: &WorkflowMailboxMessage) -> Self {
450            match message {
451                WorkflowMailboxMessage::TimerFired { timer_id, fire_at } => Self::TimerFired {
452                    timer_id: timer_id.clone(),
453                    fire_at: *fire_at,
454                },
455                WorkflowMailboxMessage::SignalReceived { name, payload } => Self::SignalReceived {
456                    name: name.clone(),
457                    payload: payload.clone(),
458                },
459                WorkflowMailboxMessage::Query {
460                    name,
461                    payload,
462                    reply_to: _,
463                } => Self::Query {
464                    name: name.clone(),
465                    payload: payload.clone(),
466                },
467                WorkflowMailboxMessage::ChildWorkflowCompleted {
468                    child_workflow_id,
469                    correlation,
470                    result,
471                } => Self::ChildWorkflowCompleted {
472                    child_workflow_id: child_workflow_id.clone(),
473                    correlation: *correlation,
474                    result: result.clone(),
475                },
476                WorkflowMailboxMessage::ChildWorkflowFailed {
477                    child_workflow_id,
478                    correlation,
479                    error,
480                } => Self::ChildWorkflowFailed {
481                    child_workflow_id: child_workflow_id.clone(),
482                    correlation: *correlation,
483                    error: error.clone(),
484                },
485                WorkflowMailboxMessage::ChildWorkflowCancelled {
486                    child_workflow_id,
487                    correlation,
488                } => Self::ChildWorkflowCancelled {
489                    child_workflow_id: child_workflow_id.clone(),
490                    correlation: *correlation,
491                },
492            }
493        }
494    }
495
496    /// Test-only fake implementation of [`EngineHandle`].
497    #[derive(Default)]
498    pub struct FakeEngineHandle {
499        state: Mutex<FakeEngineState>,
500    }
501
502    impl FakeEngineHandle {
503        /// Creates an empty fake engine handle.
504        #[must_use]
505        pub fn new() -> Self {
506            Self::default()
507        }
508
509        /// Creates a fake whose recorder seam appends to the supplied store with event sequencing.
510        #[must_use]
511        pub fn recording_to(store: Arc<dyn WritableEventStore>) -> Self {
512            Self {
513                state: Mutex::new(FakeEngineState {
514                    recorder_store: Some(store),
515                    ..FakeEngineState::default()
516                }),
517            }
518        }
519
520        /// Sets the residency response returned for a workflow.
521        ///
522        /// # Errors
523        ///
524        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
525        pub fn set_residency(
526            &self,
527            workflow_id: WorkflowId,
528            residency: WorkflowResidency,
529        ) -> Result<(), EngineSeamError> {
530            self.state()?.residency.insert(workflow_id, residency);
531            Ok(())
532        }
533
534        /// Queues the next response returned by mailbox-delivery seam calls.
535        ///
536        /// # Errors
537        ///
538        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
539        pub fn push_delivery_response(
540            &self,
541            response: Result<(), EngineSeamError>,
542        ) -> Result<(), EngineSeamError> {
543            self.state()?.delivery_responses.push_back(response);
544            Ok(())
545        }
546
547        /// Queues the next response returned by workflow-event recording seam calls.
548        ///
549        /// Used to simulate the engine rejecting a recorded event — e.g. firing a
550        /// timer for a workflow that no longer exists ([`EngineSeamError::UnknownWorkflow`]).
551        ///
552        /// # Errors
553        ///
554        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
555        pub fn push_record_response(
556            &self,
557            response: Result<(), EngineSeamError>,
558        ) -> Result<(), EngineSeamError> {
559            self.state()?.record_responses.push_back(response);
560            Ok(())
561        }
562
563        /// Arms the next `record_workflow_event` to refuse as a terminal late
564        /// arrival: it appends nothing and returns [`RecordOutcome::RefusedTerminal`].
565        ///
566        /// # Errors
567        ///
568        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
569        pub fn refuse_next_record_as_terminal(&self) -> Result<(), EngineSeamError> {
570            self.state()?.refuse_next_record_as_terminal = true;
571            Ok(())
572        }
573
574        /// Returns a snapshot of seam operations in observed order.
575        ///
576        /// # Errors
577        ///
578        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
579        pub fn operations(&self) -> Result<Vec<FakeEngineOperation>, EngineSeamError> {
580            Ok(self.state()?.operations.clone())
581        }
582
583        /// Returns a snapshot of delivered mailbox messages.
584        ///
585        /// # Errors
586        ///
587        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
588        pub fn delivered_messages(
589            &self,
590        ) -> Result<Vec<(WorkflowProcessHandle, DeliveredWorkflowMessage)>, EngineSeamError>
591        {
592            Ok(self.state()?.delivered.clone())
593        }
594
595        /// Returns a snapshot of armed timer-wheel entries.
596        ///
597        /// # Errors
598        ///
599        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
600        pub fn armed_timers(&self) -> Result<Vec<TimerWheelEntry>, EngineSeamError> {
601            Ok(self.state()?.armed_timers.clone())
602        }
603
604        /// Queues the next child-spawn response returned by the fake.
605        ///
606        /// # Errors
607        ///
608        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
609        pub fn push_child_spawn_response(
610            &self,
611            response: Result<ChildWorkflowSpawnResult, EngineSeamError>,
612        ) -> Result<(), EngineSeamError> {
613            self.state()?.child_spawn_responses.push_back(response);
614            Ok(())
615        }
616
617        /// Returns events recorded through the fake recorder seam.
618        ///
619        /// # Errors
620        ///
621        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
622        pub fn recorded_events(&self) -> Result<Vec<(WorkflowId, Event)>, EngineSeamError> {
623            Ok(self.state()?.recorded_events.clone())
624        }
625
626        fn state(&self) -> Result<MutexGuard<'_, FakeEngineState>, EngineSeamError> {
627            self.state.lock().map_err(|_| EngineSeamError::Recorder {
628                reason: "fake engine state lock was poisoned".to_owned(),
629            })
630        }
631    }
632
633    impl EngineHandle for FakeEngineHandle {
634        fn resolve_workflow(
635            &self,
636            workflow_id: &WorkflowId,
637        ) -> Result<WorkflowResidency, EngineSeamError> {
638            Ok(self
639                .state()?
640                .residency
641                .get(workflow_id)
642                .copied()
643                .unwrap_or(WorkflowResidency::Unknown))
644        }
645
646        fn deliver_workflow_message(
647            &self,
648            process: WorkflowProcessHandle,
649            message: WorkflowMailboxMessage,
650        ) -> Result<(), EngineSeamError> {
651            let mut state = self.state()?;
652            if let Some(response) = state.delivery_responses.pop_front() {
653                response?;
654            }
655            let delivered = DeliveredWorkflowMessage::from_message(&message);
656            state.delivered.push((process, delivered.clone()));
657            state.operations.push(FakeEngineOperation::Delivered {
658                process,
659                message: delivered,
660            });
661            Ok(())
662        }
663
664        fn spawn_child_workflow(
665            &self,
666            request: ChildWorkflowSpawnRequest,
667        ) -> Result<ChildWorkflowSpawnResult, EngineSeamError> {
668            let mut state = self.state()?;
669            state
670                .operations
671                .push(FakeEngineOperation::ChildSpawnRequested(request.clone()));
672            if let Some(response) = state.child_spawn_responses.pop_front() {
673                response
674            } else {
675                Err(EngineSeamError::ChildSpawn {
676                    reason: "fake child spawn response was not queued".to_owned(),
677                })
678            }
679        }
680
681        fn terminate_linked_child_workflow(
682            &self,
683            parent_workflow_id: &WorkflowId,
684            child_process: WorkflowProcessHandle,
685            correlation: u64,
686        ) -> Result<(), EngineSeamError> {
687            let mut state = self.state()?;
688            state
689                .operations
690                .push(FakeEngineOperation::LinkedChildWorkflowTerminated {
691                    parent_workflow_id: parent_workflow_id.clone(),
692                    child_process,
693                    correlation,
694                });
695            Ok(())
696        }
697
698        fn terminate_linked_activity(
699            &self,
700            parent_workflow_id: &WorkflowId,
701            activity_process: Pid,
702            correlation: u64,
703        ) -> Result<(), EngineSeamError> {
704            let mut state = self.state()?;
705            state
706                .operations
707                .push(FakeEngineOperation::LinkedActivityTerminated {
708                    parent_workflow_id: parent_workflow_id.clone(),
709                    activity_process,
710                    correlation,
711                });
712            Ok(())
713        }
714
715        fn arm_timer(&self, entry: TimerWheelEntry) -> Result<(), EngineSeamError> {
716            let mut state = self.state()?;
717            state.armed_timers.push(entry.clone());
718            state
719                .operations
720                .push(FakeEngineOperation::TimerArmed(entry));
721            Ok(())
722        }
723
724        fn disarm_timer(
725            &self,
726            process: WorkflowProcessHandle,
727            timer_id: &TimerId,
728        ) -> Result<(), EngineSeamError> {
729            let mut state = self.state()?;
730            state
731                .armed_timers
732                .retain(|entry| !(entry.process == process && &entry.timer_id == timer_id));
733            state.disarmed_timers.push((process, timer_id.clone()));
734            state.operations.push(FakeEngineOperation::TimerDisarmed {
735                process,
736                timer_id: timer_id.clone(),
737            });
738            Ok(())
739        }
740
741        fn record_workflow_event(
742            &self,
743            workflow_id: &WorkflowId,
744            event: Event,
745        ) -> Result<RecordOutcome, EngineSeamError> {
746            let mut state = self.state()?;
747            if let Some(response) = state.record_responses.pop_front() {
748                response?;
749            }
750            if std::mem::take(&mut state.refuse_next_record_as_terminal) {
751                // Simulate the production bridge refusing a late fire/cancel under
752                // the recorder lock: nothing is appended and no wake must follow.
753                return Ok(RecordOutcome::RefusedTerminal);
754            }
755            state
756                .recorded_events
757                .push((workflow_id.clone(), event.clone()));
758            let recorder_store = state.recorder_store.clone();
759            state.operations.push(FakeEngineOperation::EventRecorded {
760                workflow_id: workflow_id.clone(),
761                event: event.clone(),
762            });
763            drop(state);
764
765            if let Some(store) = recorder_store {
766                let expected_seq = event.seq().saturating_sub(1);
767                futures::executor::block_on(store.append(
768                    WriteToken::recorder(),
769                    workflow_id,
770                    &[event],
771                    expected_seq,
772                ))
773                .map_err(|error| EngineSeamError::Recorder {
774                    reason: error.to_string(),
775                })?;
776            }
777            Ok(RecordOutcome::Recorded)
778        }
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use aion_core::{ContentType, Payload, WorkflowId};
785
786    use super::test_support::{DeliveredWorkflowMessage, FakeEngineHandle};
787    use super::{
788        EngineHandle, EngineSeamError, WorkflowMailboxMessage, WorkflowProcessHandle,
789        WorkflowResidency,
790    };
791
792    #[test]
793    fn fake_captures_delivered_message_for_resident_workflow()
794    -> Result<(), Box<dyn std::error::Error>> {
795        let engine = FakeEngineHandle::new();
796        let workflow_id = WorkflowId::new_v4();
797        let process = WorkflowProcessHandle::new(42);
798        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
799
800        let resolved = engine.resolve_workflow(&workflow_id)?;
801        assert_eq!(resolved, WorkflowResidency::Resident(process));
802
803        let payload = Payload::new(ContentType::Json, b"null".to_vec());
804        let message = WorkflowMailboxMessage::SignalReceived {
805            name: "wake".to_owned(),
806            payload: payload.clone(),
807        };
808        engine.deliver_workflow_message(process, message)?;
809
810        assert_eq!(
811            engine.delivered_messages()?,
812            vec![(
813                process,
814                DeliveredWorkflowMessage::SignalReceived {
815                    name: "wake".to_owned(),
816                    payload,
817                }
818            )]
819        );
820        Ok(())
821    }
822
823    #[test]
824    fn fake_can_inject_delivery_failure() -> Result<(), Box<dyn std::error::Error>> {
825        let engine = FakeEngineHandle::new();
826        let process = WorkflowProcessHandle::new(43);
827        engine.push_delivery_response(Err(EngineSeamError::Delivery {
828            reason: "mailbox unavailable".to_owned(),
829        }))?;
830
831        let error = engine
832            .deliver_workflow_message(
833                process,
834                WorkflowMailboxMessage::SignalReceived {
835                    name: "wake".to_owned(),
836                    payload: Payload::new(ContentType::Json, b"null".to_vec()),
837                },
838            )
839            .err()
840            .ok_or_else(|| std::io::Error::other("delivery failure was not returned"))?;
841
842        assert!(matches!(error, EngineSeamError::Delivery { .. }));
843        assert!(engine.delivered_messages()?.is_empty());
844        assert!(engine.operations()?.is_empty());
845        Ok(())
846    }
847}