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