1use std::sync::Arc;
49use std::sync::atomic::{AtomicU64, Ordering};
50use std::time::{Duration, Instant};
51
52use aion::ActivityDispatcher;
53use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
54use aion_proto::{ProtoActivityId, ProtoActivityTask, ProtoPayload, ProtoWorkflowId};
55use dashmap::DashMap;
56
57use super::dispatch::{ActivityCompletion, ActivityCompletionOutcome, ActivityCompletionSink};
58use super::heartbeat::{HeartbeatTracker, InFlightActivity};
59use super::registry::{ConnectedWorkerRegistry, WorkerHandle, WorkerId, WorkerMessage};
60use crate::error::ServerError;
61use crate::shutdown::DrainState;
62use tracing::info_span;
63
64type SyncSender = std::sync::mpsc::SyncSender<Result<String, String>>;
65type SyncReceiver = std::sync::mpsc::Receiver<Result<String, String>>;
66
67type PendingActivityKey = (WorkflowId, ActivityId);
87
88#[derive(Clone, Debug, Default)]
96pub struct PendingActivities {
97 pending: Arc<DashMap<PendingActivityKey, SyncSender>>,
98}
99
100impl PendingActivities {
101 fn insert(&self, workflow_id: WorkflowId, activity_id: ActivityId) -> SyncReceiver {
102 let (tx, rx) = std::sync::mpsc::sync_channel(1);
103 self.pending.insert((workflow_id, activity_id), tx);
104 rx
105 }
106
107 fn complete(&self, key: &PendingActivityKey, result: Result<String, String>) -> bool {
108 if let Some((_, sender)) = self.pending.remove(key) {
109 sender.send(result).is_ok()
110 } else {
111 false
112 }
113 }
114}
115
116impl ActivityCompletionSink for PendingActivities {
117 fn complete_activity(&self, completion: ActivityCompletion) -> Result<(), ServerError> {
118 let result = match completion.outcome {
119 ActivityCompletionOutcome::Succeeded(payload) => {
120 payload_to_string(&payload).map_err(|reason| {
121 tracing::error!(
122 operation = "activity_complete",
123 workflow_id = %completion.workflow_id,
124 activity_id = %completion.activity_id,
125 error_type = "ActivityResultDecode",
126 %reason,
127 "activity completion failed"
128 );
129 ServerError::worker_dispatch("", "", format!("payload decode: {reason}"))
130 })?
131 }
132 ActivityCompletionOutcome::Failed(error) => {
133 let prefix = if error.is_retryable() {
134 "retryable"
135 } else {
136 "terminal"
137 };
138 tracing::error!(
139 operation = "activity_complete",
140 workflow_id = %completion.workflow_id,
141 activity_id = %completion.activity_id,
142 error_type = "ActivityFailed",
143 error_kind = prefix,
144 reason = %error.message,
145 "activity completion failed"
146 );
147 Err(format!("{prefix}:{}", error.message))
148 }
149 };
150 self.complete(&(completion.workflow_id, completion.activity_id), result);
151 Ok(())
152 }
153}
154
155fn payload_to_string(payload: &Payload) -> Result<Result<String, String>, String> {
156 match payload.content_type() {
157 ContentType::Json => String::from_utf8(payload.bytes().to_vec())
158 .map(Ok)
159 .map_err(|_| "activity result payload is not valid UTF-8".to_owned()),
160 }
161}
162
163pub struct WorkerActivityDispatcher {
171 registry: ConnectedWorkerRegistry,
172 namespace: String,
173 pending: PendingActivities,
174 heartbeat_tracker: HeartbeatTracker,
175 drain_state: DrainState,
176 next_id: AtomicU64,
177 workflow_registry: Option<Arc<aion::Registry>>,
178 tokio_handle: Option<tokio::runtime::Handle>,
179}
180
181impl std::fmt::Debug for WorkerActivityDispatcher {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("WorkerActivityDispatcher")
184 .field("namespace", &self.namespace)
185 .finish_non_exhaustive()
186 }
187}
188
189impl WorkerActivityDispatcher {
190 #[must_use]
198 pub fn new(
199 registry: ConnectedWorkerRegistry,
200 namespace: impl Into<String>,
201 heartbeat_tracker: HeartbeatTracker,
202 ) -> Self {
203 Self {
204 registry,
205 namespace: namespace.into(),
206 pending: PendingActivities::default(),
207 heartbeat_tracker,
208 drain_state: DrainState::default(),
209 next_id: AtomicU64::new(1),
210 workflow_registry: None,
211 tokio_handle: None,
212 }
213 }
214
215 #[must_use]
217 pub fn with_pending(mut self, pending: PendingActivities) -> Self {
218 self.pending = pending;
219 self
220 }
221
222 #[must_use]
224 pub fn with_drain_state(mut self, drain_state: DrainState) -> Self {
225 self.drain_state = drain_state;
226 self
227 }
228
229 #[must_use]
231 pub fn with_workflow_registry(mut self, workflow_registry: Arc<aion::Registry>) -> Self {
232 self.workflow_registry = Some(workflow_registry);
233 self
234 }
235
236 #[must_use]
238 pub fn with_tokio_handle(mut self, tokio_handle: tokio::runtime::Handle) -> Self {
239 self.tokio_handle = Some(tokio_handle);
240 self
241 }
242}
243
244impl WorkerActivityDispatcher {
245 fn ensure_accepting(
246 &self,
247 activity_type: &str,
248 workflow_id: &WorkflowId,
249 activity_id: &ActivityId,
250 worker_id: Option<WorkerId>,
251 ) -> Result<(), String> {
252 self.drain_state
253 .ensure_accepting(&self.namespace, activity_type)
254 .map_err(|error| {
255 let reason = error.to_string();
256 log_worker_error(
257 "WorkerDispatch",
258 &self.namespace,
259 activity_type,
260 workflow_id,
261 activity_id,
262 worker_id,
263 &reason,
264 );
265 reason
266 })
267 }
268
269 fn select_worker(
270 &self,
271 activity_type: &str,
272 workflow_id: &WorkflowId,
273 activity_id: &ActivityId,
274 ) -> Result<WorkerHandle, String> {
275 self.registry
276 .select_worker(&self.namespace, activity_type)
277 .map_err(|error| {
278 let reason = format!("registry error: {error}");
279 log_worker_error(
280 "WorkerRegistry",
281 &self.namespace,
282 activity_type,
283 workflow_id,
284 activity_id,
285 None,
286 &reason,
287 );
288 reason
289 })?
290 .ok_or_else(|| {
291 let reason = format!(
292 "no connected worker for activity type '{activity_type}' in namespace '{}'",
293 self.namespace
294 );
295 log_worker_error(
296 "WorkerUnavailable",
297 &self.namespace,
298 activity_type,
299 workflow_id,
300 activity_id,
301 None,
302 &reason,
303 );
304 reason
305 })
306 }
307
308 fn track_worker_task(
309 &self,
310 worker_id: WorkerId,
311 activity_type: &str,
312 workflow_id: &WorkflowId,
313 activity_id: &ActivityId,
314 ) -> Result<(), String> {
315 self.heartbeat_tracker
316 .track_task(
317 worker_id,
318 InFlightActivity {
319 workflow_id: workflow_id.clone(),
320 activity_id: activity_id.clone(),
321 },
322 Instant::now(),
323 )
324 .map_err(|error| {
325 let reason = error.to_string();
326 log_worker_error(
327 "WorkerHeartbeatTracker",
328 &self.namespace,
329 activity_type,
330 workflow_id,
331 activity_id,
332 Some(worker_id),
333 &reason,
334 );
335 reason
336 })
337 }
338
339 fn cleanup_activity(
340 &self,
341 worker_id: WorkerId,
342 workflow_id: &WorkflowId,
343 activity_id: &ActivityId,
344 ) {
345 self.pending
346 .pending
347 .remove(&(workflow_id.clone(), activity_id.clone()));
348 let _ = self
349 .heartbeat_tracker
350 .complete_task(worker_id, workflow_id, activity_id);
351 self.drain_state.notify_activity_drained();
352 }
353
354 fn send_activity_task(
355 &self,
356 worker: &WorkerHandle,
357 task: ProtoActivityTask,
358 activity_type: &str,
359 workflow_id: &WorkflowId,
360 activity_id: &ActivityId,
361 ) -> Result<(), String> {
362 match worker.sender().try_send(WorkerMessage::ActivityTask(task)) {
363 Ok(()) => Ok(()),
364 Err(error) => {
365 let worker_id = worker.id();
366 let reason = format!("worker task channel full or closed: {error}");
367 self.cleanup_activity(worker_id, workflow_id, activity_id);
368 log_worker_error(
369 "WorkerChannelClosed",
370 &self.namespace,
371 activity_type,
372 workflow_id,
373 activity_id,
374 Some(worker_id),
375 &reason,
376 );
377 Err(reason)
378 }
379 }
380 }
381
382 fn await_activity_result(
386 &self,
387 context: &ActivityDispatchContext<'_>,
388 rx: &SyncReceiver,
389 ) -> Result<String, String> {
390 match self.registry.is_registered(context.worker_id) {
398 Ok(true) => {}
399 Ok(false) => {
400 if let Ok(result) = rx.try_recv() {
404 return self.deliver_result(context, result);
405 }
406 self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
407 let reason = format!(
408 "retryable:{}",
409 super::dispatch::lost_worker_error(context.worker_id).message
410 );
411 log_worker_error(
412 "WorkerLost",
413 &self.namespace,
414 context.activity_type,
415 context.workflow_id,
416 context.activity_id,
417 Some(context.worker_id),
418 &reason,
419 );
420 return Err(reason);
421 }
422 Err(error) => {
423 self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
424 let reason = format!("worker registry inspection failed: {error}");
425 log_worker_error(
426 "WorkerRegistry",
427 &self.namespace,
428 context.activity_type,
429 context.workflow_id,
430 context.activity_id,
431 Some(context.worker_id),
432 &reason,
433 );
434 return Err(reason);
435 }
436 }
437 if let Ok(result) = rx.recv() {
438 return self.deliver_result(context, result);
439 }
440 self.cleanup_activity(context.worker_id, context.workflow_id, context.activity_id);
443 let reason = "activity response channel dropped".to_owned();
444 log_worker_error(
445 "WorkerChannelClosed",
446 &self.namespace,
447 context.activity_type,
448 context.workflow_id,
449 context.activity_id,
450 Some(context.worker_id),
451 &reason,
452 );
453 Err(reason)
454 }
455
456 fn deliver_result(
457 &self,
458 context: &ActivityDispatchContext<'_>,
459 result: Result<String, String>,
460 ) -> Result<String, String> {
461 self.pending
462 .pending
463 .remove(&(context.workflow_id.clone(), context.activity_id.clone()));
464 log_activity_completion(context, result.is_ok());
465 result.inspect_err(|reason| {
466 log_worker_error(
467 "ActivityFailed",
468 &self.namespace,
469 context.activity_type,
470 context.workflow_id,
471 context.activity_id,
472 Some(context.worker_id),
473 reason,
474 );
475 })
476 }
477}
478
479impl ActivityDispatcher for WorkerActivityDispatcher {
480 fn dispatch(
481 &self,
482 name: &str,
483 input: &str,
484 config: &str,
485 attempt: u32,
486 ) -> Result<String, String> {
487 match tokio::runtime::Handle::try_current() {
488 Ok(handle) => match handle.runtime_flavor() {
489 tokio::runtime::RuntimeFlavor::MultiThread => {
490 tokio::task::block_in_place(|| {
497 self.dispatch_blocking(name, input, config, attempt)
498 })
499 }
500 flavor => Err(format!(
501 "activity dispatch blocks the calling thread until the worker responds; \
502 a {flavor:?} tokio runtime cannot host that wait because the worker \
503 stream forwarder shares its only executor thread and the task could \
504 never be delivered — run the engine on a multi-thread tokio runtime"
505 )),
506 },
507 Err(_) => self.dispatch_blocking(name, input, config, attempt),
511 }
512 }
513}
514
515impl WorkerActivityDispatcher {
516 fn dispatch_blocking(
527 &self,
528 name: &str,
529 input: &str,
530 config: &str,
531 attempt: u32,
532 ) -> Result<String, String> {
533 let _ = config;
534 let started_at = Instant::now();
535 let sequence = self.next_id.fetch_add(1, Ordering::Relaxed);
536 let activity_id = ActivityId::from_sequence_position(sequence);
537 let workflow_id = WorkflowId::new_v4();
538 self.ensure_accepting(name, &workflow_id, &activity_id, None)?;
539 let worker = self.select_worker(name, &workflow_id, &activity_id)?;
540 let worker_id = worker.id();
541 let span = info_span!(
542 "activity_dispatch",
543 operation = "activity_dispatch",
544 namespace = %self.namespace,
545 workflow_id = %workflow_id,
546 activity_id = %activity_id,
547 activity_type = %name,
548 worker_id = ?worker_id,
549 );
550 let _span_guard = span.enter();
551 self.ensure_accepting(name, &workflow_id, &activity_id, Some(worker_id))?;
552
553 let task = activity_task(name, input, &workflow_id, &activity_id, attempt);
554 let rx = self
555 .pending
556 .insert(workflow_id.clone(), activity_id.clone());
557 self.track_worker_task(worker_id, name, &workflow_id, &activity_id)?;
558 self.send_activity_task(&worker, task, name, &workflow_id, &activity_id)?;
559 let context = ActivityDispatchContext {
560 namespace: &self.namespace,
561 activity_type: name,
562 worker_id,
563 workflow_id: &workflow_id,
564 activity_id: &activity_id,
565 started_at,
566 };
567 self.await_activity_result(&context, &rx)
568 }
569}
570
571struct ActivityDispatchContext<'a> {
572 namespace: &'a str,
573 activity_type: &'a str,
574 worker_id: WorkerId,
575 workflow_id: &'a WorkflowId,
576 activity_id: &'a ActivityId,
577 started_at: Instant,
578}
579
580fn activity_task(
581 activity_type: &str,
582 input: &str,
583 workflow_id: &WorkflowId,
584 activity_id: &ActivityId,
585 attempt: u32,
586) -> ProtoActivityTask {
587 ProtoActivityTask {
588 workflow_id: Some(ProtoWorkflowId::from(workflow_id.clone())),
589 activity_id: Some(ProtoActivityId::from(activity_id.clone())),
590 activity_type: activity_type.to_owned(),
591 input: Some(ProtoPayload {
592 content_type: String::from("application/json"),
593 bytes: input.as_bytes().to_vec(),
594 }),
595 attempt,
596 }
597}
598
599fn log_activity_completion(context: &ActivityDispatchContext<'_>, succeeded: bool) {
600 let duration_ms = duration_ms(context.started_at.elapsed());
601 tracing::info!(
602 operation = "activity_complete",
603 namespace = context.namespace,
604 workflow_id = %context.workflow_id,
605 activity_id = %context.activity_id,
606 activity_type = context.activity_type,
607 worker_id = ?context.worker_id,
608 duration_ms,
609 outcome = if succeeded { "succeeded" } else { "failed" },
610 "activity completed"
611 );
612}
613
614fn duration_ms(duration: Duration) -> u64 {
615 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
616}
617
618fn log_worker_error(
619 error_type: &'static str,
620 namespace: &str,
621 activity_type: &str,
622 workflow_id: &WorkflowId,
623 activity_id: &ActivityId,
624 worker_id: Option<super::registry::WorkerId>,
625 reason: &str,
626) {
627 tracing::error!(
628 operation = "activity_dispatch",
629 namespace,
630 workflow_id = %workflow_id,
631 activity_id = %activity_id,
632 activity_type,
633 worker_id = ?worker_id,
634 error_type,
635 reason,
636 "worker interaction failed"
637 );
638}
639
640#[cfg(test)]
641mod tests {
642 use aion_core::{ActivityError, ActivityErrorKind, ContentType, Payload};
643
644 use super::*;
645
646 fn activity_id(pos: u64) -> ActivityId {
647 ActivityId::from_sequence_position(pos)
648 }
649
650 #[test]
651 fn pending_insert_and_complete_delivers_result() {
652 let pending = PendingActivities::default();
653 let workflow_id = WorkflowId::new_v4();
654 let id = activity_id(1);
655 let rx = pending.insert(workflow_id.clone(), id.clone());
656
657 assert!(pending.complete(&(workflow_id, id), Ok("done".to_owned())));
658 assert_eq!(
659 rx.recv_timeout(Duration::from_millis(50)),
660 Ok(Ok("done".to_owned()))
661 );
662 }
663
664 #[test]
665 fn pending_complete_unknown_returns_false() {
666 let pending = PendingActivities::default();
667 assert!(!pending.complete(
668 &(WorkflowId::new_v4(), activity_id(99)),
669 Ok("orphan".to_owned())
670 ));
671 }
672
673 #[test]
674 fn completion_sink_routes_success() -> Result<(), ServerError> {
675 let pending = PendingActivities::default();
676 let workflow_id = WorkflowId::new_v4();
677 let id = activity_id(2);
678 let rx = pending.insert(workflow_id.clone(), id.clone());
679 let payload = Payload::new(ContentType::Json, br#"{"greeting":"hi"}"#.to_vec());
680
681 pending.complete_activity(ActivityCompletion {
682 workflow_id,
683 activity_id: id,
684 outcome: ActivityCompletionOutcome::Succeeded(payload),
685 })?;
686
687 let result = rx
688 .recv_timeout(Duration::from_millis(50))
689 .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
690 assert_eq!(result, Ok(r#"{"greeting":"hi"}"#.to_owned()));
691 Ok(())
692 }
693
694 #[test]
695 fn completion_sink_routes_retryable_error() -> Result<(), ServerError> {
696 let pending = PendingActivities::default();
697 let workflow_id = WorkflowId::new_v4();
698 let id = activity_id(3);
699 let rx = pending.insert(workflow_id.clone(), id.clone());
700
701 pending.complete_activity(ActivityCompletion {
702 workflow_id,
703 activity_id: id,
704 outcome: ActivityCompletionOutcome::Failed(ActivityError {
705 kind: ActivityErrorKind::Retryable,
706 message: "temporary".to_owned(),
707 details: None,
708 }),
709 })?;
710
711 let result = rx
712 .recv_timeout(Duration::from_millis(50))
713 .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
714 assert_eq!(result, Err("retryable:temporary".to_owned()));
715 Ok(())
716 }
717
718 #[test]
727 fn stale_result_for_other_workflow_does_not_complete_pending_dispatch()
728 -> Result<(), ServerError> {
729 let pending = PendingActivities::default();
730 let post_restart_workflow = WorkflowId::new_v4();
731 let pre_restart_workflow = WorkflowId::new_v4();
732 let id = activity_id(1);
734 let rx = pending.insert(post_restart_workflow.clone(), id.clone());
735
736 pending.complete_activity(ActivityCompletion {
738 workflow_id: pre_restart_workflow,
739 activity_id: id.clone(),
740 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
741 ContentType::Json,
742 br#""stale""#.to_vec(),
743 )),
744 })?;
745 assert!(
746 rx.try_recv().is_err(),
747 "stale result for a different workflow must not complete this dispatch"
748 );
749
750 pending.complete_activity(ActivityCompletion {
752 workflow_id: post_restart_workflow,
753 activity_id: id,
754 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
755 ContentType::Json,
756 br#""fresh""#.to_vec(),
757 )),
758 })?;
759 let result = rx
760 .recv_timeout(Duration::from_millis(50))
761 .map_err(|e| ServerError::worker_dispatch("", "", format!("channel: {e}")))?;
762 assert_eq!(result, Ok(r#""fresh""#.to_owned()));
763 Ok(())
764 }
765
766 fn test_tracker() -> HeartbeatTracker {
769 HeartbeatTracker::new(Duration::from_secs(5))
770 }
771
772 #[test]
773 fn dispatcher_returns_error_when_no_worker_registered() {
774 let registry = ConnectedWorkerRegistry::default();
775 let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker());
776
777 let result = dispatcher.dispatch("greet", "{}", "{}", 1);
778
779 assert!(result.is_err());
780 let err = result.err().unwrap_or_default();
781 assert!(
782 err.contains("no connected worker"),
783 "unexpected error: {err}"
784 );
785 }
786
787 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
803 async fn dispatch_inside_runtime_task_delivers_promptly_and_round_trips()
804 -> Result<(), Box<dyn std::error::Error>> {
805 let registry = ConnectedWorkerRegistry::default();
806 let pending = PendingActivities::default();
807 let (worker_tx, mut worker_rx) = tokio::sync::mpsc::channel(32);
808 let activity_types = [String::from("greet")];
809 let registration = registry.register("default", activity_types.iter(), worker_tx)?;
810
811 let sink = pending.clone();
812 let echo_worker = tokio::spawn(async move {
813 let Some(WorkerMessage::ActivityTask(task)) = worker_rx.recv().await else {
814 return Err("expected an activity task on the worker channel".to_owned());
815 };
816 let workflow_id = task
817 .workflow_id
818 .ok_or("task missing workflow id")
819 .and_then(|id| WorkflowId::try_from(id).map_err(|_| "bad workflow id"))?;
820 let activity_id = task
821 .activity_id
822 .map(ActivityId::from)
823 .ok_or("task missing activity id")?;
824 sink.complete_activity(ActivityCompletion {
825 workflow_id,
826 activity_id,
827 outcome: ActivityCompletionOutcome::Succeeded(Payload::new(
828 ContentType::Json,
829 br#"{"greeting":"hello"}"#.to_vec(),
830 )),
831 })
832 .map_err(|error| error.to_string())
833 });
834
835 let dispatcher = Arc::new(
836 WorkerActivityDispatcher::new(registry, "default", test_tracker())
837 .with_pending(pending),
838 );
839 let started = Instant::now();
840 let dispatch_task = tokio::spawn(futures::future::lazy(move |_| {
843 dispatcher.dispatch("greet", "{}", "{}", 1)
844 }));
845 let result = dispatch_task.await.map_err(|error| error.to_string())?;
846 let elapsed = started.elapsed();
847
848 assert_eq!(result, Ok(r#"{"greeting":"hello"}"#.to_owned()));
849 assert!(
850 elapsed < Duration::from_secs(5),
851 "dispatch round trip took {elapsed:?}; task delivery must not \
852 depend on the blocked dispatch thread"
853 );
854 echo_worker.await.map_err(|error| error.to_string())??;
855 registration.deregister()?;
856 Ok(())
857 }
858
859 #[tokio::test]
863 async fn dispatch_on_current_thread_runtime_fails_fast()
864 -> Result<(), Box<dyn std::error::Error>> {
865 let registry = ConnectedWorkerRegistry::default();
866 let (worker_tx, _worker_rx) = tokio::sync::mpsc::channel(32);
867 let activity_types = [String::from("greet")];
868 let registration = registry.register("default", activity_types.iter(), worker_tx)?;
869 let dispatcher = WorkerActivityDispatcher::new(registry, "default", test_tracker());
870
871 let started = Instant::now();
872 let result = dispatcher.dispatch("greet", "{}", "{}", 1);
873 let elapsed = started.elapsed();
874
875 let err = result.err().ok_or("expected dispatch to fail")?;
876 assert!(
877 err.contains("multi-thread tokio runtime"),
878 "unexpected error: {err}"
879 );
880 assert!(
881 elapsed < Duration::from_secs(5),
882 "fail-fast path took {elapsed:?}"
883 );
884 registration.deregister()?;
885 Ok(())
886 }
887}