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