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, 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::envelope::{CompletionFences, CompletionToken, idempotency_key};
14use crate::worker::queue_service::declarations::QueueDeclarationSource;
15use crate::worker::queue_service::policy::QueueServiceConfig;
16use crate::worker::queue_service::state::QueueServiceState;
17use crate::worker::queue_service::taxonomy::{QueueServiceReason, ServiceAddress};
18use crate::worker::queue_service::wait::{
19    ServiceWait, clear_selection_miss, observe_selection_miss,
20};
21use crate::worker::registry::{ConnectedWorkerRegistry, WorkerMessage};
22use tracing::{Instrument, info_span};
23
24/// Scheduled remote activity that must be placed with a connected worker.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct ScheduledActivity {
27    /// Namespace selected by the adapter boundary before dispatch — the
28    /// correctness/isolation boundary the activity may dispatch within.
29    pub namespace: String,
30    /// Task queue (pool/flavour) selected within the namespace. The worker-pool
31    /// address is `(namespace, task_queue)`; an empty value is normalized to the
32    /// named default pool by the registry lookup.
33    pub task_queue: String,
34    /// Activity type to match against worker registrations, *within* the
35    /// selected pool.
36    pub activity_type: String,
37    /// Optional node locality affinity. `Some(node)` pins this dispatch to
38    /// workers advertising that node (require semantics: it waits if none are
39    /// present, exactly like the no-worker path); `None` is unpinned and reaches
40    /// any worker in the `(namespace, task_queue)` pool — byte-identical to the
41    /// pre-NODE behaviour. Producers stamp `None` until SDK selection (NODE-4)
42    /// and the durable column (NODE-2) land.
43    pub node: Option<String>,
44    /// Owning workflow id.
45    pub workflow_id: WorkflowId,
46    /// Correlating activity id.
47    pub activity_id: ActivityId,
48    /// Concrete workflow run that staged this task, when known.
49    pub run_id: Option<RunId>,
50    /// Opaque activity input payload.
51    pub input: Payload,
52    /// One-based delivery attempt stamped by the dispatching engine seam.
53    /// Zero is malformed on the wire; producers must always stamp it.
54    pub attempt: u32,
55    /// Display labels the workflow attached to the activity. Display metadata
56    /// only — carried to the worker for its logs and the dashboard.
57    pub labels: BTreeMap<String, String>,
58}
59
60impl ScheduledActivity {
61    /// Return the concrete run required to derive a run-scoped effect key.
62    /// Refuses a legacy row without a run id because no run-scoped
63    /// idempotency key can be truthfully derived.
64    fn require_run_id(&self) -> Result<&RunId, ServerError> {
65        self.run_id.as_ref().ok_or_else(|| {
66            ServerError::worker_dispatch(
67                self.namespace.clone(),
68                self.activity_type.clone(),
69                "activity run id is missing; refusing unfenced external effect",
70            )
71        })
72    }
73
74    /// Build the wire task pushed to the worker stream.
75    ///
76    /// # Errors
77    ///
78    /// Refuses a legacy row without a run id because no run-scoped
79    /// idempotency key can be truthfully derived.
80    pub fn to_task(
81        &self,
82        completion_token: &CompletionToken,
83    ) -> Result<ProtoActivityTask, ServerError> {
84        let run_id = self.require_run_id()?;
85        Ok(ProtoActivityTask {
86            workflow_id: Some(ProtoWorkflowId::from(self.workflow_id.clone())),
87            activity_id: Some(ProtoActivityId::from(self.activity_id.clone())),
88            activity_type: self.activity_type.clone(),
89            input: Some(ProtoPayload::from(self.input.clone())),
90            attempt: self.attempt,
91            labels: self.labels.clone().into_iter().collect(),
92            run_id: Some(ProtoRunId::from(run_id.clone())),
93            completion_token: completion_token.as_str().to_owned(),
94            idempotency_key: idempotency_key(&self.workflow_id, run_id, &self.activity_id),
95        })
96    }
97}
98
99/// Push dispatcher backed by the connected-worker registry.
100#[derive(Clone, Debug)]
101pub struct ActivityDispatcher {
102    registry: ConnectedWorkerRegistry,
103    drain_state: DrainState,
104    completion_fences: CompletionFences,
105    /// Deployed queue declarations, live unserved state, and the operator's
106    /// queue-service policy — the three things a selection miss must be
107    /// classified against for the park to be visible rather than silent.
108    ///
109    /// Defaulted like `drain_state` above, and shared with the rest of the
110    /// server by `with_queue_service`. An unshared default still classifies
111    /// and still logs; what it loses is only the queryable state, which is why
112    /// the loud half of the report can never be switched off by wiring.
113    queue_declarations: QueueDeclarationSource,
114    queue_service_state: QueueServiceState,
115    queue_service_config: QueueServiceConfig,
116    /// Cluster-event publisher an unbounded park announces itself on (#266
117    /// T4). `None` (isolated tests) loses only the pushed echo; the WARN and
118    /// the queryable state above cannot be switched off by wiring.
119    cluster_publisher: Option<crate::cluster_publisher::ClusterEventPublisher>,
120}
121
122impl ActivityDispatcher {
123    /// Build a dispatcher over the shared worker registry.
124    #[must_use]
125    pub fn new(registry: ConnectedWorkerRegistry) -> Self {
126        Self {
127            registry,
128            drain_state: DrainState::default(),
129            completion_fences: CompletionFences::default(),
130            queue_declarations: QueueDeclarationSource::default(),
131            queue_service_state: QueueServiceState::default(),
132            queue_service_config: QueueServiceConfig::default(),
133            cluster_publisher: None,
134        }
135    }
136
137    /// Share the deployment-global cluster-event publisher so a dispatch
138    /// parked with no availability deadline on this leg is announced on the
139    /// operator's real-time channel, not only in the log (#266 T4).
140    #[must_use]
141    pub fn with_cluster_publisher(
142        mut self,
143        cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
144    ) -> Self {
145        self.cluster_publisher = Some(cluster_publisher);
146        self
147    }
148
149    /// Share the queue-service seams so a park on this path reaches the same
150    /// `GET /queues/unserved` and `describe` surfaces the direct path feeds.
151    #[must_use]
152    pub fn with_queue_service(
153        mut self,
154        declarations: QueueDeclarationSource,
155        state: QueueServiceState,
156        config: QueueServiceConfig,
157    ) -> Self {
158        self.queue_declarations = declarations;
159        self.queue_service_state = state;
160        self.queue_service_config = config;
161        self
162    }
163
164    /// Share the server drain gate.
165    #[must_use]
166    pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
167        self.drain_state = drain_state;
168        self
169    }
170
171    /// Share the completion-generation registry used by result ingestion.
172    #[must_use]
173    pub fn with_completion_fences(mut self, completion_fences: CompletionFences) -> Self {
174        self.completion_fences = completion_fences;
175        self
176    }
177
178    /// Push a scheduled activity to a matching worker.
179    ///
180    /// # Errors
181    ///
182    /// Returns a typed dispatch error if no worker is available or the selected
183    /// stream is closed; returns lock poison if registry access cannot be trusted.
184    pub async fn dispatch(&self, activity: &ScheduledActivity) -> Result<(), ServerError> {
185        let span = info_span!(
186            "activity_dispatch",
187            operation = "activity_dispatch",
188            namespace = %activity.namespace,
189            task_queue = %activity.task_queue,
190            node = activity.node.as_deref(),
191            workflow_id = %activity.workflow_id,
192            activity_id = %activity.activity_id,
193            activity_type = %activity.activity_type,
194            worker_id = tracing::field::Empty,
195        );
196        let span_fields = span.clone();
197
198        async {
199            self.dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
200                .await
201        }
202        .instrument(span)
203        .await
204        .inspect_err(|error| {
205            log_dispatch_error("activity_dispatch", activity, error);
206        })
207    }
208
209    /// Dispatch `activity` preferring workers on one of the `preferred` node
210    /// labels, spilling to ANY live worker when none of the preferred labels has a
211    /// live worker (Control-Plane Phase 2, P2-P3 — the `Prefer{L}` soft spill).
212    ///
213    /// This is consulted ONLY for an UNPINNED activity (`activity.node == None`):
214    /// a per-activity authored pin always wins and is dispatched through
215    /// [`Self::dispatch`] unchanged. The recorded row's `node` is NEVER mutated —
216    /// preference is a pure dispatch-time worker-selection optimization in this
217    /// non-replayed path, exactly like the existing round-robin, so replay is
218    /// untouched (CP-Phase-2 §2.4).
219    ///
220    /// The prefer-then-spill tier sequence is derived ONCE, from the shared
221    /// [`preferred_node_order`](crate::worker::preferred_node_order), so this gRPC
222    /// path and the liminal
223    /// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) can never
224    /// diverge on what "prefer labelled worker, spill to any" means:
225    ///
226    /// Tier 1..N: for each preferred label (deterministic set order) try a
227    /// NON-WAITING `workers_for(node = Some(label))` and dispatch to the first
228    /// live worker found. Tier N+1 (spill): if no preferred label has a live
229    /// worker, fall back to [`Self::dispatch`] with the activity's own (unpinned)
230    /// node, so the wait-for-worker backstop and round-robin behave exactly as
231    /// today. An empty `preferred` set is the spill case immediately.
232    ///
233    /// # Errors
234    ///
235    /// As [`Self::dispatch`].
236    pub async fn dispatch_preferring(
237        &self,
238        activity: &ScheduledActivity,
239        preferred: &std::collections::BTreeSet<String>,
240    ) -> Result<(), ServerError> {
241        // Reconstruct the shared tier order from the preferred labels so gRPC and
242        // liminal consult ONE prefer-then-spill implementation.
243        let tiers = crate::worker::preferred_node_order(&aion_store::NamespacePlacement::Prefer {
244            nodes: preferred.clone(),
245        });
246        self.dispatch_over_tiers(activity, &tiers).await
247    }
248
249    /// Dispatch `activity` REQUIRING a worker whose advertised node is one of the
250    /// `required` labels, WAITING when none is live and NEVER spilling to a
251    /// node=`None` any-worker dispatch (Control-Plane Phase 2, P2-I1 — the
252    /// `Pinned{L}` hard pin). This is the opposite of [`Self::dispatch_preferring`]:
253    /// a `Prefer` set appends a `None` spill tier; a `Pinned` set has NO `None`
254    /// tier and instead holds on the wait-for-worker backstop until an L-labelled
255    /// worker registers.
256    ///
257    /// Consulted ONLY for an UNPINNED activity (`activity.node == None`): a
258    /// per-activity authored pin always wins and dispatches through
259    /// [`Self::dispatch`] unchanged. The recorded row's `node` is NEVER mutated —
260    /// the required set is a pure dispatch-time worker-selection input in this
261    /// non-replayed path, so replay is untouched (CP-Phase-2 §2.4).
262    ///
263    /// Each retry tries every required label (deterministic [`BTreeSet`] order) via
264    /// a NON-WAITING `workers_for(node = Some(label))` and delivers to the first
265    /// live worker found, preserving the round-robin exactly like
266    /// [`Self::dispatch_to_node`]. When no required label has a live worker across
267    /// the whole set, it awaits [`wait_for_worker`](crate::worker::ConnectedWorkerRegistry::wait_for_worker)
268    /// and retries — the same isolation-stall a per-activity `Some(N)` pin already
269    /// exhibits. An EMPTY required set can never be satisfied by any labelled
270    /// worker, so it stalls (isolation > availability); the caller sets a non-empty
271    /// `Pinned{L}` for a live pin.
272    ///
273    /// # Errors
274    ///
275    /// As [`Self::dispatch`].
276    pub async fn dispatch_requiring(
277        &self,
278        activity: &ScheduledActivity,
279        required: &std::collections::BTreeSet<String>,
280    ) -> Result<(), ServerError> {
281        let span = info_span!(
282            "activity_dispatch",
283            operation = "activity_dispatch_requiring",
284            namespace = %activity.namespace,
285            task_queue = %activity.task_queue,
286            workflow_id = %activity.workflow_id,
287            activity_id = %activity.activity_id,
288            activity_type = %activity.activity_type,
289            worker_id = tracing::field::Empty,
290        );
291        let span_fields = span.clone();
292        async {
293            loop {
294                for label in required {
295                    self.drain_state
296                        .ensure_accepting(&activity.namespace, &activity.activity_type)?;
297                    let candidates = self.registry.workers_for(
298                        &activity.namespace,
299                        &activity.task_queue,
300                        &activity.activity_type,
301                        Some(label.as_str()),
302                    )?;
303                    if let Some(()) = self
304                        .send_to_candidates(activity, candidates, &span_fields)
305                        .await?
306                    {
307                        return Ok(());
308                    }
309                }
310                // No required label had a live worker this pass. WAIT for a worker
311                // to register, then retry the WHOLE required set — never fall back
312                // to a node=None any-worker dispatch (the hard-pin invariant).
313                tracing::info!(
314                    namespace = %activity.namespace,
315                    task_queue = %activity.task_queue,
316                    activity_type = %activity.activity_type,
317                    workflow_id = %activity.workflow_id,
318                    activity_id = %activity.activity_id,
319                    "no worker on a required (Pinned) node; waiting — will NOT spill to any-node"
320                );
321                self.registry.wait_for_worker().await;
322            }
323        }
324        .instrument(span)
325        .await
326        .inspect_err(|error| {
327            log_dispatch_error("activity_dispatch_requiring", activity, error);
328        })
329    }
330
331    /// Dispatch `activity` over an ordered `tiers` sequence of node filters, each
332    /// a `Some(label)` preference or the final `None` spill (the shared
333    /// [`preferred_node_order`](crate::worker::preferred_node_order) output). The
334    /// first non-spill tier with a live worker wins via a NON-WAITING
335    /// `workers_for`; the `None` spill tier falls back to the waiting
336    /// [`Self::dispatch_to_node`] so the wait-for-worker backstop and round-robin
337    /// behave exactly as today.
338    ///
339    /// # Errors
340    ///
341    /// As [`Self::dispatch`].
342    async fn dispatch_over_tiers(
343        &self,
344        activity: &ScheduledActivity,
345        tiers: &[Option<String>],
346    ) -> Result<(), ServerError> {
347        let span = info_span!(
348            "activity_dispatch",
349            operation = "activity_dispatch_preferring",
350            namespace = %activity.namespace,
351            task_queue = %activity.task_queue,
352            workflow_id = %activity.workflow_id,
353            activity_id = %activity.activity_id,
354            activity_type = %activity.activity_type,
355            worker_id = tracing::field::Empty,
356        );
357        let span_fields = span.clone();
358        async {
359            for tier in tiers {
360                let Some(label) = tier else {
361                    // The `None` spill tier: fall back to the waiting unpinned
362                    // dispatch (wait-for-worker backstop + round-robin).
363                    return self
364                        .dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
365                        .await;
366                };
367                self.drain_state
368                    .ensure_accepting(&activity.namespace, &activity.activity_type)?;
369                let candidates = self.registry.workers_for(
370                    &activity.namespace,
371                    &activity.task_queue,
372                    &activity.activity_type,
373                    Some(label.as_str()),
374                )?;
375                if let Some(()) = self
376                    .send_to_candidates(activity, candidates, &span_fields)
377                    .await?
378                {
379                    return Ok(());
380                }
381            }
382            // An empty tier list (never produced by `preferred_node_order`, which
383            // always appends the spill) still degrades to the unpinned dispatch.
384            self.dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
385                .await
386        }
387        .instrument(span)
388        .await
389        .inspect_err(|error| {
390            log_dispatch_error("activity_dispatch_preferring", activity, error);
391        })
392    }
393
394    /// The waiting dispatch core: select a worker for `node` (waiting for one to
395    /// register when none is live, exactly as before), then push the task.
396    async fn dispatch_to_node(
397        &self,
398        activity: &ScheduledActivity,
399        node: Option<&str>,
400        span_fields: &tracing::Span,
401    ) -> Result<(), ServerError> {
402        // The wait is unbounded, deliberately and unchanged: bounding a dispatch
403        // to an unserved queue is a semantics decision that is the operator's,
404        // and inventing one here would refuse work nobody asked to have refused.
405        // What changes is that the park is now VISIBLE. This loop used to emit
406        // one `info!` and block, so a permanently parked row had no state to
407        // query, `dispatch_parked` read false while it was in fact parked
408        // forever, and — because `dispatch` never returns — the outbox row sat
409        // `claimed` where dead-letter and redrive could not see it either.
410        let address = ServiceAddress {
411            namespace: activity.namespace.clone(),
412            task_queue: activity.task_queue.clone(),
413            activity_type: activity.activity_type.clone(),
414            node: node.map(ToOwned::to_owned),
415        };
416        let wait = ServiceWait {
417            registry: &self.registry,
418            declarations: &self.queue_declarations,
419            config: &self.queue_service_config,
420            state: &self.queue_service_state,
421            address: &address,
422            workflow_id: &activity.workflow_id,
423            activity_id: &activity.activity_id,
424            publisher: self.cluster_publisher.as_ref(),
425        };
426        let policy = self
427            .queue_service_config
428            .policy_for(&activity.namespace, &activity.task_queue);
429        let started_at = std::time::Instant::now();
430        let mut reported: Option<QueueServiceReason> = None;
431        let workers = loop {
432            self.drain_state
433                .ensure_accepting(&activity.namespace, &activity.activity_type)
434                .inspect_err(|_| clear_selection_miss(&wait))?;
435            let candidates = self
436                .registry
437                .workers_for(
438                    &activity.namespace,
439                    &activity.task_queue,
440                    &activity.activity_type,
441                    node,
442                )
443                .inspect_err(|_| clear_selection_miss(&wait))?;
444            if !candidates.is_empty() {
445                if let Some(reason) = reported {
446                    tracing::info!(
447                        namespace = %activity.namespace,
448                        task_queue = %activity.task_queue,
449                        activity_type = %activity.activity_type,
450                        workflow_id = %activity.workflow_id,
451                        activity_id = %activity.activity_id,
452                        queue_service_reason = reason.as_str(),
453                        "queue service restored; the parked dispatch has a worker"
454                    );
455                }
456                clear_selection_miss(&wait);
457                break candidates;
458            }
459            match observe_selection_miss(&wait, policy, None, started_at.elapsed(), reported) {
460                // A worker arrived between the miss and the census: re-select
461                // rather than announce a state that is already untrue.
462                Ok(None) => continue,
463                Ok(Some(observed)) => reported = Some(observed.reason),
464                Err(refusal) => {
465                    clear_selection_miss(&wait);
466                    return Err(ServerError::worker_dispatch(
467                        activity.namespace.clone(),
468                        activity.activity_type.clone(),
469                        refusal.reason_string(),
470                    ));
471                }
472            }
473            self.registry.wait_for_worker().await;
474        };
475        match self
476            .send_to_candidates(activity, workers, span_fields)
477            .await?
478        {
479            Some(()) => Ok(()),
480            None => Err(ServerError::worker_dispatch(
481                activity.namespace.clone(),
482                activity.activity_type.clone(),
483                format!(
484                    "all matching worker streams in task queue {} closed before task could be \
485                     delivered",
486                    activity.task_queue
487                ),
488            )),
489        }
490    }
491
492    /// Try each candidate in order, pushing the task to the first live stream.
493    /// Returns `Ok(Some(()))` on a delivered task, `Ok(None)` when every candidate
494    /// stream was already closed (deregistered as it went). An empty candidate
495    /// list returns `Ok(None)` so callers can treat it as "no live worker here".
496    async fn send_to_candidates(
497        &self,
498        activity: &ScheduledActivity,
499        candidates: Vec<crate::worker::registry::WorkerHandle>,
500        span_fields: &tracing::Span,
501    ) -> Result<Option<()>, ServerError> {
502        activity.require_run_id()?;
503        let completion_token = self
504            .completion_fences
505            .issue(&activity.workflow_id, &activity.activity_id)?;
506        let task = activity.to_task(&completion_token)?;
507        for worker in candidates {
508            if let Err(error) = self
509                .drain_state
510                .ensure_accepting(&activity.namespace, &activity.activity_type)
511            {
512                self.completion_fences.revoke(
513                    &activity.workflow_id,
514                    &activity.activity_id,
515                    &completion_token,
516                )?;
517                return Err(error);
518            }
519            span_fields.record("worker_id", format!("{:?}", worker.id()));
520            // The gRPC dispatch path only registers gRPC-delivery workers, so a
521            // worker here always carries a stream sender; a missing one means a
522            // non-gRPC-transport worker leaked into this path and cannot be served
523            // over it, so it is deregistered like a closed stream.
524            if let Some(sender) = worker.sender()
525                && sender
526                    .send(WorkerMessage::ActivityTask(Box::new(task.clone())))
527                    .await
528                    .is_ok()
529            {
530                return Ok(Some(()));
531            }
532            self.registry.deregister(worker.id())?;
533        }
534        self.completion_fences.revoke(
535            &activity.workflow_id,
536            &activity.activity_id,
537            &completion_token,
538        )?;
539        Ok(None)
540    }
541}
542
543fn log_dispatch_error(operation: &'static str, activity: &ScheduledActivity, error: &ServerError) {
544    let fields = error.trace_fields();
545    tracing::error!(
546        operation,
547        namespace = %activity.namespace,
548        task_queue = %activity.task_queue,
549        node = activity.node.as_deref(),
550        workflow_id = %activity.workflow_id,
551        activity_id = %activity.activity_id,
552        activity_type = %activity.activity_type,
553        error_type = %fields.error_type,
554        store_error_type = fields.store_error_type,
555        reason = %fields.reason,
556        "activity dispatch failed"
557    );
558}
559
560/// Decoded activity outcome reported by a worker.
561#[derive(Clone, Debug, Eq, PartialEq)]
562pub enum ActivityCompletionOutcome {
563    /// Activity completed successfully with an output payload.
564    Succeeded(Payload),
565    /// Activity failed, preserving retryability classification for the engine.
566    Failed(ActivityError),
567    /// The worker was lost BEFORE the activity reported any result — a
568    /// TRANSPORT-domain loss, not an activity failure.
569    ///
570    /// A distinct variant rather than a `Failed` wearing a retryable kind,
571    /// because the two are different failure domains and were being conflated:
572    /// a synthesized `retryable:worker ... lost` was delivered verbatim as a
573    /// TERMINAL failure whenever the activity carried no authored retry policy,
574    /// so every infrastructure death read as a red action. The classification
575    /// and the transport's own re-dispatch budget live in
576    /// [`transport_loss`](crate::worker::transport_loss).
577    WorkerLost {
578        /// The worker that died holding this activity.
579        worker_id: crate::worker::registry::WorkerId,
580    },
581}
582
583/// Correlated activity completion handed to the engine-owned activity contract.
584#[derive(Clone, Debug, Eq, PartialEq)]
585pub struct ActivityCompletion {
586    /// Owning workflow id.
587    pub workflow_id: WorkflowId,
588    /// Correlating activity id.
589    pub activity_id: ActivityId,
590    /// Concrete workflow run echoed by the worker, when known.
591    pub run_id: Option<RunId>,
592    /// Opaque execution generation echoed from the dispatched task.
593    pub completion_token: CompletionToken,
594    /// Worker-reported outcome.
595    pub outcome: ActivityCompletionOutcome,
596}
597
598impl TryFrom<ProtoActivityResult> for ActivityCompletion {
599    type Error = ServerError;
600
601    fn try_from(value: ProtoActivityResult) -> Result<Self, Self::Error> {
602        let workflow_id = value
603            .workflow_id
604            .ok_or_else(|| wire_error("activity result workflow id is missing"))
605            .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
606        let activity_id = value
607            .activity_id
608            .ok_or_else(|| wire_error("activity result activity id is missing"))
609            .map(ActivityId::from)?;
610        let run_id = value
611            .run_id
612            .ok_or_else(|| wire_error("activity result run id is missing"))
613            .and_then(|id| RunId::try_from(id).map_err(ServerError::from))?;
614        let completion_token =
615            CompletionToken::from_wire(&workflow_id, &activity_id, value.completion_token)?;
616        let outcome = match value.outcome {
617            Some(proto_activity_result::Outcome::Result(payload)) => {
618                ActivityCompletionOutcome::Succeeded(
619                    Payload::try_from(payload).map_err(ServerError::from)?,
620                )
621            }
622            Some(proto_activity_result::Outcome::Error(error)) => {
623                ActivityCompletionOutcome::Failed(
624                    ActivityError::try_from(error).map_err(ServerError::from)?,
625                )
626            }
627            None => return Err(wire_error("activity result outcome is missing")),
628        };
629
630        Ok(Self {
631            workflow_id,
632            activity_id,
633            run_id: Some(run_id),
634            completion_token,
635            outcome,
636        })
637    }
638}
639
640/// Engine-owned activity completion contract used by the worker endpoint.
641pub trait ActivityCompletionSink {
642    /// Feed one worker-reported result into the engine activity contract.
643    ///
644    /// # Errors
645    ///
646    /// Returns [`ServerError`] when the engine rejects or cannot record the completion.
647    fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError>;
648
649    /// Park one in-flight dispatch for restart recovery during a graceful
650    /// drain (#207): resolve the LOCAL waiter with the ephemeral parked
651    /// sentinel and nothing else.
652    ///
653    /// Parking is the anti-completion — it writes nothing durable, delivers
654    /// nothing to workflow code, and never crosses the SDK wire. It exists so a
655    /// drain leaves the durable log at exactly the dangling
656    /// `ActivityScheduled`/`ActivityStarted` a kill -9 would leave (the proven
657    /// re-dispatchable state) while still unblocking the blocking dispatcher
658    /// thread, so process exit is never wedged on tokio's blocking pool. A
659    /// dispatch with no matching waiter (already resolved) is a no-op — a park
660    /// must never be routed as an outbox failure delivery.
661    ///
662    /// # Errors
663    ///
664    /// Returns [`ServerError`] when sink state cannot be trusted.
665    fn park_activity(
666        &self,
667        workflow_id: &WorkflowId,
668        activity_id: &ActivityId,
669    ) -> Result<(), ServerError>;
670}
671
672/// Decode and hand a worker result to the engine-owned activity completion sink.
673///
674/// # Errors
675///
676/// Returns [`ServerError`] for malformed wire results or sink failures.
677pub fn handle_activity_result(
678    sink: &impl ActivityCompletionSink,
679    result: ProtoActivityResult,
680) -> Result<(), ServerError> {
681    sink.complete_activity(ActivityCompletion::try_from(result)?)
682}
683
684fn wire_error(message: &'static str) -> ServerError {
685    ServerError::Wire {
686        wire: WireError::backend(message),
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use std::sync::Mutex;
693
694    use aion_core::{ActivityErrorKind, ContentType};
695    use aion_proto::{ProtoActivityError, ProtoActivityErrorKind};
696    use serde_json::json;
697    use uuid::Uuid;
698
699    use crate::worker::registry::ConnectedWorkerRegistry;
700
701    use super::*;
702
703    fn workflow_id() -> WorkflowId {
704        WorkflowId::new(Uuid::nil())
705    }
706
707    fn activity_id() -> ActivityId {
708        ActivityId::from_sequence_position(42)
709    }
710
711    fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
712        Ok(Payload::from_json(value)?)
713    }
714
715    #[tokio::test]
716    async fn dispatch_pushes_activity_task_with_correlation()
717    -> Result<(), Box<dyn std::error::Error>> {
718        let registry = ConnectedWorkerRegistry::default();
719        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
720        let activity_types = [String::from("charge-card")];
721        let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
722        let dispatcher = ActivityDispatcher::new(registry.clone());
723        let input = payload(&json!({"amount": 1200}))?;
724        let scheduled = ScheduledActivity {
725            namespace: String::from("tenant-a"),
726            task_queue: String::from("default"),
727            activity_type: String::from("charge-card"),
728            node: None,
729            workflow_id: workflow_id(),
730            activity_id: activity_id(),
731            run_id: Some(RunId::new_v4()),
732            input: input.clone(),
733            attempt: 1,
734            labels: std::collections::BTreeMap::new(),
735        };
736
737        dispatcher.dispatch(&scheduled).await?;
738        let message = rx.recv().await.ok_or("expected pushed activity task")?;
739        let WorkerMessage::ActivityTask(task) = message else {
740            return Err("expected activity task message".into());
741        };
742
743        assert_eq!(task.workflow_id, Some(ProtoWorkflowId::from(workflow_id())));
744        assert_eq!(task.activity_id, Some(ProtoActivityId::from(activity_id())));
745        assert_eq!(task.activity_type, "charge-card");
746        assert_eq!(task.input, Some(ProtoPayload::from(input)));
747        assert_eq!(task.attempt, 1, "wire task must carry the stamped attempt");
748
749        registration.deregister()?;
750        Ok(())
751    }
752
753    #[tokio::test]
754    async fn dispatch_waits_for_worker_then_delivers() -> Result<(), Box<dyn std::error::Error>> {
755        let registry = ConnectedWorkerRegistry::default();
756        let dispatcher = ActivityDispatcher::new(registry.clone());
757        let scheduled = ScheduledActivity {
758            namespace: String::from("tenant-a"),
759            task_queue: String::from("default"),
760            activity_type: String::from("charge-card"),
761            node: None,
762            workflow_id: workflow_id(),
763            activity_id: activity_id(),
764            run_id: Some(RunId::new_v4()),
765            input: Payload::new(ContentType::Json, b"{}".to_vec()),
766            attempt: 1,
767            labels: std::collections::BTreeMap::new(),
768        };
769
770        let dispatch_handle = tokio::spawn({
771            let dispatcher = dispatcher.clone();
772            let scheduled = scheduled.clone();
773            async move { dispatcher.dispatch(&scheduled).await }
774        });
775
776        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
777        assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
778
779        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
780        let activity_types = [String::from("charge-card")];
781        let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
782
783        dispatch_handle.await??;
784        assert!(rx.recv().await.is_some());
785        Ok(())
786    }
787
788    /// The park on this leg must be VISIBLE — queryable, not merely logged.
789    ///
790    /// This loop predates the queue-service taxonomy and never adopted it, so a
791    /// dispatch parked here published no state at all: `GET /queues/unserved`
792    /// and `describe`'s `unserved` list both read empty while a row sat parked
793    /// forever, and because `dispatch` never returns, the outbox row stayed
794    /// `claimed` where dead-letter and redrive could not see it either. Three
795    /// surfaces, all reading "nothing to see".
796    ///
797    /// The wait is deliberately still unbounded. Bounding it is the operator's
798    /// decision, not this function's.
799    #[tokio::test]
800    async fn a_dispatch_with_no_worker_publishes_its_park_and_clears_on_arrival()
801    -> Result<(), Box<dyn std::error::Error>> {
802        let registry = ConnectedWorkerRegistry::default();
803        let state = QueueServiceState::default();
804        let dispatcher = ActivityDispatcher::new(registry.clone()).with_queue_service(
805            QueueDeclarationSource::default(),
806            state.clone(),
807            QueueServiceConfig::default(),
808        );
809        let scheduled = ScheduledActivity {
810            namespace: String::from("tenant-a"),
811            task_queue: String::from("default"),
812            activity_type: String::from("charge-card"),
813            node: None,
814            workflow_id: workflow_id(),
815            activity_id: activity_id(),
816            run_id: Some(RunId::new_v4()),
817            input: Payload::new(ContentType::Json, b"{}".to_vec()),
818            attempt: 1,
819            labels: std::collections::BTreeMap::new(),
820        };
821
822        // Nothing is parked before the dispatch: the assertion below would pass
823        // vacuously against a state that reported everything as unserved.
824        assert!(
825            state.unserved()?.is_empty(),
826            "no dispatch has been made yet"
827        );
828
829        // CONTROL ARM, built for this promotion. A dispatcher that does NOT
830        // share the queue-service seams behaves exactly as this loop did before
831        // the change: it parks, and the shared state learns nothing. Running it
832        // first proves the assertion below detects the ABSENCE of publishing
833        // rather than passing on any state at all.
834        let unwired = ActivityDispatcher::new(registry.clone());
835        let unwired_handle = tokio::spawn({
836            let scheduled = scheduled.clone();
837            async move { unwired.dispatch(&scheduled).await }
838        });
839        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
840        assert!(
841            state.unserved()?.is_empty(),
842            "an unshared dispatcher must publish nothing HERE — that is the \
843             defect this test exists to catch, reproduced on purpose"
844        );
845        unwired_handle.abort();
846
847        let dispatch_handle = tokio::spawn({
848            let dispatcher = dispatcher.clone();
849            let scheduled = scheduled.clone();
850            async move { dispatcher.dispatch(&scheduled).await }
851        });
852        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
853        assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
854
855        let unserved = state.unserved()?;
856        assert_eq!(
857            unserved.len(),
858            1,
859            "the parked dispatch must be queryable, not just logged: {unserved:?}"
860        );
861        assert_eq!(unserved[0].key.task_queue, "default");
862        assert_eq!(
863            unserved[0].reason,
864            QueueServiceReason::NoLivePollers,
865            "an empty pool must be classified, not reported as a bare miss"
866        );
867        assert_eq!(
868            state.parked_on_queue("default")?,
869            1,
870            "the run parked on the queue must be attributable to the queue"
871        );
872
873        // A worker arrives: the dispatch completes AND the state clears, so an
874        // operator is not left reading a park that has already resolved.
875        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
876        let activity_types = [String::from("charge-card")];
877        let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
878
879        dispatch_handle.await??;
880        assert!(rx.recv().await.is_some(), "the task must be delivered");
881        assert!(
882            state.unserved()?.is_empty(),
883            "a served dispatch must not be left published as unserved: {:?}",
884            state.unserved()?
885        );
886        Ok(())
887    }
888
889    #[tokio::test]
890    async fn dispatch_skips_closed_worker_and_uses_next_match()
891    -> Result<(), Box<dyn std::error::Error>> {
892        let registry = ConnectedWorkerRegistry::default();
893        let (closed_tx, closed_rx) = tokio::sync::mpsc::channel(1);
894        let (live_tx, mut live_rx) = tokio::sync::mpsc::channel(1);
895        let activity_types = [String::from("charge-card")];
896        let closed_registration =
897            registry.register("tenant-a", activity_types.iter(), closed_tx)?;
898        let live_registration = registry.register("tenant-a", activity_types.iter(), live_tx)?;
899        drop(closed_rx);
900
901        let dispatcher = ActivityDispatcher::new(registry.clone());
902        let scheduled = ScheduledActivity {
903            namespace: String::from("tenant-a"),
904            task_queue: String::from("default"),
905            activity_type: String::from("charge-card"),
906            node: None,
907            workflow_id: workflow_id(),
908            activity_id: activity_id(),
909            run_id: Some(RunId::new_v4()),
910            input: Payload::new(ContentType::Json, b"{}".to_vec()),
911            attempt: 1,
912            labels: std::collections::BTreeMap::new(),
913        };
914
915        dispatcher.dispatch(&scheduled).await?;
916
917        assert!(live_rx.recv().await.is_some());
918        assert_eq!(
919            registry
920                .workers_for("tenant-a", "default", "charge-card", None)?
921                .len(),
922            1
923        );
924
925        closed_registration.deregister()?;
926        live_registration.deregister()?;
927        Ok(())
928    }
929
930    fn scheduled_unpinned() -> ScheduledActivity {
931        ScheduledActivity {
932            namespace: String::from("tenant-a"),
933            task_queue: String::from("default"),
934            activity_type: String::from("charge-card"),
935            // UNPINNED row: `node == None`, so placement (here a Pinned require) is
936            // the worker-selection input — the row's own node is never set.
937            node: None,
938            workflow_id: workflow_id(),
939            activity_id: activity_id(),
940            run_id: Some(RunId::new_v4()),
941            input: Payload::new(ContentType::Json, b"{}".to_vec()),
942            attempt: 1,
943            labels: std::collections::BTreeMap::new(),
944        }
945    }
946
947    fn required(labels: &[&str]) -> std::collections::BTreeSet<String> {
948        labels.iter().map(|l| (*l).to_owned()).collect()
949    }
950
951    /// P2-I1 gRPC hard-pin: an unpinned row in a `Pinned{n1}` namespace WAITS when
952    /// no `n1` worker is live and NEVER spills to a live any-node worker — the
953    /// opposite of `Prefer`. This test would FAIL under the old fall-through (which
954    /// dispatched `Pinned` to any worker).
955    #[tokio::test]
956    async fn dispatch_requiring_waits_and_never_spills_to_a_wrong_node_worker()
957    -> Result<(), Box<dyn std::error::Error>> {
958        let registry = ConnectedWorkerRegistry::default();
959        let dispatcher = ActivityDispatcher::new(registry.clone());
960        let scheduled = scheduled_unpinned();
961        let types = [String::from("charge-card")];
962
963        // A LIVE worker on the WRONG node (n2) — a Prefer would spill to it; a
964        // Pinned{n1} must NOT.
965        let (wrong_tx, mut wrong_rx) = tokio::sync::mpsc::channel(1);
966        let _wrong = registry.register_namespaces(
967            [String::from("tenant-a")],
968            "default",
969            Some(String::from("n2")),
970            types.iter(),
971            wrong_tx,
972        )?;
973
974        let handle = tokio::spawn({
975            let dispatcher = dispatcher.clone();
976            let scheduled = scheduled.clone();
977            async move {
978                dispatcher
979                    .dispatch_requiring(&scheduled, &required(&["n1"]))
980                    .await
981            }
982        });
983
984        // The wrong-node worker is idle and live, yet dispatch must still be waiting.
985        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
986        assert!(
987            !handle.is_finished(),
988            "Pinned{{n1}} must WAIT rather than spill to the live n2 worker"
989        );
990        assert!(
991            wrong_rx.try_recv().is_err(),
992            "the wrong-node (n2) worker must never receive the task"
993        );
994
995        // Bring up the REQUIRED n1 worker: the wait resolves onto it.
996        let (right_tx, mut right_rx) = tokio::sync::mpsc::channel(1);
997        let _right = registry.register_namespaces(
998            [String::from("tenant-a")],
999            "default",
1000            Some(String::from("n1")),
1001            types.iter(),
1002            right_tx,
1003        )?;
1004
1005        handle.await??;
1006        assert!(
1007            right_rx.recv().await.is_some(),
1008            "the required n1 worker receives the task once live"
1009        );
1010        assert!(
1011            wrong_rx.try_recv().is_err(),
1012            "the wrong-node worker still never received it"
1013        );
1014        Ok(())
1015    }
1016
1017    /// P2-I1 determinism: the row's authored `node` stays `None` through a Pinned
1018    /// dispatch — placement is a pure selection input, never written back.
1019    #[tokio::test]
1020    async fn dispatch_requiring_never_mutates_the_rows_node()
1021    -> Result<(), Box<dyn std::error::Error>> {
1022        let registry = ConnectedWorkerRegistry::default();
1023        let dispatcher = ActivityDispatcher::new(registry.clone());
1024        let scheduled = scheduled_unpinned();
1025        assert_eq!(scheduled.node, None, "precondition: the row is unpinned");
1026        let types = [String::from("charge-card")];
1027        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
1028        let _right = registry.register_namespaces(
1029            [String::from("tenant-a")],
1030            "default",
1031            Some(String::from("n1")),
1032            types.iter(),
1033            tx,
1034        )?;
1035
1036        dispatcher
1037            .dispatch_requiring(&scheduled, &required(&["n1"]))
1038            .await?;
1039
1040        assert!(rx.recv().await.is_some(), "the n1 worker received the task");
1041        assert_eq!(
1042            scheduled.node, None,
1043            "the row's authored node MUST remain None through a Pinned dispatch \
1044             (the determinism invariant, CP-Phase-2 §2.4)"
1045        );
1046        Ok(())
1047    }
1048
1049    #[derive(Default)]
1050    struct RecordingSink {
1051        completions: Mutex<Vec<ActivityCompletion>>,
1052    }
1053
1054    impl ActivityCompletionSink for RecordingSink {
1055        fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
1056            self.completions
1057                .lock()
1058                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1059                .push(completion);
1060            Ok(())
1061        }
1062
1063        fn park_activity(
1064            &self,
1065            _workflow_id: &WorkflowId,
1066            _activity_id: &ActivityId,
1067        ) -> Result<(), ServerError> {
1068            Err(ServerError::worker_dispatch(
1069                "",
1070                "",
1071                "result-handoff tests never park a dispatch",
1072            ))
1073        }
1074    }
1075
1076    #[test]
1077    fn successful_activity_result_calls_completion_sink() -> Result<(), Box<dyn std::error::Error>>
1078    {
1079        let sink = RecordingSink::default();
1080        let output = payload(&json!({"ok": true}))?;
1081        let result = ProtoActivityResult {
1082            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
1083            activity_id: Some(ProtoActivityId::from(activity_id())),
1084            run_id: Some(ProtoRunId::from(RunId::new_v4())),
1085            completion_token: String::from("generation-1"),
1086            outcome: Some(proto_activity_result::Outcome::Result(ProtoPayload::from(
1087                output.clone(),
1088            ))),
1089        };
1090
1091        handle_activity_result(&sink, result)?;
1092        let completions = sink
1093            .completions
1094            .lock()
1095            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1096
1097        assert_eq!(completions.len(), 1);
1098        assert_eq!(completions[0].workflow_id, workflow_id());
1099        assert_eq!(completions[0].activity_id, activity_id());
1100        assert_eq!(
1101            completions[0].outcome,
1102            ActivityCompletionOutcome::Succeeded(output)
1103        );
1104        Ok(())
1105    }
1106
1107    #[test]
1108    fn failed_activity_result_preserves_error_classification()
1109    -> Result<(), Box<dyn std::error::Error>> {
1110        let sink = RecordingSink::default();
1111        let error = ProtoActivityError {
1112            kind: ProtoActivityErrorKind::Retryable as i32,
1113            message: String::from("temporary outage"),
1114            details: Some(ProtoPayload::from(payload(
1115                &json!({"retry_after_ms": 500}),
1116            )?)),
1117        };
1118        let result = ProtoActivityResult {
1119            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
1120            activity_id: Some(ProtoActivityId::from(activity_id())),
1121            run_id: Some(ProtoRunId::from(RunId::new_v4())),
1122            completion_token: String::from("generation-1"),
1123            outcome: Some(proto_activity_result::Outcome::Error(error)),
1124        };
1125
1126        handle_activity_result(&sink, result)?;
1127        let completions = sink
1128            .completions
1129            .lock()
1130            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1131
1132        assert_eq!(completions.len(), 1);
1133        match &completions[0].outcome {
1134            ActivityCompletionOutcome::Failed(error) => {
1135                assert_eq!(error.kind, ActivityErrorKind::Retryable);
1136                assert!(error.is_retryable());
1137            }
1138            other => return Err(format!("expected failed outcome, got {other:?}").into()),
1139        }
1140        Ok(())
1141    }
1142}