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