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