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