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