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