Skip to main content

aion/engine/
delegated.rs

1//! signal/query/subscribe surface (AT/AD delegation)
2
3use aion_core::{Event, Payload, RunId, WorkflowId, current_lease_terminal, run_segment};
4use async_trait::async_trait;
5use futures::stream::{self, BoxStream};
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8
9use crate::{Engine, EngineError, SignalRouterError, WorkflowHandle};
10
11use super::api::workflow_not_found;
12
13/// Live-event subscription filter consumed by the AD/AT publisher seam.
14///
15/// The `run` field is part of the cross-cluster contract even though the
16/// current core [`Event`] envelope does not yet carry run metadata; publisher
17/// implementations that know run residency out-of-band can apply it there.
18#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
19pub struct EventFilter {
20    /// Match events for this workflow execution id.
21    pub workflow_id: Option<WorkflowId>,
22    /// Match events for this run id when the publisher has run metadata.
23    pub run: Option<RunId>,
24    /// Match events belonging to this event family.
25    pub family: Option<EventFamily>,
26}
27
28impl EventFilter {
29    /// Returns whether an event satisfies the constraints visible on [`Event`].
30    ///
31    /// Run filtering is intentionally not decided here because [`Event`] does
32    /// not currently include a [`RunId`]; the publisher seam applies that field
33    /// with its own metadata when available.
34    #[must_use]
35    pub fn matches(&self, event: &Event) -> bool {
36        self.workflow_id
37            .as_ref()
38            .is_none_or(|workflow_id| event.workflow_id() == workflow_id)
39            && self
40                .family
41                .is_none_or(|family| family == event_family(event))
42    }
43}
44
45/// Coarse event families for live subscription filtering.
46#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
47pub enum EventFamily {
48    /// Workflow lifecycle events.
49    Workflow,
50    /// Activity scheduling and completion events.
51    Activity,
52    /// Timer events owned by AT.
53    Timer,
54    /// Signal delivery events owned by AT/AD.
55    Signal,
56    /// Child-workflow lifecycle events.
57    ChildWorkflow,
58    /// Schedule lifecycle and trigger events.
59    Schedule,
60}
61
62/// AT-005/AT-006 signal-routing seam.
63///
64/// Implementations record the signal through the workflow recorder and deliver
65/// it to the target workflow mailbox. The engine only resolves the live target
66/// handle and delegates to this trait.
67#[async_trait]
68pub trait SignalRouter: Send + Sync {
69    /// Route a signal to the already-resolved workflow target.
70    async fn route(
71        &self,
72        target: &WorkflowHandle,
73        name: String,
74        payload: Payload,
75    ) -> Result<(), EngineError>;
76}
77
78/// AT-007 query-dispatch seam.
79///
80/// Implementations dispatch a read-only query to workflow code and map their
81/// query errors into [`EngineError`]. The engine only resolves the live target
82/// handle and delegates to this trait.
83#[async_trait]
84pub trait QueryService: Send + Sync {
85    /// Dispatch a named query to the already-resolved workflow target.
86    async fn query(&self, target: &WorkflowHandle, name: String) -> Result<Payload, EngineError>;
87}
88
89/// A live subscription fell behind the publisher and skipped events.
90///
91/// Publishers yield this as a stream item — never a silent skip and never a
92/// silent stream end — and then continue with subsequent live events, so the
93/// consumer always learns exactly how many events it missed.
94#[derive(thiserror::Error, Clone, Copy, Debug, PartialEq, Eq)]
95#[error("event subscription lagged behind the live stream and skipped {skipped} events")]
96pub struct EventStreamLagged {
97    /// Number of events the subscriber missed.
98    pub skipped: u64,
99}
100
101/// AD/AT live event publisher seam.
102///
103/// Implementations own event publication and filtering. The engine exposes the
104/// in-process subscription surface without owning publication machinery.
105pub trait EventPublisher: Send + Sync {
106    /// Subscribe to a filtered stream of live workflow events.
107    ///
108    /// A subscriber that falls behind the publisher receives one
109    /// `Err(`[`EventStreamLagged`]`)` item carrying the skipped count and then
110    /// continues with subsequent events; lag never silently drops events and
111    /// never silently ends the stream.
112    fn subscribe(
113        &self,
114        filter: EventFilter,
115    ) -> BoxStream<'static, Result<Event, EventStreamLagged>>;
116}
117
118/// Object-safe delegated seams held by the engine for AT/AD integration.
119#[derive(Clone)]
120pub struct DelegatedSeams {
121    signal_router: Arc<dyn SignalRouter>,
122    query_service: Arc<dyn QueryService>,
123    event_publisher: Arc<dyn EventPublisher>,
124}
125
126impl DelegatedSeams {
127    /// Build a seam bundle from concrete AT/AD implementations.
128    #[must_use]
129    pub const fn new(
130        signal_router: Arc<dyn SignalRouter>,
131        query_service: Arc<dyn QueryService>,
132        event_publisher: Arc<dyn EventPublisher>,
133    ) -> Self {
134        Self {
135            signal_router,
136            query_service,
137            event_publisher,
138        }
139    }
140
141    /// Signal routing seam installed for AT-005/AT-006 delegation.
142    #[must_use]
143    pub fn signal_router(&self) -> &dyn SignalRouter {
144        self.signal_router.as_ref()
145    }
146
147    /// Query dispatch seam installed for AT-007 delegation.
148    #[must_use]
149    pub fn query_service(&self) -> &dyn QueryService {
150        self.query_service.as_ref()
151    }
152
153    /// Live event publisher seam installed for AD/AT delegation.
154    #[must_use]
155    pub fn event_publisher(&self) -> &dyn EventPublisher {
156        self.event_publisher.as_ref()
157    }
158
159    pub(crate) fn signal_router_arc(&self) -> Arc<dyn SignalRouter> {
160        Arc::clone(&self.signal_router)
161    }
162
163    pub(crate) fn query_service_arc(&self) -> Arc<dyn QueryService> {
164        Arc::clone(&self.query_service)
165    }
166
167    pub(crate) fn event_publisher_arc(&self) -> Arc<dyn EventPublisher> {
168        Arc::clone(&self.event_publisher)
169    }
170}
171
172impl Default for DelegatedSeams {
173    fn default() -> Self {
174        Self::new(
175            Arc::new(DeferredSignalRouter),
176            Arc::new(DeferredQueryService),
177            Arc::new(DeferredEventPublisher),
178        )
179    }
180}
181
182/// Deferred signal seam used until AT-005/AT-006 installs a concrete router.
183#[derive(Debug, Default)]
184pub struct DeferredSignalRouter;
185
186#[async_trait]
187impl SignalRouter for DeferredSignalRouter {
188    async fn route(
189        &self,
190        target: &WorkflowHandle,
191        name: String,
192        payload: Payload,
193    ) -> Result<(), EngineError> {
194        let _ = (target, name, payload);
195        Err(EngineError::Runtime {
196            reason: "signal routing seam is not configured".to_owned(),
197        })
198    }
199}
200
201/// Deferred query seam used until AT-007 installs a concrete service.
202#[derive(Debug, Default)]
203pub struct DeferredQueryService;
204
205#[async_trait]
206impl QueryService for DeferredQueryService {
207    async fn query(&self, target: &WorkflowHandle, name: String) -> Result<Payload, EngineError> {
208        let _ = (target, name);
209        Err(EngineError::Runtime {
210            reason: "query service seam is not configured".to_owned(),
211        })
212    }
213}
214
215/// Deferred publisher seam used until AD/AT installs a concrete publisher.
216#[derive(Debug, Default)]
217pub struct DeferredEventPublisher;
218
219impl EventPublisher for DeferredEventPublisher {
220    fn subscribe(
221        &self,
222        filter: EventFilter,
223    ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
224        let _ = filter;
225        Box::pin(stream::empty())
226    }
227}
228
229impl Engine {
230    /// Send a signal to a live workflow run through the AT routing seam.
231    ///
232    /// The signal is admitted against the signal type declared by the EXACT
233    /// package identity the target run is pinned to, BEFORE anything is
234    /// recorded and before the arrival can be consumed. A refused signal
235    /// therefore leaves the run's history byte-identical and the run parked on
236    /// exactly the wait it was parked on — the refusal reaches the caller, not
237    /// the run.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`EngineError::SignalRefused`] when the name is not declared by
242    /// the target's package or the payload does not satisfy the declared type
243    /// (nothing recorded, nothing consumed),
244    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair is unknown,
245    /// [`SignalRouterError::Terminal`] when it is durably terminal, or other typed errors from
246    /// the configured signal seam.
247    pub async fn signal(
248        &self,
249        id: &WorkflowId,
250        run: &RunId,
251        name: impl Into<String>,
252        payload: Payload,
253    ) -> Result<(), EngineError> {
254        let name = name.into();
255        let handle = if let Some(handle) = self.registry().get(id, run)? {
256            // BOUNDARY ADMISSION (resident): the live handle carries the exact
257            // identity this run resolved at start, so it is the ruler here.
258            crate::signal::admission::admit_against_handle(
259                self.workflow_catalog(),
260                &handle,
261                &name,
262                &payload,
263            )?;
264            handle
265        } else {
266            let history = self.store().read_history(id).await?;
267            if run_has_terminal_history(&history, run) {
268                return Err(SignalRouterError::Terminal {
269                    workflow_id: id.clone(),
270                    run_id: run.clone(),
271                }
272                .into());
273            }
274            // BOUNDARY ADMISSION (non-resident): the recorded `WorkflowStarted`
275            // pins the same identity the live handle would have carried. This
276            // runs before BOTH remaining record paths below — the paused
277            // one-shot recorder and the birth-window handle's router — so no
278            // branch out of here can record a refused payload.
279            crate::signal::admission::admit_against_history(
280                self.workflow_catalog(),
281                id,
282                run,
283                &history,
284                &name,
285                &payload,
286            )?;
287            // Paused-but-not-resident (crashed while paused, #204): the run is
288            // durably non-terminal but was deliberately excluded from respawn, so
289            // no live handle exists and handle_after_birth_window would time out
290            // with WorkflowNotFound. Durably record SignalReceived through a
291            // one-shot recorder built from the validated read (record-before-
292            // deliver preserved); it takes effect on resume replay.
293            let segment = aion_core::run_segment(&history, run);
294            if matches!(
295                aion_core::status_from_events(segment),
296                aion_core::WorkflowStatus::Paused
297            ) {
298                let head = history.last().map(Event::seq).unwrap_or_default();
299                let mut recorder =
300                    crate::durability::Recorder::resume_at(id.clone(), self.store(), head)
301                        .with_visibility(run.clone(), self.visibility_store());
302                recorder
303                    .record_signal_received(chrono::Utc::now(), name, payload)
304                    .await?;
305                return Ok(());
306            }
307            self.handle_after_birth_window(id, run, &history)
308                .await?
309                .ok_or_else(|| workflow_not_found(id, run))?
310        };
311        self.delegated()
312            .signal_router()
313            .route(&handle, name, payload)
314            .await
315    }
316
317    /// Dispatch a read-only query to a live workflow run through the AT seam.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`EngineError::Query`] with [`crate::query::QueryError::NotRunning`]
322    /// when the `(workflow, run)` pair is durably terminal,
323    /// [`EngineError::WorkflowNotFound`] when it is unknown, and other typed
324    /// errors from the configured query seam.
325    pub async fn query(
326        &self,
327        id: &WorkflowId,
328        run: &RunId,
329        name: impl Into<String>,
330    ) -> Result<Payload, EngineError> {
331        let handle = if let Some(handle) = self.registry().get(id, run)? {
332            handle
333        } else {
334            // Mirror Engine::signal's registry-miss handling: a completed
335            // workflow is NotRunning per the query contract, never NotFound.
336            let history = self.store().read_history(id).await?;
337            if run_has_terminal_history(&history, run) {
338                return Err(EngineError::Query(crate::query::QueryError::NotRunning(
339                    id.clone(),
340                )));
341            }
342            self.handle_after_birth_window(id, run, &history)
343                .await?
344                .ok_or_else(|| workflow_not_found(id, run))?
345        };
346        self.delegated()
347            .query_service()
348            .query(&handle, name.into())
349            .await
350    }
351
352    /// Resolve a registry miss against the registration birth window.
353    ///
354    /// The start path records `WorkflowStarted` durably *before* it inserts
355    /// the registry handle, so an embedder acting on observed history (or on
356    /// a `start_workflow` racing on another task) can legitimately arrive
357    /// here after the record and before the insert. When the requested run
358    /// is durably started and non-terminal, the registry is re-polled within
359    /// the builder-supplied delivery policy budget; `None` after the budget
360    /// means the run truly has no live handle (its start failed or its
361    /// engine is gone) and the caller fails typed.
362    pub(crate) async fn handle_after_birth_window(
363        &self,
364        id: &WorkflowId,
365        run: &RunId,
366        history: &[Event],
367    ) -> Result<Option<WorkflowHandle>, EngineError> {
368        let started = history
369            .iter()
370            .any(|event| matches!(event, Event::WorkflowStarted { run_id, .. } if run_id == run));
371        if !started {
372            return Ok(None);
373        }
374        wait_for_registered_handle(self.registry(), id, run, self.runtime().signal_delivery()).await
375    }
376
377    /// Subscribe to the live event stream through the AD/AT publisher seam.
378    ///
379    /// A subscriber that falls behind receives one `Err(`[`EventStreamLagged`]`)`
380    /// item with the skipped count and then continues with subsequent events.
381    #[must_use]
382    pub fn subscribe(
383        &self,
384        filter: EventFilter,
385    ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
386        self.delegated().event_publisher().subscribe(filter)
387    }
388}
389
390pub(crate) fn run_has_terminal_history(history: &[Event], run: &RunId) -> bool {
391    // Reset-aware: a run is terminal only if its current lease ended in a
392    // terminal event. A WorkflowReopened after a terminal reopens the run, so a
393    // reopened run is not treated as terminal (it can receive signals and
394    // complete again).
395    current_lease_terminal(run_segment(history, run)).is_some()
396}
397
398/// Poll the registry for `(id, run)` until the handle appears or the
399/// builder-supplied delivery budget is spent.
400///
401/// The budget is the policy's full persistence — `ready_timeout ×
402/// max_enqueue_attempts`, the same product the runtime's enqueue retry
403/// expresses — polled with the policy's backoff ladder. A single
404/// `ready_timeout` is the typical insert latency, but the start thread can
405/// be preempted past it under host oversubscription, and the cost of giving
406/// up early is a typed not-found for a workflow that is durably started.
407pub(crate) async fn wait_for_registered_handle(
408    registry: &crate::registry::Registry,
409    id: &WorkflowId,
410    run: &RunId,
411    policy: crate::runtime::SignalDeliveryConfig,
412) -> Result<Option<WorkflowHandle>, EngineError> {
413    let budget = policy
414        .ready_timeout
415        .saturating_mul(policy.max_enqueue_attempts.max(1));
416    let deadline = std::time::Instant::now() + budget;
417    let mut backoff = policy.initial_backoff;
418    loop {
419        if let Some(handle) = registry.get(id, run)? {
420            return Ok(Some(handle));
421        }
422        if std::time::Instant::now() >= deadline {
423            return Ok(None);
424        }
425        tokio::time::sleep(backoff).await;
426        let doubled = backoff.saturating_mul(2);
427        backoff = if doubled > policy.max_backoff {
428            policy.max_backoff
429        } else {
430            doubled
431        };
432    }
433}
434
435const fn event_family(event: &Event) -> EventFamily {
436    match event {
437        Event::WorkflowStarted { .. }
438        | Event::WorkflowCompleted { .. }
439        | Event::WorkflowFailed { .. }
440        | Event::WorkflowCancelled { .. }
441        | Event::WorkflowTimedOut { .. }
442        | Event::WorkflowContinuedAsNew { .. }
443        | Event::WorkflowReopened { .. }
444        | Event::WorkflowPaused { .. }
445        | Event::WorkflowResumed { .. }
446        | Event::SearchAttributesUpdated { .. } => EventFamily::Workflow,
447        Event::ActivityScheduled { .. }
448        | Event::ActivityStarted { .. }
449        | Event::ActivityCompleted { .. }
450        | Event::ActivityFailed { .. }
451        | Event::ActivityAdvisoryExhausted { .. }
452        | Event::ActivityCancelled { .. } => EventFamily::Activity,
453        Event::TimerStarted { .. }
454        | Event::TimerFired { .. }
455        | Event::TimerCancelled { .. }
456        | Event::WithTimeoutCompleted { .. } => EventFamily::Timer,
457        Event::SignalReceived { .. } | Event::SignalSent { .. } => EventFamily::Signal,
458        Event::ChildWorkflowStarted { .. }
459        | Event::ChildWorkflowCompleted { .. }
460        | Event::ChildWorkflowFailed { .. }
461        | Event::ChildWorkflowCancelled { .. } => EventFamily::ChildWorkflow,
462        Event::ScheduleCreated { .. }
463        | Event::ScheduleUpdated { .. }
464        | Event::SchedulePaused { .. }
465        | Event::ScheduleResumed { .. }
466        | Event::ScheduleDeleted { .. }
467        | Event::ScheduleTriggered { .. } => EventFamily::Schedule,
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use std::sync::{Arc, Mutex};
474
475    use aion_core::{EventEnvelope, WorkflowStatus};
476    use aion_package::ContentHash;
477    use aion_store::visibility::VisibilityStore;
478    use aion_store::{EventStore, InMemoryStore};
479    use futures::{StreamExt, stream};
480    use serde_json::json;
481
482    use crate::durability::Recorder;
483    use crate::engine::api::EngineComponents;
484    use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
485    use crate::{
486        Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog, WorkflowHandle,
487    };
488
489    use super::*;
490
491    #[derive(Debug, Default)]
492    struct SignalCapture {
493        calls: Mutex<Vec<(u64, String, Payload)>>,
494    }
495
496    #[async_trait]
497    impl SignalRouter for SignalCapture {
498        async fn route(
499            &self,
500            target: &WorkflowHandle,
501            name: String,
502            payload: Payload,
503        ) -> Result<(), EngineError> {
504            self.calls
505                .lock()
506                .map_err(|_| EngineError::RegistryPoisoned)?
507                .push((target.pid(), name, payload));
508            Ok(())
509        }
510    }
511
512    #[derive(Debug)]
513    struct QueryCapture {
514        calls: Mutex<Vec<(u64, String)>>,
515        reply: Payload,
516    }
517
518    #[async_trait]
519    impl QueryService for QueryCapture {
520        async fn query(
521            &self,
522            target: &WorkflowHandle,
523            name: String,
524        ) -> Result<Payload, EngineError> {
525            self.calls
526                .lock()
527                .map_err(|_| EngineError::RegistryPoisoned)?
528                .push((target.pid(), name));
529            Ok(self.reply.clone())
530        }
531    }
532
533    #[derive(Debug)]
534    struct FakePublisher {
535        events: Vec<Event>,
536    }
537
538    impl EventPublisher for FakePublisher {
539        fn subscribe(
540            &self,
541            filter: EventFilter,
542        ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
543            let events = self
544                .events
545                .iter()
546                .filter(|event| filter.matches(event))
547                .cloned()
548                .map(Ok)
549                .collect::<Vec<_>>();
550            stream::iter(events).boxed()
551        }
552    }
553
554    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
555        Payload::from_json(&json!({ "label": label }))
556    }
557
558    fn engine_with_seams(
559        signal_router: Arc<dyn SignalRouter>,
560        query_service: Arc<dyn QueryService>,
561        event_publisher: Arc<dyn EventPublisher>,
562    ) -> Result<Engine, EngineError> {
563        let backing = Arc::new(InMemoryStore::default());
564        let store: Arc<dyn EventStore> = Arc::clone(&backing) as _;
565        let visibility_store: Arc<dyn VisibilityStore> = backing;
566        Ok(Engine::new(EngineComponents {
567            store,
568            visibility_store,
569            runtime: Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?),
570            catalog: Arc::new(WorkflowCatalog::new()),
571            registry: Arc::new(Registry::default()),
572            supervision: Arc::new(SupervisionTree::new()),
573            delegated: DelegatedSeams::new(signal_router, query_service, event_publisher),
574            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
575            search_attribute_schema: Arc::new(aion_core::SearchAttributeSchema::new()),
576            visibility_reconciliation_task: None,
577        }))
578    }
579
580    /// Record `WorkflowStarted` durably and build the matching handle
581    /// without inserting it into the registry — the exact state of the
582    /// registration birth window.
583    async fn recorded_active_handle(
584        engine: &Engine,
585    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
586        let workflow_id = WorkflowId::new_v4();
587        let run_id = RunId::new_v4();
588        let store = engine.store();
589        let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
590        recorder
591            .record_workflow_started(
592                chrono::Utc::now(),
593                crate::durability::WorkflowStartRecord {
594                    workflow_type: "checkout".to_owned(),
595                    input: payload("input")?,
596                    run_id: run_id.clone(),
597                    parent_run_id: None,
598                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
599                },
600            )
601            .await?;
602        Ok(WorkflowHandle::new(WorkflowHandleParts {
603            workflow_id,
604            run_id,
605            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
606            workflow_type: "checkout".to_owned(),
607            namespace: String::from("default"),
608            loaded_version: ContentHash::from_bytes([1; 32]),
609            cached_status: WorkflowStatus::Running,
610            residency: HandleResidency::Resident,
611            recorder,
612            completion: CompletionNotifier::new(),
613        }))
614    }
615
616    async fn insert_active_handle(
617        engine: &Engine,
618    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
619        let handle = recorded_active_handle(engine).await?;
620        engine.registry().insert(
621            (handle.workflow_id().clone(), handle.run_id().clone()),
622            handle.clone(),
623        )?;
624        Ok(handle)
625    }
626
627    fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
628        EventEnvelope {
629            seq,
630            recorded_at: chrono::Utc::now(),
631            workflow_id: workflow_id.clone(),
632        }
633    }
634
635    /// Registration birth window (the 1/300 release-signal flake): the start
636    /// path records `WorkflowStarted` durably before it inserts the registry
637    /// handle, so a caller acting on observed history can signal before the
638    /// insert lands. The signal must wait the handle out within the delivery
639    /// policy budget — before the fix it returned `WorkflowNotFound`
640    /// immediately.
641    #[tokio::test(flavor = "multi_thread")]
642    async fn signal_inside_the_registration_birth_window_waits_for_the_handle()
643    -> Result<(), Box<dyn std::error::Error>> {
644        let signal = Arc::new(SignalCapture::default());
645        let engine = Arc::new(engine_with_seams(
646            signal.clone(),
647            Arc::new(DeferredQueryService),
648            Arc::new(DeferredEventPublisher),
649        )?);
650        let handle = recorded_active_handle(&engine).await?;
651
652        // The insert lands mid-wait, exactly as the start thread's does.
653        let late_engine = Arc::clone(&engine);
654        let late_handle = handle.clone();
655        let inserter = tokio::spawn(async move {
656            tokio::time::sleep(std::time::Duration::from_millis(15)).await;
657            late_engine.registry().insert(
658                (
659                    late_handle.workflow_id().clone(),
660                    late_handle.run_id().clone(),
661                ),
662                late_handle,
663            )
664        });
665
666        engine
667            .signal(
668                handle.workflow_id(),
669                handle.run_id(),
670                "approve",
671                payload("birth")?,
672            )
673            .await?;
674        inserter.await??;
675
676        let calls = signal
677            .calls
678            .lock()
679            .map_err(|_| EngineError::RegistryPoisoned)?;
680        assert_eq!(calls.len(), 1, "the signal must reach the routed handle");
681        drop(calls);
682        engine.shutdown()?;
683        Ok(())
684    }
685
686    /// The birth wait is bounded: a durably started run whose handle never
687    /// appears (its start failed, or its engine is gone) still fails typed
688    /// after the policy budget.
689    #[tokio::test(flavor = "multi_thread")]
690    async fn signal_for_a_started_run_with_no_handle_fails_typed_after_the_budget()
691    -> Result<(), Box<dyn std::error::Error>> {
692        let engine = engine_with_seams(
693            Arc::new(SignalCapture::default()),
694            Arc::new(DeferredQueryService),
695            Arc::new(DeferredEventPublisher),
696        )?;
697        let handle = recorded_active_handle(&engine).await?;
698
699        let outcome = engine
700            .signal(
701                handle.workflow_id(),
702                handle.run_id(),
703                "approve",
704                payload("never")?,
705            )
706            .await;
707
708        assert!(matches!(outcome, Err(EngineError::WorkflowNotFound { .. })));
709        engine.shutdown()?;
710        Ok(())
711    }
712
713    #[tokio::test]
714    async fn signal_delegates_to_router_and_unknown_returns_not_found()
715    -> Result<(), Box<dyn std::error::Error>> {
716        let signal = Arc::new(SignalCapture::default());
717        let engine = engine_with_seams(
718            signal.clone(),
719            Arc::new(DeferredQueryService),
720            Arc::new(DeferredEventPublisher),
721        )?;
722        let handle = insert_active_handle(&engine).await?;
723        let sent_payload = payload("signal")?;
724
725        engine
726            .signal(
727                handle.workflow_id(),
728                handle.run_id(),
729                "approve",
730                sent_payload.clone(),
731            )
732            .await?;
733
734        {
735            let calls = signal
736                .calls
737                .lock()
738                .map_err(|_| EngineError::RegistryPoisoned)?;
739            assert_eq!(
740                calls.as_slice(),
741                &[(handle.pid(), "approve".to_owned(), sent_payload)]
742            );
743        }
744        let unknown = engine
745            .signal(
746                &WorkflowId::new_v4(),
747                &RunId::new_v4(),
748                "approve",
749                payload("unknown")?,
750            )
751            .await;
752        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
753        engine.shutdown()?;
754        Ok(())
755    }
756
757    #[tokio::test]
758    async fn query_delegates_to_service_and_returns_payload()
759    -> Result<(), Box<dyn std::error::Error>> {
760        let reply = payload("reply")?;
761        let query = Arc::new(QueryCapture {
762            calls: Mutex::new(Vec::new()),
763            reply: reply.clone(),
764        });
765        let engine = engine_with_seams(
766            Arc::new(DeferredSignalRouter),
767            query.clone(),
768            Arc::new(DeferredEventPublisher),
769        )?;
770        let handle = insert_active_handle(&engine).await?;
771
772        let returned = engine
773            .query(handle.workflow_id(), handle.run_id(), "state")
774            .await?;
775
776        assert_eq!(returned, reply);
777        let calls = query
778            .calls
779            .lock()
780            .map_err(|_| EngineError::RegistryPoisoned)?;
781        assert_eq!(calls.as_slice(), &[(handle.pid(), "state".to_owned())]);
782        drop(calls);
783        engine.shutdown()?;
784        Ok(())
785    }
786
787    #[tokio::test]
788    async fn query_terminal_run_is_not_running_and_unknown_is_not_found()
789    -> Result<(), Box<dyn std::error::Error>> {
790        let engine = engine_with_seams(
791            Arc::new(DeferredSignalRouter),
792            Arc::new(DeferredQueryService),
793            Arc::new(DeferredEventPublisher),
794        )?;
795        // Durably terminal run with no registry entry: a completed workflow.
796        let workflow_id = WorkflowId::new_v4();
797        let run_id = aion_core::RunId::new_v4();
798        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
799        recorder
800            .record_workflow_started(
801                chrono::Utc::now(),
802                crate::durability::WorkflowStartRecord {
803                    workflow_type: "checkout".to_owned(),
804                    input: payload("input")?,
805                    run_id: run_id.clone(),
806                    parent_run_id: None,
807                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
808                },
809            )
810            .await?;
811        recorder
812            .record_workflow_completed(chrono::Utc::now(), payload("result")?)
813            .await?;
814
815        let terminal = engine.query(&workflow_id, &run_id, "state").await;
816        assert!(matches!(
817            terminal,
818            Err(EngineError::Query(crate::query::QueryError::NotRunning(id))) if id == workflow_id
819        ));
820
821        let unknown = engine
822            .query(&WorkflowId::new_v4(), &RunId::new_v4(), "state")
823            .await;
824        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
825        engine.shutdown()?;
826        Ok(())
827    }
828
829    #[tokio::test]
830    async fn subscribe_delegates_to_publisher_stream_with_filter()
831    -> Result<(), Box<dyn std::error::Error>> {
832        let workflow_id = WorkflowId::new_v4();
833        let other_id = WorkflowId::new_v4();
834        let matching = Event::SignalReceived {
835            envelope: envelope(1, &workflow_id),
836            name: "approved".to_owned(),
837            payload: payload("signal")?,
838        };
839        let filtered = Event::WorkflowStarted {
840            envelope: envelope(1, &other_id),
841            workflow_type: "checkout".to_owned(),
842            input: payload("input")?,
843            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
844            parent_run_id: None,
845            package_version: aion_core::PackageVersion::new("a".repeat(64)),
846        };
847        let engine = engine_with_seams(
848            Arc::new(DeferredSignalRouter),
849            Arc::new(DeferredQueryService),
850            Arc::new(FakePublisher {
851                events: vec![matching.clone(), filtered],
852            }),
853        )?;
854
855        let events = engine
856            .subscribe(EventFilter {
857                workflow_id: Some(workflow_id),
858                run: None,
859                family: Some(EventFamily::Signal),
860            })
861            .collect::<Vec<_>>()
862            .await;
863
864        assert_eq!(events, vec![Ok(matching)]);
865        engine.shutdown()?;
866        Ok(())
867    }
868}