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                if sender
526                    .send(WorkerMessage::ActivityTask(Box::new(task.clone())))
527                    .await
528                    .is_ok()
529                {
530                    return Ok(Some(()));
531                }
532            }
533            self.registry.deregister(worker.id())?;
534        }
535        self.completion_fences.revoke(
536            &activity.workflow_id,
537            &activity.activity_id,
538            &completion_token,
539        )?;
540        Ok(None)
541    }
542}
543
544fn log_dispatch_error(operation: &'static str, activity: &ScheduledActivity, error: &ServerError) {
545    let fields = error.trace_fields();
546    tracing::error!(
547        operation,
548        namespace = %activity.namespace,
549        task_queue = %activity.task_queue,
550        node = activity.node.as_deref(),
551        workflow_id = %activity.workflow_id,
552        activity_id = %activity.activity_id,
553        activity_type = %activity.activity_type,
554        error_type = %fields.error_type,
555        store_error_type = fields.store_error_type,
556        reason = %fields.reason,
557        "activity dispatch failed"
558    );
559}
560
561/// Decoded activity outcome reported by a worker.
562#[derive(Clone, Debug, Eq, PartialEq)]
563pub enum ActivityCompletionOutcome {
564    /// Activity completed successfully with an output payload.
565    Succeeded(Payload),
566    /// Activity failed, preserving retryability classification for the engine.
567    Failed(ActivityError),
568    /// The worker was lost BEFORE the activity reported any result — a
569    /// TRANSPORT-domain loss, not an activity failure.
570    ///
571    /// A distinct variant rather than a `Failed` wearing a retryable kind,
572    /// because the two are different failure domains and were being conflated:
573    /// a synthesized `retryable:worker ... lost` was delivered verbatim as a
574    /// TERMINAL failure whenever the activity carried no authored retry policy,
575    /// so every infrastructure death read as a red action. The classification
576    /// and the transport's own re-dispatch budget live in
577    /// [`transport_loss`](crate::worker::transport_loss).
578    WorkerLost {
579        /// The worker that died holding this activity.
580        worker_id: crate::worker::registry::WorkerId,
581    },
582}
583
584/// Correlated activity completion handed to the engine-owned activity contract.
585#[derive(Clone, Debug, Eq, PartialEq)]
586pub struct ActivityCompletion {
587    /// Owning workflow id.
588    pub workflow_id: WorkflowId,
589    /// Correlating activity id.
590    pub activity_id: ActivityId,
591    /// Concrete workflow run echoed by the worker, when known.
592    pub run_id: Option<RunId>,
593    /// Opaque execution generation echoed from the dispatched task.
594    pub completion_token: CompletionToken,
595    /// Worker-reported outcome.
596    pub outcome: ActivityCompletionOutcome,
597}
598
599impl TryFrom<ProtoActivityResult> for ActivityCompletion {
600    type Error = ServerError;
601
602    fn try_from(value: ProtoActivityResult) -> Result<Self, Self::Error> {
603        let workflow_id = value
604            .workflow_id
605            .ok_or_else(|| wire_error("activity result workflow id is missing"))
606            .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
607        let activity_id = value
608            .activity_id
609            .ok_or_else(|| wire_error("activity result activity id is missing"))
610            .map(ActivityId::from)?;
611        let run_id = value
612            .run_id
613            .ok_or_else(|| wire_error("activity result run id is missing"))
614            .and_then(|id| RunId::try_from(id).map_err(ServerError::from))?;
615        let completion_token =
616            CompletionToken::from_wire(&workflow_id, &activity_id, value.completion_token)?;
617        let outcome = match value.outcome {
618            Some(proto_activity_result::Outcome::Result(payload)) => {
619                ActivityCompletionOutcome::Succeeded(
620                    Payload::try_from(payload).map_err(ServerError::from)?,
621                )
622            }
623            Some(proto_activity_result::Outcome::Error(error)) => {
624                ActivityCompletionOutcome::Failed(
625                    ActivityError::try_from(error).map_err(ServerError::from)?,
626                )
627            }
628            None => return Err(wire_error("activity result outcome is missing")),
629        };
630
631        Ok(Self {
632            workflow_id,
633            activity_id,
634            run_id: Some(run_id),
635            completion_token,
636            outcome,
637        })
638    }
639}
640
641/// Engine-owned activity completion contract used by the worker endpoint.
642pub trait ActivityCompletionSink {
643    /// Feed one worker-reported result into the engine activity contract.
644    ///
645    /// # Errors
646    ///
647    /// Returns [`ServerError`] when the engine rejects or cannot record the completion.
648    fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError>;
649
650    /// Park one in-flight dispatch for restart recovery during a graceful
651    /// drain (#207): resolve the LOCAL waiter with the ephemeral parked
652    /// sentinel and nothing else.
653    ///
654    /// Parking is the anti-completion — it writes nothing durable, delivers
655    /// nothing to workflow code, and never crosses the SDK wire. It exists so a
656    /// drain leaves the durable log at exactly the dangling
657    /// `ActivityScheduled`/`ActivityStarted` a kill -9 would leave (the proven
658    /// re-dispatchable state) while still unblocking the blocking dispatcher
659    /// thread, so process exit is never wedged on tokio's blocking pool. A
660    /// dispatch with no matching waiter (already resolved) is a no-op — a park
661    /// must never be routed as an outbox failure delivery.
662    ///
663    /// # Errors
664    ///
665    /// Returns [`ServerError`] when sink state cannot be trusted.
666    fn park_activity(
667        &self,
668        workflow_id: &WorkflowId,
669        activity_id: &ActivityId,
670    ) -> Result<(), ServerError>;
671}
672
673/// Decode and hand a worker result to the engine-owned activity completion sink.
674///
675/// # Errors
676///
677/// Returns [`ServerError`] for malformed wire results or sink failures.
678pub fn handle_activity_result(
679    sink: &impl ActivityCompletionSink,
680    result: ProtoActivityResult,
681) -> Result<(), ServerError> {
682    sink.complete_activity(ActivityCompletion::try_from(result)?)
683}
684
685fn wire_error(message: &'static str) -> ServerError {
686    ServerError::Wire {
687        wire: WireError::backend(message),
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use std::sync::Mutex;
694
695    use aion_core::{ActivityErrorKind, ContentType};
696    use aion_proto::{ProtoActivityError, ProtoActivityErrorKind};
697    use serde_json::json;
698    use uuid::Uuid;
699
700    use crate::worker::registry::ConnectedWorkerRegistry;
701
702    use super::*;
703
704    fn workflow_id() -> WorkflowId {
705        WorkflowId::new(Uuid::nil())
706    }
707
708    fn activity_id() -> ActivityId {
709        ActivityId::from_sequence_position(42)
710    }
711
712    fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
713        Ok(Payload::from_json(value)?)
714    }
715
716    #[tokio::test]
717    async fn dispatch_pushes_activity_task_with_correlation()
718    -> Result<(), Box<dyn std::error::Error>> {
719        let registry = ConnectedWorkerRegistry::default();
720        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
721        let activity_types = [String::from("charge-card")];
722        let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
723        let dispatcher = ActivityDispatcher::new(registry.clone());
724        let input = payload(&json!({"amount": 1200}))?;
725        let scheduled = ScheduledActivity {
726            namespace: String::from("tenant-a"),
727            task_queue: String::from("default"),
728            activity_type: String::from("charge-card"),
729            node: None,
730            workflow_id: workflow_id(),
731            activity_id: activity_id(),
732            run_id: Some(RunId::new_v4()),
733            input: input.clone(),
734            attempt: 1,
735            labels: std::collections::BTreeMap::new(),
736        };
737
738        dispatcher.dispatch(&scheduled).await?;
739        let message = rx.recv().await.ok_or("expected pushed activity task")?;
740        let WorkerMessage::ActivityTask(task) = message else {
741            return Err("expected activity task message".into());
742        };
743
744        assert_eq!(task.workflow_id, Some(ProtoWorkflowId::from(workflow_id())));
745        assert_eq!(task.activity_id, Some(ProtoActivityId::from(activity_id())));
746        assert_eq!(task.activity_type, "charge-card");
747        assert_eq!(task.input, Some(ProtoPayload::from(input)));
748        assert_eq!(task.attempt, 1, "wire task must carry the stamped attempt");
749
750        registration.deregister()?;
751        Ok(())
752    }
753
754    #[tokio::test]
755    async fn dispatch_waits_for_worker_then_delivers() -> Result<(), Box<dyn std::error::Error>> {
756        let registry = ConnectedWorkerRegistry::default();
757        let dispatcher = ActivityDispatcher::new(registry.clone());
758        let scheduled = ScheduledActivity {
759            namespace: String::from("tenant-a"),
760            task_queue: String::from("default"),
761            activity_type: String::from("charge-card"),
762            node: None,
763            workflow_id: workflow_id(),
764            activity_id: activity_id(),
765            run_id: Some(RunId::new_v4()),
766            input: Payload::new(ContentType::Json, b"{}".to_vec()),
767            attempt: 1,
768            labels: std::collections::BTreeMap::new(),
769        };
770
771        let dispatch_handle = tokio::spawn({
772            let dispatcher = dispatcher.clone();
773            let scheduled = scheduled.clone();
774            async move { dispatcher.dispatch(&scheduled).await }
775        });
776
777        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
778        assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
779
780        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
781        let activity_types = [String::from("charge-card")];
782        let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
783
784        dispatch_handle.await??;
785        assert!(rx.recv().await.is_some());
786        Ok(())
787    }
788
789    /// The park on this leg must be VISIBLE — queryable, not merely logged.
790    ///
791    /// This loop predates the queue-service taxonomy and never adopted it, so a
792    /// dispatch parked here published no state at all: `GET /queues/unserved`
793    /// and `describe`'s `unserved` list both read empty while a row sat parked
794    /// forever, and because `dispatch` never returns, the outbox row stayed
795    /// `claimed` where dead-letter and redrive could not see it either. Three
796    /// surfaces, all reading "nothing to see".
797    ///
798    /// The wait is deliberately still unbounded. Bounding it is the operator's
799    /// decision, not this function's.
800    #[tokio::test]
801    async fn a_dispatch_with_no_worker_publishes_its_park_and_clears_on_arrival()
802    -> Result<(), Box<dyn std::error::Error>> {
803        let registry = ConnectedWorkerRegistry::default();
804        let state = QueueServiceState::default();
805        let dispatcher = ActivityDispatcher::new(registry.clone()).with_queue_service(
806            QueueDeclarationSource::default(),
807            state.clone(),
808            QueueServiceConfig::default(),
809        );
810        let scheduled = ScheduledActivity {
811            namespace: String::from("tenant-a"),
812            task_queue: String::from("default"),
813            activity_type: String::from("charge-card"),
814            node: None,
815            workflow_id: workflow_id(),
816            activity_id: activity_id(),
817            run_id: Some(RunId::new_v4()),
818            input: Payload::new(ContentType::Json, b"{}".to_vec()),
819            attempt: 1,
820            labels: std::collections::BTreeMap::new(),
821        };
822
823        // Nothing is parked before the dispatch: the assertion below would pass
824        // vacuously against a state that reported everything as unserved.
825        assert!(
826            state.unserved()?.is_empty(),
827            "no dispatch has been made yet"
828        );
829
830        // CONTROL ARM, built for this promotion. A dispatcher that does NOT
831        // share the queue-service seams behaves exactly as this loop did before
832        // the change: it parks, and the shared state learns nothing. Running it
833        // first proves the assertion below detects the ABSENCE of publishing
834        // rather than passing on any state at all.
835        let unwired = ActivityDispatcher::new(registry.clone());
836        let unwired_handle = tokio::spawn({
837            let scheduled = scheduled.clone();
838            async move { unwired.dispatch(&scheduled).await }
839        });
840        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
841        assert!(
842            state.unserved()?.is_empty(),
843            "an unshared dispatcher must publish nothing HERE — that is the \
844             defect this test exists to catch, reproduced on purpose"
845        );
846        unwired_handle.abort();
847
848        let dispatch_handle = tokio::spawn({
849            let dispatcher = dispatcher.clone();
850            let scheduled = scheduled.clone();
851            async move { dispatcher.dispatch(&scheduled).await }
852        });
853        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
854        assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
855
856        let unserved = state.unserved()?;
857        assert_eq!(
858            unserved.len(),
859            1,
860            "the parked dispatch must be queryable, not just logged: {unserved:?}"
861        );
862        assert_eq!(unserved[0].key.task_queue, "default");
863        assert_eq!(
864            unserved[0].reason,
865            QueueServiceReason::NoLivePollers,
866            "an empty pool must be classified, not reported as a bare miss"
867        );
868        assert_eq!(
869            state.parked_on_queue("default")?,
870            1,
871            "the run parked on the queue must be attributable to the queue"
872        );
873
874        // A worker arrives: the dispatch completes AND the state clears, so an
875        // operator is not left reading a park that has already resolved.
876        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
877        let activity_types = [String::from("charge-card")];
878        let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
879
880        dispatch_handle.await??;
881        assert!(rx.recv().await.is_some(), "the task must be delivered");
882        assert!(
883            state.unserved()?.is_empty(),
884            "a served dispatch must not be left published as unserved: {:?}",
885            state.unserved()?
886        );
887        Ok(())
888    }
889
890    #[tokio::test]
891    async fn dispatch_skips_closed_worker_and_uses_next_match()
892    -> Result<(), Box<dyn std::error::Error>> {
893        let registry = ConnectedWorkerRegistry::default();
894        let (closed_tx, closed_rx) = tokio::sync::mpsc::channel(1);
895        let (live_tx, mut live_rx) = tokio::sync::mpsc::channel(1);
896        let activity_types = [String::from("charge-card")];
897        let closed_registration =
898            registry.register("tenant-a", activity_types.iter(), closed_tx)?;
899        let live_registration = registry.register("tenant-a", activity_types.iter(), live_tx)?;
900        drop(closed_rx);
901
902        let dispatcher = ActivityDispatcher::new(registry.clone());
903        let scheduled = ScheduledActivity {
904            namespace: String::from("tenant-a"),
905            task_queue: String::from("default"),
906            activity_type: String::from("charge-card"),
907            node: None,
908            workflow_id: workflow_id(),
909            activity_id: activity_id(),
910            run_id: Some(RunId::new_v4()),
911            input: Payload::new(ContentType::Json, b"{}".to_vec()),
912            attempt: 1,
913            labels: std::collections::BTreeMap::new(),
914        };
915
916        dispatcher.dispatch(&scheduled).await?;
917
918        assert!(live_rx.recv().await.is_some());
919        assert_eq!(
920            registry
921                .workers_for("tenant-a", "default", "charge-card", None)?
922                .len(),
923            1
924        );
925
926        closed_registration.deregister()?;
927        live_registration.deregister()?;
928        Ok(())
929    }
930
931    fn scheduled_unpinned() -> ScheduledActivity {
932        ScheduledActivity {
933            namespace: String::from("tenant-a"),
934            task_queue: String::from("default"),
935            activity_type: String::from("charge-card"),
936            // UNPINNED row: `node == None`, so placement (here a Pinned require) is
937            // the worker-selection input — the row's own node is never set.
938            node: None,
939            workflow_id: workflow_id(),
940            activity_id: activity_id(),
941            run_id: Some(RunId::new_v4()),
942            input: Payload::new(ContentType::Json, b"{}".to_vec()),
943            attempt: 1,
944            labels: std::collections::BTreeMap::new(),
945        }
946    }
947
948    fn required(labels: &[&str]) -> std::collections::BTreeSet<String> {
949        labels.iter().map(|l| (*l).to_owned()).collect()
950    }
951
952    /// P2-I1 gRPC hard-pin: an unpinned row in a `Pinned{n1}` namespace WAITS when
953    /// no `n1` worker is live and NEVER spills to a live any-node worker — the
954    /// opposite of `Prefer`. This test would FAIL under the old fall-through (which
955    /// dispatched `Pinned` to any worker).
956    #[tokio::test]
957    async fn dispatch_requiring_waits_and_never_spills_to_a_wrong_node_worker()
958    -> Result<(), Box<dyn std::error::Error>> {
959        let registry = ConnectedWorkerRegistry::default();
960        let dispatcher = ActivityDispatcher::new(registry.clone());
961        let scheduled = scheduled_unpinned();
962        let types = [String::from("charge-card")];
963
964        // A LIVE worker on the WRONG node (n2) — a Prefer would spill to it; a
965        // Pinned{n1} must NOT.
966        let (wrong_tx, mut wrong_rx) = tokio::sync::mpsc::channel(1);
967        let _wrong = registry.register_namespaces(
968            [String::from("tenant-a")],
969            "default",
970            Some(String::from("n2")),
971            types.iter(),
972            wrong_tx,
973        )?;
974
975        let handle = tokio::spawn({
976            let dispatcher = dispatcher.clone();
977            let scheduled = scheduled.clone();
978            async move {
979                dispatcher
980                    .dispatch_requiring(&scheduled, &required(&["n1"]))
981                    .await
982            }
983        });
984
985        // The wrong-node worker is idle and live, yet dispatch must still be waiting.
986        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
987        assert!(
988            !handle.is_finished(),
989            "Pinned{{n1}} must WAIT rather than spill to the live n2 worker"
990        );
991        assert!(
992            wrong_rx.try_recv().is_err(),
993            "the wrong-node (n2) worker must never receive the task"
994        );
995
996        // Bring up the REQUIRED n1 worker: the wait resolves onto it.
997        let (right_tx, mut right_rx) = tokio::sync::mpsc::channel(1);
998        let _right = registry.register_namespaces(
999            [String::from("tenant-a")],
1000            "default",
1001            Some(String::from("n1")),
1002            types.iter(),
1003            right_tx,
1004        )?;
1005
1006        handle.await??;
1007        assert!(
1008            right_rx.recv().await.is_some(),
1009            "the required n1 worker receives the task once live"
1010        );
1011        assert!(
1012            wrong_rx.try_recv().is_err(),
1013            "the wrong-node worker still never received it"
1014        );
1015        Ok(())
1016    }
1017
1018    /// P2-I1 determinism: the row's authored `node` stays `None` through a Pinned
1019    /// dispatch — placement is a pure selection input, never written back.
1020    #[tokio::test]
1021    async fn dispatch_requiring_never_mutates_the_rows_node()
1022    -> Result<(), Box<dyn std::error::Error>> {
1023        let registry = ConnectedWorkerRegistry::default();
1024        let dispatcher = ActivityDispatcher::new(registry.clone());
1025        let scheduled = scheduled_unpinned();
1026        assert_eq!(scheduled.node, None, "precondition: the row is unpinned");
1027        let types = [String::from("charge-card")];
1028        let (tx, mut rx) = tokio::sync::mpsc::channel(1);
1029        let _right = registry.register_namespaces(
1030            [String::from("tenant-a")],
1031            "default",
1032            Some(String::from("n1")),
1033            types.iter(),
1034            tx,
1035        )?;
1036
1037        dispatcher
1038            .dispatch_requiring(&scheduled, &required(&["n1"]))
1039            .await?;
1040
1041        assert!(rx.recv().await.is_some(), "the n1 worker received the task");
1042        assert_eq!(
1043            scheduled.node, None,
1044            "the row's authored node MUST remain None through a Pinned dispatch \
1045             (the determinism invariant, CP-Phase-2 §2.4)"
1046        );
1047        Ok(())
1048    }
1049
1050    #[derive(Default)]
1051    struct RecordingSink {
1052        completions: Mutex<Vec<ActivityCompletion>>,
1053    }
1054
1055    impl ActivityCompletionSink for RecordingSink {
1056        fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
1057            self.completions
1058                .lock()
1059                .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1060                .push(completion);
1061            Ok(())
1062        }
1063
1064        fn park_activity(
1065            &self,
1066            _workflow_id: &WorkflowId,
1067            _activity_id: &ActivityId,
1068        ) -> Result<(), ServerError> {
1069            Err(ServerError::worker_dispatch(
1070                "",
1071                "",
1072                "result-handoff tests never park a dispatch",
1073            ))
1074        }
1075    }
1076
1077    #[test]
1078    fn successful_activity_result_calls_completion_sink() -> Result<(), Box<dyn std::error::Error>>
1079    {
1080        let sink = RecordingSink::default();
1081        let output = payload(&json!({"ok": true}))?;
1082        let result = ProtoActivityResult {
1083            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
1084            activity_id: Some(ProtoActivityId::from(activity_id())),
1085            run_id: Some(ProtoRunId::from(RunId::new_v4())),
1086            completion_token: String::from("generation-1"),
1087            outcome: Some(proto_activity_result::Outcome::Result(ProtoPayload::from(
1088                output.clone(),
1089            ))),
1090        };
1091
1092        handle_activity_result(&sink, result)?;
1093        let completions = sink
1094            .completions
1095            .lock()
1096            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1097
1098        assert_eq!(completions.len(), 1);
1099        assert_eq!(completions[0].workflow_id, workflow_id());
1100        assert_eq!(completions[0].activity_id, activity_id());
1101        assert_eq!(
1102            completions[0].outcome,
1103            ActivityCompletionOutcome::Succeeded(output)
1104        );
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn failed_activity_result_preserves_error_classification()
1110    -> Result<(), Box<dyn std::error::Error>> {
1111        let sink = RecordingSink::default();
1112        let error = ProtoActivityError {
1113            kind: ProtoActivityErrorKind::Retryable as i32,
1114            message: String::from("temporary outage"),
1115            details: Some(ProtoPayload::from(payload(
1116                &json!({"retry_after_ms": 500}),
1117            )?)),
1118        };
1119        let result = ProtoActivityResult {
1120            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
1121            activity_id: Some(ProtoActivityId::from(activity_id())),
1122            run_id: Some(ProtoRunId::from(RunId::new_v4())),
1123            completion_token: String::from("generation-1"),
1124            outcome: Some(proto_activity_result::Outcome::Error(error)),
1125        };
1126
1127        handle_activity_result(&sink, result)?;
1128        let completions = sink
1129            .completions
1130            .lock()
1131            .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1132
1133        assert_eq!(completions.len(), 1);
1134        match &completions[0].outcome {
1135            ActivityCompletionOutcome::Failed(error) => {
1136                assert_eq!(error.kind, ActivityErrorKind::Retryable);
1137                assert!(error.is_retryable());
1138            }
1139            other => return Err(format!("expected failed outcome, got {other:?}").into()),
1140        }
1141        Ok(())
1142    }
1143}