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