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::collections::BTreeMap;
49use std::sync::{Arc, OnceLock};
50use std::time::{Duration, Instant};
51
52use aion::{ActivityDispatch, ActivityDispatcher};
53use aion_core::{ActivityId, ContentType, Payload, RunId, 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/// The engine seam ([`ActivityDispatch`]) carries the *real* workflow id and
70/// the *real* per-workflow activity ordinal recorded in history, so this pair
71/// uniquely and stably identifies one execution. Keying by bare [`ActivityId`]
72/// would be unsafe across server restarts — a stale result re-reported from a
73/// worker's previous session could complete a *different* post-restart
74/// dispatch reusing the same ordinal — but pairing it with the real workflow
75/// id closes that race: two different workflow executions never share a
76/// workflow id, so a stale `(workflow_id, activity_id)` from a previous server
77/// life can only ever match the exact execution it belongs to.
78///
79/// The wire (`ActivityResult`) carries both ids, plus an attempt discriminator
80/// (`ActivityTask.attempt`). The pending key stays attempt-free for now: a
81/// retry re-dispatches under the same `(workflow_id, activity_id)` and the
82/// outstanding entry is the one awaiting completion. Redelivery bookkeeping
83/// can widen this key with the wire attempt later — no protocol change needed.
84type PendingActivityKey = (WorkflowId, ActivityId);
85
86/// Routes an unmatched durable-outbox completion into the live workflow.
87///
88/// When the outbox is ON a worker completion can arrive at the sink with no
89/// pending oneshot (the dispatch was non-blocking fan-out, or the original
90/// waiter was lost). Rather than dropping it, [`PendingActivities::complete`]
91/// hands it to this callback, which resolves the workflow to its live engine
92/// process and delivers the terminal into its mailbox. The callback is only
93/// installed when the outbox is enabled, so flag-off the unmatched branch
94/// stays a silent drop.
95pub trait OutboxDeliveryCallback: Send + Sync {
96    /// Deliver a successful completion to the live workflow.
97    ///
98    /// Returns `Ok(true)` when delivered to a live workflow and `Ok(false)`
99    /// when no run is currently live (the expected stale-completion case that
100    /// recovery re-arms).
101    ///
102    /// # Errors
103    ///
104    /// Returns [`ServerError`] when the engine rejects the delivery.
105    fn deliver_completion(
106        &self,
107        workflow_id: &WorkflowId,
108        activity_id: &ActivityId,
109        run_id: Option<&RunId>,
110        result: String,
111    ) -> Result<bool, ServerError>;
112
113    /// Deliver a failure to the live workflow. Same `bool`/error contract as
114    /// [`Self::deliver_completion`].
115    ///
116    /// # Errors
117    ///
118    /// Returns [`ServerError`] when the engine rejects the delivery.
119    fn deliver_failure(
120        &self,
121        workflow_id: &WorkflowId,
122        activity_id: &ActivityId,
123        run_id: Option<&RunId>,
124        reason: String,
125    ) -> Result<bool, ServerError>;
126}
127
128/// Tracks in-flight activity dispatches waiting for worker results.
129///
130/// When the server's worker stream handler receives an `ActivityResult`, it
131/// calls [`complete_activity`](ActivityCompletionSink::complete_activity) to
132/// deliver the result to the blocked NIF thread. Entries are keyed by
133/// [`PendingActivityKey`] so a stale result from a previous server life can
134/// never be matched to a different execution (#59).
135///
136/// Clones share both the pending map and the outbox-delivery callback through
137/// `Arc`, so [`set_outbox_delivery`](Self::set_outbox_delivery) called once on
138/// any clone after construction is visible to the clone the dispatcher holds.
139#[derive(Clone, Default)]
140pub struct PendingActivities {
141    pending: Arc<DashMap<PendingActivityKey, SyncSender>>,
142    outbox_delivery: Arc<OnceLock<Arc<dyn OutboxDeliveryCallback>>>,
143}
144
145impl std::fmt::Debug for PendingActivities {
146    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        formatter
148            .debug_struct("PendingActivities")
149            .field("pending", &self.pending.len())
150            .field(
151                "outbox_delivery_installed",
152                &self.outbox_delivery.get().is_some(),
153            )
154            .finish()
155    }
156}
157
158impl PendingActivities {
159    fn insert(&self, workflow_id: WorkflowId, activity_id: ActivityId) -> SyncReceiver {
160        let (tx, rx) = std::sync::mpsc::sync_channel(1);
161        self.pending.insert((workflow_id, activity_id), tx);
162        rx
163    }
164
165    /// Install the unmatched-completion delivery callback (idempotent).
166    ///
167    /// Set once, after construction, when the durable outbox is enabled. A
168    /// second set is ignored and logged: the callback is process-wide and must
169    /// not silently change identity.
170    pub fn set_outbox_delivery(&self, callback: Arc<dyn OutboxDeliveryCallback>) {
171        if self.outbox_delivery.set(callback).is_err() {
172            tracing::warn!("outbox delivery callback already installed; ignoring duplicate set");
173        }
174    }
175
176    /// Complete a pending dispatch, or route an unmatched completion to the
177    /// outbox delivery callback when one is installed.
178    ///
179    /// A matched entry delivers to its waiting oneshot exactly as before. An
180    /// unmatched completion is dropped silently when no callback is installed
181    /// (outbox OFF — byte-identical to the prior behaviour); with a callback
182    /// installed (outbox ON) it is routed into the live workflow's mailbox.
183    fn complete(
184        &self,
185        workflow_id: &WorkflowId,
186        activity_id: &ActivityId,
187        run_id: Option<&RunId>,
188        result: Result<String, String>,
189    ) -> bool {
190        // Take and drop the DashMap guard before any callback runs: the engine
191        // delivery the callback invokes must never execute under a shard lock.
192        let matched = self
193            .pending
194            .remove(&(workflow_id.clone(), activity_id.clone()));
195        if let Some((_, sender)) = matched {
196            return sender.send(result).is_ok();
197        }
198        let Some(callback) = self.outbox_delivery.get() else {
199            // Outbox OFF: silent drop, byte-identical to the prior behaviour.
200            return false;
201        };
202        let outcome = match result {
203            Ok(payload) => callback.deliver_completion(workflow_id, activity_id, run_id, payload),
204            Err(reason) => callback.deliver_failure(workflow_id, activity_id, run_id, reason),
205        };
206        match outcome {
207            Ok(true) => true,
208            Ok(false) => {
209                // Not live: the expected stale-completion case recovery re-arms.
210                tracing::debug!(
211                    workflow_id = %workflow_id,
212                    activity_id = %activity_id,
213                    "unmatched outbox completion for a workflow that is not currently live; \
214                     recovery will re-arm it"
215                );
216                false
217            }
218            Err(error) => {
219                tracing::warn!(
220                    workflow_id = %workflow_id,
221                    activity_id = %activity_id,
222                    %error,
223                    "failed to deliver unmatched outbox completion to the live workflow"
224                );
225                false
226            }
227        }
228    }
229}
230
231impl ActivityCompletionSink for PendingActivities {
232    fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
233        let result = match completion.outcome {
234            ActivityCompletionOutcome::Succeeded(payload) => {
235                payload_to_string(&payload).map_err(|reason| {
236                    tracing::error!(
237                        operation = "activity_complete",
238                        workflow_id = %completion.workflow_id,
239                        activity_id = %completion.activity_id,
240                        error_type = "ActivityResultDecode",
241                        %reason,
242                        "activity completion failed"
243                    );
244                    ServerError::worker_dispatch("", "", format!("payload decode: {reason}"))
245                })?
246            }
247            ActivityCompletionOutcome::Failed(error) => {
248                let prefix = if error.is_retryable() {
249                    "retryable"
250                } else {
251                    "terminal"
252                };
253                tracing::error!(
254                    operation = "activity_complete",
255                    workflow_id = %completion.workflow_id,
256                    activity_id = %completion.activity_id,
257                    error_type = "ActivityFailed",
258                    error_kind = prefix,
259                    reason = %error.message,
260                    "activity completion failed"
261                );
262                Err(format!("{prefix}:{}", error.message))
263            }
264        };
265        self.complete(
266            &completion.workflow_id,
267            &completion.activity_id,
268            completion.run_id.as_ref(),
269            result,
270        );
271        Ok(())
272    }
273}
274
275fn payload_to_string(payload: &Payload) -> Result<Result<String, String>, String> {
276    match payload.content_type() {
277        ContentType::Json => String::from_utf8(payload.bytes().to_vec())
278            .map(Ok)
279            .map_err(|_| "activity result payload is not valid UTF-8".to_owned()),
280    }
281}
282
283/// Dispatcher that routes `run_activity` NIF calls to connected workers.
284///
285/// Synchronous interface — uses `try_send` for the task channel and
286/// `std::sync::mpsc::Receiver::recv` for the response. Callers on a
287/// multi-thread tokio runtime are detected and moved into
288/// `tokio::task::block_in_place` so the blocking wait never starves the
289/// runtime tasks that flush the worker stream (see the module docs).
290pub struct WorkerActivityDispatcher {
291    registry: ConnectedWorkerRegistry,
292    namespace: String,
293    pending: PendingActivities,
294    heartbeat_tracker: HeartbeatTracker,
295    drain_state: DrainState,
296    tokio_handle: Option<tokio::runtime::Handle>,
297}
298
299impl std::fmt::Debug for WorkerActivityDispatcher {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        f.debug_struct("WorkerActivityDispatcher")
302            .field("namespace", &self.namespace)
303            .finish_non_exhaustive()
304    }
305}
306
307impl WorkerActivityDispatcher {
308    /// Build a dispatcher for the given namespace, worker registry, and
309    /// liveness tracker.
310    ///
311    /// The tracker must be the same instance the worker stream handler and
312    /// shutdown coordinator share: the unbounded completion wait relies on
313    /// stream teardown sweeping this tracker's in-flight entries to fail
314    /// dispatches whose worker was lost.
315    #[must_use]
316    pub fn new(
317        registry: ConnectedWorkerRegistry,
318        namespace: impl Into<String>,
319        heartbeat_tracker: HeartbeatTracker,
320    ) -> Self {
321        Self {
322            registry,
323            namespace: namespace.into(),
324            pending: PendingActivities::default(),
325            heartbeat_tracker,
326            drain_state: DrainState::default(),
327            tokio_handle: None,
328        }
329    }
330
331    /// Share a caller-supplied pending-activities tracker.
332    #[must_use]
333    pub fn with_pending(mut self, pending: PendingActivities) -> Self {
334        self.pending = pending;
335        self
336    }
337
338    /// Share the server drain gate.
339    #[must_use]
340    pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
341        self.drain_state = drain_state;
342        self
343    }
344
345    /// Share the server runtime handle for sync history writes from dirty NIF threads.
346    #[must_use]
347    pub fn with_tokio_handle(mut self, tokio_handle: tokio::runtime::Handle) -> Self {
348        self.tokio_handle = Some(tokio_handle);
349        self
350    }
351}
352
353impl WorkerActivityDispatcher {
354    fn ensure_accepting(
355        &self,
356        namespace: &str,
357        activity_type: &str,
358        workflow_id: &WorkflowId,
359        activity_id: &ActivityId,
360        worker_id: Option<WorkerId>,
361    ) -> Result<(), String> {
362        self.drain_state
363            .ensure_accepting(namespace, activity_type)
364            .map_err(|error| {
365                let reason = error.to_string();
366                log_worker_error(
367                    "WorkerDispatch",
368                    namespace,
369                    activity_type,
370                    workflow_id,
371                    activity_id,
372                    worker_id,
373                    &reason,
374                );
375                reason
376            })
377    }
378
379    /// Select a worker for the namespace and activity type, waiting if none is
380    /// currently available. Blocks until a matching worker registers or the
381    /// server begins draining.
382    fn select_worker_or_wait(
383        &self,
384        namespace: &str,
385        task_queue: &str,
386        activity_type: &str,
387        node: Option<&str>,
388        workflow_id: &WorkflowId,
389        activity_id: &ActivityId,
390    ) -> Result<WorkerHandle, String> {
391        loop {
392            // `node` is the OPTIONAL within-pool affinity carried on the
393            // dispatch: `Some(n)` pins selection to workers advertising node
394            // `n` (require semantics — it waits, via the no-worker path below,
395            // if none are present); `None` is unpinned and reaches any worker
396            // in the (namespace, task_queue) pool — byte-identical to the
397            // pre-NODE behaviour.
398            match self
399                .registry
400                .select_worker(namespace, task_queue, activity_type, node)
401            {
402                Ok(Some(worker)) => return Ok(worker),
403                Ok(None) => {
404                    self.ensure_accepting(
405                        namespace,
406                        activity_type,
407                        workflow_id,
408                        activity_id,
409                        None,
410                    )?;
411                    tracing::info!(
412                        namespace,
413                        activity_type,
414                        node,
415                        workflow_id = %workflow_id,
416                        activity_id = %activity_id,
417                        "no connected worker; waiting for a matching worker to register"
418                    );
419                    match &self.tokio_handle {
420                        Some(handle) => {
421                            handle.block_on(self.registry.wait_for_worker());
422                        }
423                        None => match tokio::runtime::Handle::try_current() {
424                            Ok(handle) => {
425                                handle.block_on(self.registry.wait_for_worker());
426                            }
427                            Err(_) => {
428                                std::thread::sleep(Duration::from_millis(500));
429                            }
430                        },
431                    }
432                }
433                Err(error) => {
434                    let reason = format!("registry error: {error}");
435                    log_worker_error(
436                        "WorkerRegistry",
437                        namespace,
438                        activity_type,
439                        workflow_id,
440                        activity_id,
441                        None,
442                        &reason,
443                    );
444                    return Err(reason);
445                }
446            }
447        }
448    }
449
450    fn track_worker_task(
451        &self,
452        worker_id: WorkerId,
453        activity_type: &str,
454        workflow_id: &WorkflowId,
455        activity_id: &ActivityId,
456    ) -> Result<(), String> {
457        self.heartbeat_tracker
458            .track_task(
459                worker_id,
460                InFlightActivity {
461                    workflow_id: workflow_id.clone(),
462                    activity_id: activity_id.clone(),
463                },
464                Instant::now(),
465            )
466            .map_err(|error| {
467                let reason = error.to_string();
468                log_worker_error(
469                    "WorkerHeartbeatTracker",
470                    &self.namespace,
471                    activity_type,
472                    workflow_id,
473                    activity_id,
474                    Some(worker_id),
475                    &reason,
476                );
477                reason
478            })
479    }
480
481    fn cleanup_activity(
482        &self,
483        worker_id: WorkerId,
484        workflow_id: &WorkflowId,
485        activity_id: &ActivityId,
486    ) {
487        self.pending
488            .pending
489            .remove(&(workflow_id.clone(), activity_id.clone()));
490        let _ = self
491            .heartbeat_tracker
492            .complete_task(worker_id, workflow_id, activity_id);
493        self.drain_state.notify_activity_drained();
494    }
495
496    fn send_activity_task(
497        &self,
498        worker: &WorkerHandle,
499        task: ProtoActivityTask,
500        activity_type: &str,
501        workflow_id: &WorkflowId,
502        activity_id: &ActivityId,
503    ) -> Result<(), String> {
504        let Some(sender) = worker.sender() else {
505            // The gRPC dispatch path only ever holds gRPC-delivery workers, so a
506            // missing stream sender means a worker on another transport leaked
507            // into this path — treat it as a closed channel and clean up.
508            let worker_id = worker.id();
509            self.cleanup_activity(worker_id, workflow_id, activity_id);
510            return Err(format!(
511                "worker {worker_id:?} has no gRPC stream sender (non-gRPC transport)"
512            ));
513        };
514        match sender.try_send(WorkerMessage::ActivityTask(task)) {
515            Ok(()) => Ok(()),
516            Err(error) => {
517                let worker_id = worker.id();
518                let reason = format!("worker task channel full or closed: {error}");
519                self.cleanup_activity(worker_id, workflow_id, activity_id);
520                log_worker_error(
521                    "WorkerChannelClosed",
522                    &self.namespace,
523                    activity_type,
524                    workflow_id,
525                    activity_id,
526                    Some(worker_id),
527                    &reason,
528                );
529                Err(reason)
530            }
531        }
532    }
533
534    /// Block until the dispatch terminates (see the module docs for the
535    /// exhaustive termination list). The wait is deliberately unbounded:
536    /// the engine imposes no activity timeout of its own.
537    fn await_activity_result(
538        &self,
539        context: &ActivityDispatchContext<'_>,
540        rx: &SyncReceiver,
541    ) -> Result<String, String> {
542        // Close the dispatch/disconnect race before blocking. A worker whose
543        // stream tore down *before* this dispatch tracked its task was swept
544        // without this entry, so nothing would ever deliver through `rx`.
545        // `fail_lost_worker` deregisters before it collects tasks, and this
546        // dispatch tracked its task before sending, so: if the worker is
547        // still registered here, any later sweep is guaranteed to include
548        // this task and unblock the `recv` below.
549        match self.registry.is_registered(context.worker_id) {
550            Ok(true) => {}
551            Ok(false) => {
552                // A sweep that did include this task may have delivered
553                // already; prefer its verdict (or a genuine result that
554                // raced the disconnect) over fabricating one.
555                if let Ok(result) = rx.try_recv() {
556                    return self.deliver_result(context, result);
557                }
558                self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
559                let reason = format!(
560                    "retryable:{}",
561                    super::dispatch::lost_worker_error(context.worker_id).message
562                );
563                log_worker_error(
564                    "WorkerLost",
565                    &self.namespace,
566                    context.activity_type,
567                    context.workflow_id,
568                    context.activity_id,
569                    Some(context.worker_id),
570                    &reason,
571                );
572                return Err(reason);
573            }
574            Err(error) => {
575                self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
576                let reason = format!("worker registry inspection failed: {error}");
577                log_worker_error(
578                    "WorkerRegistry",
579                    &self.namespace,
580                    context.activity_type,
581                    context.workflow_id,
582                    context.activity_id,
583                    Some(context.worker_id),
584                    &reason,
585                );
586                return Err(reason);
587            }
588        }
589        if let Ok(result) = rx.recv() {
590            return self.deliver_result(context, result);
591        }
592        // Every sender was dropped without completing: a cleanup path
593        // removed the pending entry. Surface it instead of hanging.
594        self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
595        let reason = "activity response channel dropped".to_owned();
596        log_worker_error(
597            "WorkerChannelClosed",
598            &self.namespace,
599            context.activity_type,
600            context.workflow_id,
601            context.activity_id,
602            Some(context.worker_id),
603            &reason,
604        );
605        Err(reason)
606    }
607
608    fn deliver_result(
609        &self,
610        context: &ActivityDispatchContext<'_>,
611        result: Result<String, String>,
612    ) -> Result<String, String> {
613        self.pending
614            .pending
615            .remove(&(context.workflow_id.clone(), context.activity_id.clone()));
616        log_activity_completion(context, result.is_ok());
617        result.inspect_err(|reason| {
618            log_worker_error(
619                "ActivityFailed",
620                &self.namespace,
621                context.activity_type,
622                context.workflow_id,
623                context.activity_id,
624                Some(context.worker_id),
625                reason,
626            );
627        })
628    }
629}
630
631impl ActivityDispatcher for WorkerActivityDispatcher {
632    fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
633        match tokio::runtime::Handle::try_current() {
634            Ok(handle) => match handle.runtime_flavor() {
635                tokio::runtime::RuntimeFlavor::MultiThread => {
636                    // We are inside a tokio runtime (the engine spawns the
637                    // sync dispatch onto its handle). Hand this worker's
638                    // scheduler core to another thread before blocking so the
639                    // stream forwarder woken by our `try_send` can actually
640                    // run — otherwise it is trapped in this worker's
641                    // non-stealable LIFO slot for as long as we block.
642                    tokio::task::block_in_place(|| self.dispatch_blocking(request))
643                }
644                flavor => Err(format!(
645                    "activity dispatch blocks the calling thread until the worker responds; \
646                     a {flavor:?} tokio runtime cannot host that wait because the worker \
647                     stream forwarder shares its only executor thread and the task could \
648                     never be delivered — run the engine on a multi-thread tokio runtime"
649                )),
650            },
651            // No tokio context: a beamr scheduler thread or other plain OS
652            // thread. Blocking here is the designed contract and cannot starve
653            // the server runtime.
654            Err(_) => self.dispatch_blocking(request),
655        }
656    }
657}
658
659impl WorkerActivityDispatcher {
660    /// Dispatch the activity and block the calling thread until the worker
661    /// responds, the worker is declared lost, or the server drains (see the
662    /// module docs for the exhaustive termination list).
663    ///
664    /// The request carries the *real* workflow and activity ids the engine
665    /// recorded in history, so the worker logs, the pending-completion key,
666    /// and the heartbeat tracker all correlate directly against the event
667    /// store. `config` is forwarded by the engine seam but not yet consumed
668    /// here (the retry executor that reads it is unbuilt).
669    ///
670    /// Must never run while the calling thread still owns a tokio scheduler
671    /// core: the response can only arrive after the runtime's stream
672    /// forwarder flushes the queued [`WorkerMessage::ActivityTask`] to the
673    /// worker, so the thread blocking here must not be the one responsible
674    /// for polling that forwarder. [`ActivityDispatcher::dispatch`] enforces
675    /// this with `tokio::task::block_in_place`.
676    fn dispatch_blocking(&self, request: ActivityDispatch) -> Result<String, String> {
677        let ActivityDispatch {
678            namespace,
679            task_queue,
680            // OPTIONAL within-pool node affinity (NODE-4): `Some(n)` pins this
681            // dispatch to workers advertising node `n` (require semantics);
682            // `None` is unpinned and reaches any worker in the pool.
683            node,
684            workflow_id,
685            activity_id,
686            name,
687            input,
688            config: _,
689            attempt,
690            labels,
691        } = request;
692        let started_at = Instant::now();
693        self.ensure_accepting(&namespace, &name, &workflow_id, &activity_id, None)?;
694        let worker = self.select_worker_or_wait(
695            &namespace,
696            &task_queue,
697            &name,
698            node.as_deref(),
699            &workflow_id,
700            &activity_id,
701        )?;
702        let worker_id = worker.id();
703        let span = info_span!(
704            "activity_dispatch",
705            operation = "activity_dispatch",
706            namespace = %namespace,
707            task_queue = %task_queue,
708            node = node.as_deref(),
709            workflow_id = %workflow_id,
710            activity_id = %activity_id,
711            activity_type = %name,
712            worker_id = ?worker_id,
713        );
714        let _span_guard = span.enter();
715        self.ensure_accepting(
716            &namespace,
717            &name,
718            &workflow_id,
719            &activity_id,
720            Some(worker_id),
721        )?;
722
723        let task = activity_task(&name, &input, &workflow_id, &activity_id, attempt, labels);
724        let rx = self
725            .pending
726            .insert(workflow_id.clone(), activity_id.clone());
727        self.track_worker_task(worker_id, &name, &workflow_id, &activity_id)?;
728        self.send_activity_task(&worker, task, &name, &workflow_id, &activity_id)?;
729        let context = ActivityDispatchContext {
730            namespace: &namespace,
731            activity_type: &name,
732            worker_id,
733            workflow_id: &workflow_id,
734            activity_id: &activity_id,
735            started_at,
736        };
737        self.await_activity_result(&context, &rx)
738    }
739}
740
741struct ActivityDispatchContext<'a> {
742    namespace: &'a str,
743    activity_type: &'a str,
744    worker_id: WorkerId,
745    workflow_id: &'a WorkflowId,
746    activity_id: &'a ActivityId,
747    started_at: Instant,
748}
749
750fn activity_task(
751    activity_type: &str,
752    input: &str,
753    workflow_id: &WorkflowId,
754    activity_id: &ActivityId,
755    attempt: u32,
756    labels: BTreeMap<String, String>,
757) -> ProtoActivityTask {
758    ProtoActivityTask {
759        workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
760        activity_id: Some(ProtoActivityId::from(activity_id.clone())),
761        activity_type: activity_type.to_owned(),
762        input: Some(ProtoPayload {
763            content_type: String::from("application/json"),
764            bytes: input.as_bytes().to_vec(),
765        }),
766        attempt,
767        labels: labels.into_iter().collect(),
768        // The synchronous `ActivityDispatch` bridge path carries no run context
769        // (run scoping is threaded through the durable-outbox path; OBX-011).
770        run_id: None,
771    }
772}
773
774fn log_activity_completion(context: &ActivityDispatchContext<'_>, succeeded: bool) {
775    let duration_ms = duration_ms(context.started_at.elapsed());
776    tracing::info!(
777        operation = "activity_complete",
778        namespace = context.namespace,
779        workflow_id = %context.workflow_id,
780        activity_id = %context.activity_id,
781        activity_type = context.activity_type,
782        worker_id = ?context.worker_id,
783        duration_ms,
784        outcome = if succeeded { "succeeded" } else { "failed" },
785        "activity completed"
786    );
787}
788
789fn duration_ms(duration: Duration) -> u64 {
790    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
791}
792
793fn log_worker_error(
794    error_type: &'static str,
795    namespace: &str,
796    activity_type: &str,
797    workflow_id: &WorkflowId,
798    activity_id: &ActivityId,
799    worker_id: Option<super::registry::WorkerId>,
800    reason: &str,
801) {
802    tracing::error!(
803        operation = "activity_dispatch",
804        namespace,
805        workflow_id = %workflow_id,
806        activity_id = %activity_id,
807        activity_type,
808        worker_id = ?worker_id,
809        error_type,
810        reason,
811        "worker interaction failed"
812    );
813}
814
815#[cfg(test)]
816mod tests {
817    use std::sync::Mutex;
818
819    use aion_core::{ActivityError, ActivityErrorKind, ContentType, Payload};
820
821    use super::*;
822
823    fn activity_id(pos: u64) -> ActivityId {
824        ActivityId::from_sequence_position(pos)
825    }
826
827    #[test]
828    fn pending_insert_and_complete_delivers_result() {
829        let pending = PendingActivities::default();
830        let workflow_id = WorkflowId::new_v4();
831        let id = activity_id(1);
832        let rx = pending.insert(workflow_id.clone(), id.clone());
833
834        assert!(pending.complete(&workflow_id, &id, None, Ok("done".to_owned())));
835        assert_eq!(
836            rx.recv_timeout(Duration::from_millis(50)),
837            Ok(Ok("done".to_owned()))
838        );
839    }
840
841    #[test]
842    fn pending_complete_unknown_returns_false() {
843        let pending = PendingActivities::default();
844        assert!(!pending.complete(
845            &WorkflowId::new_v4(),
846            &activity_id(99),
847            None,
848            Ok("orphan".to_owned())
849        ));
850    }
851
852    #[derive(Default)]
853    struct RecordingOutboxCallback {
854        completions: Mutex<Vec<(WorkflowId, ActivityId, String)>>,
855        failures: Mutex<Vec<(WorkflowId, ActivityId, String)>>,
856        live: bool,
857    }
858
859    impl OutboxDeliveryCallback for RecordingOutboxCallback {
860        fn deliver_completion(
861            &self,
862            workflow_id: &WorkflowId,
863            activity_id: &ActivityId,
864            run_id: Option<&RunId>,
865            result: String,
866        ) -> Result<bool, ServerError> {
867            let _ = run_id;
868            self.completions
869                .lock()
870                .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
871                .push((workflow_id.clone(), activity_id.clone(), result));
872            Ok(self.live)
873        }
874
875        fn deliver_failure(
876            &self,
877            workflow_id: &WorkflowId,
878            activity_id: &ActivityId,
879            run_id: Option<&RunId>,
880            reason: String,
881        ) -> Result<bool, ServerError> {
882            let _ = run_id;
883            self.failures
884                .lock()
885                .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
886                .push((workflow_id.clone(), activity_id.clone(), reason));
887            Ok(self.live)
888        }
889    }
890
891    #[test]
892    fn unmatched_completion_routes_to_outbox_callback_when_installed() -> Result<(), ServerError> {
893        let pending = PendingActivities::default();
894        let callback = Arc::new(RecordingOutboxCallback {
895            live: true,
896            ..RecordingOutboxCallback::default()
897        });
898        // Install on one clone; the wiring must be visible to every clone.
899        pending.clone().set_outbox_delivery(callback.clone());
900
901        let workflow_id = WorkflowId::new_v4();
902        let id = activity_id(7);
903
904        // No pending entry: the completion is unmatched and must route to the
905        // callback rather than being dropped. A live workflow reports true.
906        assert!(pending.complete(&workflow_id, &id, None, Ok("done".to_owned())));
907        let completions = callback
908            .completions
909            .lock()
910            .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?;
911        assert_eq!(completions.len(), 1);
912        assert_eq!(completions[0].0, workflow_id);
913        assert_eq!(completions[0].1, id);
914        assert_eq!(completions[0].2, "done");
915        Ok(())
916    }
917
918    #[test]
919    fn unmatched_failure_routes_to_outbox_callback_and_not_live_reports_false()
920    -> Result<(), ServerError> {
921        let pending = PendingActivities::default();
922        // live = false models the expected stale-completion case.
923        let callback = Arc::new(RecordingOutboxCallback::default());
924        pending.set_outbox_delivery(callback.clone());
925
926        let workflow_id = WorkflowId::new_v4();
927        let id = activity_id(8);
928
929        assert!(!pending.complete(&workflow_id, &id, None, Err("retryable:boom".to_owned())));
930        let failures = callback
931            .failures
932            .lock()
933            .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?;
934        assert_eq!(failures.len(), 1);
935        assert_eq!(failures[0].2, "retryable:boom");
936        Ok(())
937    }
938
939    #[test]
940    fn unmatched_completion_is_silent_drop_when_no_callback_installed() {
941        // Flag-off byte-identical behaviour: no callback, unmatched returns
942        // false (silent drop) exactly as before.
943        let pending = PendingActivities::default();
944        assert!(!pending.complete(
945            &WorkflowId::new_v4(),
946            &activity_id(9),
947            None,
948            Ok("x".to_owned())
949        ));
950    }
951
952    #[test]
953    fn matched_completion_never_reaches_outbox_callback() -> Result<(), ServerError> {
954        let pending = PendingActivities::default();
955        let callback = Arc::new(RecordingOutboxCallback {
956            live: true,
957            ..RecordingOutboxCallback::default()
958        });
959        pending.set_outbox_delivery(callback.clone());
960
961        let workflow_id = WorkflowId::new_v4();
962        let id = activity_id(10);
963        let rx = pending.insert(workflow_id.clone(), id.clone());
964
965        assert!(pending.complete(&workflow_id, &id, None, Ok("matched".to_owned())));
966        assert_eq!(
967            rx.recv_timeout(Duration::from_millis(50)),
968            Ok(Ok("matched".to_owned()))
969        );
970        assert!(
971            callback
972                .completions
973                .lock()
974                .map_err(|_| ServerError::lock_poisoned("recording outbox callback"))?
975                .is_empty(),
976            "a matched completion must deliver to its waiter, not the outbox callback"
977        );
978        Ok(())
979    }
980
981    #[test]
982    fn completion_sink_routes_success() -> Result<(), ServerError> {
983        let pending = PendingActivities::default();
984        let workflow_id = WorkflowId::new_v4();
985        let id = activity_id(2);
986        let rx = pending.insert(workflow_id.clone(), id.clone());
987        let payload = Payload::new(ContentType::Json, br#"{"greeting":"hi"}"#.to_vec());
988
989        pending.complete_activity(ActivityCompletion {
990            workflow_id,
991            activity_id: id,
992            run_id: None,
993            outcome: ActivityCompletionOutcome::Succeeded(payload),
994        })?;
995
996        let result = rx
997            .recv_timeout(Duration::from_millis(50))
998            .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
999        assert_eq!(result, Ok(r#"{"greeting":"hi"}"#.to_owned()));
1000        Ok(())
1001    }
1002
1003    #[test]
1004    fn completion_sink_routes_retryable_error() -> Result<(), ServerError> {
1005        let pending = PendingActivities::default();
1006        let workflow_id = WorkflowId::new_v4();
1007        let id = activity_id(3);
1008        let rx = pending.insert(workflow_id.clone(), id.clone());
1009
1010        pending.complete_activity(ActivityCompletion {
1011            workflow_id,
1012            activity_id: id,
1013            run_id: None,
1014            outcome: ActivityCompletionOutcome::Failed(ActivityError {
1015                kind: ActivityErrorKind::Retryable,
1016                message: "temporary".to_owned(),
1017                details: None,
1018            }),
1019        })?;
1020
1021        let result = rx
1022            .recv_timeout(Duration::from_millis(50))
1023            .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
1024        assert_eq!(result, Err("retryable:temporary".to_owned()));
1025        Ok(())
1026    }
1027
1028    /// Regression test (#59, brief D12): pending tracking must be keyed by
1029    /// the full `(WorkflowId, ActivityId)` pair. The dispatcher fabricates
1030    /// activity ids from a process-local counter that resets on server
1031    /// restart, so a stale result re-reported from a worker's previous
1032    /// session carries the same bare `ActivityId` as a fresh post-restart
1033    /// dispatch. Under bare-`ActivityId` keying the stale result completed
1034    /// the wrong execution; with pair keying it is dropped and the genuine
1035    /// result still completes.
1036    #[test]
1037    fn stale_result_for_other_workflow_does_not_complete_pending_dispatch()
1038    -> Result<(), ServerError> {
1039        let pending = PendingActivities::default();
1040        let post_restart_workflow = WorkflowId::new_v4();
1041        let pre_restart_workflow = WorkflowId::new_v4();
1042        // Counter resets to the same sequence position after restart.
1043        let id = activity_id(1);
1044        let rx = pending.insert(post_restart_workflow.clone(), id.clone());
1045
1046        // Stale pre-restart result: same activity id, different workflow.
1047        pending.complete_activity(ActivityCompletion {
1048            workflow_id: pre_restart_workflow,
1049            activity_id: id.clone(),
1050            run_id: None,
1051            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1052                ContentType::Json,
1053                br#""stale""#.to_vec(),
1054            )),
1055        })?;
1056        assert!(
1057            rx.try_recv().is_err(),
1058            "stale result for a different workflow must not complete this dispatch"
1059        );
1060
1061        // The genuine result for the pending execution still completes.
1062        pending.complete_activity(ActivityCompletion {
1063            workflow_id: post_restart_workflow,
1064            activity_id: id,
1065            run_id: None,
1066            outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1067                ContentType::Json,
1068                br#""fresh""#.to_vec(),
1069            )),
1070        })?;
1071        let result = rx
1072            .recv_timeout(Duration::from_millis(50))
1073            .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
1074        assert_eq!(result, Ok(r#""fresh""#.to_owned()));
1075        Ok(())
1076    }
1077
1078    /// Liveness tracker for dispatcher unit tests; the window only matters
1079    /// to expiry checks, which nothing in these tests drives.
1080    fn test_tracker() -> HeartbeatTracker {
1081        HeartbeatTracker::new(Duration::from_secs(5))
1082    }
1083
1084    /// A `greet` dispatch request carrying real (test-synthesized) ids, the
1085    /// engine-seam shape `WorkerActivityDispatcher::dispatch` now consumes.
1086    fn greet_request() -> ActivityDispatch {
1087        ActivityDispatch {
1088            namespace: "default".to_owned(),
1089            task_queue: "default".to_owned(),
1090            node: None,
1091            workflow_id: WorkflowId::new_v4(),
1092            activity_id: ActivityId::from_sequence_position(0),
1093            name: "greet".to_owned(),
1094            input: "{}".to_owned(),
1095            config: "{}".to_owned(),
1096            attempt: 1,
1097            labels: std::collections::BTreeMap::new(),
1098        }
1099    }
1100
1101    #[test]
1102    fn dispatcher_fails_immediately_when_draining_without_workers() {
1103        let registry = ConnectedWorkerRegistry::default();
1104        let drain = DrainState::default();
1105        let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker())
1106            .with_drain_state(drain.clone());
1107
1108        let _ = drain.begin();
1109
1110        let result = dispatcher.dispatch(greet_request());
1111
1112        assert!(result.is_err());
1113        let err = result.err().unwrap_or_default();
1114        assert!(
1115            err.contains("drain"),
1116            "expected drain rejection, got: {err}"
1117        );
1118    }
1119
1120    /// Regression test for the production stall where every remote activity
1121    /// timed out: the engine invoked the sync `dispatch` from inside a
1122    /// spawned tokio task (`futures::future::lazy` polled on a runtime
1123    /// worker), and the woken stream-consumer task landed in that blocked
1124    /// worker's non-stealable LIFO slot, so the queued `ActivityTask` was
1125    /// only delivered when the then-extant 30s dispatch timeout fired (the
1126    /// dispatch wait is unbounded today; the stall would now be a hang).
1127    ///
1128    /// Mirrors the real wiring minus tonic: the real registry channel that
1129    /// the gRPC stream forwarder drains, a worker task awaiting that channel
1130    /// on the same runtime, completion through the production
1131    /// `ActivityCompletionSink`, and the sync dispatch invoked from a
1132    /// runtime worker task — the worst case the `block_in_place` guard in
1133    /// `dispatch` defends against (the engine itself now routes through
1134    /// `dispatch_async`, off the async workers).
1135    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1136    async fn dispatch_inside_runtime_task_delivers_promptly_and_round_trips()
1137    -> Result<(), Box<dyn std::error::Error>> {
1138        let registry = ConnectedWorkerRegistry::default();
1139        let pending = PendingActivities::default();
1140        let (worker_tx, mut worker_rx) = tokio::sync::mpsc::channel(32);
1141        let activity_types = [String::from("greet")];
1142        let registration = registry.register("default", activity_types.iter(), worker_tx)?;
1143
1144        let sink = pending.clone();
1145        let echo_worker = tokio::spawn(async move {
1146            let Some(WorkerMessage::ActivityTask(task)) = worker_rx.recv().await else {
1147                return Err("expected an activity task on the worker channel".to_owned());
1148            };
1149            let workflow_id = task
1150                .workflow_id
1151                .ok_or("task missing workflow id")
1152                .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
1153            let activity_id = task
1154                .activity_id
1155                .map(ActivityId::from)
1156                .ok_or("task missing activity id")?;
1157            sink.complete_activity(ActivityCompletion {
1158                workflow_id,
1159                activity_id,
1160                run_id: None,
1161                outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1162                    ContentType::Json,
1163                    br#"{"greeting":"hello"}"#.to_vec(),
1164                )),
1165            })
1166            .map_err(|error| error.to_string())
1167        });
1168
1169        let dispatcher = Arc::new(
1170            WorkerActivityDispatcher::new(registry, "default", test_tracker())
1171                .with_pending(pending),
1172        );
1173        let started = Instant::now();
1174        // Invoke the sync dispatch inside the first poll of a spawned task:
1175        // the worst-case calling context for the `block_in_place` guard.
1176        let dispatch_task = tokio::spawn(futures::future::lazy(move |_| {
1177            dispatcher.dispatch(greet_request())
1178        }));
1179        let result = dispatch_task.await.map_err(|error| error.to_string())?;
1180        let elapsed = started.elapsed();
1181
1182        assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
1183        assert!(
1184            elapsed < Duration::from_secs(5),
1185            "dispatch round trip took {elapsed:?}; task delivery must not \
1186             depend on the blocked dispatch thread"
1187        );
1188        echo_worker.await.map_err(|error| error.to_string())??;
1189        registration.deregister()?;
1190        Ok(())
1191    }
1192
1193    /// A current-thread runtime cannot host the blocking wait (the stream
1194    /// forwarder would share its only executor thread), so dispatch must
1195    /// fail fast with a precise error instead of blocking forever.
1196    #[tokio::test]
1197    async fn dispatch_on_current_thread_runtime_fails_fast()
1198    -> Result<(), Box<dyn std::error::Error>> {
1199        let registry = ConnectedWorkerRegistry::default();
1200        let (worker_tx, _worker_rx) = tokio::sync::mpsc::channel(32);
1201        let activity_types = [String::from("greet")];
1202        let registration = registry.register("default", activity_types.iter(), worker_tx)?;
1203        let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker());
1204
1205        let started = Instant::now();
1206        let result = dispatcher.dispatch(greet_request());
1207        let elapsed = started.elapsed();
1208
1209        let err = result.err().ok_or("expected dispatch to fail")?;
1210        assert!(
1211            err.contains("multi-thread tokio runtime"),
1212            "unexpected error: {err}"
1213        );
1214        assert!(
1215            elapsed < Duration::from_secs(5),
1216            "fail-fast path took {elapsed:?}"
1217        );
1218        registration.deregister()?;
1219        Ok(())
1220    }
1221
1222    /// Bridge-level mirror of the e2e node-pin proof: two workers share the
1223    /// `(namespace, task_queue)` pool but advertise different nodes; an
1224    /// `ActivityDispatch` pinned to one node must reach ONLY the worker on that
1225    /// node through the live engine-seam `WorkerActivityDispatcher`. This is the
1226    /// regression guard for the bridge discarding the dispatch's `node`.
1227    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1228    async fn dispatch_pinned_to_node_reaches_only_that_node()
1229    -> Result<(), Box<dyn std::error::Error>> {
1230        let registry = ConnectedWorkerRegistry::default();
1231        let pending = PendingActivities::default();
1232        let activity_types = [String::from("greet")];
1233        let (n1_tx, mut n1_rx) = tokio::sync::mpsc::channel(32);
1234        let (n2_tx, mut n2_rx) = tokio::sync::mpsc::channel(32);
1235        // Register the DECOY (n2) FIRST so it owns the lowest worker id. The
1236        // bridge's `select_worker` picks the lowest-id matching worker, so a
1237        // bridge that DISCARDED the node would route to n2 (the decoy) here —
1238        // the n1 echo would never fire and the round trip would time out. With
1239        // the node threaded through, selection is filtered to n1.
1240        let on_n2 = registry.register_namespaces(
1241            [String::from("default")],
1242            "default",
1243            Some(String::from("n2")),
1244            activity_types.iter(),
1245            n2_tx,
1246        )?;
1247        let on_n1 = registry.register_namespaces(
1248            [String::from("default")],
1249            "default",
1250            Some(String::from("n1")),
1251            activity_types.iter(),
1252            n1_tx,
1253        )?;
1254
1255        // Echo only on the n1 channel: the dispatch can only complete if the
1256        // task was routed to n1. If it leaked to n2, the n1 wait would stall and
1257        // the round trip below would time out instead.
1258        let sink = pending.clone();
1259        let echo_n1 = tokio::spawn(async move {
1260            let Some(WorkerMessage::ActivityTask(task)) = n1_rx.recv().await else {
1261                return Err("expected an activity task on the n1 worker channel".to_owned());
1262            };
1263            let workflow_id = task
1264                .workflow_id
1265                .ok_or("task missing workflow id")
1266                .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
1267            let activity_id = task
1268                .activity_id
1269                .map(ActivityId::from)
1270                .ok_or("task missing activity id")?;
1271            sink.complete_activity(ActivityCompletion {
1272                workflow_id,
1273                activity_id,
1274                run_id: None,
1275                outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1276                    ContentType::Json,
1277                    br#"{"greeting":"hello"}"#.to_vec(),
1278                )),
1279            })
1280            .map_err(|error| error.to_string())
1281        });
1282
1283        let dispatcher = Arc::new(
1284            WorkerActivityDispatcher::new(registry.clone(), "default", test_tracker())
1285                .with_pending(pending),
1286        );
1287
1288        let pinned = ActivityDispatch {
1289            node: Some(String::from("n1")),
1290            ..greet_request()
1291        };
1292        let started = Instant::now();
1293        let result = tokio::spawn(futures::future::lazy(move |_| dispatcher.dispatch(pinned)))
1294            .await
1295            .map_err(|error| error.to_string())?;
1296        let elapsed = started.elapsed();
1297
1298        assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
1299        assert!(
1300            elapsed < Duration::from_secs(5),
1301            "pinned dispatch round trip took {elapsed:?}; the task must route to n1"
1302        );
1303        echo_n1.await.map_err(|error| error.to_string())??;
1304
1305        // The n2 worker (wrong node) must never have been handed the task.
1306        assert!(
1307            n2_rx.try_recv().is_err(),
1308            "node=Some(\"n1\") dispatch must not reach the n2 worker"
1309        );
1310
1311        on_n1.deregister()?;
1312        on_n2.deregister()?;
1313        Ok(())
1314    }
1315
1316    /// An unpinned (`node = None`) dispatch is byte-identical to today: it
1317    /// reaches a worker in the pool regardless of the worker's advertised node.
1318    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1319    async fn unpinned_dispatch_reaches_a_pooled_worker_regardless_of_node()
1320    -> Result<(), Box<dyn std::error::Error>> {
1321        let registry = ConnectedWorkerRegistry::default();
1322        let pending = PendingActivities::default();
1323        let activity_types = [String::from("greet")];
1324        let (n1_tx, mut n1_rx) = tokio::sync::mpsc::channel(32);
1325        let on_n1 = registry.register_namespaces(
1326            [String::from("default")],
1327            "default",
1328            Some(String::from("n1")),
1329            activity_types.iter(),
1330            n1_tx,
1331        )?;
1332
1333        let sink = pending.clone();
1334        let echo = tokio::spawn(async move {
1335            let Some(WorkerMessage::ActivityTask(task)) = n1_rx.recv().await else {
1336                return Err("expected an activity task on the worker channel".to_owned());
1337            };
1338            let workflow_id = task
1339                .workflow_id
1340                .ok_or("task missing workflow id")
1341                .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
1342            let activity_id = task
1343                .activity_id
1344                .map(ActivityId::from)
1345                .ok_or("task missing activity id")?;
1346            sink.complete_activity(ActivityCompletion {
1347                workflow_id,
1348                activity_id,
1349                run_id: None,
1350                outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
1351                    ContentType::Json,
1352                    br#"{"greeting":"hello"}"#.to_vec(),
1353                )),
1354            })
1355            .map_err(|error| error.to_string())
1356        });
1357
1358        let dispatcher = Arc::new(
1359            WorkerActivityDispatcher::new(registry.clone(), "default", test_tracker())
1360                .with_pending(pending),
1361        );
1362
1363        // greet_request() carries node: None — the unpinned path.
1364        let result = tokio::spawn(futures::future::lazy(move |_| {
1365            dispatcher.dispatch(greet_request())
1366        }))
1367        .await
1368        .map_err(|error| error.to_string())?;
1369
1370        assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
1371        echo.await.map_err(|error| error.to_string())??;
1372        on_n1.deregister()?;
1373        Ok(())
1374    }
1375}