Skip to main content

aion_server/worker/
bridge.rs

1//! NIF bridge dispatcher that routes `run_activity` calls to connected workers.
2//!
3//! `WorkerActivityDispatcher` implements `aion::ActivityDispatcher` so the
4//! engine's activity NIFs can synchronously dispatch to a remote worker and
5//! block until the result comes back.
6//!
7//! # Threading contract
8//!
9//! The engine invokes [`aion::ActivityDispatcher::dispatch`] from two kinds of
10//! threads: beamr scheduler threads (concurrency combinators) and spawned
11//! tokio tasks (the two-phase `dispatch_activity` completion task). The task
12//! send uses `try_send()` (non-blocking channel push) and the response wait
13//! blocks on `std::sync::mpsc::Receiver::recv`.
14//!
15//! Blocking is harmless on a beamr thread, but on a tokio runtime worker it
16//! must be wrapped in `tokio::task::block_in_place`: the `try_send` wakes the
17//! per-worker gRPC stream forwarder task, and tokio schedules a task woken
18//! from task context into the *current* worker's LIFO slot, which no other
19//! runtime worker can steal. Without the `block_in_place` core handoff the
20//! forwarder sits trapped in that slot while this thread blocks, so the queued
21//! `ActivityTask` is never flushed to the worker even though the worker is
22//! healthy. `block_in_place` moves the worker's scheduler core (LIFO slot
23//! included) to another thread before the wait begins, so dispatch-to-delivery
24//! stays in the millisecond range and the runtime keeps full parallelism.
25//!
26//! # Wait termination
27//!
28//! The engine imposes no activity timeout of its own: agent-style activities
29//! legitimately run for over an hour, so the completion wait is unbounded.
30//! The blocking `recv` terminates on exactly one of:
31//!
32//! - **Completion** — the worker reports a result and the stream handler
33//!   delivers it through [`ActivityCompletionSink::complete_activity`].
34//! - **Worker loss** — the worker's gRPC stream ends (process death,
35//!   disconnect, expired token); the stream teardown sweeps the worker's
36//!   in-flight tasks through the same sink as retryable lost-worker
37//!   failures ([`HeartbeatTracker::fail_disconnected_worker`]).
38//! - **Drain timeout at shutdown** — the shutdown coordinator fails all
39//!   remaining in-flight tasks through the sink
40//!   (`HeartbeatTracker::fail_all_in_flight_workers`).
41//! - **Channel teardown** — every sender for the pending entry is dropped
42//!   (a cleanup path removed the entry without completing it); surfaced as
43//!   a channel-closed dispatch error, never a hang.
44//!
45//! An activity's duration is bounded only by the workflow's own
46//! `timeout_seconds` and by worker liveness — never by an engine constant.
47
48use std::sync::Arc;
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::time::{Duration, Instant};
51
52use aion::ActivityDispatcher;
53use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
54use aion_proto::{ProtoActivityId, ProtoActivityTask, ProtoPayload, ProtoWorkflowId};
55use dashmap::DashMap;
56
57use super::dispatch::{ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink};
58use super::heartbeat::{HeartbeatTracker, InFlightActivity};
59use super::registry::{ConnectedWorkerRegistry, WorkerHandle, WorkerId, WorkerMessage};
60use crate::error::ServerError;
61use crate::shutdown::DrainState;
62use tracing::info_span;
63
64type SyncSender = std::sync::mpsc::SyncSender<Result<String, String>>;
65type SyncReceiver = std::sync::mpsc::Receiver<Result<String, String>>;
66
67/// Execution-scoped key for an in-flight activity dispatch.
68///
69/// Keying by bare [`ActivityId`] is unsafe across server restarts: the
70/// dispatcher fabricates activity ids from a process-local counter
71/// ([`WorkerActivityDispatcher::dispatch_blocking`]) that resets on restart,
72/// so a stale result re-reported from a worker's previous session would
73/// complete a *different* post-restart dispatch reusing the same sequence
74/// position. The wire (`ActivityResult`) already carries both ids, and the
75/// dispatcher fabricates a fresh `WorkflowId::new_v4()` per dispatch, so the
76/// pair is collision-safe across restarts — a v4 uuid from the old server
77/// life can never equal a fresh one.
78///
79/// The wire now carries an attempt discriminator (`ActivityTask.attempt`,
80/// stamped from the engine-seam dispatch parameter), but the pending key
81/// stays attempt-free: the dispatcher fabricates fresh ids per dispatch, so
82/// two attempts of one logical activity are distinct `(workflow_id,
83/// activity_id)` pairs here. When the engine passes *real* workflow ids,
84/// redelivery bookkeeping can widen this key with the attempt it already has
85/// on the wire — no further protocol change needed.
86type PendingActivityKey = (WorkflowId, ActivityId);
87
88/// Tracks in-flight activity dispatches waiting for worker results.
89///
90/// When the server's worker stream handler receives an `ActivityResult`, it
91/// calls [`complete_activity`](ActivityCompletionSink::complete_activity) to
92/// deliver the result to the blocked NIF thread. Entries are keyed by
93/// [`PendingActivityKey`] so a stale result from a previous server life can
94/// never be matched to a different execution (#59).
95#[derive(Clone, Debug, Default)]
96pub struct PendingActivities {
97    pending: Arc<DashMap<PendingActivityKey, SyncSender>>,
98}
99
100impl PendingActivities {
101    fn insert(&self, workflow_id: WorkflowId, activity_id: ActivityId) -> SyncReceiver {
102        let (tx, rx) = std::sync::mpsc::sync_channel(1);
103        self.pending.insert((workflow_id, activity_id), tx);
104        rx
105    }
106
107    fn complete(&self, key: &PendingActivityKey, result: Result<String, String>) -> bool {
108        if let Some((_, sender)) = self.pending.remove(key) {
109            sender.send(result).is_ok()
110        } else {
111            false
112        }
113    }
114}
115
116impl ActivityCompletionSink for PendingActivities {
117    fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
118        let result = match completion.outcome {
119            ActivityCompletionOutcome::Succeeded(payload) => {
120                payload_to_string(&payload).map_err(|reason| {
121                    tracing::error!(
122                        operation = "activity_complete",
123                        workflow_id = %completion.workflow_id,
124                        activity_id = %completion.activity_id,
125                        error_type = "ActivityResultDecode",
126                        %reason,
127                        "activity completion failed"
128                    );
129                    ServerError::worker_dispatch("", "", format!("payload decode: {reason}"))
130                })?
131            }
132            ActivityCompletionOutcome::Failed(error) => {
133                let prefix = if error.is_retryable() {
134                    "retryable"
135                } else {
136                    "terminal"
137                };
138                tracing::error!(
139                    operation = "activity_complete",
140                    workflow_id = %completion.workflow_id,
141                    activity_id = %completion.activity_id,
142                    error_type = "ActivityFailed",
143                    error_kind = prefix,
144                    reason = %error.message,
145                    "activity completion failed"
146                );
147                Err(format!("{prefix}:{}", error.message))
148            }
149        };
150        self.complete(&(completion.workflow_id, completion.activity_id), result);
151        Ok(())
152    }
153}
154
155fn payload_to_string(payload: &Payload) -> Result<Result<String, String>, String> {
156    match payload.content_type() {
157        ContentType::Json => String::from_utf8(payload.bytes().to_vec())
158            .map(Ok)
159            .map_err(|_| "activity result payload is not valid UTF-8".to_owned()),
160    }
161}
162
163/// Dispatcher that routes `run_activity` NIF calls to connected workers.
164///
165/// Synchronous interface — uses `try_send` for the task channel and
166/// `std::sync::mpsc::Receiver::recv` for the response. Callers on a
167/// multi-thread tokio runtime are detected and moved into
168/// `tokio::task::block_in_place` so the blocking wait never starves the
169/// runtime tasks that flush the worker stream (see the module docs).
170pub struct WorkerActivityDispatcher {
171    registry: ConnectedWorkerRegistry,
172    namespace: String,
173    pending: PendingActivities,
174    heartbeat_tracker: HeartbeatTracker,
175    drain_state: DrainState,
176    next_id: AtomicU64,
177    workflow_registry: Option<Arc<aion::Registry>>,
178    tokio_handle: Option<tokio::runtime::Handle>,
179}
180
181impl std::fmt::Debug for WorkerActivityDispatcher {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("WorkerActivityDispatcher")
184            .field("namespace", &self.namespace)
185            .finish_non_exhaustive()
186    }
187}
188
189impl WorkerActivityDispatcher {
190    /// Build a dispatcher for the given namespace, worker registry, and
191    /// liveness tracker.
192    ///
193    /// The tracker must be the same instance the worker stream handler and
194    /// shutdown coordinator share: the unbounded completion wait relies on
195    /// stream teardown sweeping this tracker's in-flight entries to fail
196    /// dispatches whose worker was lost.
197    #[must_use]
198    pub fn new(
199        registry: ConnectedWorkerRegistry,
200        namespace: impl Into<String>,
201        heartbeat_tracker: HeartbeatTracker,
202    ) -> Self {
203        Self {
204            registry,
205            namespace: namespace.into(),
206            pending: PendingActivities::default(),
207            heartbeat_tracker,
208            drain_state: DrainState::default(),
209            next_id: AtomicU64::new(1),
210            workflow_registry: None,
211            tokio_handle: None,
212        }
213    }
214
215    /// Share a caller-supplied pending-activities tracker.
216    #[must_use]
217    pub fn with_pending(mut self, pending: PendingActivities) -> Self {
218        self.pending = pending;
219        self
220    }
221
222    /// Share the server drain gate.
223    #[must_use]
224    pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
225        self.drain_state = drain_state;
226        self
227    }
228
229    /// Share the engine's active workflow registry for PID-to-handle correlation.
230    #[must_use]
231    pub fn with_workflow_registry(mut self, workflow_registry: Arc<aion::Registry>) -> Self {
232        self.workflow_registry = Some(workflow_registry);
233        self
234    }
235
236    /// Share the server runtime handle for sync history writes from dirty NIF threads.
237    #[must_use]
238    pub fn with_tokio_handle(mut self, tokio_handle: tokio::runtime::Handle) -> Self {
239        self.tokio_handle = Some(tokio_handle);
240        self
241    }
242}
243
244impl WorkerActivityDispatcher {
245    fn ensure_accepting(
246        &self,
247        activity_type: &str,
248        workflow_id: &WorkflowId,
249        activity_id: &ActivityId,
250        worker_id: Option<WorkerId>,
251    ) -> Result<(), String> {
252        self.drain_state
253            .ensure_accepting(&self.namespace, activity_type)
254            .map_err(|error| {
255                let reason = error.to_string();
256                log_worker_error(
257                    "WorkerDispatch",
258                    &self.namespace,
259                    activity_type,
260                    workflow_id,
261                    activity_id,
262                    worker_id,
263                    &reason,
264                );
265                reason
266            })
267    }
268
269    fn select_worker(
270        &self,
271        activity_type: &str,
272        workflow_id: &WorkflowId,
273        activity_id: &ActivityId,
274    ) -> Result<WorkerHandle, String> {
275        self.registry
276            .select_worker(&self.namespace, activity_type)
277            .map_err(|error| {
278                let reason = format!("registry error: {error}");
279                log_worker_error(
280                    "WorkerRegistry",
281                    &self.namespace,
282                    activity_type,
283                    workflow_id,
284                    activity_id,
285                    None,
286                    &reason,
287                );
288                reason
289            })?
290            .ok_or_else(|| {
291                let reason = format!(
292                    "no connected worker for activity type '{activity_type}' in namespace '{}'",
293                    self.namespace
294                );
295                log_worker_error(
296                    "WorkerUnavailable",
297                    &self.namespace,
298                    activity_type,
299                    workflow_id,
300                    activity_id,
301                    None,
302                    &reason,
303                );
304                reason
305            })
306    }
307
308    fn track_worker_task(
309        &self,
310        worker_id: WorkerId,
311        activity_type: &str,
312        workflow_id: &WorkflowId,
313        activity_id: &ActivityId,
314    ) -> Result<(), String> {
315        self.heartbeat_tracker
316            .track_task(
317                worker_id,
318                InFlightActivity {
319                    workflow_id: workflow_id.clone(),
320                    activity_id: activity_id.clone(),
321                },
322                Instant::now(),
323            )
324            .map_err(|error| {
325                let reason = error.to_string();
326                log_worker_error(
327                    "WorkerHeartbeatTracker",
328                    &self.namespace,
329                    activity_type,
330                    workflow_id,
331                    activity_id,
332                    Some(worker_id),
333                    &reason,
334                );
335                reason
336            })
337    }
338
339    fn cleanup_activity(
340        &self,
341        worker_id: WorkerId,
342        workflow_id: &WorkflowId,
343        activity_id: &ActivityId,
344    ) {
345        self.pending
346            .pending
347            .remove(&(workflow_id.clone(), activity_id.clone()));
348        let _ = self
349            .heartbeat_tracker
350            .complete_task(worker_id, workflow_id, activity_id);
351        self.drain_state.notify_activity_drained();
352    }
353
354    fn send_activity_task(
355        &self,
356        worker: &WorkerHandle,
357        task: ProtoActivityTask,
358        activity_type: &str,
359        workflow_id: &WorkflowId,
360        activity_id: &ActivityId,
361    ) -> Result<(), String> {
362        match worker.sender().try_send(WorkerMessage::ActivityTask(task)) {
363            Ok(()) => Ok(()),
364            Err(error) => {
365                let worker_id = worker.id();
366                let reason = format!("worker task channel full or closed: {error}");
367                self.cleanup_activity(worker_id, workflow_id, activity_id);
368                log_worker_error(
369                    "WorkerChannelClosed",
370                    &self.namespace,
371                    activity_type,
372                    workflow_id,
373                    activity_id,
374                    Some(worker_id),
375                    &reason,
376                );
377                Err(reason)
378            }
379        }
380    }
381
382    /// Block until the dispatch terminates (see the module docs for the
383    /// exhaustive termination list). The wait is deliberately unbounded:
384    /// the engine imposes no activity timeout of its own.
385    fn await_activity_result(
386        &self,
387        context: &ActivityDispatchContext<'_>,
388        rx: &SyncReceiver,
389    ) -> Result<String, String> {
390        // Close the dispatch/disconnect race before blocking. A worker whose
391        // stream tore down *before* this dispatch tracked its task was swept
392        // without this entry, so nothing would ever deliver through `rx`.
393        // `fail_lost_worker` deregisters before it collects tasks, and this
394        // dispatch tracked its task before sending, so: if the worker is
395        // still registered here, any later sweep is guaranteed to include
396        // this task and unblock the `recv` below.
397        match self.registry.is_registered(context.worker_id) {
398            Ok(true) => {}
399            Ok(false) => {
400                // A sweep that did include this task may have delivered
401                // already; prefer its verdict (or a genuine result that
402                // raced the disconnect) over fabricating one.
403                if let Ok(result) = rx.try_recv() {
404                    return self.deliver_result(context, result);
405                }
406                self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
407                let reason = format!(
408                    "retryable:{}",
409                    super::dispatch::lost_worker_error(context.worker_id).message
410                );
411                log_worker_error(
412                    "WorkerLost",
413                    &self.namespace,
414                    context.activity_type,
415                    context.workflow_id,
416                    context.activity_id,
417                    Some(context.worker_id),
418                    &reason,
419                );
420                return Err(reason);
421            }
422            Err(error) => {
423                self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
424                let reason = format!("worker registry inspection failed: {error}");
425                log_worker_error(
426                    "WorkerRegistry",
427                    &self.namespace,
428                    context.activity_type,
429                    context.workflow_id,
430                    context.activity_id,
431                    Some(context.worker_id),
432                    &reason,
433                );
434                return Err(reason);
435            }
436        }
437        if let Ok(result) = rx.recv() {
438            return self.deliver_result(context, result);
439        }
440        // Every sender was dropped without completing: a cleanup path
441        // removed the pending entry. Surface it instead of hanging.
442        self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
443        let reason = "activity response channel dropped".to_owned();
444        log_worker_error(
445            "WorkerChannelClosed",
446            &self.namespace,
447            context.activity_type,
448            context.workflow_id,
449            context.activity_id,
450            Some(context.worker_id),
451            &reason,
452        );
453        Err(reason)
454    }
455
456    fn deliver_result(
457        &self,
458        context: &ActivityDispatchContext<'_>,
459        result: Result<String, String>,
460    ) -> Result<String, String> {
461        self.pending
462            .pending
463            .remove(&(context.workflow_id.clone(), context.activity_id.clone()));
464        log_activity_completion(context, result.is_ok());
465        result.inspect_err(|reason| {
466            log_worker_error(
467                "ActivityFailed",
468                &self.namespace,
469                context.activity_type,
470                context.workflow_id,
471                context.activity_id,
472                Some(context.worker_id),
473                reason,
474            );
475        })
476    }
477}
478
479impl ActivityDispatcher for WorkerActivityDispatcher {
480    fn dispatch(
481        &self,
482        name: &str,
483        input: &str,
484        config: &str,
485        attempt: u32,
486    ) -> Result<String, String> {
487        match tokio::runtime::Handle::try_current() {
488            Ok(handle) => match handle.runtime_flavor() {
489                tokio::runtime::RuntimeFlavor::MultiThread => {
490                    // We are inside a tokio runtime (the engine spawns the
491                    // sync dispatch onto its handle). Hand this worker's
492                    // scheduler core to another thread before blocking so the
493                    // stream forwarder woken by our `try_send` can actually
494                    // run — otherwise it is trapped in this worker's
495                    // non-stealable LIFO slot for as long as we block.
496                    tokio::task::block_in_place(|| {
497                        self.dispatch_blocking(name, input, config, attempt)
498                    })
499                }
500                flavor => Err(format!(
501                    "activity dispatch blocks the calling thread until the worker responds; \
502                     a {flavor:?} tokio runtime cannot host that wait because the worker \
503                     stream forwarder shares its only executor thread and the task could \
504                     never be delivered — run the engine on a multi-thread tokio runtime"
505                )),
506            },
507            // No tokio context: a beamr scheduler thread or other plain OS
508            // thread. Blocking here is the designed contract and cannot starve
509            // the server runtime.
510            Err(_) => self.dispatch_blocking(name, input, config, attempt),
511        }
512    }
513}
514
515impl WorkerActivityDispatcher {
516    /// Dispatch the activity and block the calling thread until the worker
517    /// responds, the worker is declared lost, or the server drains (see the
518    /// module docs for the exhaustive termination list).
519    ///
520    /// Must never run while the calling thread still owns a tokio scheduler
521    /// core: the response can only arrive after the runtime's stream
522    /// forwarder flushes the queued [`WorkerMessage::ActivityTask`] to the
523    /// worker, so the thread blocking here must not be the one responsible
524    /// for polling that forwarder. [`ActivityDispatcher::dispatch`] enforces
525    /// this with `tokio::task::block_in_place`.
526    fn dispatch_blocking(
527        &self,
528        name: &str,
529        input: &str,
530        config: &str,
531        attempt: u32,
532    ) -> Result<String, String> {
533        let _ = config;
534        let started_at = Instant::now();
535        let sequence = self.next_id.fetch_add(1, Ordering::Relaxed);
536        let activity_id = ActivityId::from_sequence_position(sequence);
537        let workflow_id = WorkflowId::new_v4();
538        self.ensure_accepting(name, &workflow_id, &activity_id, None)?;
539        let worker = self.select_worker(name, &workflow_id, &activity_id)?;
540        let worker_id = worker.id();
541        let span = info_span!(
542            "activity_dispatch",
543            operation = "activity_dispatch",
544            namespace = %self.namespace,
545            workflow_id = %workflow_id,
546            activity_id = %activity_id,
547            activity_type = %name,
548            worker_id = ?worker_id,
549        );
550        let _span_guard = span.enter();
551        self.ensure_accepting(name, &workflow_id, &activity_id, Some(worker_id))?;
552
553        let task = activity_task(name, input, &workflow_id, &activity_id, attempt);
554        let rx = self
555            .pending
556            .insert(workflow_id.clone(), activity_id.clone());
557        self.track_worker_task(worker_id, name, &workflow_id, &activity_id)?;
558        self.send_activity_task(&worker, task, name, &workflow_id, &activity_id)?;
559        let context = ActivityDispatchContext {
560            namespace: &self.namespace,
561            activity_type: name,
562            worker_id,
563            workflow_id: &workflow_id,
564            activity_id: &activity_id,
565            started_at,
566        };
567        self.await_activity_result(&context, &rx)
568    }
569}
570
571struct ActivityDispatchContext<'a> {
572    namespace: &'a str,
573    activity_type: &'a str,
574    worker_id: WorkerId,
575    workflow_id: &'a WorkflowId,
576    activity_id: &'a ActivityId,
577    started_at: Instant,
578}
579
580fn activity_task(
581    activity_type: &str,
582    input: &str,
583    workflow_id: &WorkflowId,
584    activity_id: &ActivityId,
585    attempt: u32,
586) -> ProtoActivityTask {
587    ProtoActivityTask {
588        workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
589        activity_id: Some(ProtoActivityId::from(activity_id.clone())),
590        activity_type: activity_type.to_owned(),
591        input: Some(ProtoPayload {
592            content_type: String::from("application/json"),
593            bytes: input.as_bytes().to_vec(),
594        }),
595        attempt,
596    }
597}
598
599fn log_activity_completion(context: &ActivityDispatchContext<'_>, succeeded: bool) {
600    let duration_ms = duration_ms(context.started_at.elapsed());
601    tracing::info!(
602        operation = "activity_complete",
603        namespace = context.namespace,
604        workflow_id = %context.workflow_id,
605        activity_id = %context.activity_id,
606        activity_type = context.activity_type,
607        worker_id = ?context.worker_id,
608        duration_ms,
609        outcome = if succeeded { "succeeded" } else { "failed" },
610        "activity completed"
611    );
612}
613
614fn duration_ms(duration: Duration) -> u64 {
615    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
616}
617
618fn log_worker_error(
619    error_type: &'static str,
620    namespace: &str,
621    activity_type: &str,
622    workflow_id: &WorkflowId,
623    activity_id: &ActivityId,
624    worker_id: Option<super::registry::WorkerId>,
625    reason: &str,
626) {
627    tracing::error!(
628        operation = "activity_dispatch",
629        namespace,
630        workflow_id = %workflow_id,
631        activity_id = %activity_id,
632        activity_type,
633        worker_id = ?worker_id,
634        error_type,
635        reason,
636        "worker interaction failed"
637    );
638}
639
640#[cfg(test)]
641mod tests {
642    use aion_core::{ActivityError, ActivityErrorKind, ContentType, Payload};
643
644    use super::*;
645
646    fn activity_id(pos: u64) -> ActivityId {
647        ActivityId::from_sequence_position(pos)
648    }
649
650    #[test]
651    fn pending_insert_and_complete_delivers_result() {
652        let pending = PendingActivities::default();
653        let workflow_id = WorkflowId::new_v4();
654        let id = activity_id(1);
655        let rx = pending.insert(workflow_id.clone(), id.clone());
656
657        assert!(pending.complete(&(workflow_id, id), Ok("done".to_owned())));
658        assert_eq!(
659            rx.recv_timeout(Duration::from_millis(50)),
660            Ok(Ok("done".to_owned()))
661        );
662    }
663
664    #[test]
665    fn pending_complete_unknown_returns_false() {
666        let pending = PendingActivities::default();
667        assert!(!pending.complete(
668            &(WorkflowId::new_v4(), activity_id(99)),
669            Ok("orphan".to_owned())
670        ));
671    }
672
673    #[test]
674    fn completion_sink_routes_success() -> Result<(), ServerError> {
675        let pending = PendingActivities::default();
676        let workflow_id = WorkflowId::new_v4();
677        let id = activity_id(2);
678        let rx = pending.insert(workflow_id.clone(), id.clone());
679        let payload = Payload::new(ContentType::Json, br#"{"greeting":"hi"}"#.to_vec());
680
681        pending.complete_activity(ActivityCompletion {
682            workflow_id,
683            activity_id: id,
684            outcome: ActivityCompletionOutcome::Succeeded(payload),
685        })?;
686
687        let result = rx
688            .recv_timeout(Duration::from_millis(50))
689            .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
690        assert_eq!(result, Ok(r#"{"greeting":"hi"}"#.to_owned()));
691        Ok(())
692    }
693
694    #[test]
695    fn completion_sink_routes_retryable_error() -> Result<(), ServerError> {
696        let pending = PendingActivities::default();
697        let workflow_id = WorkflowId::new_v4();
698        let id = activity_id(3);
699        let rx = pending.insert(workflow_id.clone(), id.clone());
700
701        pending.complete_activity(ActivityCompletion {
702            workflow_id,
703            activity_id: id,
704            outcome: ActivityCompletionOutcome::Failed(ActivityError {
705                kind: ActivityErrorKind::Retryable,
706                message: "temporary".to_owned(),
707                details: None,
708            }),
709        })?;
710
711        let result = rx
712            .recv_timeout(Duration::from_millis(50))
713            .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
714        assert_eq!(result, Err("retryable:temporary".to_owned()));
715        Ok(())
716    }
717
718    /// Regression test (#59, brief D12): pending tracking must be keyed by
719    /// the full `(WorkflowId, ActivityId)` pair. The dispatcher fabricates
720    /// activity ids from a process-local counter that resets on server
721    /// restart, so a stale result re-reported from a worker's previous
722    /// session carries the same bare `ActivityId` as a fresh post-restart
723    /// dispatch. Under bare-`ActivityId` keying the stale result completed
724    /// the wrong execution; with pair keying it is dropped and the genuine
725    /// result still completes.
726    #[test]
727    fn stale_result_for_other_workflow_does_not_complete_pending_dispatch()
728    -> Result<(), ServerError> {
729        let pending = PendingActivities::default();
730        let post_restart_workflow = WorkflowId::new_v4();
731        let pre_restart_workflow = WorkflowId::new_v4();
732        // Counter resets to the same sequence position after restart.
733        let id = activity_id(1);
734        let rx = pending.insert(post_restart_workflow.clone(), id.clone());
735
736        // Stale pre-restart result: same activity id, different workflow.
737        pending.complete_activity(ActivityCompletion {
738            workflow_id: pre_restart_workflow,
739            activity_id: id.clone(),
740            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
741                ContentType::Json,
742                br#""stale""#.to_vec(),
743            )),
744        })?;
745        assert!(
746            rx.try_recv().is_err(),
747            "stale result for a different workflow must not complete this dispatch"
748        );
749
750        // The genuine result for the pending execution still completes.
751        pending.complete_activity(ActivityCompletion {
752            workflow_id: post_restart_workflow,
753            activity_id: id,
754            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
755                ContentType::Json,
756                br#""fresh""#.to_vec(),
757            )),
758        })?;
759        let result = rx
760            .recv_timeout(Duration::from_millis(50))
761            .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
762        assert_eq!(result, Ok(r#""fresh""#.to_owned()));
763        Ok(())
764    }
765
766    /// Liveness tracker for dispatcher unit tests; the window only matters
767    /// to expiry checks, which nothing in these tests drives.
768    fn test_tracker() -> HeartbeatTracker {
769        HeartbeatTracker::new(Duration::from_secs(5))
770    }
771
772    #[test]
773    fn dispatcher_returns_error_when_no_worker_registered() {
774        let registry = ConnectedWorkerRegistry::default();
775        let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker());
776
777        let result = dispatcher.dispatch("greet", "{}", "{}", 1);
778
779        assert!(result.is_err());
780        let err = result.err().unwrap_or_default();
781        assert!(
782            err.contains("no connected worker"),
783            "unexpected error: {err}"
784        );
785    }
786
787    /// Regression test for the production stall where every remote activity
788    /// timed out: the engine invoked the sync `dispatch` from inside a
789    /// spawned tokio task (`futures::future::lazy` polled on a runtime
790    /// worker), and the woken stream-consumer task landed in that blocked
791    /// worker's non-stealable LIFO slot, so the queued `ActivityTask` was
792    /// only delivered when the then-extant 30s dispatch timeout fired (the
793    /// dispatch wait is unbounded today; the stall would now be a hang).
794    ///
795    /// Mirrors the real wiring minus tonic: the real registry channel that
796    /// the gRPC stream forwarder drains, a worker task awaiting that channel
797    /// on the same runtime, completion through the production
798    /// `ActivityCompletionSink`, and the sync dispatch invoked from a
799    /// runtime worker task — the worst case the `block_in_place` guard in
800    /// `dispatch` defends against (the engine itself now routes through
801    /// `dispatch_async_from_process`, off the async workers).
802    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
803    async fn dispatch_inside_runtime_task_delivers_promptly_and_round_trips()
804    -> Result<(), Box<dyn std::error::Error>> {
805        let registry = ConnectedWorkerRegistry::default();
806        let pending = PendingActivities::default();
807        let (worker_tx, mut worker_rx) = tokio::sync::mpsc::channel(32);
808        let activity_types = [String::from("greet")];
809        let registration = registry.register("default", activity_types.iter(), worker_tx)?;
810
811        let sink = pending.clone();
812        let echo_worker = tokio::spawn(async move {
813            let Some(WorkerMessage::ActivityTask(task)) = worker_rx.recv().await else {
814                return Err("expected an activity task on the worker channel".to_owned());
815            };
816            let workflow_id = task
817                .workflow_id
818                .ok_or("task missing workflow id")
819                .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
820            let activity_id = task
821                .activity_id
822                .map(ActivityId::from)
823                .ok_or("task missing activity id")?;
824            sink.complete_activity(ActivityCompletion {
825                workflow_id,
826                activity_id,
827                outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
828                    ContentType::Json,
829                    br#"{"greeting":"hello"}"#.to_vec(),
830                )),
831            })
832            .map_err(|error| error.to_string())
833        });
834
835        let dispatcher = Arc::new(
836            WorkerActivityDispatcher::new(registry, "default", test_tracker())
837                .with_pending(pending),
838        );
839        let started = Instant::now();
840        // Invoke the sync dispatch inside the first poll of a spawned task:
841        // the worst-case calling context for the `block_in_place` guard.
842        let dispatch_task = tokio::spawn(futures::future::lazy(move |_| {
843            dispatcher.dispatch("greet", "{}", "{}", 1)
844        }));
845        let result = dispatch_task.await.map_err(|error| error.to_string())?;
846        let elapsed = started.elapsed();
847
848        assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
849        assert!(
850            elapsed < Duration::from_secs(5),
851            "dispatch round trip took {elapsed:?}; task delivery must not \
852             depend on the blocked dispatch thread"
853        );
854        echo_worker.await.map_err(|error| error.to_string())??;
855        registration.deregister()?;
856        Ok(())
857    }
858
859    /// A current-thread runtime cannot host the blocking wait (the stream
860    /// forwarder would share its only executor thread), so dispatch must
861    /// fail fast with a precise error instead of blocking forever.
862    #[tokio::test]
863    async fn dispatch_on_current_thread_runtime_fails_fast()
864    -> Result<(), Box<dyn std::error::Error>> {
865        let registry = ConnectedWorkerRegistry::default();
866        let (worker_tx, _worker_rx) = tokio::sync::mpsc::channel(32);
867        let activity_types = [String::from("greet")];
868        let registration = registry.register("default", activity_types.iter(), worker_tx)?;
869        let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker());
870
871        let started = Instant::now();
872        let result = dispatcher.dispatch("greet", "{}", "{}", 1);
873        let elapsed = started.elapsed();
874
875        let err = result.err().ok_or("expected dispatch to fail")?;
876        assert!(
877            err.contains("multi-thread tokio runtime"),
878            "unexpected error: {err}"
879        );
880        assert!(
881            elapsed < Duration::from_secs(5),
882            "fail-fast path took {elapsed:?}"
883        );
884        registration.deregister()?;
885        Ok(())
886    }
887}