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 && sender
526 .send(WorkerMessage::ActivityTask(Box::new(task.clone())))
527 .await
528 .is_ok()
529 {
530 return Ok(Some(()));
531 }
532 self.registry.deregister(worker.id())?;
533 }
534 self.completion_fences.revoke(
535 &activity.workflow_id,
536 &activity.activity_id,
537 &completion_token,
538 )?;
539 Ok(None)
540 }
541}
542
543fn log_dispatch_error(operation: &'static str, activity: &ScheduledActivity, error: &ServerError) {
544 let fields = error.trace_fields();
545 tracing::error!(
546 operation,
547 namespace = %activity.namespace,
548 task_queue = %activity.task_queue,
549 node = activity.node.as_deref(),
550 workflow_id = %activity.workflow_id,
551 activity_id = %activity.activity_id,
552 activity_type = %activity.activity_type,
553 error_type = %fields.error_type,
554 store_error_type = fields.store_error_type,
555 reason = %fields.reason,
556 "activity dispatch failed"
557 );
558}
559
560#[derive(Clone, Debug, Eq, PartialEq)]
562pub enum ActivityCompletionOutcome {
563 Succeeded(Payload),
565 Failed(ActivityError),
567 WorkerLost {
578 worker_id: crate::worker::registry::WorkerId,
580 },
581}
582
583#[derive(Clone, Debug, Eq, PartialEq)]
585pub struct ActivityCompletion {
586 pub workflow_id: WorkflowId,
588 pub activity_id: ActivityId,
590 pub run_id: Option<RunId>,
592 pub completion_token: CompletionToken,
594 pub outcome: ActivityCompletionOutcome,
596}
597
598impl TryFrom<ProtoActivityResult> for ActivityCompletion {
599 type Error = ServerError;
600
601 fn try_from(value: ProtoActivityResult) -> Result<Self, Self::Error> {
602 let workflow_id = value
603 .workflow_id
604 .ok_or_else(|| wire_error("activity result workflow id is missing"))
605 .and_then(|id| WorkflowId::try_from(id).map_err(ServerError::from))?;
606 let activity_id = value
607 .activity_id
608 .ok_or_else(|| wire_error("activity result activity id is missing"))
609 .map(ActivityId::from)?;
610 let run_id = value
611 .run_id
612 .ok_or_else(|| wire_error("activity result run id is missing"))
613 .and_then(|id| RunId::try_from(id).map_err(ServerError::from))?;
614 let completion_token =
615 CompletionToken::from_wire(&workflow_id, &activity_id, value.completion_token)?;
616 let outcome = match value.outcome {
617 Some(proto_activity_result::Outcome::Result(payload)) => {
618 ActivityCompletionOutcome::Succeeded(
619 Payload::try_from(payload).map_err(ServerError::from)?,
620 )
621 }
622 Some(proto_activity_result::Outcome::Error(error)) => {
623 ActivityCompletionOutcome::Failed(
624 ActivityError::try_from(error).map_err(ServerError::from)?,
625 )
626 }
627 None => return Err(wire_error("activity result outcome is missing")),
628 };
629
630 Ok(Self {
631 workflow_id,
632 activity_id,
633 run_id: Some(run_id),
634 completion_token,
635 outcome,
636 })
637 }
638}
639
640pub trait ActivityCompletionSink {
642 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError>;
648
649 fn park_activity(
666 &self,
667 workflow_id: &WorkflowId,
668 activity_id: &ActivityId,
669 ) -> Result<(), ServerError>;
670}
671
672pub fn handle_activity_result(
678 sink: &impl ActivityCompletionSink,
679 result: ProtoActivityResult,
680) -> Result<(), ServerError> {
681 sink.complete_activity(ActivityCompletion::try_from(result)?)
682}
683
684fn wire_error(message: &'static str) -> ServerError {
685 ServerError::Wire {
686 wire: WireError::backend(message),
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use std::sync::Mutex;
693
694 use aion_core::{ActivityErrorKind, ContentType};
695 use aion_proto::{ProtoActivityError, ProtoActivityErrorKind};
696 use serde_json::json;
697 use uuid::Uuid;
698
699 use crate::worker::registry::ConnectedWorkerRegistry;
700
701 use super::*;
702
703 fn workflow_id() -> WorkflowId {
704 WorkflowId::new(Uuid::nil())
705 }
706
707 fn activity_id() -> ActivityId {
708 ActivityId::from_sequence_position(42)
709 }
710
711 fn payload(value: &serde_json::Value) -> Result<Payload, Box<dyn std::error::Error>> {
712 Ok(Payload::from_json(value)?)
713 }
714
715 #[tokio::test]
716 async fn dispatch_pushes_activity_task_with_correlation()
717 -> Result<(), Box<dyn std::error::Error>> {
718 let registry = ConnectedWorkerRegistry::default();
719 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
720 let activity_types = [String::from("charge-card")];
721 let registration = registry.register("tenant-a", activity_types.iter(), tx)?;
722 let dispatcher = ActivityDispatcher::new(registry.clone());
723 let input = payload(&json!({"amount": 1200}))?;
724 let scheduled = ScheduledActivity {
725 namespace: String::from("tenant-a"),
726 task_queue: String::from("default"),
727 activity_type: String::from("charge-card"),
728 node: None,
729 workflow_id: workflow_id(),
730 activity_id: activity_id(),
731 run_id: Some(RunId::new_v4()),
732 input: input.clone(),
733 attempt: 1,
734 labels: std::collections::BTreeMap::new(),
735 };
736
737 dispatcher.dispatch(&scheduled).await?;
738 let message = rx.recv().await.ok_or("expected pushed activity task")?;
739 let WorkerMessage::ActivityTask(task) = message else {
740 return Err("expected activity task message".into());
741 };
742
743 assert_eq!(task.workflow_id, Some(ProtoWorkflowId::from(workflow_id())));
744 assert_eq!(task.activity_id, Some(ProtoActivityId::from(activity_id())));
745 assert_eq!(task.activity_type, "charge-card");
746 assert_eq!(task.input, Some(ProtoPayload::from(input)));
747 assert_eq!(task.attempt, 1, "wire task must carry the stamped attempt");
748
749 registration.deregister()?;
750 Ok(())
751 }
752
753 #[tokio::test]
754 async fn dispatch_waits_for_worker_then_delivers() -> Result<(), Box<dyn std::error::Error>> {
755 let registry = ConnectedWorkerRegistry::default();
756 let dispatcher = ActivityDispatcher::new(registry.clone());
757 let scheduled = ScheduledActivity {
758 namespace: String::from("tenant-a"),
759 task_queue: String::from("default"),
760 activity_type: String::from("charge-card"),
761 node: None,
762 workflow_id: workflow_id(),
763 activity_id: activity_id(),
764 run_id: Some(RunId::new_v4()),
765 input: Payload::new(ContentType::Json, b"{}".to_vec()),
766 attempt: 1,
767 labels: std::collections::BTreeMap::new(),
768 };
769
770 let dispatch_handle = tokio::spawn({
771 let dispatcher = dispatcher.clone();
772 let scheduled = scheduled.clone();
773 async move { dispatcher.dispatch(&scheduled).await }
774 });
775
776 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
777 assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
778
779 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
780 let activity_types = [String::from("charge-card")];
781 let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
782
783 dispatch_handle.await??;
784 assert!(rx.recv().await.is_some());
785 Ok(())
786 }
787
788 #[tokio::test]
800 async fn a_dispatch_with_no_worker_publishes_its_park_and_clears_on_arrival()
801 -> Result<(), Box<dyn std::error::Error>> {
802 let registry = ConnectedWorkerRegistry::default();
803 let state = QueueServiceState::default();
804 let dispatcher = ActivityDispatcher::new(registry.clone()).with_queue_service(
805 QueueDeclarationSource::default(),
806 state.clone(),
807 QueueServiceConfig::default(),
808 );
809 let scheduled = ScheduledActivity {
810 namespace: String::from("tenant-a"),
811 task_queue: String::from("default"),
812 activity_type: String::from("charge-card"),
813 node: None,
814 workflow_id: workflow_id(),
815 activity_id: activity_id(),
816 run_id: Some(RunId::new_v4()),
817 input: Payload::new(ContentType::Json, b"{}".to_vec()),
818 attempt: 1,
819 labels: std::collections::BTreeMap::new(),
820 };
821
822 assert!(
825 state.unserved()?.is_empty(),
826 "no dispatch has been made yet"
827 );
828
829 let unwired = ActivityDispatcher::new(registry.clone());
835 let unwired_handle = tokio::spawn({
836 let scheduled = scheduled.clone();
837 async move { unwired.dispatch(&scheduled).await }
838 });
839 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
840 assert!(
841 state.unserved()?.is_empty(),
842 "an unshared dispatcher must publish nothing HERE — that is the \
843 defect this test exists to catch, reproduced on purpose"
844 );
845 unwired_handle.abort();
846
847 let dispatch_handle = tokio::spawn({
848 let dispatcher = dispatcher.clone();
849 let scheduled = scheduled.clone();
850 async move { dispatcher.dispatch(&scheduled).await }
851 });
852 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
853 assert!(!dispatch_handle.is_finished(), "dispatch should be waiting");
854
855 let unserved = state.unserved()?;
856 assert_eq!(
857 unserved.len(),
858 1,
859 "the parked dispatch must be queryable, not just logged: {unserved:?}"
860 );
861 assert_eq!(unserved[0].key.task_queue, "default");
862 assert_eq!(
863 unserved[0].reason,
864 QueueServiceReason::NoLivePollers,
865 "an empty pool must be classified, not reported as a bare miss"
866 );
867 assert_eq!(
868 state.parked_on_queue("default")?,
869 1,
870 "the run parked on the queue must be attributable to the queue"
871 );
872
873 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
876 let activity_types = [String::from("charge-card")];
877 let _registration = registry.register("tenant-a", activity_types.iter(), tx)?;
878
879 dispatch_handle.await??;
880 assert!(rx.recv().await.is_some(), "the task must be delivered");
881 assert!(
882 state.unserved()?.is_empty(),
883 "a served dispatch must not be left published as unserved: {:?}",
884 state.unserved()?
885 );
886 Ok(())
887 }
888
889 #[tokio::test]
890 async fn dispatch_skips_closed_worker_and_uses_next_match()
891 -> Result<(), Box<dyn std::error::Error>> {
892 let registry = ConnectedWorkerRegistry::default();
893 let (closed_tx, closed_rx) = tokio::sync::mpsc::channel(1);
894 let (live_tx, mut live_rx) = tokio::sync::mpsc::channel(1);
895 let activity_types = [String::from("charge-card")];
896 let closed_registration =
897 registry.register("tenant-a", activity_types.iter(), closed_tx)?;
898 let live_registration = registry.register("tenant-a", activity_types.iter(), live_tx)?;
899 drop(closed_rx);
900
901 let dispatcher = ActivityDispatcher::new(registry.clone());
902 let scheduled = ScheduledActivity {
903 namespace: String::from("tenant-a"),
904 task_queue: String::from("default"),
905 activity_type: String::from("charge-card"),
906 node: None,
907 workflow_id: workflow_id(),
908 activity_id: activity_id(),
909 run_id: Some(RunId::new_v4()),
910 input: Payload::new(ContentType::Json, b"{}".to_vec()),
911 attempt: 1,
912 labels: std::collections::BTreeMap::new(),
913 };
914
915 dispatcher.dispatch(&scheduled).await?;
916
917 assert!(live_rx.recv().await.is_some());
918 assert_eq!(
919 registry
920 .workers_for("tenant-a", "default", "charge-card", None)?
921 .len(),
922 1
923 );
924
925 closed_registration.deregister()?;
926 live_registration.deregister()?;
927 Ok(())
928 }
929
930 fn scheduled_unpinned() -> ScheduledActivity {
931 ScheduledActivity {
932 namespace: String::from("tenant-a"),
933 task_queue: String::from("default"),
934 activity_type: String::from("charge-card"),
935 node: None,
938 workflow_id: workflow_id(),
939 activity_id: activity_id(),
940 run_id: Some(RunId::new_v4()),
941 input: Payload::new(ContentType::Json, b"{}".to_vec()),
942 attempt: 1,
943 labels: std::collections::BTreeMap::new(),
944 }
945 }
946
947 fn required(labels: &[&str]) -> std::collections::BTreeSet<String> {
948 labels.iter().map(|l| (*l).to_owned()).collect()
949 }
950
951 #[tokio::test]
956 async fn dispatch_requiring_waits_and_never_spills_to_a_wrong_node_worker()
957 -> Result<(), Box<dyn std::error::Error>> {
958 let registry = ConnectedWorkerRegistry::default();
959 let dispatcher = ActivityDispatcher::new(registry.clone());
960 let scheduled = scheduled_unpinned();
961 let types = [String::from("charge-card")];
962
963 let (wrong_tx, mut wrong_rx) = tokio::sync::mpsc::channel(1);
966 let _wrong = registry.register_namespaces(
967 [String::from("tenant-a")],
968 "default",
969 Some(String::from("n2")),
970 types.iter(),
971 wrong_tx,
972 )?;
973
974 let handle = tokio::spawn({
975 let dispatcher = dispatcher.clone();
976 let scheduled = scheduled.clone();
977 async move {
978 dispatcher
979 .dispatch_requiring(&scheduled, &required(&["n1"]))
980 .await
981 }
982 });
983
984 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
986 assert!(
987 !handle.is_finished(),
988 "Pinned{{n1}} must WAIT rather than spill to the live n2 worker"
989 );
990 assert!(
991 wrong_rx.try_recv().is_err(),
992 "the wrong-node (n2) worker must never receive the task"
993 );
994
995 let (right_tx, mut right_rx) = tokio::sync::mpsc::channel(1);
997 let _right = registry.register_namespaces(
998 [String::from("tenant-a")],
999 "default",
1000 Some(String::from("n1")),
1001 types.iter(),
1002 right_tx,
1003 )?;
1004
1005 handle.await??;
1006 assert!(
1007 right_rx.recv().await.is_some(),
1008 "the required n1 worker receives the task once live"
1009 );
1010 assert!(
1011 wrong_rx.try_recv().is_err(),
1012 "the wrong-node worker still never received it"
1013 );
1014 Ok(())
1015 }
1016
1017 #[tokio::test]
1020 async fn dispatch_requiring_never_mutates_the_rows_node()
1021 -> Result<(), Box<dyn std::error::Error>> {
1022 let registry = ConnectedWorkerRegistry::default();
1023 let dispatcher = ActivityDispatcher::new(registry.clone());
1024 let scheduled = scheduled_unpinned();
1025 assert_eq!(scheduled.node, None, "precondition: the row is unpinned");
1026 let types = [String::from("charge-card")];
1027 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
1028 let _right = registry.register_namespaces(
1029 [String::from("tenant-a")],
1030 "default",
1031 Some(String::from("n1")),
1032 types.iter(),
1033 tx,
1034 )?;
1035
1036 dispatcher
1037 .dispatch_requiring(&scheduled, &required(&["n1"]))
1038 .await?;
1039
1040 assert!(rx.recv().await.is_some(), "the n1 worker received the task");
1041 assert_eq!(
1042 scheduled.node, None,
1043 "the row's authored node MUST remain None through a Pinned dispatch \
1044 (the determinism invariant, CP-Phase-2 §2.4)"
1045 );
1046 Ok(())
1047 }
1048
1049 #[derive(Default)]
1050 struct RecordingSink {
1051 completions: Mutex<Vec<ActivityCompletion>>,
1052 }
1053
1054 impl ActivityCompletionSink for RecordingSink {
1055 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
1056 self.completions
1057 .lock()
1058 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?
1059 .push(completion);
1060 Ok(())
1061 }
1062
1063 fn park_activity(
1064 &self,
1065 _workflow_id: &WorkflowId,
1066 _activity_id: &ActivityId,
1067 ) -> Result<(), ServerError> {
1068 Err(ServerError::worker_dispatch(
1069 "",
1070 "",
1071 "result-handoff tests never park a dispatch",
1072 ))
1073 }
1074 }
1075
1076 #[test]
1077 fn successful_activity_result_calls_completion_sink() -> Result<(), Box<dyn std::error::Error>>
1078 {
1079 let sink = RecordingSink::default();
1080 let output = payload(&json!({"ok": true}))?;
1081 let result = ProtoActivityResult {
1082 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
1083 activity_id: Some(ProtoActivityId::from(activity_id())),
1084 run_id: Some(ProtoRunId::from(RunId::new_v4())),
1085 completion_token: String::from("generation-1"),
1086 outcome: Some(proto_activity_result::Outcome::Result(ProtoPayload::from(
1087 output.clone(),
1088 ))),
1089 };
1090
1091 handle_activity_result(&sink, result)?;
1092 let completions = sink
1093 .completions
1094 .lock()
1095 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1096
1097 assert_eq!(completions.len(), 1);
1098 assert_eq!(completions[0].workflow_id, workflow_id());
1099 assert_eq!(completions[0].activity_id, activity_id());
1100 assert_eq!(
1101 completions[0].outcome,
1102 ActivityCompletionOutcome::Succeeded(output)
1103 );
1104 Ok(())
1105 }
1106
1107 #[test]
1108 fn failed_activity_result_preserves_error_classification()
1109 -> Result<(), Box<dyn std::error::Error>> {
1110 let sink = RecordingSink::default();
1111 let error = ProtoActivityError {
1112 kind: ProtoActivityErrorKind::Retryable as i32,
1113 message: String::from("temporary outage"),
1114 details: Some(ProtoPayload::from(payload(
1115 &json!({"retry_after_ms": 500}),
1116 )?)),
1117 };
1118 let result = ProtoActivityResult {
1119 workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
1120 activity_id: Some(ProtoActivityId::from(activity_id())),
1121 run_id: Some(ProtoRunId::from(RunId::new_v4())),
1122 completion_token: String::from("generation-1"),
1123 outcome: Some(proto_activity_result::Outcome::Error(error)),
1124 };
1125
1126 handle_activity_result(&sink, result)?;
1127 let completions = sink
1128 .completions
1129 .lock()
1130 .map_err(|_| ServerError::lock_poisoned("recording completion sink"))?;
1131
1132 assert_eq!(completions.len(), 1);
1133 match &completions[0].outcome {
1134 ActivityCompletionOutcome::Failed(error) => {
1135 assert_eq!(error.kind, ActivityErrorKind::Retryable);
1136 assert!(error.is_retryable());
1137 }
1138 other => return Err(format!("expected failed outcome, got {other:?}").into()),
1139 }
1140 Ok(())
1141 }
1142}