1use 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#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct ScheduledActivity {
27 pub namespace: String,
30 pub task_queue: String,
34 pub activity_type: String,
37 pub node: Option<String>,
44 pub workflow_id: WorkflowId,
46 pub activity_id: ActivityId,
48 pub run_id: Option<RunId>,
50 pub input: Payload,
52 pub attempt: u32,
55 pub labels: BTreeMap<String, String>,
58}
59
60impl ScheduledActivity {
61 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 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#[derive(Clone, Debug)]
101pub struct ActivityDispatcher {
102 registry: ConnectedWorkerRegistry,
103 drain_state: DrainState,
104 completion_fences: CompletionFences,
105 queue_declarations: QueueDeclarationSource,
114 queue_service_state: QueueServiceState,
115 queue_service_config: QueueServiceConfig,
116 cluster_publisher: Option<crate::cluster_publisher::ClusterEventPublisher>,
120}
121
122impl ActivityDispatcher {
123 #[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 #[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 #[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 #[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 #[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 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 pub async fn dispatch_preferring(
237 &self,
238 activity: &ScheduledActivity,
239 preferred: &std::collections::BTreeSet<String>,
240 ) -> Result<(), ServerError> {
241 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 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 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 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 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 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 async fn dispatch_to_node(
397 &self,
398 activity: &ScheduledActivity,
399 node: Option<&str>,
400 span_fields: &tracing::Span,
401 ) -> Result<(), ServerError> {
402 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 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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
563pub enum ActivityCompletionOutcome {
564 Succeeded(Payload),
566 Failed(ActivityError),
568 WorkerLost {
579 worker_id: crate::worker::registry::WorkerId,
581 },
582}
583
584#[derive(Clone, Debug, Eq, PartialEq)]
586pub struct ActivityCompletion {
587 pub workflow_id: WorkflowId,
589 pub activity_id: ActivityId,
591 pub run_id: Option<RunId>,
593 pub completion_token: CompletionToken,
595 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
641pub trait ActivityCompletionSink {
643 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError>;
649
650 fn park_activity(
667 &self,
668 workflow_id: &WorkflowId,
669 activity_id: &ActivityId,
670 ) -> Result<(), ServerError>;
671}
672
673pub 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 #[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 assert!(
826 state.unserved()?.is_empty(),
827 "no dispatch has been made yet"
828 );
829
830 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 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 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 #[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 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 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 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 #[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}