Skip to main content

aion/runtime/
nif_context.rs

1//! Per-call NIF context resolution and durability replay checks.
2
3use std::future::Future;
4use std::sync::Arc;
5
6use aion_core::{ActivityError, ActivityId, Payload, RunId, WorkflowId};
7use aion_store::EventStore;
8use chrono::{DateTime, Utc};
9use tokio::runtime::Handle;
10use tokio::sync::Mutex;
11
12use crate::EngineError;
13use crate::durability::{
14    Command, DurabilityError, FanOutCompletionResult, FanOutItem, FanOutOutcome, HistoryCursor,
15    Recorder, ResolveOutcome, Resolver,
16};
17use crate::registry::{Registry, WorkflowHandle};
18
19/// Errors surfaced while constructing or using a per-call NIF context.
20#[derive(thiserror::Error, Debug)]
21pub enum NifContextError {
22    /// No live workflow handle is registered for the calling process.
23    #[error("unknown workflow process pid {pid}")]
24    UnknownProcess {
25        /// Runtime process identifier that could not be resolved.
26        pid: u64,
27    },
28    /// The recorder lock could not be acquired.
29    #[error("workflow recorder lock is poisoned")]
30    RecorderPoisoned,
31    /// Durability replay or recording failed.
32    #[error("durability error: {0}")]
33    Durability(#[from] DurabilityError),
34    /// A BEAM return term could not be encoded.
35    #[error("term encoding error: {reason}")]
36    TermEncoding {
37        /// Human-readable encoding failure reason.
38        reason: String,
39    },
40}
41
42impl NifContextError {
43    /// NIF-convention reason string for `{error, <<reason>>}` results.
44    ///
45    /// Term construction lives with the callers, which allocate on the
46    /// calling process heap through their [`beamr::native::ProcessContext`]
47    /// (N-6); this type only renders the stable reason text.
48    pub(crate) fn error_reason(&self) -> String {
49        match self {
50            Self::UnknownProcess { pid } => format!("unknown_process:{pid}"),
51            Self::RecorderPoisoned => "recorder_poisoned".to_owned(),
52            Self::Durability(error) => format!("durability:{error}"),
53            Self::TermEncoding { reason } => format!("term_encoding:{reason}"),
54        }
55    }
56}
57
58/// Per-NIF-call context resolved from the calling runtime process.
59pub struct NifContext {
60    handle: WorkflowHandle,
61    recorder: Arc<Mutex<Recorder>>,
62    tokio_handle: Handle,
63    resolver: Resolver,
64    last_recorded_at: Option<DateTime<Utc>>,
65}
66
67impl NifContext {
68    /// Resolves `pid` against the active registry and builds a replay resolver from recorded history.
69    ///
70    /// `birth_wait` bounds the registry-registration wait for a just-spawned
71    /// process (see [`resolve_handle_with_birth_wait`]).
72    ///
73    /// # Errors
74    ///
75    /// Returns [`NifContextError::UnknownProcess`] when the registry has no matching active handle,
76    /// or [`NifContextError::Durability`] when recorded history cannot be read or cursor-validated.
77    pub fn new(
78        pid: u64,
79        registry: &Registry,
80        tokio_handle: Handle,
81        birth_wait: crate::runtime::SignalDeliveryConfig,
82    ) -> Result<Self, NifContextError> {
83        Self::new_with_history_store(pid, registry, tokio_handle, None, birth_wait)
84    }
85
86    /// Resolves `pid` and reads recorded history from an explicit store when supplied.
87    ///
88    /// If no store is supplied, the history is read through the resolved handle's recorder-owned
89    /// store. The explicit store seam lets the runtime pass the engine store without exposing any
90    /// mutable event-store append path to NIF code.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`NifContextError::UnknownProcess`] when no active handle matches `pid`, or wraps any
95    /// durability read/cursor error in [`NifContextError::Durability`].
96    pub fn new_with_history_store(
97        pid: u64,
98        registry: &Registry,
99        tokio_handle: Handle,
100        store: Option<Arc<dyn EventStore>>,
101        birth_wait: crate::runtime::SignalDeliveryConfig,
102    ) -> Result<Self, NifContextError> {
103        let handle = resolve_handle_with_birth_wait(registry, pid, birth_wait)?;
104        let recorder = handle.recorder();
105        let workflow_id = handle.workflow_id().clone();
106        let history = match store {
107            Some(store) => tokio_handle
108                .block_on(store.read_history(&workflow_id))
109                .map_err(DurabilityError::from)?,
110            None => tokio_handle.block_on(async {
111                let recorder = recorder.lock().await;
112                recorder.read_history().await
113            })?,
114        };
115        // Correlation identities (ordinals, signal occurrence indices) are
116        // run-scoped; resolve only against this run's history segment.
117        let history = crate::durability::current_run_segment(history, handle.run_id())?;
118        let last_recorded_at = history.last().map(|event| *event.recorded_at());
119        let cursor = HistoryCursor::new(history)?;
120        let resolver = Resolver::new(workflow_id, cursor);
121
122        Ok(Self {
123            handle,
124            recorder,
125            tokio_handle,
126            resolver,
127            last_recorded_at,
128        })
129    }
130
131    /// Returns the logical workflow identifier for the resolved handle.
132    #[must_use]
133    pub fn workflow_id(&self) -> &WorkflowId {
134        self.handle.workflow_id()
135    }
136
137    /// Returns the concrete run identifier for the resolved handle.
138    #[must_use]
139    pub fn run_id(&self) -> &RunId {
140        self.handle.run_id()
141    }
142
143    /// Returns the next deterministic activity key ordinal.
144    ///
145    /// Ordinals come from the run-scoped monotonic sequence on the workflow
146    /// handle: every NIF call shares it, so successive workflow steps get
147    /// unique correlation keys even though each call constructs a fresh
148    /// resolver over the full history.
149    #[must_use]
150    pub fn next_activity_ordinal(&self) -> u64 {
151        self.handle.allocate_activity_ordinals(1)
152    }
153
154    /// Allocates `count` consecutive activity key ordinals for a fan-out.
155    #[must_use]
156    pub fn allocate_activity_ordinals(&self, count: u64) -> u64 {
157        self.handle.allocate_activity_ordinals(count)
158    }
159
160    /// Returns the next deterministic timer ordinal.
161    ///
162    /// Same run-scoped sequence contract as [`Self::next_activity_ordinal`];
163    /// used to derive anonymous timer identities that replay deterministically.
164    #[must_use]
165    pub fn next_timer_ordinal(&self) -> u64 {
166        self.handle.allocate_timer_ordinals(1)
167    }
168
169    /// Returns the next deterministic child-workflow spawn ordinal.
170    ///
171    /// Same run-scoped sequence contract as [`Self::next_activity_ordinal`]:
172    /// the n-th `spawn_child` call a run makes correlates with the n-th
173    /// recorded `ChildWorkflowStarted` in the run's history segment. The
174    /// ordinal is never derived from the recorder's sequence head, which
175    /// moves with asynchronous-arrival appends and with the resume position
176    /// after recovery.
177    #[must_use]
178    pub fn next_child_ordinal(&self) -> u64 {
179        self.handle.allocate_child_ordinals(1)
180    }
181
182    /// Number of `receive_signal(name)` calls this run has completed.
183    #[must_use]
184    pub fn signal_receives_consumed(&self, name: &str) -> u64 {
185        self.handle.signal_receives_consumed(name)
186    }
187
188    /// Advance the completed-receive count for `name` by one.
189    pub fn mark_signal_receive_consumed(&self, name: &str) {
190        self.handle.mark_signal_receive_consumed(name);
191    }
192
193    /// Number of `send_signal(name)` calls this run has completed.
194    #[must_use]
195    pub fn signal_sends_completed(&self, name: &str) -> u64 {
196        self.handle.signal_sends_completed(name)
197    }
198
199    /// Advance the completed-send count for `name` by one.
200    pub fn mark_signal_send_completed(&self, name: &str) {
201        self.handle.mark_signal_send_completed(name);
202    }
203
204    /// Returns a clone of the resolved workflow handle.
205    #[must_use]
206    pub fn workflow_handle(&self) -> WorkflowHandle {
207        self.handle.clone()
208    }
209
210    /// Returns the runtime process identifier for the resolved handle.
211    #[must_use]
212    pub const fn pid(&self) -> u64 {
213        self.handle.pid()
214    }
215
216    /// Returns the recorded timestamp of the last event in the resolved history.
217    #[must_use]
218    pub const fn last_recorded_at(&self) -> Option<DateTime<Utc>> {
219        self.last_recorded_at
220    }
221
222    /// Returns and advances the workflow-local deterministic NIF call sequence.
223    #[must_use]
224    pub fn next_deterministic_sequence(&self) -> u64 {
225        self.handle.next_deterministic_nif_sequence()
226    }
227
228    /// Returns the shared single-writer recorder for the resolved workflow.
229    #[must_use]
230    pub fn recorder(&self) -> Arc<Mutex<Recorder>> {
231        Arc::clone(&self.recorder)
232    }
233
234    /// Synchronously runs an async recorder operation on the carried Tokio runtime handle.
235    ///
236    /// # Errors
237    ///
238    /// Propagates any [`DurabilityError`] returned by the supplied operation.
239    pub fn block_on_recorder<T, F>(&self, f: F) -> Result<T, NifContextError>
240    where
241        F: for<'a> FnOnce(
242            &'a mut Recorder,
243        ) -> std::pin::Pin<
244            Box<dyn Future<Output = Result<T, DurabilityError>> + Send + 'a>,
245        >,
246    {
247        self.tokio_handle
248            .block_on(async {
249                let mut recorder = self.recorder.lock().await;
250                f(&mut recorder).await
251            })
252            .map_err(Into::into)
253    }
254
255    /// Records activity scheduling and start through the workflow's single-writer recorder.
256    ///
257    /// # Errors
258    ///
259    /// Propagates any [`DurabilityError`] returned by the recorder.
260    pub(crate) fn record_activity_scheduled_started(
261        &self,
262        recorded_at: chrono::DateTime<chrono::Utc>,
263        activity_id: ActivityId,
264        scheduled: super::nif_activity::ScheduledActivity,
265    ) -> Result<(), NifContextError> {
266        self.tokio_handle
267            .block_on(async {
268                let mut recorder = self.recorder.lock().await;
269                recorder
270                    .record_activity_scheduled(
271                        recorded_at,
272                        activity_id.clone(),
273                        scheduled.activity_type,
274                        scheduled.input,
275                        // NSTQ-4: the resolved task queue (activity override > workflow default >
276                        // the named default), decided once at the schedule seam by the caller.
277                        scheduled.task_queue,
278                        // NODE-4: the resolved OPTIONAL node affinity (activity pin, else None),
279                        // decided once at the schedule seam by the caller.
280                        scheduled.node,
281                    )
282                    .await?;
283                recorder
284                    // NOI-0: the genuine one-based delivery attempt, threaded from the dispatch seam.
285                    .record_activity_started(recorded_at, activity_id, scheduled.attempt)
286                    .await
287            })
288            .map_err(Into::into)
289    }
290
291    /// Records successful activity completion through the workflow's single-writer recorder.
292    ///
293    /// # Errors
294    ///
295    /// Propagates any [`DurabilityError`] returned by the recorder.
296    pub fn record_activity_completed(
297        &self,
298        recorded_at: chrono::DateTime<chrono::Utc>,
299        activity_id: ActivityId,
300        result: Payload,
301        attempt: u32,
302    ) -> Result<(), NifContextError> {
303        self.tokio_handle
304            .block_on(async {
305                let mut recorder = self.recorder.lock().await;
306                recorder
307                    // NOI-0: the genuine one-based attempt that produced this completion.
308                    .record_activity_completed(recorded_at, activity_id, result, attempt)
309                    .await
310            })
311            .map_err(Into::into)
312    }
313
314    /// Records terminal activity failure through the workflow's single-writer recorder.
315    ///
316    /// # Errors
317    ///
318    /// Propagates any [`DurabilityError`] returned by the recorder.
319    pub fn record_activity_failed(
320        &self,
321        recorded_at: chrono::DateTime<chrono::Utc>,
322        activity_id: ActivityId,
323        error: ActivityError,
324        attempt: u32,
325    ) -> Result<(), NifContextError> {
326        self.tokio_handle
327            .block_on(async {
328                let mut recorder = self.recorder.lock().await;
329                recorder
330                    .record_activity_failed(recorded_at, activity_id, error, attempt)
331                    .await
332            })
333            .map_err(Into::into)
334    }
335
336    /// Records activity cancellation through the workflow's single-writer recorder.
337    ///
338    /// # Errors
339    ///
340    /// Propagates any [`DurabilityError`] returned by the recorder.
341    pub fn record_activity_cancelled(
342        &self,
343        recorded_at: chrono::DateTime<chrono::Utc>,
344        activity_id: ActivityId,
345        attempt: u32,
346    ) -> Result<(), NifContextError> {
347        self.tokio_handle
348            .block_on(async {
349                let mut recorder = self.recorder.lock().await;
350                recorder
351                    // NOI-0: the genuine one-based attempt that was cancelled.
352                    .record_activity_cancelled(recorded_at, activity_id, attempt)
353                    .await
354            })
355            .map_err(Into::into)
356    }
357
358    /// Records activity cancellation for a fan-out ordinal and settles its outbox row.
359    ///
360    /// # Errors
361    ///
362    /// Propagates any [`DurabilityError`] returned by the recorder.
363    pub fn record_activity_cancelled_and_settle_outbox(
364        &self,
365        recorded_at: chrono::DateTime<chrono::Utc>,
366        ordinal: u64,
367        attempt: u32,
368    ) -> Result<(), NifContextError> {
369        self.tokio_handle
370            .block_on(async {
371                let mut recorder = self.recorder.lock().await;
372                recorder
373                    // NOI-0: the genuine one-based attempt that was cancelled.
374                    .record_activity_cancelled_and_settle_outbox(recorded_at, ordinal, attempt)
375                    .await
376            })
377            .map_err(Into::into)
378    }
379
380    /// Records a durable fan-out dispatch batch through the workflow's single-writer recorder.
381    ///
382    /// # Errors
383    ///
384    /// Propagates any [`DurabilityError`] returned by the recorder.
385    pub fn record_fan_out_dispatch(
386        &self,
387        recorded_at: chrono::DateTime<chrono::Utc>,
388        items: &[FanOutItem],
389    ) -> Result<(), NifContextError> {
390        self.tokio_handle
391            .block_on(async {
392                let mut recorder = self.recorder.lock().await;
393                recorder.record_fan_out_dispatch(recorded_at, items).await
394            })
395            .map_err(Into::into)
396    }
397
398    /// Re-arms the durable outbox rows for a fan-out batch back to claimable `Pending` through the
399    /// workflow's single-writer recorder (crash-recovery re-stage).
400    ///
401    /// # Errors
402    ///
403    /// Propagates any [`DurabilityError`] returned by the recorder.
404    pub fn rearm_outbox_pending(
405        &self,
406        recorded_at: chrono::DateTime<chrono::Utc>,
407        items: &[FanOutItem],
408    ) -> Result<(), NifContextError> {
409        self.tokio_handle
410            .block_on(async {
411                let recorder = self.recorder.lock().await;
412                recorder.rearm_outbox_pending(recorded_at, items).await
413            })
414            .map_err(Into::into)
415    }
416
417    /// Records one fan-out completion through the workflow's single-writer recorder.
418    ///
419    /// # Errors
420    ///
421    /// Propagates any [`DurabilityError`] returned by the recorder.
422    pub fn record_fan_out_completion(
423        &self,
424        recorded_at: chrono::DateTime<chrono::Utc>,
425        ordinal: u64,
426        outcome: FanOutOutcome,
427    ) -> Result<FanOutCompletionResult, NifContextError> {
428        self.tokio_handle
429            .block_on(async {
430                let mut recorder = self.recorder.lock().await;
431                recorder
432                    .record_fan_out_completion(recorded_at, ordinal, None, outcome)
433                    .await
434            })
435            .map_err(Into::into)
436    }
437
438    /// Returns a snapshot of the recorded history visible to this NIF context.
439    #[must_use]
440    pub fn history(&self) -> &[aion_core::Event] {
441        self.resolver.history()
442    }
443
444    /// The task queue the WORKFLOW WAS STARTED ON, projected from this context's
445    /// recorded history (#144).
446    ///
447    /// Reads the `aion.task_queue` search attribute the server recorded in the
448    /// same atomic append as `WorkflowStarted`
449    /// ([`aion_core::start_time_task_queue`]). Returns `None` when the start
450    /// recorded no task-queue selection (a legacy history, or a start that left
451    /// the queue unset), so the activity-queue resolution falls back to the
452    /// named default.
453    ///
454    /// The value is a pure function of recorded history — never live or
455    /// wall-clock state — so recovery/replay re-derive the identical queue,
456    /// preserving replay determinism.
457    #[must_use]
458    pub fn start_time_task_queue(&self) -> Option<String> {
459        aion_core::start_time_task_queue(self.history())
460    }
461
462    /// Resolves a workflow command against recorded history before any live side effect runs.
463    ///
464    /// # Errors
465    ///
466    /// Returns [`NifContextError::Durability`] when replay detects non-determinism or malformed
467    /// command history.
468    pub fn resolve_command(&mut self, command: Command) -> Result<ResolveOutcome, NifContextError> {
469        // This resolver was built fresh for one NIF call, with its cursor at
470        // the top of history; commands consumed by earlier calls in the same
471        // live execution sit before the one being resolved. Skip to this
472        // command's correlation key so sequential workflow steps never
473        // re-read earlier recorded results. AwaitChild has no positional
474        // key — its replay identity is the awaited child workflow id — so it
475        // skips to that child's recorded terminal outcome instead.
476        if let Some(key) = command.key() {
477            self.resolver.fast_forward_to(key);
478        } else if let Command::AwaitChild { child_workflow_id } = &command {
479            self.resolver
480                .fast_forward_to_child_terminal(child_workflow_id);
481        }
482        self.resolver.resolve(command).map_err(Into::into)
483    }
484}
485
486fn registry_error_to_context(error: &EngineError) -> NifContextError {
487    match error {
488        EngineError::RegistryPoisoned => NifContextError::RecorderPoisoned,
489        _ => NifContextError::TermEncoding {
490            reason: format!("registry lookup failed: {error}"),
491        },
492    }
493}
494
495/// Resolve the workflow handle for `pid`, waiting out the registration birth
496/// window.
497///
498/// The start path spawns the workflow process and only then inserts its
499/// handle into the registry, so a workflow whose first instructions call a
500/// NIF can legitimately execute before its handle exists. Failing typed in
501/// that window kills the workflow at startup: the SDK and fixtures treat a
502/// context failure from `receive_signal`/`sleep`/`register_query` as fatal
503/// (`{badmatch, {error, ...}}`). The wait is bounded by the engine's
504/// builder-supplied delivery policy and converges as soon as the start
505/// thread's insert lands. The budget is the policy's full persistence —
506/// `ready_timeout × max_enqueue_attempts`, the same product the enqueue
507/// retry path expresses — not a single `ready_timeout`: the caller is a
508/// live process already executing on this engine's scheduler, so a missing
509/// entry is virtually always the in-flight insert, and the cost of giving
510/// up early is a workflow killed at birth (`ready_timeout` alone lost to
511/// OS-level preemption of the start thread roughly once per few thousand
512/// births under heavy host oversubscription). A pid that never appears
513/// (a non-workflow process misusing a workflow NIF, or a start rolled back
514/// with the pid cancelled) still fails typed after the budget.
515fn resolve_handle_with_birth_wait(
516    registry: &Registry,
517    pid: u64,
518    birth_wait: crate::runtime::SignalDeliveryConfig,
519) -> Result<WorkflowHandle, NifContextError> {
520    let lookup = |registry: &Registry| -> Result<Option<WorkflowHandle>, NifContextError> {
521        Ok(registry
522            .list()
523            .map_err(|error| registry_error_to_context(&error))?
524            .into_iter()
525            .find(|handle| handle.pid() == pid))
526    };
527    if let Some(handle) = lookup(registry)? {
528        return Ok(handle);
529    }
530    let budget = birth_wait
531        .ready_timeout
532        .saturating_mul(birth_wait.max_enqueue_attempts.max(1));
533    let deadline = std::time::Instant::now() + budget;
534    let mut backoff = birth_wait.initial_backoff;
535    while std::time::Instant::now() < deadline {
536        std::thread::sleep(backoff);
537        let doubled = backoff.saturating_mul(2);
538        backoff = if doubled > birth_wait.max_backoff {
539            birth_wait.max_backoff
540        } else {
541            doubled
542        };
543        if let Some(handle) = lookup(registry)? {
544            return Ok(handle);
545        }
546    }
547    Err(NifContextError::UnknownProcess { pid })
548}
549
550#[cfg(test)]
551mod tests {
552    use std::sync::Arc;
553
554    use aion_core::{ActivityId, Event, EventEnvelope, Payload, WorkflowStatus};
555    use aion_package::ContentHash;
556    use aion_store::{EventStore, InMemoryStore, WriteToken};
557    use chrono::{TimeZone, Utc};
558    use serde_json::json;
559
560    use super::{NifContext, NifContextError};
561    use crate::durability::{Command, CorrelationKey, Recorder, Resolution, ResolveOutcome};
562    use crate::registry::{
563        CompletionNotifier, HandleResidency, Registry, WorkflowHandle, WorkflowHandleParts,
564    };
565
566    type TestResult = Result<(), Box<dyn std::error::Error>>;
567
568    fn hash() -> ContentHash {
569        ContentHash::from_bytes([7; 32])
570    }
571
572    /// Fast birth-wait policy for tests: small budget, tight polls.
573    fn birth_wait() -> crate::runtime::SignalDeliveryConfig {
574        crate::runtime::SignalDeliveryConfig::new(
575            std::time::Duration::from_millis(200),
576            1,
577            std::time::Duration::from_millis(2),
578            std::time::Duration::from_millis(8),
579        )
580    }
581
582    fn payload(label: &str) -> Result<Payload, Box<dyn std::error::Error>> {
583        Ok(Payload::from_json(&json!({ "label": label }))?)
584    }
585
586    fn envelope(
587        workflow_id: &aion_core::WorkflowId,
588        seq: u64,
589    ) -> Result<EventEnvelope, Box<dyn std::error::Error>> {
590        let recorded_at = Utc
591            .timestamp_opt(i64::try_from(seq)?, 0)
592            .single()
593            .ok_or_else(|| "invalid timestamp".to_owned())?;
594        Ok(EventEnvelope {
595            seq,
596            recorded_at,
597            workflow_id: workflow_id.clone(),
598        })
599    }
600
601    fn started_event(
602        workflow_id: &aion_core::WorkflowId,
603        run_id: &aion_core::RunId,
604    ) -> Result<Event, Box<dyn std::error::Error>> {
605        Ok(Event::WorkflowStarted {
606            envelope: envelope(workflow_id, 1)?,
607            workflow_type: "checkout".to_owned(),
608            input: payload("input")?,
609            run_id: run_id.clone(),
610            parent_run_id: None,
611            package_version: aion_core::PackageVersion::new("a".repeat(64)),
612        })
613    }
614
615    fn handle(
616        pid: u64,
617        store: Arc<dyn EventStore>,
618        workflow_id: aion_core::WorkflowId,
619        run_id: aion_core::RunId,
620    ) -> WorkflowHandle {
621        let recorder = Recorder::resume_at(workflow_id.clone(), store, 1);
622        WorkflowHandle::new(WorkflowHandleParts {
623            workflow_id,
624            run_id,
625            pid,
626            workflow_type: "checkout".to_owned(),
627            namespace: String::from("default"),
628            loaded_version: hash(),
629            cached_status: WorkflowStatus::Running,
630            residency: HandleResidency::Resident,
631            recorder,
632            completion: CompletionNotifier::new(),
633        })
634    }
635
636    type TestContext = (Registry, Arc<dyn EventStore>, WorkflowHandle);
637
638    fn context_with_history(
639        runtime: &tokio::runtime::Runtime,
640        pid: u64,
641        workflow_id: aion_core::WorkflowId,
642        history: &[Event],
643    ) -> Result<TestContext, Box<dyn std::error::Error>> {
644        let registry = Registry::default();
645        let run_id = aion_core::RunId::new_v4();
646        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
647        let mut full_history = vec![started_event(&workflow_id, &run_id)?];
648        full_history.extend_from_slice(history);
649        runtime.block_on(store.append(WriteToken::recorder(), &workflow_id, &full_history, 0))?;
650        let recorder = Recorder::resume_at(
651            workflow_id.clone(),
652            Arc::clone(&store),
653            full_history.len() as u64,
654        );
655        let handle = WorkflowHandle::new(WorkflowHandleParts {
656            workflow_id: workflow_id.clone(),
657            run_id: run_id.clone(),
658            pid,
659            workflow_type: "checkout".to_owned(),
660            namespace: String::from("default"),
661            loaded_version: hash(),
662            cached_status: WorkflowStatus::Running,
663            residency: HandleResidency::Resident,
664            recorder,
665            completion: CompletionNotifier::new(),
666        });
667        registry.insert((workflow_id, run_id), handle.clone())?;
668        Ok((registry, store, handle))
669    }
670
671    #[test]
672    fn resolves_registered_pid_to_context() -> TestResult {
673        let runtime = tokio::runtime::Runtime::new()?;
674        let registry = Registry::default();
675        let workflow_id = aion_core::WorkflowId::new_v4();
676        let run_id = aion_core::RunId::new_v4();
677        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
678        runtime.block_on(store.append(
679            WriteToken::recorder(),
680            &workflow_id,
681            &[started_event(&workflow_id, &run_id)?],
682            0,
683        ))?;
684        let handle = handle(44, Arc::clone(&store), workflow_id.clone(), run_id.clone());
685        registry.insert((workflow_id.clone(), run_id), handle)?;
686
687        let context = NifContext::new(44, &registry, runtime.handle().clone(), birth_wait())?;
688
689        assert_eq!(context.workflow_id(), &workflow_id);
690        assert_eq!(context.pid(), 44);
691        Ok(())
692    }
693
694    #[test]
695    fn unknown_pid_returns_unknown_process() -> TestResult {
696        let runtime = tokio::runtime::Runtime::new()?;
697        let registry = Registry::default();
698
699        let error = NifContext::new(77, &registry, runtime.handle().clone(), birth_wait())
700            .err()
701            .ok_or("expected unknown process error")?;
702
703        assert!(matches!(error, NifContextError::UnknownProcess { pid: 77 }));
704        Ok(())
705    }
706
707    /// F8 registration race: the start path spawns the workflow process and
708    /// only then inserts its registry handle, so a workflow's first NIF call
709    /// can run before the handle exists. Context resolution must wait out
710    /// that birth window instead of failing typed — the SDK and fixtures
711    /// treat a context failure as fatal, so before the fix the workflow died
712    /// at startup with `{badmatch, {error, <<"unknown_process:N">>}}` (this
713    /// test then failed with `UnknownProcess`).
714    #[test]
715    fn birth_window_registration_resolves_instead_of_failing() -> TestResult {
716        let runtime = tokio::runtime::Runtime::new()?;
717        let registry = Arc::new(Registry::default());
718        let workflow_id = aion_core::WorkflowId::new_v4();
719        let run_id = aion_core::RunId::new_v4();
720        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
721        runtime.block_on(store.append(
722            WriteToken::recorder(),
723            &workflow_id,
724            &[started_event(&workflow_id, &run_id)?],
725            0,
726        ))?;
727        let handle = handle(91, Arc::clone(&store), workflow_id.clone(), run_id.clone());
728
729        // The "start thread": inserts the registry handle a beat after the
730        // workflow's first NIF call began resolving its context.
731        let late_registry = Arc::clone(&registry);
732        let inserter = std::thread::spawn(move || {
733            std::thread::sleep(std::time::Duration::from_millis(30));
734            late_registry.insert((workflow_id.clone(), run_id), handle)
735        });
736
737        let context = NifContext::new(91, &registry, runtime.handle().clone(), birth_wait())?;
738
739        assert_eq!(context.pid(), 91);
740        inserter
741            .join()
742            .map_err(|_| "registry insert thread panicked")??;
743        Ok(())
744    }
745
746    #[test]
747    fn block_on_recorder_reads_current_head_without_deadlock() -> TestResult {
748        let runtime = tokio::runtime::Runtime::new()?;
749        let registry = Registry::default();
750        let workflow_id = aion_core::WorkflowId::new_v4();
751        let run_id = aion_core::RunId::new_v4();
752        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
753        runtime.block_on(store.append(
754            WriteToken::recorder(),
755            &workflow_id,
756            &[started_event(&workflow_id, &run_id)?],
757            0,
758        ))?;
759        let recorder = Recorder::resume_at(workflow_id.clone(), Arc::clone(&store), 5);
760        let handle = WorkflowHandle::new(WorkflowHandleParts {
761            workflow_id: workflow_id.clone(),
762            run_id: run_id.clone(),
763            pid: 55,
764            workflow_type: "checkout".to_owned(),
765            namespace: String::from("default"),
766            loaded_version: hash(),
767            cached_status: WorkflowStatus::Running,
768            residency: HandleResidency::Resident,
769            recorder,
770            completion: CompletionNotifier::new(),
771        });
772        registry.insert((workflow_id, run_id), handle)?;
773        let context = NifContext::new(55, &registry, runtime.handle().clone(), birth_wait())?;
774
775        let head = context
776            .block_on_recorder(|recorder| Box::pin(async move { Ok(recorder.current_head()) }))?;
777
778        assert_eq!(head, 5);
779        Ok(())
780    }
781
782    /// #144: the context projects the workflow's recorded start-time task queue
783    /// from the `aion.task_queue` search attribute the server stamped in history.
784    #[test]
785    fn context_reads_the_recorded_start_time_task_queue() -> TestResult {
786        let runtime = tokio::runtime::Runtime::new()?;
787        let workflow_id = aion_core::WorkflowId::new_v4();
788        let history = vec![Event::SearchAttributesUpdated {
789            envelope: envelope(&workflow_id, 2)?,
790            workflow_id: workflow_id.clone(),
791            attributes: std::collections::HashMap::from([(
792                aion_core::START_TIME_TASK_QUEUE_ATTRIBUTE.to_owned(),
793                aion_core::SearchAttributeValue::String(String::from("started-on")),
794            )]),
795        }];
796        let (registry, store, _handle) = context_with_history(&runtime, 70, workflow_id, &history)?;
797        let context = NifContext::new_with_history_store(
798            70,
799            &registry,
800            runtime.handle().clone(),
801            Some(store),
802            birth_wait(),
803        )?;
804
805        assert_eq!(
806            context.start_time_task_queue().as_deref(),
807            Some("started-on")
808        );
809        Ok(())
810    }
811
812    /// #144 back-compat: a history with no recorded start-time queue (a legacy
813    /// start, or a start that selected none) projects `None`, so the
814    /// activity-queue resolution falls back to the named default — no panic.
815    #[test]
816    fn context_without_start_time_attribute_projects_none() -> TestResult {
817        let runtime = tokio::runtime::Runtime::new()?;
818        let workflow_id = aion_core::WorkflowId::new_v4();
819        // Only WorkflowStarted (seeded by context_with_history); no attribute.
820        let (registry, store, _handle) = context_with_history(&runtime, 71, workflow_id, &[])?;
821        let context = NifContext::new_with_history_store(
822            71,
823            &registry,
824            runtime.handle().clone(),
825            Some(store),
826            birth_wait(),
827        )?;
828
829        assert_eq!(context.start_time_task_queue(), None);
830        Ok(())
831    }
832
833    #[test]
834    fn resolve_command_returns_recorded_activity_resolution() -> TestResult {
835        let runtime = tokio::runtime::Runtime::new()?;
836        let workflow_id = aion_core::WorkflowId::new_v4();
837        let result = payload("activity-result")?;
838        let history = vec![
839            Event::ActivityScheduled {
840                envelope: envelope(&workflow_id, 2)?,
841                activity_id: ActivityId::from_sequence_position(0),
842                activity_type: "activity".to_owned(),
843                input: payload("activity-input")?,
844                task_queue: String::from("default"),
845                node: None,
846            },
847            Event::ActivityCompleted {
848                envelope: envelope(&workflow_id, 3)?,
849                activity_id: ActivityId::from_sequence_position(0),
850                result: result.clone(),
851                attempt: 1,
852            },
853        ];
854        let (registry, store, handle) = context_with_history(&runtime, 66, workflow_id, &history)?;
855        let mut context = NifContext::new_with_history_store(
856            66,
857            &registry,
858            runtime.handle().clone(),
859            Some(store),
860            birth_wait(),
861        )?;
862
863        assert_eq!(context.workflow_id(), handle.workflow_id());
864        assert_eq!(
865            context.resolve_command(Command::RunActivity {
866                key: CorrelationKey::Activity(0),
867                activity_type: "activity".to_owned(),
868                input: payload("activity-input")?,
869            })?,
870            ResolveOutcome::Recorded(Resolution::ActivityCompleted(result))
871        );
872        Ok(())
873    }
874
875    fn child_history(
876        workflow_id: &aion_core::WorkflowId,
877        child_workflow_id: &aion_core::WorkflowId,
878        include_terminal: bool,
879    ) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
880        let timer_id = aion_core::TimerId::anonymous(0);
881        let mut history = vec![
882            Event::ActivityScheduled {
883                envelope: envelope(workflow_id, 2)?,
884                activity_id: ActivityId::from_sequence_position(0),
885                activity_type: "activity".to_owned(),
886                input: payload("activity-input")?,
887                task_queue: String::from("default"),
888                node: None,
889            },
890            Event::ActivityCompleted {
891                envelope: envelope(workflow_id, 3)?,
892                activity_id: ActivityId::from_sequence_position(0),
893                result: payload("activity-result")?,
894                attempt: 1,
895            },
896            Event::TimerStarted {
897                envelope: envelope(workflow_id, 4)?,
898                timer_id: timer_id.clone(),
899                fire_at: Utc
900                    .timestamp_opt(99, 0)
901                    .single()
902                    .ok_or_else(|| "invalid timestamp".to_owned())?,
903            },
904            Event::TimerFired {
905                envelope: envelope(workflow_id, 5)?,
906                timer_id,
907            },
908            Event::ChildWorkflowStarted {
909                envelope: envelope(workflow_id, 6)?,
910                child_workflow_id: child_workflow_id.clone(),
911                workflow_type: "child".to_owned(),
912                input: payload("child-input")?,
913                package_version: aion_core::PackageVersion::new("a".repeat(64)),
914            },
915        ];
916        if include_terminal {
917            history.push(Event::ChildWorkflowCompleted {
918                envelope: envelope(workflow_id, 7)?,
919                child_workflow_id: child_workflow_id.clone(),
920                result: payload("child-result")?,
921            });
922        }
923        Ok(history)
924    }
925
926    #[test]
927    fn await_child_skips_consumed_commands_to_recorded_terminal() -> TestResult {
928        let runtime = tokio::runtime::Runtime::new()?;
929        let workflow_id = aion_core::WorkflowId::new_v4();
930        let child_workflow_id = aion_core::WorkflowId::new_v4();
931        // Activity, timer, and spawn history all precede the awaited child's
932        // terminal: each per-NIF resolver starts at the top of history, so
933        // AwaitChild must skip those consumed commands instead of reporting
934        // a false non-determinism mismatch on the first matchable event.
935        let history = child_history(&workflow_id, &child_workflow_id, true)?;
936        let (registry, store, _handle) = context_with_history(&runtime, 88, workflow_id, &history)?;
937        let mut context = NifContext::new_with_history_store(
938            88,
939            &registry,
940            runtime.handle().clone(),
941            Some(store),
942            birth_wait(),
943        )?;
944
945        assert_eq!(
946            context.resolve_command(Command::AwaitChild {
947                child_workflow_id: child_workflow_id.clone(),
948            })?,
949            ResolveOutcome::Recorded(Resolution::ChildCompleted(payload("child-result")?))
950        );
951        Ok(())
952    }
953
954    #[test]
955    fn await_child_without_recorded_terminal_resumes_live() -> TestResult {
956        let runtime = tokio::runtime::Runtime::new()?;
957        let workflow_id = aion_core::WorkflowId::new_v4();
958        let child_workflow_id = aion_core::WorkflowId::new_v4();
959        // History ends after ChildWorkflowStarted (crash mid-child): the
960        // await must hand off to live execution for the same child instead
961        // of mismatching on the recorded start event.
962        let history = child_history(&workflow_id, &child_workflow_id, false)?;
963        let (registry, store, _handle) = context_with_history(&runtime, 89, workflow_id, &history)?;
964        let mut context = NifContext::new_with_history_store(
965            89,
966            &registry,
967            runtime.handle().clone(),
968            Some(store),
969            birth_wait(),
970        )?;
971
972        assert_eq!(
973            context.resolve_command(Command::AwaitChild { child_workflow_id })?,
974            ResolveOutcome::ResumeLive
975        );
976        Ok(())
977    }
978}