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