1use std::collections::HashMap;
15
16use bytes::Bytes;
17use chrono;
18use futures::FutureExt;
19use sayiir_core::codec::sealed;
20use sayiir_core::codec::{Codec, EnvelopeCodec, LoopDecision};
21use sayiir_core::context::{TaskExecutionContext, with_task_context};
22use sayiir_core::error::{BoxError, CodecError, WorkflowError};
23use sayiir_core::registry::TaskRegistry;
24use sayiir_core::snapshot::{
25 ExecutionPosition, SignalKind, SignalRequest, TaskDeadline, TaskHint, WorkflowSnapshot,
26};
27use sayiir_core::task_claim::AvailableTask;
28use sayiir_core::workflow::{Workflow, WorkflowContinuation, WorkflowStatus};
29use sayiir_persistence::{PersistentBackend, TaskClaimStore, TaskWakeupHint};
30use std::num::NonZeroUsize;
31use std::panic::AssertUnwindSafe;
32use std::pin::Pin;
33use std::sync::Arc;
34use std::time::Duration;
35use tokio::sync::mpsc;
36use tokio::time;
37
38fn staggered_poll_interval(worker_id: &str, poll_interval: Duration) -> time::Interval {
44 use std::hash::{Hash, Hasher};
45 let mut hasher = std::collections::hash_map::DefaultHasher::new();
46 worker_id.hash(&mut hasher);
47 let period_ns = u64::try_from(poll_interval.as_nanos())
48 .unwrap_or(u64::MAX)
49 .max(1);
50 let offset = Duration::from_nanos(hasher.finish() % period_ns);
51 time::interval_at(time::Instant::now() + offset, poll_interval)
52}
53
54pub type WorkflowRegistry<C, Input, M> =
56 Vec<(sayiir_core::DefinitionHash, Arc<Workflow<C, Input, M>>)>;
57
58pub struct ExternalWorkflow {
64 pub continuation: Arc<WorkflowContinuation>,
66 pub task_index: Arc<sayiir_core::TaskIndex>,
70 pub workflow_id: Arc<str>,
72 pub metadata_json: Option<Arc<str>>,
74}
75
76pub type WorkflowIndex = HashMap<sayiir_core::DefinitionHash, ExternalWorkflow>;
78
79pub(crate) trait WorkflowLookup {
84 fn contains_definition_hash(&self, hash: &sayiir_core::DefinitionHash) -> bool;
85}
86
87impl WorkflowLookup for WorkflowIndex {
88 fn contains_definition_hash(&self, hash: &sayiir_core::DefinitionHash) -> bool {
89 self.contains_key(hash)
90 }
91}
92
93impl<C, Input, M> WorkflowLookup for WorkflowRegistry<C, Input, M> {
94 fn contains_definition_hash(&self, hash: &sayiir_core::DefinitionHash) -> bool {
95 self.iter().any(|(k, _)| k == hash)
96 }
97}
98
99pub type ExternalTaskExecutor = Arc<
105 dyn Fn(
106 &str,
107 Bytes,
108 ) -> Pin<Box<dyn std::future::Future<Output = Result<Bytes, BoxError>> + Send>>
109 + Send
110 + Sync,
111>;
112
113enum WorkerCommand {
115 Shutdown,
116}
117
118struct WorkerHandleInner<B> {
119 backend: Arc<B>,
120 shutdown_tx: mpsc::Sender<WorkerCommand>,
121 join_handle:
122 tokio::sync::Mutex<Option<tokio::task::JoinHandle<Result<(), crate::error::RuntimeError>>>>,
123}
124
125pub struct WorkerHandle<B> {
131 inner: Arc<WorkerHandleInner<B>>,
132}
133
134impl<B> Clone for WorkerHandle<B> {
135 fn clone(&self) -> Self {
136 Self {
137 inner: Arc::clone(&self.inner),
138 }
139 }
140}
141
142impl<B> WorkerHandle<B> {
143 pub fn shutdown(&self) {
149 let _ = self.inner.shutdown_tx.try_send(WorkerCommand::Shutdown);
150 }
151
152 pub async fn join(&self) -> Result<(), crate::error::RuntimeError> {
160 let jh = self.inner.join_handle.lock().await.take();
161 match jh {
162 Some(jh) => Ok(jh.await??),
163 None => Ok(()),
164 }
165 }
166
167 #[must_use]
169 pub fn backend(&self) -> &Arc<B> {
170 &self.inner.backend
171 }
172}
173
174struct ActiveTaskClaim<'a, B> {
178 backend: &'a B,
179 instance_id: std::sync::Arc<str>,
180 task_id: sayiir_core::TaskId,
181 worker_id: String,
182}
183
184impl<B: TaskClaimStore> ActiveTaskClaim<'_, B> {
185 async fn release(self) -> Result<(), crate::error::RuntimeError> {
187 self.backend
188 .release_task_claim(&self.instance_id, &self.task_id, &self.worker_id)
189 .await?;
190 Ok(())
191 }
192
193 async fn release_quietly(self) {
195 let _ = self.release().await;
196 }
197}
198
199enum ExecutionOutcome {
201 Success(Bytes),
203 TaskError(crate::error::RuntimeError),
205 Panic(Box<dyn std::any::Any + Send>),
207 Timeout(crate::error::RuntimeError),
209}
210
211fn extract_panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
213 if let Some(s) = payload.downcast_ref::<&str>() {
214 s.to_string()
215 } else if let Some(s) = payload.downcast_ref::<String>() {
216 s.clone()
217 } else {
218 "Task panicked with unknown payload".to_string()
219 }
220}
221
222pub struct PooledWorker<B> {
264 worker_id: String,
265 backend: Arc<B>,
266 #[allow(unused)]
267 registry: Arc<TaskRegistry>,
268 claim_ttl: Option<Duration>,
269 batch_size: NonZeroUsize,
270 aging_interval: Duration,
271 tags: Vec<String>,
272}
273
274impl<B> PooledWorker<B>
275where
276 B: PersistentBackend + TaskClaimStore + 'static,
277{
278 pub fn new(worker_id: impl Into<String>, backend: B, registry: TaskRegistry) -> Self {
292 Self {
293 worker_id: worker_id.into(),
294 backend: Arc::new(backend),
295 registry: Arc::new(registry),
296 claim_ttl: Some(Duration::from_mins(5)), batch_size: NonZeroUsize::MIN, aging_interval: Duration::from_mins(5), tags: vec![],
300 }
301 }
302
303 #[must_use]
311 pub fn with_claim_ttl(mut self, ttl: Option<Duration>) -> Self {
312 if ttl.is_none() {
313 tracing::warn!(
314 "PooledWorker::with_claim_ttl(None) disables claim expiry; \
315 a crashed worker will pin its workflow until manual release"
316 );
317 }
318 self.claim_ttl = ttl;
319 self
320 }
321
322 #[must_use]
332 pub fn with_aging_interval(mut self, interval: Duration) -> Self {
333 assert!(!interval.is_zero(), "aging interval must be non-zero");
334 self.aging_interval = interval;
335 self
336 }
337
338 #[must_use]
346 pub fn with_batch_size(mut self, size: NonZeroUsize) -> Self {
347 self.batch_size = size;
348 self
349 }
350
351 #[must_use]
357 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
358 self.tags = tags;
359 self
360 }
361
362 pub async fn cancel_workflow(
377 &self,
378 instance_id: &str,
379 reason: Option<String>,
380 cancelled_by: Option<String>,
381 ) -> Result<(), crate::error::RuntimeError> {
382 self.backend
383 .store_signal(
384 instance_id,
385 SignalKind::Cancel,
386 SignalRequest::new(reason, cancelled_by),
387 )
388 .await?;
389
390 Ok(())
391 }
392
393 pub async fn pause_workflow(
408 &self,
409 instance_id: &str,
410 reason: Option<String>,
411 paused_by: Option<String>,
412 ) -> Result<(), crate::error::RuntimeError> {
413 self.backend
414 .store_signal(
415 instance_id,
416 SignalKind::Pause,
417 SignalRequest::new(reason, paused_by),
418 )
419 .await?;
420
421 Ok(())
422 }
423
424 #[must_use]
426 pub fn backend(&self) -> &Arc<B> {
427 &self.backend
428 }
429
430 #[must_use]
447 pub fn spawn<C, Input, M>(
448 self,
449 poll_interval: Duration,
450 workflows: WorkflowRegistry<C, Input, M>,
451 ) -> WorkerHandle<B>
452 where
453 Input: Send + Sync + 'static,
454 M: Send + Sync + 'static,
455 C: Codec
456 + EnvelopeCodec
457 + sealed::DecodeValue<Input>
458 + sealed::EncodeValue<Input>
459 + 'static,
460 {
461 let (tx, rx) = mpsc::channel(1);
462 let backend = Arc::clone(&self.backend);
463 let join_handle =
464 tokio::spawn(async move { self.run_actor_loop(poll_interval, workflows, rx).await });
465 WorkerHandle {
466 inner: Arc::new(WorkerHandleInner {
467 backend,
468 shutdown_tx: tx,
469 join_handle: tokio::sync::Mutex::new(Some(join_handle)),
470 }),
471 }
472 }
473
474 #[must_use]
487 pub fn spawn_with_executor(
488 self,
489 poll_interval: Duration,
490 workflows: WorkflowIndex,
491 executor: ExternalTaskExecutor,
492 ) -> WorkerHandle<B> {
493 let (tx, rx) = mpsc::channel(1);
494 let backend = Arc::clone(&self.backend);
495 let join_handle = tokio::spawn(async move {
496 self.run_external_actor_loop(poll_interval, workflows, executor, rx)
497 .await
498 });
499 WorkerHandle {
500 inner: Arc::new(WorkerHandleInner {
501 backend,
502 shutdown_tx: tx,
503 join_handle: tokio::sync::Mutex::new(Some(join_handle)),
504 }),
505 }
506 }
507
508 async fn run_external_actor_loop(
510 &self,
511 poll_interval: Duration,
512 workflows: WorkflowIndex,
513 executor: ExternalTaskExecutor,
514 mut cmd_rx: mpsc::Receiver<WorkerCommand>,
515 ) -> Result<(), crate::error::RuntimeError> {
516 let mut interval = staggered_poll_interval(&self.worker_id, poll_interval);
517
518 loop {
519 let hint = tokio::select! {
520 biased;
521
522 cmd = cmd_rx.recv() => {
523 match cmd {
524 Some(WorkerCommand::Shutdown) | None => {
525 tracing::info!(worker_id = %self.worker_id, "Worker shutting down");
526 return Ok(());
527 }
528 }
529 }
530 _ = interval.tick() => {
531 tracing::trace!(worker_id = %self.worker_id, "fallback poll tick");
532 None
533 }
534 hint = self.backend.wait_for_wakeup(poll_interval) => {
535 let hint = hint?;
536 tracing::debug!(
537 worker_id = %self.worker_id,
538 has_hint = hint.is_some(),
539 "wakeup notification",
540 );
541 hint
542 }
543 };
544
545 if let Some(h) = hint.as_ref()
546 && !self.can_handle_hint(h, &workflows)
547 {
548 tracing::trace!(
550 worker_id = %self.worker_id,
551 instance_id = %h.instance_id,
552 "skipping wakeup, hint not handleable here",
553 );
554 continue;
555 }
556
557 if let Some(h) = hint.as_ref() {
558 match self.try_hinted_execute(h, &workflows, &executor).await {
559 Ok(true) => continue,
560 Ok(false) => {}
561 Err(ref e) if e.is_timeout() => {
562 tracing::error!(worker_id = %self.worker_id, error = %e, "Task timed out — worker shutting down");
563 return Ok(());
564 }
565 Err(e) => return Err(e),
566 }
567 }
568
569 let available_tasks = self
570 .backend
571 .find_available_tasks(
572 &self.worker_id,
573 self.batch_size.get(),
574 chrono::Duration::from_std(self.aging_interval)
575 .unwrap_or(chrono::Duration::MAX),
576 &self.tags,
577 )
578 .await?;
579
580 for task in available_tasks {
581 if let Ok(WorkerCommand::Shutdown) | Err(mpsc::error::TryRecvError::Disconnected) =
582 cmd_rx.try_recv()
583 {
584 tracing::info!(worker_id = %self.worker_id, "Worker shutting down mid-batch");
585 return Ok(());
586 }
587
588 if let Some(ext_wf) = workflows.get(&task.workflow_definition_hash) {
589 match self
590 .execute_external_task(
591 ext_wf,
592 &task.workflow_definition_hash,
593 &executor,
594 &task,
595 )
596 .await
597 {
598 Err(ref e) if e.is_timeout() => {
599 tracing::error!(
600 worker_id = %self.worker_id,
601 error = %e,
602 "Task timed out — worker shutting down"
603 );
604 return Ok(());
605 }
606 Ok(_) => {
607 tracing::info!(worker_id = %self.worker_id, "completed task");
608 }
609 Err(e) => {
610 tracing::error!(
611 worker_id = %self.worker_id,
612 error = %e,
613 "task execution failed"
614 );
615 }
616 }
617 }
618 }
619 }
620 }
621
622 fn can_handle_hint(&self, hint: &TaskWakeupHint, workflows: &impl WorkflowLookup) -> bool {
625 let hash = sayiir_core::DefinitionHash::from_bytes(hint.definition_hash);
626 if !workflows.contains_definition_hash(&hash) {
627 return false;
628 }
629 hint.tags.iter().all(|t| self.tags.contains(t))
630 }
631
632 async fn try_hinted_execute(
636 &self,
637 hint: &TaskWakeupHint,
638 workflows: &WorkflowIndex,
639 executor: &ExternalTaskExecutor,
640 ) -> Result<bool, crate::error::RuntimeError> {
641 let Some(task) = self.backend.find_hinted_task(hint).await? else {
642 return Ok(false);
643 };
644 let Some(ext_wf) = workflows.get(&task.workflow_definition_hash) else {
645 return Ok(false);
646 };
647 match self
648 .execute_external_task(ext_wf, &task.workflow_definition_hash, executor, &task)
649 .await
650 {
651 Err(e) if e.is_timeout() => return Err(e),
652 Ok(_) => tracing::info!(worker_id = %self.worker_id, "completed hinted task"),
653 Err(e) => {
654 tracing::error!(worker_id = %self.worker_id, error = %e, "hinted task execution failed");
655 }
656 }
657 Ok(true)
658 }
659
660 #[tracing::instrument(
662 name = "workflow",
663 skip_all,
664 fields(
665 worker_id = %self.worker_id,
666 instance_id = %available_task.instance_id,
667 task_id = %available_task.task_id,
668 definition_hash = %definition_hash,
669 ),
670 )]
671 async fn execute_external_task(
672 &self,
673 ext_wf: &ExternalWorkflow,
674 definition_hash: &sayiir_core::DefinitionHash,
675 executor: &ExternalTaskExecutor,
676 available_task: &AvailableTask,
677 ) -> Result<WorkflowStatus, crate::error::RuntimeError> {
678 #[cfg(feature = "otel")]
680 if let Some(ref tp) = available_task.trace_parent {
681 use tracing_opentelemetry::OpenTelemetrySpanExt;
682 let remote_ctx = crate::trace_context::context_from_trace_parent(tp);
683 let _ = tracing::Span::current().set_parent(remote_ctx);
684 }
685
686 let already_completed = Self::validate_task_preconditions(
690 definition_hash,
691 &ext_wf.task_index,
692 available_task,
693 &available_task.snapshot,
694 )?;
695 if already_completed {
696 return Ok(WorkflowStatus::InProgress);
697 }
698
699 let Some(claim) = self.claim_task(available_task).await? else {
700 return Ok(WorkflowStatus::InProgress);
701 };
702
703 if let Some(status) = self.check_post_claim_guards(available_task).await? {
704 claim.release_quietly().await;
705 return Ok(status);
706 }
707
708 let mut snapshot = (*available_task.snapshot).clone();
710
711 tracing::debug!(
712 instance_id = %available_task.instance_id,
713 task_id = %available_task.task_id,
714 "Executing task (external)"
715 );
716
717 let execution_result = self
718 .execute_with_deadline_ext(ext_wf, executor, available_task, &mut snapshot, &claim)
719 .await;
720
721 self.settle_execution_result_ext(
722 execution_result,
723 &ext_wf.continuation,
724 &ext_wf.task_index,
725 available_task,
726 &mut snapshot,
727 claim,
728 )
729 .await
730 }
731
732 async fn execute_with_deadline_ext(
734 &self,
735 ext_wf: &ExternalWorkflow,
736 executor: &ExternalTaskExecutor,
737 available_task: &AvailableTask,
738 snapshot: &mut WorkflowSnapshot,
739 claim: &ActiveTaskClaim<'_, B>,
740 ) -> ExecutionOutcome {
741 let task_id = available_task.task_id;
742 let input = available_task.input.clone();
743 let indexed_meta = ext_wf.task_index.get(&task_id);
747 let task_name: Arc<str> =
748 indexed_meta.map_or_else(|| Arc::from(task_id.to_hex()), |m| Arc::clone(m.name()));
749
750 let deadline =
751 if let Some(timeout) = indexed_meta.and_then(sayiir_core::TaskNodeMetadata::timeout) {
752 snapshot.set_task_deadline(task_id, timeout);
753 snapshot.refresh_task_deadline();
754 snapshot.task_deadline.clone()
762 } else {
763 None
764 };
765
766 let task_ctx = TaskExecutionContext {
767 workflow_id: Arc::clone(&ext_wf.workflow_id),
768 instance_id: Arc::clone(&available_task.instance_id),
769 task_id: Arc::clone(&task_name),
770 metadata: ext_wf.task_index.build_task_metadata(&task_id),
771 workflow_metadata_json: ext_wf.metadata_json.clone(),
772 };
773
774 let execution_future = with_task_context(task_ctx, executor(&task_name, input));
775
776 let heartbeat_result = self
777 .run_with_heartbeat(
778 claim,
779 deadline.as_ref(),
780 AssertUnwindSafe(execution_future).catch_unwind(),
781 )
782 .await;
783
784 snapshot.clear_task_deadline();
785
786 match heartbeat_result {
787 Err(timeout_err) => ExecutionOutcome::Timeout(timeout_err),
788 Ok(Err(panic_payload)) => ExecutionOutcome::Panic(panic_payload),
789 Ok(Ok(Err(e))) => ExecutionOutcome::TaskError(e.into()),
790 Ok(Ok(Ok(output))) => ExecutionOutcome::Success(output),
791 }
792 }
793
794 #[tracing::instrument(
796 name = "settle_result",
797 skip_all,
798 fields(worker_id = %self.worker_id, instance_id = %available_task.instance_id, task_id = %available_task.task_id),
799 )]
800 async fn settle_execution_result_ext(
801 &self,
802 outcome: ExecutionOutcome,
803 continuation: &WorkflowContinuation,
804 task_index: &sayiir_core::TaskIndex,
805 available_task: &AvailableTask,
806 snapshot: &mut WorkflowSnapshot,
807 claim: ActiveTaskClaim<'_, B>,
808 ) -> Result<WorkflowStatus, crate::error::RuntimeError> {
809 tracing::debug!("settling execution result");
810 match outcome {
811 ExecutionOutcome::Timeout(err) => {
812 if let Ok(Some(status)) = self
813 .try_schedule_retry(task_index, available_task, snapshot, &err.to_string())
814 .await
815 {
816 claim.release_quietly().await;
817 return Ok(status);
818 }
819
820 tracing::warn!(
821 instance_id = %available_task.instance_id,
822 task_id = %available_task.task_id,
823 error = %err,
824 "Task timed out via heartbeat — marking workflow failed, shutting down"
825 );
826 snapshot.mark_failed(err.to_string());
827 let _ = self.backend.save_snapshot(snapshot).await;
828 claim.release_quietly().await;
829 Err(err)
830 }
831 ExecutionOutcome::Panic(panic_payload) => {
832 let panic_msg = extract_panic_message(&panic_payload);
833
834 if let Ok(Some(status)) = self
835 .try_schedule_retry(task_index, available_task, snapshot, &panic_msg)
836 .await
837 {
838 claim.release_quietly().await;
839 return Ok(status);
840 }
841
842 tracing::error!(
843 instance_id = %available_task.instance_id,
844 task_id = %available_task.task_id,
845 panic = %panic_msg,
846 "Task panicked - releasing claim"
847 );
848 claim.release_quietly().await;
849 Err(WorkflowError::TaskPanicked(panic_msg).into())
850 }
851 ExecutionOutcome::TaskError(e) => {
852 if let Ok(Some(status)) = self
853 .try_schedule_retry(task_index, available_task, snapshot, &e.to_string())
854 .await
855 {
856 claim.release_quietly().await;
857 return Ok(status);
858 }
859
860 tracing::error!(
861 instance_id = %available_task.instance_id,
862 task_id = %available_task.task_id,
863 error = %e,
864 "Task execution failed"
865 );
866 claim.release_quietly().await;
867 Err(e)
868 }
869 ExecutionOutcome::Success(output) => {
870 snapshot.clear_retry_state(&available_task.task_id);
871 self.commit_task_result(
872 continuation,
873 available_task,
874 snapshot,
875 output.clone(),
876 claim,
877 )
878 .await?;
879 self.determine_post_task_status(continuation, available_task, snapshot, output)
880 .await
881 }
882 }
883 }
884
885 async fn run_actor_loop<C, Input, M>(
888 &self,
889 poll_interval: Duration,
890 workflows: WorkflowRegistry<C, Input, M>,
891 mut cmd_rx: mpsc::Receiver<WorkerCommand>,
892 ) -> Result<(), crate::error::RuntimeError>
893 where
894 Input: Send + 'static,
895 M: Send + Sync + 'static,
896 C: Codec
897 + EnvelopeCodec
898 + sealed::DecodeValue<Input>
899 + sealed::EncodeValue<Input>
900 + 'static,
901 {
902 let mut interval = staggered_poll_interval(&self.worker_id, poll_interval);
903
904 loop {
905 let hint = tokio::select! {
908 biased;
909
910 cmd = cmd_rx.recv() => {
911 match cmd {
912 Some(WorkerCommand::Shutdown) | None => {
913 tracing::info!(worker_id = %self.worker_id, "Worker shutting down");
914 return Ok(());
915 }
916 }
917 }
918 _ = interval.tick() => {
919 tracing::trace!(worker_id = %self.worker_id, "fallback poll tick");
920 None
921 }
922 hint = self.backend.wait_for_wakeup(poll_interval) => {
923 let hint = hint?;
924 tracing::debug!(
925 worker_id = %self.worker_id,
926 has_hint = hint.is_some(),
927 "wakeup notification",
928 );
929 hint
930 }
931 };
932
933 if let Some(h) = hint.as_ref()
934 && !self.can_handle_hint(h, &workflows)
935 {
936 tracing::trace!(
938 worker_id = %self.worker_id,
939 instance_id = %h.instance_id,
940 "skipping wakeup, hint not handleable here",
941 );
942 continue;
943 }
944
945 let available_tasks = self
946 .backend
947 .find_available_tasks(
948 &self.worker_id,
949 self.batch_size.get(),
950 chrono::Duration::from_std(self.aging_interval)
951 .unwrap_or(chrono::Duration::MAX),
952 &self.tags,
953 )
954 .await?;
955
956 for task in available_tasks {
957 if let Ok(WorkerCommand::Shutdown) | Err(mpsc::error::TryRecvError::Disconnected) =
958 cmd_rx.try_recv()
959 {
960 tracing::info!(worker_id = %self.worker_id, "Worker shutting down mid-batch");
961 return Ok(());
962 }
963
964 if let Some((_, workflow)) = workflows
965 .iter()
966 .find(|(hash, _)| *hash == task.workflow_definition_hash)
967 {
969 match self.execute_task(workflow.as_ref(), task).await {
970 Err(ref e) if e.is_timeout() => {
971 tracing::error!(
972 worker_id = %self.worker_id,
973 error = %e,
974 "Task timed out — worker shutting down"
975 );
976 return Ok(());
977 }
978 Ok(_) => {
979 tracing::info!(worker_id = %self.worker_id, "completed task");
980 }
981 Err(e) => {
982 tracing::error!(
983 worker_id = %self.worker_id,
984 error = %e,
985 "task execution failed"
986 );
987 }
988 }
989 }
990 }
991 }
992 }
993
994 async fn load_cancelled_status(&self, instance_id: &str) -> WorkflowStatus {
999 if let Ok(snapshot) = self.backend.load_snapshot(instance_id).await
1000 && let Some((reason, cancelled_by)) = snapshot.state.cancellation_details()
1001 {
1002 return WorkflowStatus::Cancelled {
1003 reason,
1004 cancelled_by,
1005 };
1006 }
1007 WorkflowStatus::Cancelled {
1008 reason: None,
1009 cancelled_by: None,
1010 }
1011 }
1012
1013 async fn load_paused_status(&self, instance_id: &str) -> WorkflowStatus {
1018 if let Ok(snapshot) = self.backend.load_snapshot(instance_id).await
1019 && let Some((reason, paused_by)) = snapshot.state.pause_details()
1020 {
1021 return WorkflowStatus::Paused { reason, paused_by };
1022 }
1023 WorkflowStatus::Paused {
1024 reason: None,
1025 paused_by: None,
1026 }
1027 }
1028
1029 #[tracing::instrument(
1041 name = "workflow",
1042 skip_all,
1043 fields(
1044 worker_id = %self.worker_id,
1045 instance_id = %available_task.instance_id,
1046 task_id = %available_task.task_id,
1047 definition_hash = %available_task.workflow_definition_hash,
1048 ),
1049 )]
1050 pub async fn execute_task<C, Input, M>(
1051 &self,
1052 workflow: &Workflow<C, Input, M>,
1053 available_task: AvailableTask,
1054 ) -> Result<WorkflowStatus, crate::error::RuntimeError>
1055 where
1056 Input: Send + 'static,
1057 M: Send + Sync + 'static,
1058 C: Codec
1059 + EnvelopeCodec
1060 + sealed::DecodeValue<Input>
1061 + sealed::EncodeValue<Input>
1062 + 'static,
1063 {
1064 #[cfg(feature = "otel")]
1066 if let Some(ref tp) = available_task.trace_parent {
1067 use tracing_opentelemetry::OpenTelemetrySpanExt;
1068 let remote_ctx = crate::trace_context::context_from_trace_parent(tp);
1069 let _ = tracing::Span::current().set_parent(remote_ctx);
1070 }
1071
1072 let already_completed = Self::validate_task_preconditions(
1076 workflow.definition_hash(),
1077 workflow.task_index(),
1078 &available_task,
1079 &available_task.snapshot,
1080 )?;
1081 if already_completed {
1082 return Ok(WorkflowStatus::InProgress);
1083 }
1084
1085 let Some(claim) = self.claim_task(&available_task).await? else {
1086 return Ok(WorkflowStatus::InProgress);
1087 };
1088
1089 if let Some(status) = self.check_post_claim_guards(&available_task).await? {
1091 claim.release_quietly().await;
1092 return Ok(status);
1093 }
1094
1095 let mut snapshot = (*available_task.snapshot).clone();
1097
1098 tracing::debug!(
1099 instance_id = %available_task.instance_id,
1100 task_id = %available_task.task_id,
1101 "Executing task"
1102 );
1103
1104 let execution_result = self
1106 .execute_with_deadline(workflow, &available_task, &mut snapshot, &claim)
1107 .await;
1108
1109 self.settle_execution_result(
1110 execution_result,
1111 workflow,
1112 &available_task,
1113 &mut snapshot,
1114 claim,
1115 )
1116 .await
1117 }
1118
1119 async fn execute_with_deadline<C, Input, M>(
1125 &self,
1126 workflow: &Workflow<C, Input, M>,
1127 available_task: &AvailableTask,
1128 snapshot: &mut WorkflowSnapshot,
1129 claim: &ActiveTaskClaim<'_, B>,
1130 ) -> ExecutionOutcome
1131 where
1132 Input: Send + 'static,
1133 M: Send + Sync + 'static,
1134 C: Codec
1135 + EnvelopeCodec
1136 + sealed::DecodeValue<Input>
1137 + sealed::EncodeValue<Input>
1138 + 'static,
1139 {
1140 let continuation = workflow.continuation();
1141 let task_index = workflow.task_index();
1142 let task_id = available_task.task_id;
1143 let input = match Self::find_fork_branches_for_join(continuation, &task_id) {
1153 Some(branches) => {
1154 let build = || -> Result<Bytes, crate::error::RuntimeError> {
1155 let mut results = Vec::with_capacity(branches.len());
1156 for branch in branches {
1157 let branch_name = branch.id().to_string();
1169 let terminal_tid = sayiir_core::TaskId::from(branch.terminal_task_id());
1170 let output = snapshot
1171 .get_task_result_bytes(&terminal_tid)
1172 .ok_or_else(|| WorkflowError::TaskNotFound(branch_name.clone()))?;
1173 results.push((branch_name, output));
1174 }
1175 crate::execution::serialize_branch_results(&results, workflow.codec().as_ref())
1176 };
1177 match build() {
1178 Ok(bytes) => bytes,
1179 Err(e) => return ExecutionOutcome::TaskError(e),
1180 }
1181 }
1182 None => available_task.input.clone(),
1183 };
1184 let indexed_meta = task_index.get(&task_id);
1187 let task_name: Arc<str> =
1188 indexed_meta.map_or_else(|| Arc::from(task_id.to_hex()), |m| Arc::clone(m.name()));
1189
1190 let deadline =
1192 if let Some(timeout) = indexed_meta.and_then(sayiir_core::TaskNodeMetadata::timeout) {
1193 snapshot.set_task_deadline(task_id, timeout);
1194 snapshot.refresh_task_deadline();
1195 snapshot.task_deadline.clone()
1203 } else {
1204 None
1205 };
1206
1207 let task_ctx = TaskExecutionContext {
1208 workflow_id: Arc::from(workflow.context().workflow_id()),
1209 instance_id: Arc::clone(&available_task.instance_id),
1210 task_id: Arc::clone(&task_name),
1211 metadata: task_index.build_task_metadata(&task_id),
1212 workflow_metadata_json: workflow.context().metadata_json.clone(),
1213 };
1214
1215 let execution_future = with_task_context(task_ctx, async move {
1216 Self::execute_task_by_id(continuation, &task_name, input).await
1217 });
1218
1219 let heartbeat_result = self
1220 .run_with_heartbeat(
1221 claim,
1222 deadline.as_ref(),
1223 AssertUnwindSafe(execution_future).catch_unwind(),
1224 )
1225 .await;
1226
1227 snapshot.clear_task_deadline();
1228
1229 match heartbeat_result {
1230 Err(timeout_err) => ExecutionOutcome::Timeout(timeout_err),
1231 Ok(Err(panic_payload)) => ExecutionOutcome::Panic(panic_payload),
1232 Ok(Ok(Err(e))) => ExecutionOutcome::TaskError(e),
1233 Ok(Ok(Ok(output))) => ExecutionOutcome::Success(output),
1234 }
1235 }
1236
1237 async fn try_schedule_retry(
1244 &self,
1245 task_index: &sayiir_core::TaskIndex,
1246 available_task: &AvailableTask,
1247 snapshot: &mut WorkflowSnapshot,
1248 error_msg: &str,
1249 ) -> Result<Option<WorkflowStatus>, crate::error::RuntimeError> {
1250 let Some(policy) = task_index.retry_policy(&available_task.task_id) else {
1251 return Ok(None);
1252 };
1253
1254 if snapshot.retries_exhausted(&available_task.task_id) {
1255 return Ok(None);
1256 }
1257
1258 let next_retry_at = snapshot.record_retry(
1259 available_task.task_id,
1260 policy,
1261 error_msg,
1262 Some(&self.worker_id),
1263 );
1264 snapshot.clear_task_deadline();
1265 let _ = self.backend.save_snapshot(snapshot).await;
1266
1267 tracing::info!(
1268 instance_id = %available_task.instance_id,
1269 task_id = %available_task.task_id,
1270 attempt = snapshot.get_retry_state(&available_task.task_id).map_or(0, |rs| rs.attempts),
1271 max_retries = policy.max_retries,
1272 %next_retry_at,
1273 "Scheduling retry"
1274 );
1275
1276 Ok(Some(WorkflowStatus::InProgress))
1277 }
1278
1279 #[tracing::instrument(
1281 name = "settle_result",
1282 skip_all,
1283 fields(worker_id = %self.worker_id, instance_id = %available_task.instance_id, task_id = %available_task.task_id),
1284 )]
1285 async fn settle_execution_result<C, Input, M>(
1286 &self,
1287 outcome: ExecutionOutcome,
1288 workflow: &Workflow<C, Input, M>,
1289 available_task: &AvailableTask,
1290 snapshot: &mut WorkflowSnapshot,
1291 claim: ActiveTaskClaim<'_, B>,
1292 ) -> Result<WorkflowStatus, crate::error::RuntimeError>
1293 where
1294 Input: Send + 'static,
1295 M: Send + Sync + 'static,
1296 C: Codec
1297 + EnvelopeCodec
1298 + sealed::DecodeValue<Input>
1299 + sealed::EncodeValue<Input>
1300 + 'static,
1301 {
1302 tracing::debug!("settling execution result");
1303 match outcome {
1304 ExecutionOutcome::Timeout(err) => {
1305 if let Ok(Some(status)) = self
1306 .try_schedule_retry(
1307 workflow.task_index(),
1308 available_task,
1309 snapshot,
1310 &err.to_string(),
1311 )
1312 .await
1313 {
1314 claim.release_quietly().await;
1315 return Ok(status);
1316 }
1317
1318 tracing::warn!(
1319 instance_id = %available_task.instance_id,
1320 task_id = %available_task.task_id,
1321 error = %err,
1322 "Task timed out via heartbeat — marking workflow failed, shutting down"
1323 );
1324 snapshot.mark_failed(err.to_string());
1325 let _ = self.backend.save_snapshot(snapshot).await;
1326 claim.release_quietly().await;
1327 Err(err)
1328 }
1329 ExecutionOutcome::Panic(panic_payload) => {
1330 let panic_msg = extract_panic_message(&panic_payload);
1331
1332 if let Ok(Some(status)) = self
1333 .try_schedule_retry(workflow.task_index(), available_task, snapshot, &panic_msg)
1334 .await
1335 {
1336 claim.release_quietly().await;
1337 return Ok(status);
1338 }
1339
1340 tracing::error!(
1341 instance_id = %available_task.instance_id,
1342 task_id = %available_task.task_id,
1343 panic = %panic_msg,
1344 "Task panicked - releasing claim"
1345 );
1346 claim.release_quietly().await;
1347 Err(WorkflowError::TaskPanicked(panic_msg).into())
1348 }
1349 ExecutionOutcome::TaskError(e) => {
1350 if let Ok(Some(status)) = self
1351 .try_schedule_retry(
1352 workflow.task_index(),
1353 available_task,
1354 snapshot,
1355 &e.to_string(),
1356 )
1357 .await
1358 {
1359 claim.release_quietly().await;
1360 return Ok(status);
1361 }
1362
1363 tracing::error!(
1364 instance_id = %available_task.instance_id,
1365 task_id = %available_task.task_id,
1366 error = %e,
1367 "Task execution failed"
1368 );
1369 claim.release_quietly().await;
1370 Err(e)
1371 }
1372 ExecutionOutcome::Success(output) => {
1373 snapshot.clear_retry_state(&available_task.task_id);
1374 self.commit_task_result(
1375 workflow.continuation(),
1376 available_task,
1377 snapshot,
1378 output.clone(),
1379 claim,
1380 )
1381 .await?;
1382 Self::resolve_loop_completions(
1385 workflow.continuation(),
1386 snapshot,
1387 self.backend.as_ref(),
1388 )
1389 .await?;
1390 self.determine_post_task_status(
1391 workflow.continuation(),
1392 available_task,
1393 snapshot,
1394 output,
1395 )
1396 .await
1397 }
1398 }
1399 }
1400
1401 fn validate_task_preconditions(
1407 definition_hash: &sayiir_core::DefinitionHash,
1408 task_index: &sayiir_core::TaskIndex,
1409 available_task: &AvailableTask,
1410 snapshot: &WorkflowSnapshot,
1411 ) -> Result<bool, crate::error::RuntimeError> {
1412 if available_task.workflow_definition_hash != *definition_hash {
1413 return Err(WorkflowError::DefinitionMismatch {
1414 expected: *definition_hash,
1415 found: available_task.workflow_definition_hash,
1416 }
1417 .into());
1418 }
1419
1420 if !task_index.contains(&available_task.task_id) {
1421 tracing::error!(
1422 instance_id = %available_task.instance_id,
1423 task_id = %available_task.task_id,
1424 "Task does not exist in workflow"
1425 );
1426 return Err(WorkflowError::TaskNotFound(available_task.task_id.to_hex()).into());
1427 }
1428
1429 if snapshot.get_task_result(&available_task.task_id).is_some() {
1430 tracing::debug!(
1431 instance_id = %available_task.instance_id,
1432 task_id = %available_task.task_id,
1433 "Task already completed, skipping"
1434 );
1435 return Ok(true);
1436 }
1437
1438 Ok(false)
1439 }
1440
1441 async fn claim_task(
1445 &self,
1446 available_task: &AvailableTask,
1447 ) -> Result<Option<ActiveTaskClaim<'_, B>>, crate::error::RuntimeError> {
1448 let claim = self
1449 .backend
1450 .claim_task(
1451 &available_task.instance_id,
1452 &available_task.task_id,
1453 &self.worker_id,
1454 self.claim_ttl
1455 .and_then(|d| chrono::Duration::from_std(d).ok()),
1456 )
1457 .await?;
1458
1459 if claim.is_some() {
1460 tracing::debug!(
1461 instance_id = %available_task.instance_id,
1462 task_id = %available_task.task_id,
1463 "Claim successful"
1464 );
1465 Ok(Some(ActiveTaskClaim {
1466 backend: &self.backend,
1467 instance_id: Arc::clone(&available_task.instance_id),
1468 task_id: available_task.task_id,
1469 worker_id: self.worker_id.clone(),
1470 }))
1471 } else {
1472 tracing::debug!(
1473 instance_id = %available_task.instance_id,
1474 task_id = %available_task.task_id,
1475 "Task was already claimed by another worker"
1476 );
1477 Ok(None)
1478 }
1479 }
1480
1481 async fn check_post_claim_guards(
1487 &self,
1488 available_task: &AvailableTask,
1489 ) -> Result<Option<WorkflowStatus>, crate::error::RuntimeError> {
1490 if self
1491 .backend
1492 .check_and_cancel(&available_task.instance_id, Some(available_task.task_id))
1493 .await?
1494 {
1495 tracing::info!(
1496 instance_id = %available_task.instance_id,
1497 task_id = %available_task.task_id,
1498 "Workflow was cancelled, releasing claim"
1499 );
1500 return Ok(Some(
1501 self.load_cancelled_status(&available_task.instance_id)
1502 .await,
1503 ));
1504 }
1505
1506 if self
1507 .backend
1508 .check_and_pause(&available_task.instance_id)
1509 .await?
1510 {
1511 tracing::info!(
1512 instance_id = %available_task.instance_id,
1513 task_id = %available_task.task_id,
1514 "Workflow was paused, releasing claim"
1515 );
1516 return Ok(Some(
1517 self.load_paused_status(&available_task.instance_id).await,
1518 ));
1519 }
1520
1521 Ok(None)
1522 }
1523
1524 #[tracing::instrument(
1530 name = "task",
1531 skip_all,
1532 fields(worker_id = %self.worker_id, instance_id = %claim.instance_id, task_id = %claim.task_id),
1533 )]
1534 async fn run_with_heartbeat<F, T>(
1535 &self,
1536 claim: &ActiveTaskClaim<'_, B>,
1537 deadline: Option<&TaskDeadline>,
1538 future: F,
1539 ) -> Result<T, crate::error::RuntimeError>
1540 where
1541 F: std::future::Future<Output = T>,
1542 {
1543 tracing::debug!("running task with heartbeat");
1544 let Some(ttl) = self.claim_ttl else {
1545 return Ok(future.await);
1546 };
1547 let Some(chrono_ttl) = chrono::Duration::from_std(ttl).ok() else {
1548 return Ok(future.await);
1549 };
1550
1551 let interval_duration = ttl / 2;
1552 let mut heartbeat_timer = time::interval(interval_duration);
1553 heartbeat_timer.tick().await; tokio::pin!(future);
1556
1557 loop {
1558 tokio::select! {
1559 result = &mut future => break Ok(result),
1560 _ = heartbeat_timer.tick() => {
1561 if let Some(dl) = deadline
1563 && chrono::Utc::now() >= dl.deadline
1564 {
1565 tracing::warn!(
1566 instance_id = %claim.instance_id,
1567 task_id = %dl.task_id,
1568 "Task deadline expired during heartbeat, cancelling"
1569 );
1570 return Err(WorkflowError::TaskTimedOut {
1571 task_id: dl.task_id,
1572 timeout: std::time::Duration::from_millis(dl.timeout_ms),
1573 }
1574 .into());
1575 }
1576
1577 tracing::trace!(
1578 instance_id = %claim.instance_id,
1579 task_id = %claim.task_id,
1580 "Extending task claim via heartbeat"
1581 );
1582 if let Err(e) = self.backend
1583 .extend_task_claim(
1584 &claim.instance_id,
1585 &claim.task_id,
1586 &claim.worker_id,
1587 chrono_ttl,
1588 )
1589 .await
1590 {
1591 tracing::warn!(
1592 instance_id = %claim.instance_id,
1593 task_id = %claim.task_id,
1594 error = %e,
1595 "Failed to extend task claim"
1596 );
1597 }
1598 }
1599 }
1600 }
1601 }
1602
1603 async fn commit_task_result(
1605 &self,
1606 continuation: &WorkflowContinuation,
1607 available_task: &AvailableTask,
1608 snapshot: &mut WorkflowSnapshot,
1609 output: Bytes,
1610 claim: ActiveTaskClaim<'_, B>,
1611 ) -> Result<(), crate::error::RuntimeError> {
1612 snapshot.mark_task_completed(available_task.task_id, output);
1613 tracing::debug!(
1614 instance_id = %available_task.instance_id,
1615 task_id = %available_task.task_id,
1616 "Task completed"
1617 );
1618
1619 Self::update_position_after_task(continuation, &available_task.task_id, snapshot)?;
1620 #[cfg(feature = "otel")]
1621 {
1622 snapshot.trace_parent = crate::trace_context::current_trace_parent();
1623 }
1624 self.backend.save_snapshot(snapshot).await?;
1625
1626 self.drain_pending_signal(&available_task.instance_id, snapshot)
1638 .await?;
1639
1640 claim.release().await?;
1641 Ok(())
1642 }
1643
1644 async fn drain_pending_signal(
1650 &self,
1651 instance_id: &Arc<str>,
1652 snapshot: &mut WorkflowSnapshot,
1653 ) -> Result<(), crate::error::RuntimeError> {
1654 loop {
1655 let (signal_id, signal_name, next_task_id) = match &snapshot.state {
1656 sayiir_core::snapshot::WorkflowSnapshotState::InProgress {
1657 position:
1658 sayiir_core::snapshot::ExecutionPosition::AtSignal {
1659 signal_id,
1660 signal_name,
1661 next_task_id,
1662 ..
1663 },
1664 ..
1665 } => (*signal_id, signal_name.clone(), *next_task_id),
1666 _ => return Ok(()),
1667 };
1668
1669 let Some(payload) = self
1670 .backend
1671 .consume_event(instance_id, &signal_name)
1672 .await?
1673 else {
1674 return Ok(());
1675 };
1676
1677 tracing::debug!(
1678 instance_id = %instance_id,
1679 %signal_name,
1680 "draining buffered signal that landed during the AtTask→AtSignal transition"
1681 );
1682 snapshot.mark_task_completed(signal_id, payload.clone());
1683 if let Some(next_id) = next_task_id {
1684 snapshot.update_position(sayiir_core::snapshot::ExecutionPosition::AtTask {
1685 task_id: next_id,
1686 });
1687 } else {
1688 snapshot.mark_completed(payload);
1689 }
1690 self.backend.save_snapshot(snapshot).await?;
1691 }
1692 }
1693
1694 async fn determine_post_task_status(
1698 &self,
1699 continuation: &WorkflowContinuation,
1700 available_task: &AvailableTask,
1701 snapshot: &mut WorkflowSnapshot,
1702 output: Bytes,
1703 ) -> Result<WorkflowStatus, crate::error::RuntimeError> {
1704 if self
1706 .backend
1707 .check_and_cancel(&available_task.instance_id, None)
1708 .await?
1709 {
1710 tracing::info!(
1711 instance_id = %available_task.instance_id,
1712 task_id = %available_task.task_id,
1713 "Workflow was cancelled after task completion"
1714 );
1715 return Ok(self
1716 .load_cancelled_status(&available_task.instance_id)
1717 .await);
1718 }
1719
1720 if self
1722 .backend
1723 .check_and_pause(&available_task.instance_id)
1724 .await?
1725 {
1726 tracing::info!(
1727 instance_id = %available_task.instance_id,
1728 task_id = %available_task.task_id,
1729 "Workflow was paused after task completion"
1730 );
1731 return Ok(self.load_paused_status(&available_task.instance_id).await);
1732 }
1733
1734 if Self::is_workflow_complete(continuation, snapshot) {
1735 tracing::info!(
1736 instance_id = %available_task.instance_id,
1737 task_id = %available_task.task_id,
1738 "Workflow complete"
1739 );
1740 snapshot.mark_completed(output);
1741 self.backend.save_snapshot(snapshot).await?;
1742 Ok(WorkflowStatus::Completed)
1743 } else {
1744 tracing::debug!(
1745 instance_id = %available_task.instance_id,
1746 task_id = %available_task.task_id,
1747 "Task completed, workflow continues"
1748 );
1749 Ok(WorkflowStatus::InProgress)
1750 }
1751 }
1752
1753 fn find_fork_branches_for_join<'a>(
1761 continuation: &'a WorkflowContinuation,
1762 task_id: &sayiir_core::TaskId,
1763 ) -> Option<&'a [Arc<WorkflowContinuation>]> {
1764 match continuation {
1765 WorkflowContinuation::Task { next, .. }
1766 | WorkflowContinuation::Delay { next, .. }
1767 | WorkflowContinuation::AwaitSignal { next, .. } => next
1768 .as_deref()
1769 .and_then(|n| Self::find_fork_branches_for_join(n, task_id)),
1770 WorkflowContinuation::Fork { branches, join, .. } => {
1771 if let Some(join_cont) = join {
1772 let join_first = sayiir_core::TaskId::from(join_cont.first_task_id());
1773 if join_first == *task_id {
1774 return Some(&branches[..]);
1775 }
1776 if let Some(b) = Self::find_fork_branches_for_join(join_cont, task_id) {
1777 return Some(b);
1778 }
1779 }
1780 for branch in branches {
1781 if let Some(b) = Self::find_fork_branches_for_join(branch, task_id) {
1782 return Some(b);
1783 }
1784 }
1785 None
1786 }
1787 WorkflowContinuation::Branch {
1788 branches,
1789 default,
1790 next,
1791 ..
1792 } => {
1793 for branch_cont in branches.values() {
1794 if let Some(b) = Self::find_fork_branches_for_join(branch_cont, task_id) {
1795 return Some(b);
1796 }
1797 }
1798 if let Some(def) = default
1799 && let Some(b) = Self::find_fork_branches_for_join(def, task_id)
1800 {
1801 return Some(b);
1802 }
1803 next.as_deref()
1804 .and_then(|n| Self::find_fork_branches_for_join(n, task_id))
1805 }
1806 WorkflowContinuation::Loop { body, next, .. } => {
1807 Self::find_fork_branches_for_join(body, task_id).or_else(|| {
1808 next.as_deref()
1809 .and_then(|n| Self::find_fork_branches_for_join(n, task_id))
1810 })
1811 }
1812 WorkflowContinuation::ChildWorkflow { child, next, .. } => {
1813 Self::find_fork_branches_for_join(child, task_id).or_else(|| {
1814 next.as_deref()
1815 .and_then(|n| Self::find_fork_branches_for_join(n, task_id))
1816 })
1817 }
1818 }
1819 }
1820
1821 fn find_task_id_in_continuation(
1829 continuation: &WorkflowContinuation,
1830 task_id: &sayiir_core::TaskId,
1831 ) -> bool {
1832 match continuation {
1833 WorkflowContinuation::Task { id, next, .. }
1834 | WorkflowContinuation::Delay { id, next, .. }
1835 | WorkflowContinuation::AwaitSignal { id, next, .. } => {
1836 if sayiir_core::TaskId::from(id.as_str()) == *task_id {
1837 return true;
1838 }
1839 next.as_ref()
1840 .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1841 }
1842 WorkflowContinuation::Fork { branches, join, .. } => {
1843 for branch in branches {
1844 if Self::find_task_id_in_continuation(branch, task_id) {
1845 return true;
1846 }
1847 }
1848 if let Some(join_cont) = join {
1849 Self::find_task_id_in_continuation(join_cont, task_id)
1850 } else {
1851 false
1852 }
1853 }
1854 WorkflowContinuation::Branch {
1855 branches,
1856 default,
1857 next,
1858 ..
1859 } => {
1860 for branch_cont in branches.values() {
1861 if Self::find_task_id_in_continuation(branch_cont, task_id) {
1862 return true;
1863 }
1864 }
1865 if let Some(def) = default
1866 && Self::find_task_id_in_continuation(def, task_id)
1867 {
1868 return true;
1869 }
1870 next.as_ref()
1871 .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1872 }
1873 WorkflowContinuation::Loop { body, next, .. } => {
1874 if Self::find_task_id_in_continuation(body, task_id) {
1875 return true;
1876 }
1877 next.as_ref()
1878 .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1879 }
1880 WorkflowContinuation::ChildWorkflow { child, next, .. } => {
1881 if Self::find_task_id_in_continuation(child, task_id) {
1882 return true;
1883 }
1884 next.as_ref()
1885 .is_some_and(|n| Self::find_task_id_in_continuation(n, task_id))
1886 }
1887 }
1888 }
1889
1890 #[allow(clippy::manual_async_fn)]
1892 fn execute_task_by_id<'a>(
1893 continuation: &'a WorkflowContinuation,
1894 task_id: &'a str,
1895 input: Bytes,
1896 ) -> impl std::future::Future<Output = Result<Bytes, crate::error::RuntimeError>> + Send + 'a
1897 {
1898 async move {
1899 let task_id_hash = sayiir_core::TaskId::from(task_id);
1900 let task_id = &task_id_hash;
1901 let mut current = continuation;
1902
1903 loop {
1904 match current {
1905 WorkflowContinuation::Task { id, func, next, .. } => {
1906 if sayiir_core::TaskId::from(id.as_str()) == *task_id {
1907 let func = func
1908 .as_ref()
1909 .ok_or_else(|| WorkflowError::TaskNotImplemented(id.clone()))?;
1910 return Ok(func.run(input).await?);
1911 } else if let Some(next_cont) = next {
1912 current = next_cont;
1913 } else {
1914 return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1915 }
1916 }
1917 WorkflowContinuation::Delay { next, .. }
1918 | WorkflowContinuation::AwaitSignal { next, .. } => {
1919 if let Some(next_cont) = next {
1921 current = next_cont;
1922 } else {
1923 return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1924 }
1925 }
1926 WorkflowContinuation::Fork { branches, join, .. } => {
1927 let mut found_in_branch = false;
1929 for branch in branches {
1930 if Self::find_task_id_in_continuation(branch, task_id) {
1931 current = branch;
1932 found_in_branch = true;
1933 break;
1934 }
1935 }
1936 if found_in_branch {
1937 continue;
1938 }
1939 if let Some(join_cont) = join {
1941 current = join_cont;
1942 } else {
1943 return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1944 }
1945 }
1946 WorkflowContinuation::Branch {
1947 branches,
1948 default,
1949 next,
1950 ..
1951 } => {
1952 let mut found = false;
1954 for branch_cont in branches.values() {
1955 if Self::find_task_id_in_continuation(branch_cont, task_id) {
1956 current = branch_cont;
1957 found = true;
1958 break;
1959 }
1960 }
1961 if found {
1962 continue;
1963 }
1964 if let Some(def) = default
1965 && Self::find_task_id_in_continuation(def, task_id)
1966 {
1967 current = def;
1968 continue;
1969 }
1970 if let Some(next_cont) = next {
1971 current = next_cont;
1972 } else {
1973 return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1974 }
1975 }
1976 WorkflowContinuation::Loop { body, next, .. } => {
1977 if Self::find_task_id_in_continuation(body, task_id) {
1978 current = body;
1979 continue;
1980 }
1981 if let Some(next_cont) = next {
1982 current = next_cont;
1983 } else {
1984 return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1985 }
1986 }
1987 WorkflowContinuation::ChildWorkflow { child, next, .. } => {
1988 if Self::find_task_id_in_continuation(child, task_id) {
1989 current = child;
1990 continue;
1991 }
1992 if let Some(next_cont) = next {
1993 current = next_cont;
1994 } else {
1995 return Err(WorkflowError::TaskNotFound(task_id.to_string()).into());
1996 }
1997 }
1998 }
1999 }
2000 }
2001 }
2002
2003 fn set_position_at(
2013 cont: &WorkflowContinuation,
2014 snapshot: &mut WorkflowSnapshot,
2015 ) -> Result<(), crate::error::RuntimeError> {
2016 use crate::execution::control_flow::{compute_signal_timeout, compute_wake_at};
2017 match cont {
2018 WorkflowContinuation::Delay { id, duration, next } => {
2019 let wake_at = compute_wake_at(duration)?;
2020 let entered_at = chrono::Utc::now();
2021 let next_hint = next.as_deref().map(WorkflowContinuation::first_task_hint);
2022 let next_task_id = next_hint.as_ref().map(|h| h.id);
2023 snapshot.set_task_hint(next_hint.as_ref().unwrap_or(&TaskHint::default()));
2027 let delay_id = sayiir_core::TaskId::from(id.as_str());
2028 snapshot.update_position(ExecutionPosition::AtDelay {
2029 delay_id,
2030 entered_at,
2031 wake_at,
2032 next_task_id,
2033 });
2034 let passthrough = snapshot.get_last_task_output().unwrap_or_default();
2040 snapshot.mark_task_completed(delay_id, passthrough);
2041 }
2042 WorkflowContinuation::AwaitSignal {
2043 id,
2044 signal_name,
2045 timeout,
2046 next,
2047 } => {
2048 let wake_at = compute_signal_timeout(timeout.as_ref());
2049 let next_hint = next.as_deref().map(WorkflowContinuation::first_task_hint);
2050 let next_task_id = next_hint.as_ref().map(|h| h.id);
2051 snapshot.set_task_hint(next_hint.as_ref().unwrap_or(&TaskHint::default()));
2055 snapshot.update_position(ExecutionPosition::AtSignal {
2056 signal_id: sayiir_core::TaskId::from(id.as_str()),
2057 signal_name: signal_name.clone(),
2058 wake_at,
2059 next_task_id,
2060 });
2061 }
2062 _ => {
2063 let hint = cont.first_task_hint();
2064 snapshot.update_position(ExecutionPosition::AtTask { task_id: hint.id });
2065 snapshot.set_task_hint(&hint);
2066 }
2067 }
2068 Ok(())
2069 }
2070
2071 fn update_position_after_task(
2073 continuation: &WorkflowContinuation,
2074 completed_task_id: &sayiir_core::TaskId,
2075 snapshot: &mut WorkflowSnapshot,
2076 ) -> Result<(), crate::error::RuntimeError> {
2077 match continuation {
2078 WorkflowContinuation::Task { id, next, .. }
2079 | WorkflowContinuation::Delay { id, next, .. }
2080 | WorkflowContinuation::AwaitSignal { id, next, .. } => {
2081 if sayiir_core::TaskId::from(id.as_str()) == *completed_task_id {
2082 if let Some(next_cont) = next.as_deref() {
2083 Self::set_position_at(next_cont, snapshot)?;
2084 }
2085 } else if let Some(next_cont) = next {
2086 Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2087 }
2088 }
2089 WorkflowContinuation::Fork { branches, join, .. } => {
2090 for branch in branches {
2093 Self::update_position_after_task(branch, completed_task_id, snapshot)?;
2094 }
2095 if let Some(join_cont) = join {
2096 Self::update_position_after_task(join_cont, completed_task_id, snapshot)?;
2097 }
2098
2099 let still_at_completed = snapshot
2109 .current_task_id()
2110 .is_some_and(|c| c == *completed_task_id);
2111 if !still_at_completed {
2112 return Ok(());
2113 }
2114
2115 for branch in branches {
2120 let first_tid = sayiir_core::TaskId::from(branch.first_task_id());
2121 if snapshot.get_task_result(&first_tid).is_none() {
2122 Self::set_position_at(branch, snapshot)?;
2123 return Ok(());
2124 }
2125 }
2126
2127 if let Some(join_cont) = join {
2131 Self::set_position_at(join_cont, snapshot)?;
2132 }
2133 }
2134 WorkflowContinuation::Branch {
2135 branches,
2136 default,
2137 next,
2138 ..
2139 } => {
2140 for branch_cont in branches.values() {
2141 Self::update_position_after_task(branch_cont, completed_task_id, snapshot)?;
2142 }
2143 if let Some(def) = default {
2144 Self::update_position_after_task(def, completed_task_id, snapshot)?;
2145 }
2146 if let Some(next_cont) = next {
2147 Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2148 }
2149 }
2150 WorkflowContinuation::Loop { body, next, .. } => {
2151 Self::update_position_after_task(body, completed_task_id, snapshot)?;
2152 if let Some(next_cont) = next {
2153 Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2154 }
2155 }
2156 WorkflowContinuation::ChildWorkflow { child, next, .. } => {
2157 Self::update_position_after_task(child, completed_task_id, snapshot)?;
2158 if let Some(next_cont) = next {
2159 Self::update_position_after_task(next_cont, completed_task_id, snapshot)?;
2160 }
2161 }
2162 }
2163 Ok(())
2164 }
2165
2166 pub fn builder(backend: B, registry: TaskRegistry) -> PooledWorkerBuilder<B> {
2187 PooledWorkerBuilder {
2188 worker_id: None,
2189 backend,
2190 registry,
2191 claim_ttl: Some(Duration::from_mins(5)),
2192 batch_size: NonZeroUsize::MIN,
2193 aging_interval: Duration::from_mins(5),
2194 tags: vec![],
2195 }
2196 }
2197
2198 async fn resolve_loop_completions(
2204 continuation: &WorkflowContinuation,
2205 snapshot: &mut WorkflowSnapshot,
2206 backend: &B,
2207 ) -> Result<(), crate::error::RuntimeError> {
2208 Self::resolve_loops_recursive(continuation, snapshot, backend).await
2209 }
2210
2211 #[allow(clippy::too_many_lines)]
2212 fn resolve_loops_recursive<'a>(
2213 continuation: &'a WorkflowContinuation,
2214 snapshot: &'a mut WorkflowSnapshot,
2215 backend: &'a B,
2216 ) -> Pin<
2217 Box<dyn std::future::Future<Output = Result<(), crate::error::RuntimeError>> + Send + 'a>,
2218 > {
2219 Box::pin(async move {
2220 match continuation {
2221 WorkflowContinuation::Loop {
2222 id,
2223 body,
2224 max_iterations,
2225 on_max,
2226 next,
2227 } => {
2228 if snapshot
2230 .get_task_result(&sayiir_core::TaskId::from(id))
2231 .is_none()
2232 {
2233 let terminal_id = body.terminal_task_id();
2234 if let Some(result) =
2235 snapshot.get_task_result(&sayiir_core::TaskId::from(terminal_id))
2236 {
2237 let output = result.output.clone();
2238 match crate::execution::decode_loop_envelope(&output) {
2239 Ok((LoopDecision::Done, inner)) => {
2240 snapshot.clear_loop_iteration(&sayiir_core::TaskId::from(id));
2241 snapshot
2242 .mark_task_completed(sayiir_core::TaskId::from(id), inner);
2243 backend.save_snapshot(snapshot).await?;
2244 }
2245 Ok((LoopDecision::Again, again_value)) => {
2246 let current_iter =
2247 snapshot.loop_iteration(&sayiir_core::TaskId::from(id));
2248 let next_iter = current_iter + 1;
2249 if next_iter >= *max_iterations {
2250 match on_max {
2251 sayiir_core::workflow::MaxIterationsPolicy::Fail => {
2252 return Err(WorkflowError::MaxIterationsExceeded {
2253 loop_id: sayiir_core::TaskId::from(id),
2254 max_iterations: *max_iterations,
2255 }
2256 .into());
2257 }
2258 sayiir_core::workflow::MaxIterationsPolicy::ExitWithLast => {
2259 snapshot.clear_loop_iteration(&sayiir_core::TaskId::from(id));
2260 snapshot.mark_task_completed(
2261 sayiir_core::TaskId::from(id.as_str()),
2262 again_value,
2263 );
2264 backend.save_snapshot(snapshot).await?;
2265 }
2266 }
2267 } else {
2268 let body_ser = body.to_serializable();
2271 for tid in &body_ser.task_ids() {
2272 snapshot.remove_task_result(
2273 &sayiir_core::TaskId::from(*tid),
2274 );
2275 }
2276 snapshot.set_loop_iteration(
2277 sayiir_core::TaskId::from(id),
2278 next_iter,
2279 );
2280 backend.save_snapshot(snapshot).await?;
2281 }
2282 }
2283 Err(e) => {
2284 return Err(CodecError::DecodeFailed {
2285 task_id: sayiir_core::TaskId::from(id),
2286 expected_type: "LoopEnvelope",
2287 source: e,
2288 }
2289 .into());
2290 }
2291 }
2292 }
2293 }
2294 Self::resolve_loops_recursive(body, snapshot, backend).await?;
2296 if let Some(next) = next {
2297 Self::resolve_loops_recursive(next, snapshot, backend).await?;
2298 }
2299 }
2300 WorkflowContinuation::Task { next, .. }
2301 | WorkflowContinuation::Delay { next, .. }
2302 | WorkflowContinuation::AwaitSignal { next, .. }
2303 | WorkflowContinuation::Branch { next, .. } => {
2304 if let Some(next) = next {
2305 Self::resolve_loops_recursive(next, snapshot, backend).await?;
2306 }
2307 }
2308 WorkflowContinuation::Fork { branches, join, .. } => {
2309 for branch in branches {
2310 Self::resolve_loops_recursive(branch, snapshot, backend).await?;
2311 }
2312 if let Some(join) = join {
2313 Self::resolve_loops_recursive(join, snapshot, backend).await?;
2314 }
2315 }
2316 WorkflowContinuation::ChildWorkflow { child, next, .. } => {
2317 Self::resolve_loops_recursive(child, snapshot, backend).await?;
2318 if let Some(next) = next {
2319 Self::resolve_loops_recursive(next, snapshot, backend).await?;
2320 }
2321 }
2322 }
2323 Ok(())
2324 })
2325 }
2326
2327 fn is_workflow_complete(
2329 continuation: &WorkflowContinuation,
2330 snapshot: &WorkflowSnapshot,
2331 ) -> bool {
2332 match continuation {
2334 WorkflowContinuation::Task { id, next, .. } => {
2335 if snapshot
2336 .get_task_result(&sayiir_core::TaskId::from(id))
2337 .is_none()
2338 {
2339 return false;
2340 }
2341 if let Some(next_cont) = next {
2342 Self::is_workflow_complete(next_cont, snapshot)
2343 } else {
2344 true }
2346 }
2347 WorkflowContinuation::Delay { id, next, .. }
2348 | WorkflowContinuation::AwaitSignal { id, next, .. } => {
2349 if snapshot
2350 .get_task_result(&sayiir_core::TaskId::from(id))
2351 .is_none()
2352 {
2353 return false;
2354 }
2355 next.as_ref()
2356 .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2357 }
2358 WorkflowContinuation::Fork { branches, join, .. } => {
2359 for branch in branches {
2361 if !Self::is_workflow_complete(branch, snapshot) {
2362 return false;
2363 }
2364 }
2365 if let Some(join_cont) = join {
2367 Self::is_workflow_complete(join_cont, snapshot)
2368 } else {
2369 true
2370 }
2371 }
2372 WorkflowContinuation::Branch { id, next, .. } => {
2373 if snapshot
2375 .get_task_result(&sayiir_core::TaskId::from(id))
2376 .is_none()
2377 {
2378 return false;
2379 }
2380 next.as_ref()
2381 .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2382 }
2383 WorkflowContinuation::Loop { id, next, .. } => {
2384 if snapshot
2386 .get_task_result(&sayiir_core::TaskId::from(id))
2387 .is_none()
2388 {
2389 return false;
2390 }
2391 next.as_ref()
2392 .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2393 }
2394 WorkflowContinuation::ChildWorkflow { id, next, .. } => {
2395 if snapshot
2397 .get_task_result(&sayiir_core::TaskId::from(id))
2398 .is_none()
2399 {
2400 return false;
2401 }
2402 next.as_ref()
2403 .is_none_or(|n| Self::is_workflow_complete(n, snapshot))
2404 }
2405 }
2406 }
2407}
2408
2409fn default_worker_id() -> String {
2411 let host = hostname::get()
2412 .ok()
2413 .and_then(|h| h.into_string().ok())
2414 .unwrap_or_else(|| "unknown".to_string());
2415 format!("{host}-{}", std::process::id())
2416}
2417
2418pub struct PooledWorkerBuilder<B> {
2425 worker_id: Option<String>,
2426 backend: B,
2427 registry: TaskRegistry,
2428 claim_ttl: Option<Duration>,
2429 batch_size: NonZeroUsize,
2430 aging_interval: Duration,
2431 tags: Vec<String>,
2432}
2433
2434impl<B> PooledWorkerBuilder<B>
2435where
2436 B: PersistentBackend + TaskClaimStore + 'static,
2437{
2438 #[must_use]
2442 pub fn worker_id(mut self, id: impl Into<String>) -> Self {
2443 self.worker_id = Some(id.into());
2444 self
2445 }
2446
2447 #[must_use]
2449 pub fn claim_ttl(mut self, ttl: Option<Duration>) -> Self {
2450 self.claim_ttl = ttl;
2451 self
2452 }
2453
2454 #[must_use]
2456 pub fn batch_size(mut self, size: NonZeroUsize) -> Self {
2457 self.batch_size = size;
2458 self
2459 }
2460
2461 #[must_use]
2467 pub fn aging_interval(mut self, interval: Duration) -> Self {
2468 assert!(!interval.is_zero(), "aging interval must be non-zero");
2469 self.aging_interval = interval;
2470 self
2471 }
2472
2473 #[must_use]
2479 pub fn tags(mut self, tags: Vec<String>) -> Self {
2480 self.tags = tags;
2481 self
2482 }
2483
2484 #[must_use]
2488 pub fn build(self) -> PooledWorker<B> {
2489 let worker_id = self.worker_id.unwrap_or_else(default_worker_id);
2490 PooledWorker {
2491 worker_id,
2492 backend: Arc::new(self.backend),
2493 registry: Arc::new(self.registry),
2494 claim_ttl: self.claim_ttl,
2495 batch_size: self.batch_size,
2496 aging_interval: self.aging_interval,
2497 tags: self.tags,
2498 }
2499 }
2500}
2501
2502#[cfg(test)]
2503#[allow(clippy::unwrap_used)]
2504mod tests {
2505 use super::*;
2506 use crate::serialization::JsonCodec;
2507 use sayiir_core::registry::TaskRegistry;
2508 use sayiir_core::snapshot::WorkflowSnapshot;
2509 use sayiir_persistence::{InMemoryBackend, SignalStore, SnapshotStore};
2510
2511 type EmptyWorkflows = WorkflowRegistry<JsonCodec, (), ()>;
2512
2513 fn make_worker() -> PooledWorker<InMemoryBackend> {
2514 let backend = InMemoryBackend::new();
2515 let registry = TaskRegistry::new();
2516 PooledWorker::new("test-worker", backend, registry)
2517 }
2518
2519 #[tokio::test]
2520 async fn test_spawn_and_shutdown() {
2521 let worker = make_worker();
2522 let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2523
2524 handle.shutdown();
2525
2526 let result = tokio::time::timeout(Duration::from_secs(5), handle.join()).await;
2527 assert!(result.is_ok(), "Worker should exit cleanly after shutdown");
2528 assert!(result.unwrap().is_ok());
2529 }
2530
2531 #[tokio::test]
2532 async fn test_handle_is_clone_and_send() {
2533 let worker = make_worker();
2534 let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2535
2536 let handle2 = handle.clone();
2537 let remote = tokio::spawn(async move {
2538 handle2.shutdown();
2539 });
2540 remote.await.ok();
2541
2542 let result = tokio::time::timeout(Duration::from_secs(5), handle.join()).await;
2543 assert!(result.is_ok_and(|r| r.is_ok()));
2544 }
2545
2546 #[tokio::test]
2547 async fn test_cancel_via_client() {
2548 let backend = InMemoryBackend::new();
2549 let registry = TaskRegistry::new();
2550
2551 let mut snapshot = WorkflowSnapshot::new("wf-1", "hash-1".into());
2553 backend.save_snapshot(&mut snapshot).await.ok();
2554
2555 let worker = PooledWorker::new("test-worker", backend, registry);
2556 let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2557
2558 let client = crate::WorkflowClient::from_shared(std::sync::Arc::clone(handle.backend()));
2560 client
2561 .cancel(
2562 "wf-1",
2563 Some("test reason".to_string()),
2564 Some("tester".to_string()),
2565 )
2566 .await
2567 .ok();
2568
2569 let signal = handle
2571 .backend()
2572 .get_signal("wf-1", SignalKind::Cancel)
2573 .await;
2574 assert!(signal.is_ok_and(|s| s.is_some()));
2575
2576 handle.shutdown();
2577 tokio::time::timeout(Duration::from_secs(5), handle.join())
2578 .await
2579 .ok();
2580 }
2581
2582 #[test]
2583 fn test_builder_auto_generates_worker_id() {
2584 let backend = InMemoryBackend::new();
2585 let registry = TaskRegistry::new();
2586 let worker = PooledWorker::builder(backend, registry).build();
2587
2588 let pid = std::process::id().to_string();
2590 assert!(
2591 worker.worker_id.contains(&pid),
2592 "Auto-generated ID '{}' should contain PID '{}'",
2593 worker.worker_id,
2594 pid
2595 );
2596 }
2597
2598 #[test]
2599 fn test_builder_explicit_worker_id() {
2600 let backend = InMemoryBackend::new();
2601 let registry = TaskRegistry::new();
2602 let worker = PooledWorker::builder(backend, registry)
2603 .worker_id("my-worker")
2604 .build();
2605
2606 assert_eq!(worker.worker_id, "my-worker");
2607 }
2608
2609 #[test]
2610 fn test_builder_custom_settings() {
2611 let backend = InMemoryBackend::new();
2612 let registry = TaskRegistry::new();
2613 let worker = PooledWorker::builder(backend, registry)
2614 .worker_id("w1")
2615 .claim_ttl(Some(Duration::from_mins(2)))
2616 .batch_size(NonZeroUsize::new(8).unwrap())
2617 .build();
2618
2619 assert_eq!(worker.worker_id, "w1");
2620 assert_eq!(worker.claim_ttl, Some(Duration::from_mins(2)));
2621 assert_eq!(worker.batch_size.get(), 8);
2622 }
2623
2624 #[tokio::test]
2625 async fn test_dropped_handle_shuts_down_worker() {
2626 let worker = make_worker();
2627 let handle = worker.spawn(Duration::from_millis(50), EmptyWorkflows::new());
2628
2629 let join_handle = handle.inner.join_handle.lock().await.take().unwrap();
2631 drop(handle);
2632
2633 let result = tokio::time::timeout(Duration::from_secs(5), join_handle)
2634 .await
2635 .ok()
2636 .and_then(Result::ok);
2637 assert!(
2638 result.is_some(),
2639 "Worker should exit when all handles are dropped"
2640 );
2641 assert!(result.is_some_and(|r| r.is_ok()));
2642 }
2643}