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