1use std::collections::BTreeMap;
4
5use aion_core::{ActivityError, ActivityErrorKind, ActivityId, Payload, RunId, WorkflowId};
6use aion_proto::{
7 ProtoActivityId, ProtoActivityResult, ProtoActivityTask, ProtoPayload, ProtoRunId,
8 ProtoWorkflowId, WireError, proto_activity_result,
9};
10
11use crate::error::ServerError;
12use crate::shutdown::DrainState;
13use crate::worker::registry::{ConnectedWorkerRegistry, WorkerMessage};
14use tracing::{Instrument, info_span};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct ScheduledActivity {
19 pub namespace: String,
22 pub task_queue: String,
26 pub activity_type: String,
29 pub node: Option<String>,
36 pub workflow_id: WorkflowId,
38 pub activity_id: ActivityId,
40 pub run_id: Option<RunId>,
42 pub input: Payload,
44 pub attempt: u32,
47 pub labels: BTreeMap<String, String>,
50}
51
52impl ScheduledActivity {
53 #[must_use]
55 pub fn to_task(&self) -> ProtoActivityTask {
56 ProtoActivityTask {
57 workflow_id: Some(ProtoWorkflowId::from(self.workflow_id.clone())),
58 activity_id: Some(ProtoActivityId::from(self.activity_id.clone())),
59 activity_type: self.activity_type.clone(),
60 input: Some(ProtoPayload::from(self.input.clone())),
61 attempt: self.attempt,
62 labels: self.labels.clone().into_iter().collect(),
63 run_id: self.run_id.clone().map(ProtoRunId::from),
64 }
65 }
66}
67
68#[derive(Clone, Debug)]
70pub struct ActivityDispatcher {
71 registry: ConnectedWorkerRegistry,
72 drain_state: DrainState,
73}
74
75impl ActivityDispatcher {
76 #[must_use]
78 pub fn new(registry: ConnectedWorkerRegistry) -> Self {
79 Self {
80 registry,
81 drain_state: DrainState::default(),
82 }
83 }
84
85 #[must_use]
87 pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
88 self.drain_state = drain_state;
89 self
90 }
91
92 pub async fn dispatch(&self, activity: &ScheduledActivity) -> Result<(), ServerError> {
99 let span = info_span!(
100 "activity_dispatch",
101 operation = "activity_dispatch",
102 namespace = %activity.namespace,
103 task_queue = %activity.task_queue,
104 node = activity.node.as_deref(),
105 workflow_id = %activity.workflow_id,
106 activity_id = %activity.activity_id,
107 activity_type = %activity.activity_type,
108 worker_id = tracing::field::Empty,
109 );
110 let span_fields = span.clone();
111
112 async {
113 self.dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
114 .await
115 }
116 .instrument(span)
117 .await
118 .inspect_err(|error| {
119 log_dispatch_error("activity_dispatch", activity, error);
120 })
121 }
122
123 pub async fn dispatch_preferring(
151 &self,
152 activity: &ScheduledActivity,
153 preferred: &std::collections::BTreeSet<String>,
154 ) -> Result<(), ServerError> {
155 let tiers = crate::worker::preferred_node_order(&aion_store::NamespacePlacement::Prefer {
158 nodes: preferred.clone(),
159 });
160 self.dispatch_over_tiers(activity, &tiers).await
161 }
162
163 pub async fn dispatch_requiring(
191 &self,
192 activity: &ScheduledActivity,
193 required: &std::collections::BTreeSet<String>,
194 ) -> Result<(), ServerError> {
195 let span = info_span!(
196 "activity_dispatch",
197 operation = "activity_dispatch_requiring",
198 namespace = %activity.namespace,
199 task_queue = %activity.task_queue,
200 workflow_id = %activity.workflow_id,
201 activity_id = %activity.activity_id,
202 activity_type = %activity.activity_type,
203 worker_id = tracing::field::Empty,
204 );
205 let span_fields = span.clone();
206 async {
207 loop {
208 for label in required {
209 self.drain_state
210 .ensure_accepting(&activity.namespace, &activity.activity_type)?;
211 let candidates = self.registry.workers_for(
212 &activity.namespace,
213 &activity.task_queue,
214 &activity.activity_type,
215 Some(label.as_str()),
216 )?;
217 if let Some(()) = self
218 .send_to_candidates(activity, candidates, &span_fields)
219 .await?
220 {
221 return Ok(());
222 }
223 }
224 tracing::info!(
228 namespace = %activity.namespace,
229 task_queue = %activity.task_queue,
230 activity_type = %activity.activity_type,
231 workflow_id = %activity.workflow_id,
232 activity_id = %activity.activity_id,
233 "no worker on a required (Pinned) node; waiting — will NOT spill to any-node"
234 );
235 self.registry.wait_for_worker().await;
236 }
237 }
238 .instrument(span)
239 .await
240 .inspect_err(|error| {
241 log_dispatch_error("activity_dispatch_requiring", activity, error);
242 })
243 }
244
245 async fn dispatch_over_tiers(
257 &self,
258 activity: &ScheduledActivity,
259 tiers: &[Option<String>],
260 ) -> Result<(), ServerError> {
261 let span = info_span!(
262 "activity_dispatch",
263 operation = "activity_dispatch_preferring",
264 namespace = %activity.namespace,
265 task_queue = %activity.task_queue,
266 workflow_id = %activity.workflow_id,
267 activity_id = %activity.activity_id,
268 activity_type = %activity.activity_type,
269 worker_id = tracing::field::Empty,
270 );
271 let span_fields = span.clone();
272 async {
273 for tier in tiers {
274 let Some(label) = tier else {
275 return self
278 .dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
279 .await;
280 };
281 self.drain_state
282 .ensure_accepting(&activity.namespace, &activity.activity_type)?;
283 let candidates = self.registry.workers_for(
284 &activity.namespace,
285 &activity.task_queue,
286 &activity.activity_type,
287 Some(label.as_str()),
288 )?;
289 if let Some(()) = self
290 .send_to_candidates(activity, candidates, &span_fields)
291 .await?
292 {
293 return Ok(());
294 }
295 }
296 self.dispatch_to_node(activity, activity.node.as_deref(), &span_fields)
299 .await
300 }
301 .instrument(span)
302 .await
303 .inspect_err(|error| {
304 log_dispatch_error("activity_dispatch_preferring", activity, error);
305 })
306 }
307
308 async fn dispatch_to_node(
311 &self,
312 activity: &ScheduledActivity,
313 node: Option<&str>,
314 span_fields: &tracing::Span,
315 ) -> Result<(), ServerError> {
316 let workers = loop {
317 self.drain_state
318 .ensure_accepting(&activity.namespace, &activity.activity_type)?;
319 let candidates = self.registry.workers_for(
320 &activity.namespace,
321 &activity.task_queue,
322 &activity.activity_type,
323 node,
324 )?;
325 if !candidates.is_empty() {
326 break candidates;
327 }
328 tracing::info!(
329 namespace = %activity.namespace,
330 task_queue = %activity.task_queue,
331 node = node,
332 activity_type = %activity.activity_type,
333 workflow_id = %activity.workflow_id,
334 activity_id = %activity.activity_id,
335 "no connected worker; waiting for a matching worker to register"
336 );
337 self.registry.wait_for_worker().await;
338 };
339 match self
340 .send_to_candidates(activity, workers, span_fields)
341 .await?
342 {
343 Some(()) => Ok(()),
344 None => Err(ServerError::worker_dispatch(
345 activity.namespace.clone(),
346 activity.activity_type.clone(),
347 format!(
348 "all matching worker streams in task queue {} closed before task could be \
349 delivered",
350 activity.task_queue
351 ),
352 )),
353 }
354 }
355
356 async fn send_to_candidates(
361 &self,
362 activity: &ScheduledActivity,
363 candidates: Vec<crate::worker::registry::WorkerHandle>,
364 span_fields: &tracing::Span,
365 ) -> Result<Option<()>, ServerError> {
366 for worker in candidates {
367 self.drain_state
368 .ensure_accepting(&activity.namespace, &activity.activity_type)?;
369 span_fields.record("worker_id", format!("{:?}", worker.id()));
370 if let Some(sender) = worker.sender() {
375 if sender
376 .send(WorkerMessage::ActivityTask(activity.to_task()))
377 .await
378 .is_ok()
379 {
380 return Ok(Some(()));
381 }
382 }
383 self.registry.deregister(worker.id())?;
384 }
385 Ok(None)
386 }
387}
388
389fn log_dispatch_error(operation: &'static str, activity: &ScheduledActivity, error: &ServerError) {
390 let fields = error.trace_fields();
391 tracing::error!(
392 operation,
393 namespace = %activity.namespace,
394 task_queue = %activity.task_queue,
395 node = activity.node.as_deref(),
396 workflow_id = %activity.workflow_id,
397 activity_id = %activity.activity_id,
398 activity_type = %activity.activity_type,
399 error_type = %fields.error_type,
400 store_error_type = fields.store_error_type,
401 reason = %fields.reason,
402 "activity dispatch failed"
403 );
404}
405
406#[derive(Clone, Debug, Eq, PartialEq)]
408pub enum ActivityCompletionOutcome {
409 Succeeded(Payload),
411 Failed(ActivityError),
413}
414
415#[derive(Clone, Debug, Eq, PartialEq)]
417pub struct ActivityCompletion {
418 pub workflow_id: WorkflowId,
420 pub activity_id: ActivityId,
422 pub run_id: Option<RunId>,
424 pub outcome: ActivityCompletionOutcome,
426}
427
428impl TryFrom<ProtoActivityResult> for ActivityCompletion {
429 type Error = ServerError;
430
431 fn try_from(value: ProtoActivityResult) -> Result<Self, Self::Error> {
432 let workflow_id = value
433 .workflow_id
434 .ok_or_else(|| wire_error("activity result workflow id is missing"))
435 .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
436 let activity_id = value
437 .activity_id
438 .ok_or_else(|| wire_error("activity result activity id is missing"))
439 .map(ActivityId::from)?;
440 let run_id = value
441 .run_id
442 .map(|id| RunId::try_from(id).map_err(ServerError::from))
443 .transpose()?;
444 let outcome = match value.outcome {
445 Some(proto_activity_result::Outcome::Result(payload)) => {
446 ActivityCompletionOutcome::Succeeded(
447 Payload::try_from(payload).map_err(ServerError::from)?,
448 )
449 }
450 Some(proto_activity_result::Outcome::Error(error)) => {
451 ActivityCompletionOutcome::Failed(
452 ActivityError::try_from(error).map_err(ServerError::from)?,
453 )
454 }
455 None => return Err(wire_error("activity result outcome is missing")),
456 };
457
458 Ok(Self {
459 workflow_id,
460 activity_id,
461 run_id,
462 outcome,
463 })
464 }
465}
466
467pub trait ActivityCompletionSink {
469 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError>;
475
476 fn park_activity(
493 &self,
494 workflow_id: &WorkflowId,
495 activity_id: &ActivityId,
496 ) -> Result<(), ServerError>;
497}
498
499pub fn handle_activity_result(
505 sink: &impl ActivityCompletionSink,
506 result: ProtoActivityResult,
507) -> Result<(), ServerError> {
508 sink.complete_activity(ActivityCompletion::try_from(result)?)
509}
510
511#[must_use]
517pub fn lost_worker_error(worker_id: crate::worker::registry::WorkerId) -> ActivityError {
518 ActivityError {
519 kind: ActivityErrorKind::Retryable,
520 message: format!("worker {worker_id:?} lost before reporting activity result"),
521 details: None,
522 }
523}
524
525fn wire_error(message: &'static str) -> ServerError {
526 ServerError::Wire {
527 wire: WireError::backend(message),
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use std::sync::Mutex;
534
535 use aion_core::{ActivityErrorKind, ContentType};
536 use aion_proto::{ProtoActivityError, ProtoActivityErrorKind};
537 use serde_json::json;
538 use uuid::Uuid;
539
540 use crate::worker::registry::ConnectedWorkerRegistry;
541
542 use super::*;
543
544 fn workflow_id() -> WorkflowId {
545 WorkflowId::new(Uuid::nil())
546 }
547
548 fn activity_id() -> ActivityId {
549 ActivityId::from_sequence_position(42)
550 }
551
552 fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
553 Ok(Payload::from_json(value)?)
554 }
555
556 #[tokio::test]
557 async fn dispatch_pushes_activity_task_with_correlation()
558 -> Result<(), Box<dyn std::error::Error>> {
559 let registry = ConnectedWorkerRegistry::default();
560 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
561 let activity_types = [String::from("charge-card")];
562 let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
563 let dispatcher = ActivityDispatcher::new(registry.clone());
564 let input = payload(&json!({"amount": 1200}))?;
565 let scheduled = ScheduledActivity {
566 namespace: String::from("tenant-a"),
567 task_queue: String::from("default"),
568 activity_type: String::from("charge-card"),
569 node: None,
570 workflow_id: workflow_id(),
571 activity_id: activity_id(),
572 run_id: None,
573 input: input.clone(),
574 attempt: 1,
575 labels: std::collections::BTreeMap::new(),
576 };
577
578 dispatcher.dispatch(&scheduled).await?;
579 let message = rx.recv().await.ok_or("expected pushed activity task")?;
580 let WorkerMessage::ActivityTask(task) = message else {
581 return Err("expected activity task message".into());
582 };
583
584 assert_eq!(task.workflow_id, Some(ProtoWorkflowId::from(workflow_id())));
585 assert_eq!(task.activity_id, Some(ProtoActivityId::from(activity_id())));
586 assert_eq!(task.activity_type, "charge-card");
587 assert_eq!(task.input, Some(ProtoPayload::from(input)));
588 assert_eq!(task.attempt, 1, "wire task must carry the stamped attempt");
589
590 registration.deregister()?;
591 Ok(())
592 }
593
594 #[tokio::test]
595 async fn dispatch_waits_for_worker_then_delivers() -> Result<(), Box<dyn std::error::Error>> {
596 let registry = ConnectedWorkerRegistry::default();
597 let dispatcher = ActivityDispatcher::new(registry.clone());
598 let scheduled = ScheduledActivity {
599 namespace: String::from("tenant-a"),
600 task_queue: String::from("default"),
601 activity_type: String::from("charge-card"),
602 node: None,
603 workflow_id: workflow_id(),
604 activity_id: activity_id(),
605 run_id: None,
606 input: Payload::new(ContentType::Json, b"{}".to_vec()),
607 attempt: 1,
608 labels: std::collections::BTreeMap::new(),
609 };
610
611 let dispatch_handle = tokio::spawn({
612 let dispatcher = dispatcher.clone();
613 let scheduled = scheduled.clone();
614 async move { dispatcher.dispatch(&scheduled).await }
615 });
616
617 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
618 assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
619
620 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
621 let activity_types = [String::from("charge-card")];
622 let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
623
624 dispatch_handle.await??;
625 assert!(rx.recv().await.is_some());
626 Ok(())
627 }
628
629 #[tokio::test]
630 async fn dispatch_skips_closed_worker_and_uses_next_match()
631 -> Result<(), Box<dyn std::error::Error>> {
632 let registry = ConnectedWorkerRegistry::default();
633 let (closed_tx, closed_rx) = tokio::sync::mpsc::channel(1);
634 let (live_tx, mut live_rx) = tokio::sync::mpsc::channel(1);
635 let activity_types = [String::from("charge-card")];
636 let closed_registration =
637 registry.register("tenant-a", activity_types.iter(), closed_tx)?;
638 let live_registration = registry.register("tenant-a", activity_types.iter(), live_tx)?;
639 drop(closed_rx);
640
641 let dispatcher = ActivityDispatcher::new(registry.clone());
642 let scheduled = ScheduledActivity {
643 namespace: String::from("tenant-a"),
644 task_queue: String::from("default"),
645 activity_type: String::from("charge-card"),
646 node: None,
647 workflow_id: workflow_id(),
648 activity_id: activity_id(),
649 run_id: None,
650 input: Payload::new(ContentType::Json, b"{}".to_vec()),
651 attempt: 1,
652 labels: std::collections::BTreeMap::new(),
653 };
654
655 dispatcher.dispatch(&scheduled).await?;
656
657 assert!(live_rx.recv().await.is_some());
658 assert_eq!(
659 registry
660 .workers_for("tenant-a", "default", "charge-card", None)?
661 .len(),
662 1
663 );
664
665 closed_registration.deregister()?;
666 live_registration.deregister()?;
667 Ok(())
668 }
669
670 fn scheduled_unpinned() -> ScheduledActivity {
671 ScheduledActivity {
672 namespace: String::from("tenant-a"),
673 task_queue: String::from("default"),
674 activity_type: String::from("charge-card"),
675 node: None,
678 workflow_id: workflow_id(),
679 activity_id: activity_id(),
680 run_id: None,
681 input: Payload::new(ContentType::Json, b"{}".to_vec()),
682 attempt: 1,
683 labels: std::collections::BTreeMap::new(),
684 }
685 }
686
687 fn required(labels: &[&str]) -> std::collections::BTreeSet<String> {
688 labels.iter().map(|l| (*l).to_owned()).collect()
689 }
690
691 #[tokio::test]
696 async fn dispatch_requiring_waits_and_never_spills_to_a_wrong_node_worker()
697 -> Result<(), Box<dyn std::error::Error>> {
698 let registry = ConnectedWorkerRegistry::default();
699 let dispatcher = ActivityDispatcher::new(registry.clone());
700 let scheduled = scheduled_unpinned();
701 let types = [String::from("charge-card")];
702
703 let (wrong_tx, mut wrong_rx) = tokio::sync::mpsc::channel(1);
706 let _wrong = registry.register_namespaces(
707 [String::from("tenant-a")],
708 "default",
709 Some(String::from("n2")),
710 types.iter(),
711 wrong_tx,
712 )?;
713
714 let handle = tokio::spawn({
715 let dispatcher = dispatcher.clone();
716 let scheduled = scheduled.clone();
717 async move {
718 dispatcher
719 .dispatch_requiring(&scheduled, &required(&["n1"]))
720 .await
721 }
722 });
723
724 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
726 assert!(
727 !handle.is_finished(),
728 "Pinned{{n1}} must WAIT rather than spill to the live n2 worker"
729 );
730 assert!(
731 wrong_rx.try_recv().is_err(),
732 "the wrong-node (n2) worker must never receive the task"
733 );
734
735 let (right_tx, mut right_rx) = tokio::sync::mpsc::channel(1);
737 let _right = registry.register_namespaces(
738 [String::from("tenant-a")],
739 "default",
740 Some(String::from("n1")),
741 types.iter(),
742 right_tx,
743 )?;
744
745 handle.await??;
746 assert!(
747 right_rx.recv().await.is_some(),
748 "the required n1 worker receives the task once live"
749 );
750 assert!(
751 wrong_rx.try_recv().is_err(),
752 "the wrong-node worker still never received it"
753 );
754 Ok(())
755 }
756
757 #[tokio::test]
760 async fn dispatch_requiring_never_mutates_the_rows_node()
761 -> Result<(), Box<dyn std::error::Error>> {
762 let registry = ConnectedWorkerRegistry::default();
763 let dispatcher = ActivityDispatcher::new(registry.clone());
764 let scheduled = scheduled_unpinned();
765 assert_eq!(scheduled.node, None, "precondition: the row is unpinned");
766 let types = [String::from("charge-card")];
767 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
768 let _right = registry.register_namespaces(
769 [String::from("tenant-a")],
770 "default",
771 Some(String::from("n1")),
772 types.iter(),
773 tx,
774 )?;
775
776 dispatcher
777 .dispatch_requiring(&scheduled, &required(&["n1"]))
778 .await?;
779
780 assert!(rx.recv().await.is_some(), "the n1 worker received the task");
781 assert_eq!(
782 scheduled.node, None,
783 "the row's authored node MUST remain None through a Pinned dispatch \
784 (the determinism invariant, CP-Phase-2 §2.4)"
785 );
786 Ok(())
787 }
788
789 #[derive(Default)]
790 struct RecordingSink {
791 completions: Mutex<Vec<ActivityCompletion>>,
792 }
793
794 impl ActivityCompletionSink for RecordingSink {
795 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
796 self.completions
797 .lock()
798 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
799 .push(completion);
800 Ok(())
801 }
802
803 fn park_activity(
804 &self,
805 _workflow_id: &WorkflowId,
806 _activity_id: &ActivityId,
807 ) -> Result<(), ServerError> {
808 Err(ServerError::worker_dispatch(
809 "",
810 "",
811 "result-handoff tests never park a dispatch",
812 ))
813 }
814 }
815
816 #[test]
817 fn successful_activity_result_calls_completion_sink() -> Result<(), Box<dyn std::error::Error>>
818 {
819 let sink = RecordingSink::default();
820 let output = payload(&json!({"ok": true}))?;
821 let result = ProtoActivityResult {
822 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
823 activity_id: Some(ProtoActivityId::from(activity_id())),
824 run_id: None,
825 outcome: Some(proto_activity_result::Outcome::Result(ProtoPayload::from(
826 output.clone(),
827 ))),
828 };
829
830 handle_activity_result(&sink, result)?;
831 let completions = sink
832 .completions
833 .lock()
834 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
835
836 assert_eq!(completions.len(), 1);
837 assert_eq!(completions[0].workflow_id, workflow_id());
838 assert_eq!(completions[0].activity_id, activity_id());
839 assert_eq!(
840 completions[0].outcome,
841 ActivityCompletionOutcome::Succeeded(output)
842 );
843 Ok(())
844 }
845
846 #[test]
847 fn failed_activity_result_preserves_error_classification()
848 -> Result<(), Box<dyn std::error::Error>> {
849 let sink = RecordingSink::default();
850 let error = ProtoActivityError {
851 kind: ProtoActivityErrorKind::Retryable as i32,
852 message: String::from("temporary outage"),
853 details: Some(ProtoPayload::from(payload(
854 &json!({"retry_after_ms": 500}),
855 )?)),
856 };
857 let result = ProtoActivityResult {
858 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
859 activity_id: Some(ProtoActivityId::from(activity_id())),
860 run_id: None,
861 outcome: Some(proto_activity_result::Outcome::Error(error)),
862 };
863
864 handle_activity_result(&sink, result)?;
865 let completions = sink
866 .completions
867 .lock()
868 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
869
870 assert_eq!(completions.len(), 1);
871 match &completions[0].outcome {
872 ActivityCompletionOutcome::Failed(error) => {
873 assert_eq!(error.kind, ActivityErrorKind::Retryable);
874 assert!(error.is_retryable());
875 }
876 ActivityCompletionOutcome::Succeeded(_) => return Err("expected failed outcome".into()),
877 }
878 Ok(())
879 }
880}