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    /// AE could not arm or disarm the timer wheel.
193    #[error("timer wheel operation failed: {reason}")]
194    TimerWheel {
195        /// Human-readable timer-wheel failure reason.
196        reason: String,
197    },
198
199    /// AE could not terminate a linked child process.
200    #[error("linked child termination failed: {reason}")]
201    ChildTermination {
202        /// Human-readable child termination failure reason.
203        reason: String,
204    },
205
206    /// AD's single Recorder path could not record the event.
207    #[error("workflow recorder failed: {reason}")]
208    Recorder {
209        /// Human-readable recorder failure reason.
210        reason: String,
211    },
212}
213
214/// Whether a recorder seam durably appended a timer event, or refused it because
215/// the workflow's active run already reached a terminal.
216///
217/// A refusal is a benign, expected outcome — a late timer fire or cancel that
218/// lands after the run terminated — not an error. It exists as an explicit value
219/// so the timer service can distinguish "appended" from "refused" and deliver a
220/// mailbox wake ONLY for a genuine append: a refused post-terminal fire must
221/// never wake (and thereby reschedule) a workflow that has already terminated.
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub enum RecordOutcome {
224    /// The event was durably appended through the target run's Recorder.
225    Recorded,
226    /// The append was refused because the active run already recorded a terminal;
227    /// no history was mutated and no wake must follow.
228    RefusedTerminal,
229}
230
231/// Engine-facing capabilities consumed by AT services and implemented by AE.
232///
233/// This trait deliberately does not expose operations that start, supervise, tear down, or load
234/// top-level workflow processes. Child spawning, residency resolution, and recording are requests
235/// into AE/AD-owned infrastructure. In particular, [`EngineHandle::record_workflow_event`] must
236/// route asynchronous-arrival events through the target workflow's single Recorder; AT services must
237/// not append directly to the event store.
238pub trait EngineHandle: Send + Sync {
239    /// Resolves a workflow identifier to its current residency state.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`EngineSeamError`] when AE cannot inspect residency for the requested workflow.
244    fn resolve_workflow(
245        &self,
246        workflow_id: &WorkflowId,
247    ) -> Result<WorkflowResidency, EngineSeamError>;
248
249    /// Delivers a message to a resident workflow process mailbox.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`EngineSeamError`] when AE cannot enqueue the message on the target mailbox.
254    fn deliver_workflow_message(
255        &self,
256        process: WorkflowProcessHandle,
257        message: WorkflowMailboxMessage,
258    ) -> Result<(), EngineSeamError>;
259
260    /// Requests AE to spawn a child workflow execution linked to the parent process.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`EngineSeamError`] when AE rejects or fails the linked child-spawn request.
265    fn spawn_child_workflow(
266        &self,
267        request: ChildWorkflowSpawnRequest,
268    ) -> Result<ChildWorkflowSpawnResult, EngineSeamError>;
269
270    /// Terminates a linked child workflow process through AE's process-link boundary.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`EngineSeamError`] when AE cannot send the cancellation exit to the linked child.
275    fn terminate_linked_child_workflow(
276        &self,
277        parent_workflow_id: &WorkflowId,
278        child_process: WorkflowProcessHandle,
279        correlation: u64,
280    ) -> Result<(), EngineSeamError>;
281
282    /// Terminates a linked in-VM activity process through AE's process-link boundary.
283    ///
284    /// # Errors
285    ///
286    /// Returns [`EngineSeamError`] when AE cannot send the cancellation exit to the linked child.
287    fn terminate_linked_activity(
288        &self,
289        parent_workflow_id: &WorkflowId,
290        activity_process: Pid,
291        correlation: u64,
292    ) -> Result<(), EngineSeamError>;
293
294    /// Arms a timer-wheel entry for a resident workflow process.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`EngineSeamError`] when AE cannot register the timer with the live wheel.
299    fn arm_timer(&self, entry: TimerWheelEntry) -> Result<(), EngineSeamError>;
300
301    /// Disarms a timer-wheel entry for a resident workflow process.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`EngineSeamError`] when AE cannot remove the timer from the live wheel.
306    fn disarm_timer(
307        &self,
308        process: WorkflowProcessHandle,
309        timer_id: &TimerId,
310    ) -> Result<(), EngineSeamError>;
311
312    /// Records an event through the target workflow's single AD Recorder.
313    ///
314    /// Returns [`RecordOutcome::Recorded`] when the event was durably appended, or
315    /// [`RecordOutcome::RefusedTerminal`] when the append was declined because the
316    /// active run already recorded a terminal (a benign late arrival). Callers
317    /// that follow a recorded fire with a mailbox wake MUST gate the wake on
318    /// `Recorded` — a `RefusedTerminal` fire must not reschedule a terminated
319    /// workflow.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`EngineSeamError`] when the target workflow's Recorder cannot append the event.
324    fn record_workflow_event(
325        &self,
326        workflow_id: &WorkflowId,
327        event: Event,
328    ) -> Result<RecordOutcome, EngineSeamError>;
329}
330
331#[cfg(test)]
332pub(crate) mod test_support {
333    use std::collections::{HashMap, VecDeque};
334    use std::sync::Arc;
335    use std::sync::{Mutex, MutexGuard};
336
337    use aion_store::{WritableEventStore, WriteToken};
338
339    use super::*;
340
341    /// Operation captured by [`FakeEngineHandle`] in observed order.
342    #[derive(Clone, Debug, PartialEq)]
343    pub enum FakeEngineOperation {
344        /// A mailbox message was delivered.
345        Delivered {
346            /// Target process handle.
347            process: WorkflowProcessHandle,
348            /// Delivered message projection.
349            message: DeliveredWorkflowMessage,
350        },
351        /// A child spawn was requested.
352        ChildSpawnRequested(ChildWorkflowSpawnRequest),
353        /// A timer-wheel entry was armed.
354        TimerArmed(TimerWheelEntry),
355        /// A timer-wheel entry was disarmed.
356        TimerDisarmed {
357            /// Target process handle.
358            process: WorkflowProcessHandle,
359            /// Timer that was disarmed.
360            timer_id: TimerId,
361        },
362        /// A linked child workflow process was terminated.
363        LinkedChildWorkflowTerminated {
364            /// Parent workflow owning the link.
365            parent_workflow_id: WorkflowId,
366            /// Linked child workflow process.
367            child_process: WorkflowProcessHandle,
368            /// Spawn correlation token.
369            correlation: u64,
370        },
371        /// A linked activity process was terminated.
372        LinkedActivityTerminated {
373            /// Parent workflow owning the link.
374            parent_workflow_id: WorkflowId,
375            /// Linked activity process.
376            activity_process: Pid,
377            /// Spawn correlation token.
378            correlation: u64,
379        },
380        /// An event was recorded through the recorder seam.
381        EventRecorded {
382            /// Workflow whose recorder received the event.
383            workflow_id: WorkflowId,
384            /// Recorded event.
385            event: Event,
386        },
387    }
388
389    #[derive(Default)]
390    struct FakeEngineState {
391        residency: HashMap<WorkflowId, WorkflowResidency>,
392        delivered: Vec<(WorkflowProcessHandle, DeliveredWorkflowMessage)>,
393        delivery_responses: VecDeque<Result<(), EngineSeamError>>,
394        child_spawn_responses: VecDeque<Result<ChildWorkflowSpawnResult, EngineSeamError>>,
395        armed_timers: Vec<TimerWheelEntry>,
396        disarmed_timers: Vec<(WorkflowProcessHandle, TimerId)>,
397        recorded_events: Vec<(WorkflowId, Event)>,
398        operations: Vec<FakeEngineOperation>,
399        recorder_store: Option<Arc<dyn WritableEventStore>>,
400        record_responses: VecDeque<Result<(), EngineSeamError>>,
401        /// When true, the next `record_workflow_event` refuses as a terminal
402        /// late-arrival (returns [`RecordOutcome::RefusedTerminal`] without
403        /// appending), letting a test drive the deliver-only-on-`Recorded` gate.
404        refuse_next_record_as_terminal: bool,
405    }
406
407    /// Cloneable projection of delivered mailbox messages for seam tests.
408    #[derive(Clone, Debug, PartialEq, Eq)]
409    pub enum DeliveredWorkflowMessage {
410        /// A timer-fired delivery was observed.
411        TimerFired {
412            timer_id: TimerId,
413            fire_at: DateTime<Utc>,
414        },
415        /// A signal delivery was observed.
416        SignalReceived { name: String, payload: Payload },
417        /// A query delivery was observed; the one-shot sender is intentionally not retained.
418        Query { name: String, payload: Payload },
419        /// A child completion delivery was observed.
420        ChildWorkflowCompleted {
421            child_workflow_id: WorkflowId,
422            correlation: u64,
423            result: Payload,
424        },
425        /// A child failure delivery was observed.
426        ChildWorkflowFailed {
427            child_workflow_id: WorkflowId,
428            correlation: u64,
429            error: WorkflowError,
430        },
431        /// A child cancellation delivery was observed.
432        ChildWorkflowCancelled {
433            child_workflow_id: WorkflowId,
434            correlation: u64,
435        },
436    }
437
438    impl DeliveredWorkflowMessage {
439        pub(crate) fn from_message(message: &WorkflowMailboxMessage) -> Self {
440            match message {
441                WorkflowMailboxMessage::TimerFired { timer_id, fire_at } => Self::TimerFired {
442                    timer_id: timer_id.clone(),
443                    fire_at: *fire_at,
444                },
445                WorkflowMailboxMessage::SignalReceived { name, payload } => Self::SignalReceived {
446                    name: name.clone(),
447                    payload: payload.clone(),
448                },
449                WorkflowMailboxMessage::Query {
450                    name,
451                    payload,
452                    reply_to: _,
453                } => Self::Query {
454                    name: name.clone(),
455                    payload: payload.clone(),
456                },
457                WorkflowMailboxMessage::ChildWorkflowCompleted {
458                    child_workflow_id,
459                    correlation,
460                    result,
461                } => Self::ChildWorkflowCompleted {
462                    child_workflow_id: child_workflow_id.clone(),
463                    correlation: *correlation,
464                    result: result.clone(),
465                },
466                WorkflowMailboxMessage::ChildWorkflowFailed {
467                    child_workflow_id,
468                    correlation,
469                    error,
470                } => Self::ChildWorkflowFailed {
471                    child_workflow_id: child_workflow_id.clone(),
472                    correlation: *correlation,
473                    error: error.clone(),
474                },
475                WorkflowMailboxMessage::ChildWorkflowCancelled {
476                    child_workflow_id,
477                    correlation,
478                } => Self::ChildWorkflowCancelled {
479                    child_workflow_id: child_workflow_id.clone(),
480                    correlation: *correlation,
481                },
482            }
483        }
484    }
485
486    /// Test-only fake implementation of [`EngineHandle`].
487    #[derive(Default)]
488    pub struct FakeEngineHandle {
489        state: Mutex<FakeEngineState>,
490    }
491
492    impl FakeEngineHandle {
493        /// Creates an empty fake engine handle.
494        #[must_use]
495        pub fn new() -> Self {
496            Self::default()
497        }
498
499        /// Creates a fake whose recorder seam appends to the supplied store with event sequencing.
500        #[must_use]
501        pub fn recording_to(store: Arc<dyn WritableEventStore>) -> Self {
502            Self {
503                state: Mutex::new(FakeEngineState {
504                    recorder_store: Some(store),
505                    ..FakeEngineState::default()
506                }),
507            }
508        }
509
510        /// Sets the residency response returned for a workflow.
511        ///
512        /// # Errors
513        ///
514        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
515        pub fn set_residency(
516            &self,
517            workflow_id: WorkflowId,
518            residency: WorkflowResidency,
519        ) -> Result<(), EngineSeamError> {
520            self.state()?.residency.insert(workflow_id, residency);
521            Ok(())
522        }
523
524        /// Queues the next response returned by mailbox-delivery seam calls.
525        ///
526        /// # Errors
527        ///
528        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
529        pub fn push_delivery_response(
530            &self,
531            response: Result<(), EngineSeamError>,
532        ) -> Result<(), EngineSeamError> {
533            self.state()?.delivery_responses.push_back(response);
534            Ok(())
535        }
536
537        /// Queues the next response returned by workflow-event recording seam calls.
538        ///
539        /// Used to simulate the engine rejecting a recorded event — e.g. firing a
540        /// timer for a workflow that no longer exists ([`EngineSeamError::UnknownWorkflow`]).
541        ///
542        /// # Errors
543        ///
544        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
545        pub fn push_record_response(
546            &self,
547            response: Result<(), EngineSeamError>,
548        ) -> Result<(), EngineSeamError> {
549            self.state()?.record_responses.push_back(response);
550            Ok(())
551        }
552
553        /// Arms the next `record_workflow_event` to refuse as a terminal late
554        /// arrival: it appends nothing and returns [`RecordOutcome::RefusedTerminal`].
555        ///
556        /// # Errors
557        ///
558        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
559        pub fn refuse_next_record_as_terminal(&self) -> Result<(), EngineSeamError> {
560            self.state()?.refuse_next_record_as_terminal = true;
561            Ok(())
562        }
563
564        /// Returns a snapshot of seam operations in observed order.
565        ///
566        /// # Errors
567        ///
568        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
569        pub fn operations(&self) -> Result<Vec<FakeEngineOperation>, EngineSeamError> {
570            Ok(self.state()?.operations.clone())
571        }
572
573        /// Returns a snapshot of delivered mailbox messages.
574        ///
575        /// # Errors
576        ///
577        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
578        pub fn delivered_messages(
579            &self,
580        ) -> Result<Vec<(WorkflowProcessHandle, DeliveredWorkflowMessage)>, EngineSeamError>
581        {
582            Ok(self.state()?.delivered.clone())
583        }
584
585        /// Returns a snapshot of armed timer-wheel entries.
586        ///
587        /// # Errors
588        ///
589        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
590        pub fn armed_timers(&self) -> Result<Vec<TimerWheelEntry>, EngineSeamError> {
591            Ok(self.state()?.armed_timers.clone())
592        }
593
594        /// Queues the next child-spawn response returned by the fake.
595        ///
596        /// # Errors
597        ///
598        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
599        pub fn push_child_spawn_response(
600            &self,
601            response: Result<ChildWorkflowSpawnResult, EngineSeamError>,
602        ) -> Result<(), EngineSeamError> {
603            self.state()?.child_spawn_responses.push_back(response);
604            Ok(())
605        }
606
607        /// Returns events recorded through the fake recorder seam.
608        ///
609        /// # Errors
610        ///
611        /// Returns [`EngineSeamError::EngineOffline`] if the fake's state lock is poisoned.
612        pub fn recorded_events(&self) -> Result<Vec<(WorkflowId, Event)>, EngineSeamError> {
613            Ok(self.state()?.recorded_events.clone())
614        }
615
616        fn state(&self) -> Result<MutexGuard<'_, FakeEngineState>, EngineSeamError> {
617            self.state.lock().map_err(|_| EngineSeamError::Recorder {
618                reason: "fake engine state lock was poisoned".to_owned(),
619            })
620        }
621    }
622
623    impl EngineHandle for FakeEngineHandle {
624        fn resolve_workflow(
625            &self,
626            workflow_id: &WorkflowId,
627        ) -> Result<WorkflowResidency, EngineSeamError> {
628            Ok(self
629                .state()?
630                .residency
631                .get(workflow_id)
632                .copied()
633                .unwrap_or(WorkflowResidency::Unknown))
634        }
635
636        fn deliver_workflow_message(
637            &self,
638            process: WorkflowProcessHandle,
639            message: WorkflowMailboxMessage,
640        ) -> Result<(), EngineSeamError> {
641            let mut state = self.state()?;
642            if let Some(response) = state.delivery_responses.pop_front() {
643                response?;
644            }
645            let delivered = DeliveredWorkflowMessage::from_message(&message);
646            state.delivered.push((process, delivered.clone()));
647            state.operations.push(FakeEngineOperation::Delivered {
648                process,
649                message: delivered,
650            });
651            Ok(())
652        }
653
654        fn spawn_child_workflow(
655            &self,
656            request: ChildWorkflowSpawnRequest,
657        ) -> Result<ChildWorkflowSpawnResult, EngineSeamError> {
658            let mut state = self.state()?;
659            state
660                .operations
661                .push(FakeEngineOperation::ChildSpawnRequested(request.clone()));
662            if let Some(response) = state.child_spawn_responses.pop_front() {
663                response
664            } else {
665                Err(EngineSeamError::ChildSpawn {
666                    reason: "fake child spawn response was not queued".to_owned(),
667                })
668            }
669        }
670
671        fn terminate_linked_child_workflow(
672            &self,
673            parent_workflow_id: &WorkflowId,
674            child_process: WorkflowProcessHandle,
675            correlation: u64,
676        ) -> Result<(), EngineSeamError> {
677            let mut state = self.state()?;
678            state
679                .operations
680                .push(FakeEngineOperation::LinkedChildWorkflowTerminated {
681                    parent_workflow_id: parent_workflow_id.clone(),
682                    child_process,
683                    correlation,
684                });
685            Ok(())
686        }
687
688        fn terminate_linked_activity(
689            &self,
690            parent_workflow_id: &WorkflowId,
691            activity_process: Pid,
692            correlation: u64,
693        ) -> Result<(), EngineSeamError> {
694            let mut state = self.state()?;
695            state
696                .operations
697                .push(FakeEngineOperation::LinkedActivityTerminated {
698                    parent_workflow_id: parent_workflow_id.clone(),
699                    activity_process,
700                    correlation,
701                });
702            Ok(())
703        }
704
705        fn arm_timer(&self, entry: TimerWheelEntry) -> Result<(), EngineSeamError> {
706            let mut state = self.state()?;
707            state.armed_timers.push(entry.clone());
708            state
709                .operations
710                .push(FakeEngineOperation::TimerArmed(entry));
711            Ok(())
712        }
713
714        fn disarm_timer(
715            &self,
716            process: WorkflowProcessHandle,
717            timer_id: &TimerId,
718        ) -> Result<(), EngineSeamError> {
719            let mut state = self.state()?;
720            state
721                .armed_timers
722                .retain(|entry| !(entry.process == process && &entry.timer_id == timer_id));
723            state.disarmed_timers.push((process, timer_id.clone()));
724            state.operations.push(FakeEngineOperation::TimerDisarmed {
725                process,
726                timer_id: timer_id.clone(),
727            });
728            Ok(())
729        }
730
731        fn record_workflow_event(
732            &self,
733            workflow_id: &WorkflowId,
734            event: Event,
735        ) -> Result<RecordOutcome, EngineSeamError> {
736            let mut state = self.state()?;
737            if let Some(response) = state.record_responses.pop_front() {
738                response?;
739            }
740            if std::mem::take(&mut state.refuse_next_record_as_terminal) {
741                // Simulate the production bridge refusing a late fire/cancel under
742                // the recorder lock: nothing is appended and no wake must follow.
743                return Ok(RecordOutcome::RefusedTerminal);
744            }
745            state
746                .recorded_events
747                .push((workflow_id.clone(), event.clone()));
748            let recorder_store = state.recorder_store.clone();
749            state.operations.push(FakeEngineOperation::EventRecorded {
750                workflow_id: workflow_id.clone(),
751                event: event.clone(),
752            });
753            drop(state);
754
755            if let Some(store) = recorder_store {
756                let expected_seq = event.seq().saturating_sub(1);
757                futures::executor::block_on(store.append(
758                    WriteToken::recorder(),
759                    workflow_id,
760                    &[event],
761                    expected_seq,
762                ))
763                .map_err(|error| EngineSeamError::Recorder {
764                    reason: error.to_string(),
765                })?;
766            }
767            Ok(RecordOutcome::Recorded)
768        }
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use aion_core::{ContentType, Payload, WorkflowId};
775
776    use super::test_support::{DeliveredWorkflowMessage, FakeEngineHandle};
777    use super::{
778        EngineHandle, EngineSeamError, WorkflowMailboxMessage, WorkflowProcessHandle,
779        WorkflowResidency,
780    };
781
782    #[test]
783    fn fake_captures_delivered_message_for_resident_workflow()
784    -> Result<(), Box<dyn std::error::Error>> {
785        let engine = FakeEngineHandle::new();
786        let workflow_id = WorkflowId::new_v4();
787        let process = WorkflowProcessHandle::new(42);
788        engine.set_residency(workflow_id.clone(), WorkflowResidency::Resident(process))?;
789
790        let resolved = engine.resolve_workflow(&workflow_id)?;
791        assert_eq!(resolved, WorkflowResidency::Resident(process));
792
793        let payload = Payload::new(ContentType::Json, b"null".to_vec());
794        let message = WorkflowMailboxMessage::SignalReceived {
795            name: "wake".to_owned(),
796            payload: payload.clone(),
797        };
798        engine.deliver_workflow_message(process, message)?;
799
800        assert_eq!(
801            engine.delivered_messages()?,
802            vec![(
803                process,
804                DeliveredWorkflowMessage::SignalReceived {
805                    name: "wake".to_owned(),
806                    payload,
807                }
808            )]
809        );
810        Ok(())
811    }
812
813    #[test]
814    fn fake_can_inject_delivery_failure() -> Result<(), Box<dyn std::error::Error>> {
815        let engine = FakeEngineHandle::new();
816        let process = WorkflowProcessHandle::new(43);
817        engine.push_delivery_response(Err(EngineSeamError::Delivery {
818            reason: "mailbox unavailable".to_owned(),
819        }))?;
820
821        let error = engine
822            .deliver_workflow_message(
823                process,
824                WorkflowMailboxMessage::SignalReceived {
825                    name: "wake".to_owned(),
826                    payload: Payload::new(ContentType::Json, b"null".to_vec()),
827                },
828            )
829            .err()
830            .ok_or_else(|| std::io::Error::other("delivery failure was not returned"))?;
831
832        assert!(matches!(error, EngineSeamError::Delivery { .. }));
833        assert!(engine.delivered_messages()?.is_empty());
834        assert!(engine.operations()?.is_empty());
835        Ok(())
836    }
837}