Skip to main content

aion_server/worker/
dispatch.rs

1//! Push dispatch for remote activity workers and result handoff to the engine contract.
2
3use std::collections::BTreeMap;
4
5use aion_core::{ActivityError, ActivityErrorKind, ActivityId, Payload, RunId, WorkflowId};
6use aion_proto::{
7    ProtoActivityId, ProtoActivityResult, ProtoActivityTask, ProtoPayload, ProtoRunId,
8    ProtoWorkflowId, WireError, proto_activity_result,
9};
10
11use crate::error::ServerError;
12use crate::shutdown::DrainState;
13use crate::worker::registry::{ConnectedWorkerRegistry, WorkerMessage};
14use tracing::{Instrument, info_span};
15
16/// Scheduled remote activity that must be placed with a connected worker.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct ScheduledActivity {
19    /// Namespace selected by the adapter boundary before dispatch — the
20    /// correctness/isolation boundary the activity may dispatch within.
21    pub namespace: String,
22    /// Task queue (pool/flavour) selected within the namespace. The worker-pool
23    /// address is `(namespace, task_queue)`; an empty value is normalized to the
24    /// named default pool by the registry lookup.
25    pub task_queue: String,
26    /// Activity type to match against worker registrations, *within* the
27    /// selected pool.
28    pub activity_type: String,
29    /// Optional node locality affinity. `Some(node)` pins this dispatch to
30    /// workers advertising that node (require semantics: it waits if none are
31    /// present, exactly like the no-worker path); `None` is unpinned and reaches
32    /// any worker in the `(namespace, task_queue)` pool — byte-identical to the
33    /// pre-NODE behaviour. Producers stamp `None` until SDK selection (NODE-4)
34    /// and the durable column (NODE-2) land.
35    pub node: Option<String>,
36    /// Owning workflow id.
37    pub workflow_id: WorkflowId,
38    /// Correlating activity id.
39    pub activity_id: ActivityId,
40    /// Concrete workflow run that staged this task, when known.
41    pub run_id: Option<RunId>,
42    /// Opaque activity input payload.
43    pub input: Payload,
44    /// One-based delivery attempt stamped by the dispatching engine seam.
45    /// Zero is malformed on the wire; producers must always stamp it.
46    pub attempt: u32,
47    /// Display labels the workflow attached to the activity. Display metadata
48    /// only — carried to the worker for its logs and the dashboard.
49    pub labels: BTreeMap<String, String>,
50}
51
52impl ScheduledActivity {
53    /// Build the wire task pushed to the worker stream.
54    #[must_use]
55    pub fn to_task(&self) -> ProtoActivityTask {
56        ProtoActivityTask {
57            workflow_id: Some(ProtoWorkflowId::from(self.workflow_id.clone())),
58            activity_id: Some(ProtoActivityId::from(self.activity_id.clone())),
59            activity_type: self.activity_type.clone(),
60            input: Some(ProtoPayload::from(self.input.clone())),
61            attempt: self.attempt,
62            labels: self.labels.clone().into_iter().collect(),
63            run_id: self.run_id.clone().map(ProtoRunId::from),
64        }
65    }
66}
67
68/// Push dispatcher backed by the connected-worker registry.
69#[derive(Clone, Debug)]
70pub struct ActivityDispatcher {
71    registry: ConnectedWorkerRegistry,
72    drain_state: DrainState,
73}
74
75impl ActivityDispatcher {
76    /// Build a dispatcher over the shared worker registry.
77    #[must_use]
78    pub fn new(registry: ConnectedWorkerRegistry) -> Self {
79        Self {
80            registry,
81            drain_state: DrainState::default(),
82        }
83    }
84
85    /// Share the server drain gate.
86    #[must_use]
87    pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
88        self.drain_state = drain_state;
89        self
90    }
91
92    /// Push a scheduled activity to a matching worker.
93    ///
94    /// # Errors
95    ///
96    /// Returns a typed dispatch error if no worker is available or the selected
97    /// stream is closed; returns lock poison if registry access cannot be trusted.
98    pub async fn dispatch(&self, activity: &ScheduledActivity) -> Result<(), ServerError> {
99        let span = info_span!(
100            "activity_dispatch",
101            operation = "activity_dispatch",
102            namespace = %activity.namespace,
103            task_queue = %activity.task_queue,
104            node = activity.node.as_deref(),
105            workflow_id = %activity.workflow_id,
106            activity_id = %activity.activity_id,
107            activity_type = %activity.activity_type,
108            worker_id = tracing::field::Empty,
109        );
110        let span_fields = span.clone();
111
112        async {
113            self.dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
114                .await
115        }
116        .instrument(span)
117        .await
118        .inspect_err(|error| {
119            log_dispatch_error("activity_dispatch", activity, error);
120        })
121    }
122
123    /// Dispatch `activity` preferring workers on one of the `preferred` node
124    /// labels, spilling to ANY live worker when none of the preferred labels has a
125    /// live worker (Control-Plane Phase 2, P2-P3 — the `Prefer{L}` soft spill).
126    ///
127    /// This is consulted ONLY for an UNPINNED activity (`activity.node == None`):
128    /// a per-activity authored pin always wins and is dispatched through
129    /// [`Self::dispatch`] unchanged. The recorded row's `node` is NEVER mutated —
130    /// preference is a pure dispatch-time worker-selection optimization in this
131    /// non-replayed path, exactly like the existing round-robin, so replay is
132    /// untouched (CP-Phase-2 §2.4).
133    ///
134    /// The prefer-then-spill tier sequence is derived ONCE, from the shared
135    /// [`preferred_node_order`](crate::worker::preferred_node_order), so this gRPC
136    /// path and the liminal
137    /// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) can never
138    /// diverge on what "prefer labelled worker, spill to any" means:
139    ///
140    /// Tier 1..N: for each preferred label (deterministic set order) try a
141    /// NON-WAITING `workers_for(node = Some(label))` and dispatch to the first
142    /// live worker found. Tier N+1 (spill): if no preferred label has a live
143    /// worker, fall back to [`Self::dispatch`] with the activity's own (unpinned)
144    /// node, so the wait-for-worker backstop and round-robin behave exactly as
145    /// today. An empty `preferred` set is the spill case immediately.
146    ///
147    /// # Errors
148    ///
149    /// As [`Self::dispatch`].
150    pub async fn dispatch_preferring(
151        &self,
152        activity: &ScheduledActivity,
153        preferred: &std::collections::BTreeSet<String>,
154    ) -> Result<(), ServerError> {
155        // Reconstruct the shared tier order from the preferred labels so gRPC and
156        // liminal consult ONE prefer-then-spill implementation.
157        let tiers = crate::worker::preferred_node_order(&aion_store::NamespacePlacement::Prefer {
158            nodes: preferred.clone(),
159        });
160        self.dispatch_over_tiers(activity, &tiers).await
161    }
162
163    /// Dispatch `activity` REQUIRING a worker whose advertised node is one of the
164    /// `required` labels, WAITING when none is live and NEVER spilling to a
165    /// node=`None` any-worker dispatch (Control-Plane Phase 2, P2-I1 — the
166    /// `Pinned{L}` hard pin). This is the opposite of [`Self::dispatch_preferring`]:
167    /// a `Prefer` set appends a `None` spill tier; a `Pinned` set has NO `None`
168    /// tier and instead holds on the wait-for-worker backstop until an L-labelled
169    /// worker registers.
170    ///
171    /// Consulted ONLY for an UNPINNED activity (`activity.node == None`): a
172    /// per-activity authored pin always wins and dispatches through
173    /// [`Self::dispatch`] unchanged. The recorded row's `node` is NEVER mutated —
174    /// the required set is a pure dispatch-time worker-selection input in this
175    /// non-replayed path, so replay is untouched (CP-Phase-2 §2.4).
176    ///
177    /// Each retry tries every required label (deterministic [`BTreeSet`] order) via
178    /// a NON-WAITING `workers_for(node = Some(label))` and delivers to the first
179    /// live worker found, preserving the round-robin exactly like
180    /// [`Self::dispatch_to_node`]. When no required label has a live worker across
181    /// the whole set, it awaits [`wait_for_worker`](crate::worker::ConnectedWorkerRegistry::wait_for_worker)
182    /// and retries — the same isolation-stall a per-activity `Some(N)` pin already
183    /// exhibits. An EMPTY required set can never be satisfied by any labelled
184    /// worker, so it stalls (isolation > availability); the caller sets a non-empty
185    /// `Pinned{L}` for a live pin.
186    ///
187    /// # Errors
188    ///
189    /// As [`Self::dispatch`].
190    pub async fn dispatch_requiring(
191        &self,
192        activity: &ScheduledActivity,
193        required: &std::collections::BTreeSet<String>,
194    ) -> Result<(), ServerError> {
195        let span = info_span!(
196            "activity_dispatch",
197            operation = "activity_dispatch_requiring",
198            namespace = %activity.namespace,
199            task_queue = %activity.task_queue,
200            workflow_id = %activity.workflow_id,
201            activity_id = %activity.activity_id,
202            activity_type = %activity.activity_type,
203            worker_id = tracing::field::Empty,
204        );
205        let span_fields = span.clone();
206        async {
207            loop {
208                for label in required {
209                    self.drain_state
210                        .ensure_accepting(&activity.namespace, &activity.activity_type)?;
211                    let candidates = self.registry.workers_for(
212                        &activity.namespace,
213                        &activity.task_queue,
214                        &activity.activity_type,
215                        Some(label.as_str()),
216                    )?;
217                    if let Some(()) = self
218                        .send_to_candidates(activity, candidates, &span_fields)
219                        .await?
220                    {
221                        return Ok(());
222                    }
223                }
224                // No required label had a live worker this pass. WAIT for a worker
225                // to register, then retry the WHOLE required set — never fall back
226                // to a node=None any-worker dispatch (the hard-pin invariant).
227                tracing::info!(
228                    namespace = %activity.namespace,
229                    task_queue = %activity.task_queue,
230                    activity_type = %activity.activity_type,
231                    workflow_id = %activity.workflow_id,
232                    activity_id = %activity.activity_id,
233                    "no worker on a required (Pinned) node; waiting — will NOT spill to any-node"
234                );
235                self.registry.wait_for_worker().await;
236            }
237        }
238        .instrument(span)
239        .await
240        .inspect_err(|error| {
241            log_dispatch_error("activity_dispatch_requiring", activity, error);
242        })
243    }
244
245    /// Dispatch `activity` over an ordered `tiers` sequence of node filters, each
246    /// a `Some(label)` preference or the final `None` spill (the shared
247    /// [`preferred_node_order`](crate::worker::preferred_node_order) output). The
248    /// first non-spill tier with a live worker wins via a NON-WAITING
249    /// `workers_for`; the `None` spill tier falls back to the waiting
250    /// [`Self::dispatch_to_node`] so the wait-for-worker backstop and round-robin
251    /// behave exactly as today.
252    ///
253    /// # Errors
254    ///
255    /// As [`Self::dispatch`].
256    async fn dispatch_over_tiers(
257        &self,
258        activity: &ScheduledActivity,
259        tiers: &[Option<String>],
260    ) -> Result<(), ServerError> {
261        let span = info_span!(
262            "activity_dispatch",
263            operation = "activity_dispatch_preferring",
264            namespace = %activity.namespace,
265            task_queue = %activity.task_queue,
266            workflow_id = %activity.workflow_id,
267            activity_id = %activity.activity_id,
268            activity_type = %activity.activity_type,
269            worker_id = tracing::field::Empty,
270        );
271        let span_fields = span.clone();
272        async {
273            for tier in tiers {
274                let Some(label) = tier else {
275                    // The `None` spill tier: fall back to the waiting unpinned
276                    // dispatch (wait-for-worker backstop + round-robin).
277                    return self
278                        .dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
279                        .await;
280                };
281                self.drain_state
282                    .ensure_accepting(&activity.namespace, &activity.activity_type)?;
283                let candidates = self.registry.workers_for(
284                    &activity.namespace,
285                    &activity.task_queue,
286                    &activity.activity_type,
287                    Some(label.as_str()),
288                )?;
289                if let Some(()) = self
290                    .send_to_candidates(activity, candidates, &span_fields)
291                    .await?
292                {
293                    return Ok(());
294                }
295            }
296            // An empty tier list (never produced by `preferred_node_order`, which
297            // always appends the spill) still degrades to the unpinned dispatch.
298            self.dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
299                .await
300        }
301        .instrument(span)
302        .await
303        .inspect_err(|error| {
304            log_dispatch_error("activity_dispatch_preferring", activity, error);
305        })
306    }
307
308    /// The waiting dispatch core: select a worker for `node` (waiting for one to
309    /// register when none is live, exactly as before), then push the task.
310    async fn dispatch_to_node(
311        &self,
312        activity: &ScheduledActivity,
313        node: Option<&str>,
314        span_fields: &tracing::Span,
315    ) -> Result<(), ServerError> {
316        let workers = loop {
317            self.drain_state
318                .ensure_accepting(&activity.namespace, &activity.activity_type)?;
319            let candidates = self.registry.workers_for(
320                &activity.namespace,
321                &activity.task_queue,
322                &activity.activity_type,
323                node,
324            )?;
325            if !candidates.is_empty() {
326                break candidates;
327            }
328            tracing::info!(
329                namespace = %activity.namespace,
330                task_queue = %activity.task_queue,
331                node = node,
332                activity_type = %activity.activity_type,
333                workflow_id = %activity.workflow_id,
334                activity_id = %activity.activity_id,
335                "no connected worker; waiting for a matching worker to register"
336            );
337            self.registry.wait_for_worker().await;
338        };
339        match self
340            .send_to_candidates(activity, workers, span_fields)
341            .await?
342        {
343            Some(()) => Ok(()),
344            None => Err(ServerError::worker_dispatch(
345                activity.namespace.clone(),
346                activity.activity_type.clone(),
347                format!(
348                    "all matching worker streams in task queue {} closed before task could be \
349                     delivered",
350                    activity.task_queue
351                ),
352            )),
353        }
354    }
355
356    /// Try each candidate in order, pushing the task to the first live stream.
357    /// Returns `Ok(Some(()))` on a delivered task, `Ok(None)` when every candidate
358    /// stream was already closed (deregistered as it went). An empty candidate
359    /// list returns `Ok(None)` so callers can treat it as "no live worker here".
360    async fn send_to_candidates(
361        &self,
362        activity: &ScheduledActivity,
363        candidates: Vec<crate::worker::registry::WorkerHandle>,
364        span_fields: &tracing::Span,
365    ) -> Result<Option<()>, ServerError> {
366        for worker in candidates {
367            self.drain_state
368                .ensure_accepting(&activity.namespace, &activity.activity_type)?;
369            span_fields.record("worker_id", format!("{:?}", worker.id()));
370            // The gRPC dispatch path only registers gRPC-delivery workers, so a
371            // worker here always carries a stream sender; a missing one means a
372            // non-gRPC-transport worker leaked into this path and cannot be served
373            // over it, so it is deregistered like a closed stream.
374            if let Some(sender) = worker.sender() {
375                if sender
376                    .send(WorkerMessage::ActivityTask(activity.to_task()))
377                    .await
378                    .is_ok()
379                {
380                    return Ok(Some(()));
381                }
382            }
383            self.registry.deregister(worker.id())?;
384        }
385        Ok(None)
386    }
387}
388
389fn log_dispatch_error(operation: &'static str, activity: &ScheduledActivity, error: &ServerError) {
390    let fields = error.trace_fields();
391    tracing::error!(
392        operation,
393        namespace = %activity.namespace,
394        task_queue = %activity.task_queue,
395        node = activity.node.as_deref(),
396        workflow_id = %activity.workflow_id,
397        activity_id = %activity.activity_id,
398        activity_type = %activity.activity_type,
399        error_type = %fields.error_type,
400        store_error_type = fields.store_error_type,
401        reason = %fields.reason,
402        "activity dispatch failed"
403    );
404}
405
406/// Decoded activity outcome reported by a worker.
407#[derive(Clone, Debug, Eq, PartialEq)]
408pub enum ActivityCompletionOutcome {
409    /// Activity completed successfully with an output payload.
410    Succeeded(Payload),
411    /// Activity failed, preserving retryability classification for the engine.
412    Failed(ActivityError),
413}
414
415/// Correlated activity completion handed to the engine-owned activity contract.
416#[derive(Clone, Debug, Eq, PartialEq)]
417pub struct ActivityCompletion {
418    /// Owning workflow id.
419    pub workflow_id: WorkflowId,
420    /// Correlating activity id.
421    pub activity_id: ActivityId,
422    /// Concrete workflow run echoed by the worker, when known.
423    pub run_id: Option<RunId>,
424    /// Worker-reported outcome.
425    pub outcome: ActivityCompletionOutcome,
426}
427
428impl TryFrom<ProtoActivityResult> for ActivityCompletion {
429    type Error = ServerError;
430
431    fn try_from(value: ProtoActivityResult) -> Result<Self, Self::Error> {
432        let workflow_id = value
433            .workflow_id
434            .ok_or_else(|| wire_error("activity result workflow id is missing"))
435            .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
436        let activity_id = value
437            .activity_id
438            .ok_or_else(|| wire_error("activity result activity id is missing"))
439            .map(ActivityId::from)?;
440        let run_id = value
441            .run_id
442            .map(|id| RunId::try_from(id).map_err(ServerError::from))
443            .transpose()?;
444        let outcome = match value.outcome {
445            Some(proto_activity_result::Outcome::Result(payload)) => {
446                ActivityCompletionOutcome::Succeeded(
447                    Payload::try_from(payload).map_err(ServerError::from)?,
448                )
449            }
450            Some(proto_activity_result::Outcome::Error(error)) => {
451                ActivityCompletionOutcome::Failed(
452                    ActivityError::try_from(error).map_err(ServerError::from)?,
453                )
454            }
455            None => return Err(wire_error("activity result outcome is missing")),
456        };
457
458        Ok(Self {
459            workflow_id,
460            activity_id,
461            run_id,
462            outcome,
463        })
464    }
465}
466
467/// Engine-owned activity completion contract used by the worker endpoint.
468pub trait ActivityCompletionSink {
469    /// Feed one worker-reported result into the engine activity contract.
470    ///
471    /// # Errors
472    ///
473    /// Returns [`ServerError`] when the engine rejects or cannot record the completion.
474    fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError>;
475
476    /// Park one in-flight dispatch for restart recovery during a graceful
477    /// drain (#207): resolve the LOCAL waiter with the ephemeral parked
478    /// sentinel and nothing else.
479    ///
480    /// Parking is the anti-completion — it writes nothing durable, delivers
481    /// nothing to workflow code, and never crosses the SDK wire. It exists so a
482    /// drain leaves the durable log at exactly the dangling
483    /// `ActivityScheduled`/`ActivityStarted` a kill -9 would leave (the proven
484    /// re-dispatchable state) while still unblocking the blocking dispatcher
485    /// thread, so process exit is never wedged on tokio's blocking pool. A
486    /// dispatch with no matching waiter (already resolved) is a no-op — a park
487    /// must never be routed as an outbox failure delivery.
488    ///
489    /// # Errors
490    ///
491    /// Returns [`ServerError`] when sink state cannot be trusted.
492    fn park_activity(
493        &self,
494        workflow_id: &WorkflowId,
495        activity_id: &ActivityId,
496    ) -> Result<(), ServerError>;
497}
498
499/// Decode and hand a worker result to the engine-owned activity completion sink.
500///
501/// # Errors
502///
503/// Returns [`ServerError`] for malformed wire results or sink failures.
504pub fn handle_activity_result(
505    sink: &impl ActivityCompletionSink,
506    result: ProtoActivityResult,
507) -> Result<(), ServerError> {
508    sink.complete_activity(ActivityCompletion::try_from(result)?)
509}
510
511/// Build the retryable failure reported when a worker loses ownership of an in-flight task.
512///
513/// The retryable classification models worker loss as infrastructure failure: aion-server
514/// only reports the failure to the engine activity contract; the engine remains responsible
515/// for applying the activity retry policy.
516#[must_use]
517pub fn lost_worker_error(worker_id: crate::worker::registry::WorkerId) -> ActivityError {
518    ActivityError {
519        kind: ActivityErrorKind::Retryable,
520        message: format!("worker {worker_id:?} lost before reporting activity result"),
521        details: None,
522    }
523}
524
525fn wire_error(message: &'static str) -> ServerError {
526    ServerError::Wire {
527        wire: WireError::backend(message),
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use std::sync::Mutex;
534
535    use aion_core::{ActivityErrorKind, ContentType};
536    use aion_proto::{ProtoActivityError, ProtoActivityErrorKind};
537    use serde_json::json;
538    use uuid::Uuid;
539
540    use crate::worker::registry::ConnectedWorkerRegistry;
541
542    use super::*;
543
544    fn workflow_id() -> WorkflowId {
545        WorkflowId::new(Uuid::nil())
546    }
547
548    fn activity_id() -> ActivityId {
549        ActivityId::from_sequence_position(42)
550    }
551
552    fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
553        Ok(Payload::from_json(value)?)
554    }
555
556    #[tokio::test]
557    async fn dispatch_pushes_activity_task_with_correlation()
558    -> Result<(), Box<dyn std::error::Error>> {
559        let registry = ConnectedWorkerRegistry::default();
560        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
561        let activity_types = [String::from("charge-card")];
562        let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
563        let dispatcher = ActivityDispatcher::new(registry.clone());
564        let input = payload(&json!({"amount": 1200}))?;
565        let scheduled = ScheduledActivity {
566            namespace: String::from("tenant-a"),
567            task_queue: String::from("default"),
568            activity_type: String::from("charge-card"),
569            node: None,
570            workflow_id: workflow_id(),
571            activity_id: activity_id(),
572            run_id: None,
573            input: input.clone(),
574            attempt: 1,
575            labels: std::collections::BTreeMap::new(),
576        };
577
578        dispatcher.dispatch(&scheduled).await?;
579        let message = rx.recv().await.ok_or("expected pushed activity task")?;
580        let WorkerMessage::ActivityTask(task) = message else {
581            return Err("expected activity task message".into());
582        };
583
584        assert_eq!(task.workflow_id, Some(ProtoWorkflowId::from(workflow_id())));
585        assert_eq!(task.activity_id, Some(ProtoActivityId::from(activity_id())));
586        assert_eq!(task.activity_type, "charge-card");
587        assert_eq!(task.input, Some(ProtoPayload::from(input)));
588        assert_eq!(task.attempt, 1, "wire task must carry the stamped attempt");
589
590        registration.deregister()?;
591        Ok(())
592    }
593
594    #[tokio::test]
595    async fn dispatch_waits_for_worker_then_delivers() -> Result<(), Box<dyn std::error::Error>> {
596        let registry = ConnectedWorkerRegistry::default();
597        let dispatcher = ActivityDispatcher::new(registry.clone());
598        let scheduled = ScheduledActivity {
599            namespace: String::from("tenant-a"),
600            task_queue: String::from("default"),
601            activity_type: String::from("charge-card"),
602            node: None,
603            workflow_id: workflow_id(),
604            activity_id: activity_id(),
605            run_id: None,
606            input: Payload::new(ContentType::Json, b"{}".to_vec()),
607            attempt: 1,
608            labels: std::collections::BTreeMap::new(),
609        };
610
611        let dispatch_handle = tokio::spawn({
612            let dispatcher = dispatcher.clone();
613            let scheduled = scheduled.clone();
614            async move { dispatcher.dispatch(&scheduled).await }
615        });
616
617        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
618        assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
619
620        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
621        let activity_types = [String::from("charge-card")];
622        let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
623
624        dispatch_handle.await??;
625        assert!(rx.recv().await.is_some());
626        Ok(())
627    }
628
629    #[tokio::test]
630    async fn dispatch_skips_closed_worker_and_uses_next_match()
631    -> Result<(), Box<dyn std::error::Error>> {
632        let registry = ConnectedWorkerRegistry::default();
633        let (closed_tx, closed_rx) = tokio::sync::mpsc::channel(1);
634        let (live_tx, mut live_rx) = tokio::sync::mpsc::channel(1);
635        let activity_types = [String::from("charge-card")];
636        let closed_registration =
637            registry.register("tenant-a", activity_types.iter(), closed_tx)?;
638        let live_registration = registry.register("tenant-a", activity_types.iter(), live_tx)?;
639        drop(closed_rx);
640
641        let dispatcher = ActivityDispatcher::new(registry.clone());
642        let scheduled = ScheduledActivity {
643            namespace: String::from("tenant-a"),
644            task_queue: String::from("default"),
645            activity_type: String::from("charge-card"),
646            node: None,
647            workflow_id: workflow_id(),
648            activity_id: activity_id(),
649            run_id: None,
650            input: Payload::new(ContentType::Json, b"{}".to_vec()),
651            attempt: 1,
652            labels: std::collections::BTreeMap::new(),
653        };
654
655        dispatcher.dispatch(&scheduled).await?;
656
657        assert!(live_rx.recv().await.is_some());
658        assert_eq!(
659            registry
660                .workers_for("tenant-a", "default", "charge-card", None)?
661                .len(),
662            1
663        );
664
665        closed_registration.deregister()?;
666        live_registration.deregister()?;
667        Ok(())
668    }
669
670    fn scheduled_unpinned() -> ScheduledActivity {
671        ScheduledActivity {
672            namespace: String::from("tenant-a"),
673            task_queue: String::from("default"),
674            activity_type: String::from("charge-card"),
675            // UNPINNED row: `node == None`, so placement (here a Pinned require) is
676            // the worker-selection input — the row's own node is never set.
677            node: None,
678            workflow_id: workflow_id(),
679            activity_id: activity_id(),
680            run_id: None,
681            input: Payload::new(ContentType::Json, b"{}".to_vec()),
682            attempt: 1,
683            labels: std::collections::BTreeMap::new(),
684        }
685    }
686
687    fn required(labels: &[&str]) -> std::collections::BTreeSet<String> {
688        labels.iter().map(|l| (*l).to_owned()).collect()
689    }
690
691    /// P2-I1 gRPC hard-pin: an unpinned row in a `Pinned{n1}` namespace WAITS when
692    /// no `n1` worker is live and NEVER spills to a live any-node worker — the
693    /// opposite of `Prefer`. This test would FAIL under the old fall-through (which
694    /// dispatched `Pinned` to any worker).
695    #[tokio::test]
696    async fn dispatch_requiring_waits_and_never_spills_to_a_wrong_node_worker()
697    -> Result<(), Box<dyn std::error::Error>> {
698        let registry = ConnectedWorkerRegistry::default();
699        let dispatcher = ActivityDispatcher::new(registry.clone());
700        let scheduled = scheduled_unpinned();
701        let types = [String::from("charge-card")];
702
703        // A LIVE worker on the WRONG node (n2) — a Prefer would spill to it; a
704        // Pinned{n1} must NOT.
705        let (wrong_tx, mut wrong_rx) = tokio::sync::mpsc::channel(1);
706        let _wrong = registry.register_namespaces(
707            [String::from("tenant-a")],
708            "default",
709            Some(String::from("n2")),
710            types.iter(),
711            wrong_tx,
712        )?;
713
714        let handle = tokio::spawn({
715            let dispatcher = dispatcher.clone();
716            let scheduled = scheduled.clone();
717            async move {
718                dispatcher
719                    .dispatch_requiring(&scheduled, &required(&["n1"]))
720                    .await
721            }
722        });
723
724        // The wrong-node worker is idle and live, yet dispatch must still be waiting.
725        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
726        assert!(
727            !handle.is_finished(),
728            "Pinned{{n1}} must WAIT rather than spill to the live n2 worker"
729        );
730        assert!(
731            wrong_rx.try_recv().is_err(),
732            "the wrong-node (n2) worker must never receive the task"
733        );
734
735        // Bring up the REQUIRED n1 worker: the wait resolves onto it.
736        let (right_tx, mut right_rx) = tokio::sync::mpsc::channel(1);
737        let _right = registry.register_namespaces(
738            [String::from("tenant-a")],
739            "default",
740            Some(String::from("n1")),
741            types.iter(),
742            right_tx,
743        )?;
744
745        handle.await??;
746        assert!(
747            right_rx.recv().await.is_some(),
748            "the required n1 worker receives the task once live"
749        );
750        assert!(
751            wrong_rx.try_recv().is_err(),
752            "the wrong-node worker still never received it"
753        );
754        Ok(())
755    }
756
757    /// P2-I1 determinism: the row's authored `node` stays `None` through a Pinned
758    /// dispatch — placement is a pure selection input, never written back.
759    #[tokio::test]
760    async fn dispatch_requiring_never_mutates_the_rows_node()
761    -> Result<(), Box<dyn std::error::Error>> {
762        let registry = ConnectedWorkerRegistry::default();
763        let dispatcher = ActivityDispatcher::new(registry.clone());
764        let scheduled = scheduled_unpinned();
765        assert_eq!(scheduled.node, None, "precondition: the row is unpinned");
766        let types = [String::from("charge-card")];
767        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
768        let _right = registry.register_namespaces(
769            [String::from("tenant-a")],
770            "default",
771            Some(String::from("n1")),
772            types.iter(),
773            tx,
774        )?;
775
776        dispatcher
777            .dispatch_requiring(&scheduled, &required(&["n1"]))
778            .await?;
779
780        assert!(rx.recv().await.is_some(), "the n1 worker received the task");
781        assert_eq!(
782            scheduled.node, None,
783            "the row's authored node MUST remain None through a Pinned dispatch \
784             (the determinism invariant, CP-Phase-2 §2.4)"
785        );
786        Ok(())
787    }
788
789    #[derive(Default)]
790    struct RecordingSink {
791        completions: Mutex<Vec<ActivityCompletion>>,
792    }
793
794    impl ActivityCompletionSink for RecordingSink {
795        fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
796            self.completions
797                .lock()
798                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
799                .push(completion);
800            Ok(())
801        }
802
803        fn park_activity(
804            &self,
805            _workflow_id: &WorkflowId,
806            _activity_id: &ActivityId,
807        ) -> Result<(), ServerError> {
808            Err(ServerError::worker_dispatch(
809                "",
810                "",
811                "result-handoff tests never park a dispatch",
812            ))
813        }
814    }
815
816    #[test]
817    fn successful_activity_result_calls_completion_sink() -> Result<(), Box<dyn std::error::Error>>
818    {
819        let sink = RecordingSink::default();
820        let output = payload(&json!({"ok": true}))?;
821        let result = ProtoActivityResult {
822            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
823            activity_id: Some(ProtoActivityId::from(activity_id())),
824            run_id: None,
825            outcome: Some(proto_activity_result::Outcome::Result(ProtoPayload::from(
826                output.clone(),
827            ))),
828        };
829
830        handle_activity_result(&sink, result)?;
831        let completions = sink
832            .completions
833            .lock()
834            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
835
836        assert_eq!(completions.len(), 1);
837        assert_eq!(completions[0].workflow_id, workflow_id());
838        assert_eq!(completions[0].activity_id, activity_id());
839        assert_eq!(
840            completions[0].outcome,
841            ActivityCompletionOutcome::Succeeded(output)
842        );
843        Ok(())
844    }
845
846    #[test]
847    fn failed_activity_result_preserves_error_classification()
848    -> Result<(), Box<dyn std::error::Error>> {
849        let sink = RecordingSink::default();
850        let error = ProtoActivityError {
851            kind: ProtoActivityErrorKind::Retryable as i32,
852            message: String::from("temporary outage"),
853            details: Some(ProtoPayload::from(payload(
854                &json!({"retry_after_ms": 500}),
855            )?)),
856        };
857        let result = ProtoActivityResult {
858            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
859            activity_id: Some(ProtoActivityId::from(activity_id())),
860            run_id: None,
861            outcome: Some(proto_activity_result::Outcome::Error(error)),
862        };
863
864        handle_activity_result(&sink, result)?;
865        let completions = sink
866            .completions
867            .lock()
868            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
869
870        assert_eq!(completions.len(), 1);
871        match &completions[0].outcome {
872            ActivityCompletionOutcome::Failed(error) => {
873                assert_eq!(error.kind, ActivityErrorKind::Retryable);
874                assert!(error.is_retryable());
875            }
876            ActivityCompletionOutcome::Succeeded(_) => return Err("expected failed outcome".into()),
877        }
878        Ok(())
879    }
880}