1use std::collections::HashMap;
4#[cfg(feature = "prometheus")]
5use std::net::SocketAddr;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
10use tokio::time::{sleep, timeout};
11use tokio::{select, spawn};
12use tokio_util::sync::CancellationToken;
13use tracing::{error, info, warn};
14use uuid::Uuid;
15
16use ironflow_core::account_strategy::{AccountStrategy, LeastUtilized};
17use ironflow_core::decision::DecisionProvider;
18#[cfg(feature = "prometheus")]
19use ironflow_core::metric_names::{WORKER_ACTIVE, WORKER_LEASES_LOST_TOTAL, WORKER_POLLS_TOTAL};
20use ironflow_core::provider::AgentProvider;
21use ironflow_engine::accounts::{AccountAwareProvider, DEFAULT_MAX_CAPACITY_WAIT};
22use ironflow_engine::engine::{Engine, chain_root};
23use ironflow_engine::error::EngineError;
24use ironflow_engine::handler::WorkflowHandler;
25use ironflow_engine::log_sender::LogReceiver;
26use ironflow_store::entities::{
27 LeaseRequest, RunStatus, WorkerCapabilities, normalize_worker_tags, validate_worker_tags,
28};
29use ironflow_store::error::StoreError;
30use ironflow_store::store::Store;
31#[cfg(feature = "prometheus")]
32use metrics::{counter, gauge, set_global_recorder};
33#[cfg(feature = "prometheus")]
34use metrics_exporter_prometheus::PrometheusBuilder;
35#[cfg(feature = "heartbeat")]
36use reqwest::Client;
37
38use crate::api_store::ApiRunStore;
39use crate::artifact_sink::ApiArtifactSink;
40use crate::error::WorkerError;
41use crate::log_pusher::LogPusher;
42#[cfg(feature = "prometheus")]
43use crate::queue_depth::QueueDepthGauge;
44
45const DEFAULT_CONCURRENCY: usize = 2;
46const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
47const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
48const DEFAULT_MAX_CONSECUTIVE_PANICS: u32 = 3;
49const DEFAULT_PANIC_COOLDOWN: Duration = Duration::from_secs(5 * 60);
50const DEFAULT_LEASE_TTL: Duration = Duration::from_secs(90);
53const DEFAULT_LEASE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
55#[cfg(feature = "heartbeat")]
56const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
57
58pub struct WorkerBuilder {
82 api_url: String,
83 worker_token: String,
84 worker_id: String,
85 provider: Option<Arc<dyn AgentProvider>>,
86 decision_provider: Option<Arc<dyn DecisionProvider>>,
87 account_strategy: Option<Arc<dyn AccountStrategy>>,
88 max_capacity_wait: Duration,
89 handlers: Vec<Box<dyn WorkflowHandler>>,
90 concurrency: usize,
91 poll_interval: Duration,
92 run_timeout: Duration,
93 max_consecutive_panics: u32,
94 panic_cooldown: Duration,
95 lease_ttl: Duration,
96 tags: Vec<String>,
97 lease_refresh_interval: Duration,
98 #[cfg(feature = "heartbeat")]
99 heartbeat_url: Option<String>,
100 #[cfg(feature = "heartbeat")]
101 heartbeat_interval: Duration,
102 #[cfg(feature = "prometheus")]
103 metrics_addr: Option<SocketAddr>,
104}
105
106impl WorkerBuilder {
107 pub fn new(api_url: &str, worker_token: &str) -> Self {
109 Self {
110 api_url: api_url.to_string(),
111 worker_token: worker_token.to_string(),
112 worker_id: format!("worker-{}", Uuid::now_v7()),
113 provider: None,
114 decision_provider: None,
115 account_strategy: None,
116 max_capacity_wait: DEFAULT_MAX_CAPACITY_WAIT,
117 handlers: Vec::new(),
118 concurrency: DEFAULT_CONCURRENCY,
119 poll_interval: DEFAULT_POLL_INTERVAL,
120 run_timeout: DEFAULT_RUN_TIMEOUT,
121 max_consecutive_panics: DEFAULT_MAX_CONSECUTIVE_PANICS,
122 panic_cooldown: DEFAULT_PANIC_COOLDOWN,
123 lease_ttl: DEFAULT_LEASE_TTL,
124 lease_refresh_interval: DEFAULT_LEASE_REFRESH_INTERVAL,
125 tags: Vec::new(),
126 #[cfg(feature = "heartbeat")]
127 heartbeat_url: None,
128 #[cfg(feature = "heartbeat")]
129 heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
130 #[cfg(feature = "prometheus")]
131 metrics_addr: None,
132 }
133 }
134
135 pub fn provider(mut self, provider: Arc<dyn AgentProvider>) -> Self {
137 self.provider = Some(provider);
138 self
139 }
140
141 pub fn decision_provider(mut self, provider: Arc<dyn DecisionProvider>) -> Self {
167 self.decision_provider = Some(provider);
168 self
169 }
170
171 pub fn account_strategy(mut self, strategy: Arc<dyn AccountStrategy>) -> Self {
194 self.account_strategy = Some(strategy);
195 self
196 }
197
198 pub fn max_capacity_wait(mut self, wait: Duration) -> Self {
223 self.max_capacity_wait = wait;
224 self
225 }
226
227 pub fn register(mut self, handler: impl WorkflowHandler + 'static) -> Self {
229 self.handlers.push(Box::new(handler));
230 self
231 }
232
233 pub fn concurrency(mut self, n: usize) -> Self {
235 self.concurrency = n;
236 self
237 }
238
239 pub fn poll_interval(mut self, interval: Duration) -> Self {
241 self.poll_interval = interval;
242 self
243 }
244
245 pub fn run_timeout(mut self, timeout: Duration) -> Self {
262 self.run_timeout = timeout;
263 self
264 }
265
266 pub fn max_consecutive_panics(mut self, n: u32) -> Self {
284 self.max_consecutive_panics = n;
285 self
286 }
287
288 pub fn panic_cooldown(mut self, cooldown: Duration) -> Self {
305 self.panic_cooldown = cooldown;
306 self
307 }
308
309 pub fn worker_id(mut self, worker_id: &str) -> Self {
326 self.worker_id = worker_id.to_string();
327 self
328 }
329
330 pub fn tags<I, S>(mut self, tags: I) -> Self
353 where
354 I: IntoIterator<Item = S>,
355 S: Into<String>,
356 {
357 self.tags.extend(tags.into_iter().map(Into::into));
358 self
359 }
360
361 pub fn lease_ttl(mut self, ttl: Duration) -> Self {
379 self.lease_ttl = ttl;
380 self
381 }
382
383 pub fn lease_refresh_interval(mut self, interval: Duration) -> Self {
400 self.lease_refresh_interval = interval;
401 self
402 }
403
404 #[cfg(feature = "prometheus")]
426 pub fn metrics_addr(mut self, addr: SocketAddr) -> Self {
427 self.metrics_addr = Some(addr);
428 self
429 }
430
431 #[cfg(feature = "heartbeat")]
450 pub fn heartbeat_url(mut self, url: &str) -> Self {
451 self.heartbeat_url = Some(url.to_string());
452 self
453 }
454
455 #[cfg(feature = "heartbeat")]
473 pub fn heartbeat_interval(mut self, interval: Duration) -> Self {
474 self.heartbeat_interval = interval;
475 self
476 }
477
478 pub fn build(self) -> Result<Worker, WorkerError> {
487 let provider = self
488 .provider
489 .ok_or_else(|| WorkerError::Internal("WorkerBuilder: provider is required".into()))?;
490 validate_worker_tags(&self.tags).map_err(EngineError::InvalidWorkerTag)?;
491 let tags = normalize_worker_tags(self.tags);
492
493 let store: Arc<dyn Store> = Arc::new(ApiRunStore::new(&self.api_url, &self.worker_token));
494 let strategy = self
495 .account_strategy
496 .unwrap_or_else(|| Arc::new(LeastUtilized));
497 let provider: Arc<dyn AgentProvider> = Arc::new(
498 AccountAwareProvider::new(provider, store.clone())
499 .with_strategy(strategy)
500 .with_max_capacity_wait(self.max_capacity_wait),
501 );
502
503 let mut engine = Engine::new(store, provider);
504 if let Some(decision_provider) = self.decision_provider {
505 engine = engine.with_decision_provider(decision_provider);
506 }
507 for handler in self.handlers {
508 engine
509 .register_boxed(handler)
510 .map_err(WorkerError::Engine)?;
511 }
512 let capabilities = WorkerCapabilities::new(registered_workflows(&engine), tags.clone());
513 engine.set_worker_tags(tags);
514
515 let (log_sender, log_receiver) = ironflow_engine::log_sender::channel();
516 engine.set_log_sender(log_sender);
517
518 engine.set_artifact_sink(Arc::new(ApiArtifactSink::new(
522 &self.api_url,
523 &self.worker_token,
524 )));
525
526 #[cfg(feature = "heartbeat")]
527 let heartbeat_client = Client::builder()
528 .timeout(Duration::from_secs(5))
529 .build()
530 .expect("failed to build heartbeat HTTP client");
531
532 Ok(Worker {
533 engine: Arc::new(engine),
534 api_url: self.api_url,
535 worker_token: self.worker_token,
536 worker_id: self.worker_id,
537 capabilities,
538 log_receiver: Mutex::new(Some(log_receiver)),
539 concurrency: self.concurrency,
540 poll_interval: self.poll_interval,
541 run_timeout: self.run_timeout,
542 max_consecutive_panics: self.max_consecutive_panics,
543 panic_cooldown: self.panic_cooldown,
544 lease_ttl: self.lease_ttl,
545 lease_refresh_interval: self.lease_refresh_interval,
546 #[cfg(feature = "heartbeat")]
547 heartbeat_url: self.heartbeat_url,
548 #[cfg(feature = "heartbeat")]
549 heartbeat_interval: self.heartbeat_interval,
550 #[cfg(feature = "heartbeat")]
551 heartbeat_client,
552 #[cfg(feature = "prometheus")]
553 metrics_addr: self.metrics_addr,
554 })
555 }
556}
557
558pub struct Worker {
560 engine: Arc<Engine>,
561 api_url: String,
562 worker_token: String,
563 worker_id: String,
564 capabilities: WorkerCapabilities,
565 log_receiver: Mutex<Option<LogReceiver>>,
566 concurrency: usize,
567 poll_interval: Duration,
568 run_timeout: Duration,
569 max_consecutive_panics: u32,
570 panic_cooldown: Duration,
571 lease_ttl: Duration,
572 lease_refresh_interval: Duration,
573 #[cfg(feature = "heartbeat")]
574 heartbeat_url: Option<String>,
575 #[cfg(feature = "heartbeat")]
576 heartbeat_interval: Duration,
577 #[cfg(feature = "heartbeat")]
578 heartbeat_client: Client,
579 #[cfg(feature = "prometheus")]
580 metrics_addr: Option<SocketAddr>,
581}
582
583struct PoisonPillTracker {
585 max_consecutive: u32,
586 cooldown: Duration,
587 state: HashMap<String, (u32, Instant)>,
589}
590
591impl PoisonPillTracker {
592 fn new(max_consecutive: u32, cooldown: Duration) -> Self {
593 Self {
594 max_consecutive,
595 cooldown,
596 state: HashMap::new(),
597 }
598 }
599
600 fn record_panic(&mut self, workflow: &str) -> bool {
603 let entry = self
604 .state
605 .entry(workflow.to_string())
606 .or_insert((0, Instant::now()));
607 entry.0 += 1;
608 entry.1 = Instant::now();
609 entry.0 >= self.max_consecutive
610 }
611
612 fn record_success(&mut self, workflow: &str) {
614 self.state.remove(workflow);
615 }
616
617 fn is_blocked(&self, workflow: &str) -> bool {
619 self.state.get(workflow).is_some_and(|(count, last_panic)| {
620 *count >= self.max_consecutive && last_panic.elapsed() < self.cooldown
621 })
622 }
623}
624
625impl Worker {
626 pub fn capabilities(&self) -> &WorkerCapabilities {
646 &self.capabilities
647 }
648
649 pub async fn run(&self) -> Result<(), WorkerError> {
663 let semaphore = Arc::new(Semaphore::new(self.concurrency));
664 let shutdown = CancellationToken::new();
665 let mut idle_streak = 0u32;
666 let poison_tracker = Arc::new(Mutex::new(PoisonPillTracker::new(
667 self.max_consecutive_panics,
668 self.panic_cooldown,
669 )));
670 let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::<RunOutcome>();
671
672 info!(
673 concurrency = self.concurrency,
674 poll_interval_ms = self.poll_interval.as_millis() as u64,
675 run_timeout_secs = self.run_timeout.as_secs(),
676 "worker started"
677 );
678
679 #[cfg(feature = "prometheus")]
680 if let Some(addr) = self.metrics_addr {
681 let (recorder, exporter) = PrometheusBuilder::new()
682 .with_http_listener(addr)
683 .build()
684 .map_err(|e| {
685 WorkerError::Internal(format!(
686 "cannot build the Prometheus exporter on {addr}: {e}"
687 ))
688 })?;
689 set_global_recorder(recorder).map_err(|e| {
690 WorkerError::Internal(format!("a metrics recorder is already installed: {e}"))
691 })?;
692 spawn(async move {
693 if let Err(e) = exporter.await {
694 error!(error = ?e, "prometheus exporter stopped");
695 }
696 });
697 info!(%addr, "prometheus metrics endpoint listening");
698 }
699
700 if let Some(receiver) = self.log_receiver.lock().expect("log_receiver lock").take() {
701 let pusher = LogPusher::new(&self.api_url, &self.worker_token);
702 spawn(pusher.run(receiver));
703 info!("log pusher started");
704 }
705
706 let shutdown_clone = shutdown.clone();
708 spawn(async move {
709 shutdown_signal().await;
710 info!("shutdown signal received, draining in-flight runs...");
711 shutdown_clone.cancel();
712 });
713
714 #[cfg(feature = "heartbeat")]
715 if let Some(ref url) = self.heartbeat_url {
716 let interval = self.heartbeat_interval;
717 let url = url.clone();
718 let client = self.heartbeat_client.clone();
719
720 spawn(async move {
721 let mut ticker = tokio::time::interval(interval);
722 ticker.tick().await;
724 loop {
725 ticker.tick().await;
726 match client.head(&url).send().await {
727 Ok(resp) if resp.status().is_success() => {
728 info!(url = %url, "heartbeat sent");
729 }
730 Ok(resp) => {
731 warn!(
732 url = %url,
733 status = %resp.status(),
734 "heartbeat ping returned non-success status"
735 );
736 }
737 Err(err) => {
738 warn!(
739 url = %url,
740 error = %err,
741 "heartbeat ping failed"
742 );
743 }
744 }
745 }
746 });
747 }
748
749 #[cfg(feature = "prometheus")]
750 let mut queue_depth =
751 QueueDepthGauge::new(&self.api_url, &self.worker_token, &self.capabilities);
752
753 while !shutdown.is_cancelled() {
754 while let Ok(outcome) = outcome_rx.try_recv() {
756 let mut tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
757 match outcome {
758 RunOutcome::Success(ref wf) => tracker.record_success(wf),
759 RunOutcome::LeaseLost(ref wf) => {
762 warn!(workflow = %wf, "run abandoned after losing its lease")
763 }
764 RunOutcome::Failed(ref wf) | RunOutcome::Timeout(ref wf) => {
765 if tracker.record_panic(wf) {
766 warn!(workflow = %wf, "workflow flagged as poison pill after consecutive failures");
767 }
768 }
769 RunOutcome::Panicked(ref wf) => {
770 if tracker.record_panic(wf) {
771 error!(workflow = %wf, "workflow flagged as poison pill after consecutive panics");
772 }
773 }
774 }
775 }
776
777 let Some(permit) = acquire_slot(&semaphore, &shutdown).await? else {
780 break;
781 };
782
783 let run = self
784 .engine
785 .store()
786 .pick_next_pending_for(
787 Some(LeaseRequest {
788 worker_id: self.worker_id.clone(),
789 ttl: self.lease_ttl,
790 }),
791 Some(self.capabilities.clone()),
792 )
793 .await;
794
795 match run {
796 Ok(Some(run)) => {
797 #[cfg(feature = "prometheus")]
798 counter!(WORKER_POLLS_TOTAL, "result" => "hit").increment(1);
799
800 let is_blocked = {
802 let tracker = poison_tracker.lock().expect("poison tracker lock poisoned");
803 tracker.is_blocked(&run.workflow_name)
804 };
805 if is_blocked {
806 warn!(
807 workflow = %run.workflow_name,
808 run_id = %run.id,
809 "skipping run: workflow flagged as poison pill, marking as failed"
810 );
811 if let Err(e) = self
812 .engine
813 .store()
814 .update_run_status(run.id, RunStatus::Failed)
815 .await
816 {
817 error!(run_id = %run.id, error = %e, "failed to mark poisoned run as failed");
818 }
819 drop(permit);
820 continue;
821 }
822
823 idle_streak = 0;
824 let engine = self.engine.clone();
825 let run_id = run.id;
826 let workflow = run.workflow_name.clone();
827 let workflow_for_watcher = workflow.clone();
828 let run_timeout = self.run_timeout;
829
830 info!(run_id = %run_id, workflow = %workflow, "executing run");
831
832 #[cfg(feature = "prometheus")]
833 gauge!(WORKER_ACTIVE).increment(1.0);
834
835 let lease_token = CancellationToken::new();
838 let refresher = spawn(refresh_lease(
839 self.engine.store().clone(),
840 run_id,
841 LeaseRequest {
842 worker_id: self.worker_id.clone(),
843 ttl: self.lease_ttl,
844 },
845 self.lease_refresh_interval,
846 lease_token.clone(),
847 ));
848
849 let handle = spawn(async move {
850 let _permit = permit;
851 let result = select! {
852 biased;
853 _ = lease_token.cancelled() => {
854 refresher.abort();
855 warn!(
858 run_id = %run_id,
859 workflow = %workflow,
860 "abandoning run: worker lease lost"
861 );
862 #[cfg(feature = "prometheus")]
863 counter!(WORKER_LEASES_LOST_TOTAL).increment(1);
864 return RunOutcome::LeaseLost(workflow);
866 }
867 result = timeout(run_timeout, engine.execute_handler_run(run_id)) => result,
868 };
869 refresher.abort();
870
871 match result {
872 Ok(Ok(_)) => {
873 info!(run_id = %run_id, workflow = %workflow, "run completed");
874 RunOutcome::Success(workflow)
875 }
876 Ok(Err(e)) => {
884 error!(run_id = %run_id, workflow = %workflow, error = %e, "run failed");
885 RunOutcome::Failed(workflow)
886 }
887 Err(_) => {
888 error!(
889 run_id = %run_id,
890 workflow = %workflow,
891 timeout_secs = run_timeout.as_secs(),
892 "run timed out"
893 );
894 let timeout_msg =
895 format!("run timed out after {}s", run_timeout.as_secs());
896 if let Err(e) = engine
898 .fail_or_schedule_retry(run_id, &timeout_msg, true, None, None)
899 .await
900 {
901 error!(run_id = %run_id, error = %e, "failed to record timed-out run");
902 }
903 RunOutcome::Timeout(workflow)
904 }
905 }
906 });
907
908 let watcher_engine = self.engine.clone();
910 let tx = outcome_tx.clone();
911 spawn(async move {
912 match handle.await {
913 Ok(outcome) => {
914 let _ = tx.send(outcome);
915 }
916 Err(e) => {
917 error!(run_id = %run_id, "spawned task panicked: {e}");
918 if let Err(store_err) = watcher_engine
922 .fail_or_schedule_retry(
923 run_id,
924 "parent run panicked",
925 true,
926 None,
927 None,
928 )
929 .await
930 {
931 error!(run_id = %run_id, error = %store_err, "failed to record panicked run");
932 }
933 let _ = tx.send(RunOutcome::Panicked(workflow_for_watcher));
934 }
935 }
936 #[cfg(feature = "prometheus")]
937 gauge!(WORKER_ACTIVE).decrement(1.0);
938 });
939 }
940 Ok(None) => {
941 drop(permit);
942 #[cfg(feature = "prometheus")]
943 counter!(WORKER_POLLS_TOTAL, "result" => "miss").increment(1);
944
945 idle_streak += 1;
946 let backoff = if idle_streak > 10 {
947 self.poll_interval * 3
948 } else if idle_streak > 5 {
949 self.poll_interval * 2
950 } else {
951 self.poll_interval
952 };
953 sleep(backoff).await;
954 }
955 Err(e) => {
956 drop(permit);
957 warn!(error = %e, "poll error");
958 sleep(self.poll_interval).await;
959 }
960 }
961
962 #[cfg(feature = "prometheus")]
963 queue_depth.refresh_if_due().await;
964 }
965
966 info!(
968 in_flight = self.concurrency - semaphore.available_permits(),
969 "waiting for in-flight runs to complete..."
970 );
971 let _ = semaphore
972 .acquire_many(self.concurrency as u32)
973 .await
974 .map_err(|_| WorkerError::Shutdown("semaphore closed during drain".to_string()))?;
975
976 info!("all in-flight runs completed, worker shut down");
977 Ok(())
978 }
979}
980
981fn registered_workflows(engine: &Engine) -> Option<Vec<String>> {
988 let mut names: Vec<String> = engine
989 .handler_names()
990 .into_iter()
991 .map(str::to_string)
992 .collect();
993 if let Some(name) = names.iter().find(|name| name.contains(',')) {
994 warn!(
995 workflow = %name,
996 "workflow name contains a comma: the worker will not filter runs by workflow"
997 );
998 return None;
999 }
1000 names.sort();
1001 Some(names)
1002}
1003
1004enum RunOutcome {
1006 Success(String),
1008 Failed(String),
1010 Timeout(String),
1012 Panicked(String),
1014 LeaseLost(String),
1017}
1018
1019async fn acquire_slot(
1028 semaphore: &Arc<Semaphore>,
1029 shutdown: &CancellationToken,
1030) -> Result<Option<OwnedSemaphorePermit>, WorkerError> {
1031 select! {
1032 biased;
1033 _ = shutdown.cancelled() => Ok(None),
1034 permit = semaphore.clone().acquire_owned() => permit
1035 .map(Some)
1036 .map_err(|e| WorkerError::Internal(format!("semaphore closed: {e}"))),
1037 }
1038}
1039
1040async fn refresh_lease(
1052 store: Arc<dyn Store>,
1053 run_id: Uuid,
1054 lease: LeaseRequest,
1055 refresh_interval: Duration,
1056 lease_token: CancellationToken,
1057) {
1058 let ttl = lease.ttl;
1059 let mut deadline = Instant::now() + ttl;
1060 let mut target = run_id;
1061
1062 loop {
1063 sleep(refresh_interval).await;
1064
1065 match store.renew_lease(target, lease.clone()).await {
1066 Ok(_) => {
1067 deadline = Instant::now() + ttl;
1068 }
1069 Err(StoreError::LeaseLost { held_by, .. }) => {
1070 let followed = if target == run_id {
1071 follow_lease_to_root(store.as_ref(), run_id, &lease).await
1072 } else {
1073 None
1074 };
1075 if let Some(root) = followed {
1076 info!(
1077 run_id = %run_id,
1078 root_run_id = %root,
1079 "lease followed the run to its root"
1080 );
1081 target = root;
1082 deadline = Instant::now() + ttl;
1083 continue;
1084 }
1085 warn!(
1086 run_id = %target,
1087 held_by = held_by.as_deref().unwrap_or("unknown"),
1088 "lease lost: taken over by another worker, or the run was paused"
1089 );
1090 lease_token.cancel();
1091 return;
1092 }
1093 Err(err) if Instant::now() >= deadline => {
1094 warn!(
1097 run_id = %target,
1098 error = %err,
1099 ttl_secs = ttl.as_secs(),
1100 "lease could not be refreshed before it expired"
1101 );
1102 lease_token.cancel();
1103 return;
1104 }
1105 Err(err) => {
1106 warn!(run_id = %target, error = %err, "lease refresh failed, retrying");
1107 }
1108 }
1109 }
1110}
1111
1112async fn follow_lease_to_root(
1119 store: &dyn Store,
1120 run_id: Uuid,
1121 lease: &LeaseRequest,
1122) -> Option<Uuid> {
1123 let run = match store.get_run(run_id).await {
1124 Ok(run) => run?,
1125 Err(err) => {
1126 warn!(run_id = %run_id, error = %err, "could not load run to follow its lease");
1127 return None;
1128 }
1129 };
1130 let root = chain_root(&run)?;
1131 match store.renew_lease(root, lease.clone()).await {
1132 Ok(_) => Some(root),
1133 Err(err) => {
1134 warn!(
1135 run_id = %run_id,
1136 root_run_id = %root,
1137 error = %err,
1138 "lease could not follow the run to its root"
1139 );
1140 None
1141 }
1142 }
1143}
1144
1145async fn shutdown_signal() {
1147 use tokio::signal;
1148
1149 let ctrl_c = async {
1150 signal::ctrl_c()
1151 .await
1152 .expect("failed to install Ctrl+C handler");
1153 };
1154
1155 #[cfg(unix)]
1156 let terminate = async {
1157 use tokio::signal::unix::{SignalKind, signal};
1158
1159 signal(SignalKind::terminate())
1160 .expect("failed to install SIGTERM handler")
1161 .recv()
1162 .await;
1163 };
1164
1165 #[cfg(not(unix))]
1166 let terminate = {
1167 use std::future::pending;
1168 pending::<()>()
1169 };
1170
1171 select! {
1172 () = ctrl_c => {},
1173 () = terminate => {},
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180
1181 use ironflow_core::account_strategy::Priority;
1182 use ironflow_core::providers::claude::ClaudeCodeProvider;
1183 use ironflow_core::providers::record_replay_decision::RecordReplayDecisionProvider;
1184 use ironflow_engine::context::WorkflowContext;
1185 use ironflow_engine::handler::HandlerFuture;
1186 use ironflow_store::entities::{MAX_WORKER_TAGS, WorkerTagError};
1187
1188 #[test]
1189 fn builder_new_creates_default_config() {
1190 let builder = WorkerBuilder::new("http://localhost:3000", "my-token");
1191 assert_eq!(builder.api_url, "http://localhost:3000");
1192 assert_eq!(builder.worker_token, "my-token");
1193 assert_eq!(builder.concurrency, DEFAULT_CONCURRENCY);
1194 assert_eq!(builder.poll_interval, DEFAULT_POLL_INTERVAL);
1195 assert_eq!(builder.run_timeout, DEFAULT_RUN_TIMEOUT);
1196 assert_eq!(
1197 builder.max_consecutive_panics,
1198 DEFAULT_MAX_CONSECUTIVE_PANICS
1199 );
1200 assert_eq!(builder.panic_cooldown, DEFAULT_PANIC_COOLDOWN);
1201 assert!(builder.provider.is_none());
1202 }
1203
1204 #[test]
1205 fn builder_with_trailing_slash_normalized() {
1206 let builder = WorkerBuilder::new("http://localhost:3000/", "token");
1207 assert_eq!(builder.api_url, "http://localhost:3000/");
1208 }
1209
1210 #[test]
1211 fn builder_provider_sets_provider() {
1212 let provider = Arc::new(ClaudeCodeProvider::new());
1213 let builder =
1214 WorkerBuilder::new("http://localhost:3000", "token").provider(provider.clone());
1215 assert!(builder.provider.is_some());
1216 }
1217
1218 #[test]
1219 fn builder_account_strategy_sets_strategy() {
1220 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1221 assert!(builder.account_strategy.is_none());
1222 let builder = builder.account_strategy(Arc::new(Priority));
1223 assert_eq!(
1224 builder.account_strategy.as_ref().map(|s| s.name()),
1225 Some("priority")
1226 );
1227 }
1228
1229 #[test]
1230 fn builder_max_capacity_wait_defaults_to_six_hours_and_can_be_set() {
1231 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1232 assert_eq!(builder.max_capacity_wait, DEFAULT_MAX_CAPACITY_WAIT);
1233 assert_eq!(builder.max_capacity_wait, Duration::from_secs(6 * 3600));
1234 let builder = builder.max_capacity_wait(Duration::ZERO);
1235 assert_eq!(builder.max_capacity_wait, Duration::ZERO);
1236 }
1237
1238 #[test]
1239 fn builder_decision_provider_defaults_to_none() {
1240 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1241 assert!(builder.decision_provider.is_none());
1242 }
1243
1244 #[test]
1245 fn builder_decision_provider_sets_provider() {
1246 let builder = WorkerBuilder::new("http://localhost:3000", "token").decision_provider(
1247 Arc::new(RecordReplayDecisionProvider::replay("tests/fixtures")),
1248 );
1249 assert!(builder.decision_provider.is_some());
1250 }
1251
1252 #[test]
1253 fn builder_concurrency_sets_concurrency() {
1254 let builder = WorkerBuilder::new("http://localhost:3000", "token").concurrency(8);
1255 assert_eq!(builder.concurrency, 8);
1256 }
1257
1258 #[test]
1259 fn builder_concurrency_zero_accepted() {
1260 let provider = Arc::new(ClaudeCodeProvider::new());
1261 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1262 .provider(provider)
1263 .concurrency(0);
1264 assert_eq!(builder.concurrency, 0);
1265 }
1266
1267 #[test]
1268 fn builder_poll_interval_sets_interval() {
1269 let interval = Duration::from_secs(5);
1270 let builder = WorkerBuilder::new("http://localhost:3000", "token").poll_interval(interval);
1271 assert_eq!(builder.poll_interval, interval);
1272 }
1273
1274 #[test]
1275 fn builder_run_timeout_sets_timeout() {
1276 let dur = Duration::from_secs(120);
1277 let builder = WorkerBuilder::new("http://localhost:3000", "token").run_timeout(dur);
1278 assert_eq!(builder.run_timeout, dur);
1279 }
1280
1281 #[test]
1282 fn builder_max_consecutive_panics_sets_value() {
1283 let builder =
1284 WorkerBuilder::new("http://localhost:3000", "token").max_consecutive_panics(10);
1285 assert_eq!(builder.max_consecutive_panics, 10);
1286 }
1287
1288 #[test]
1289 fn builder_panic_cooldown_sets_value() {
1290 let dur = Duration::from_secs(600);
1291 let builder = WorkerBuilder::new("http://localhost:3000", "token").panic_cooldown(dur);
1292 assert_eq!(builder.panic_cooldown, dur);
1293 }
1294
1295 #[test]
1296 fn builder_defaults_lease_settings() {
1297 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1298 assert_eq!(builder.lease_ttl, DEFAULT_LEASE_TTL);
1299 assert_eq!(
1300 builder.lease_refresh_interval,
1301 DEFAULT_LEASE_REFRESH_INTERVAL
1302 );
1303 assert!(builder.worker_id.starts_with("worker-"));
1304 }
1305
1306 #[test]
1307 fn builder_generates_a_distinct_worker_id_per_instance() {
1308 let a = WorkerBuilder::new("http://localhost:3000", "token");
1309 let b = WorkerBuilder::new("http://localhost:3000", "token");
1310 assert_ne!(a.worker_id, b.worker_id);
1311 }
1312
1313 #[test]
1314 fn builder_worker_id_overrides_default() {
1315 let builder = WorkerBuilder::new("http://localhost:3000", "token").worker_id("worker-eu-1");
1316 assert_eq!(builder.worker_id, "worker-eu-1");
1317 }
1318
1319 #[test]
1320 fn builder_lease_ttl_sets_value() {
1321 let dur = Duration::from_secs(120);
1322 let builder = WorkerBuilder::new("http://localhost:3000", "token").lease_ttl(dur);
1323 assert_eq!(builder.lease_ttl, dur);
1324 }
1325
1326 #[test]
1327 fn builder_lease_refresh_interval_sets_value() {
1328 let dur = Duration::from_secs(5);
1329 let builder =
1330 WorkerBuilder::new("http://localhost:3000", "token").lease_refresh_interval(dur);
1331 assert_eq!(builder.lease_refresh_interval, dur);
1332 }
1333
1334 #[test]
1335 fn builder_build_without_provider_fails() {
1336 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1337 let result = builder.build();
1338 assert!(result.is_err());
1339 match result {
1340 Err(WorkerError::Internal(msg)) => {
1341 assert!(msg.contains("provider is required"));
1342 }
1343 _ => panic!("expected Internal error about missing provider"),
1344 }
1345 }
1346
1347 #[test]
1348 fn builder_build_with_provider_succeeds() {
1349 let provider = Arc::new(ClaudeCodeProvider::new());
1350 let builder = WorkerBuilder::new("http://localhost:3000", "token").provider(provider);
1351 let result = builder.build();
1352 assert!(result.is_ok());
1353 }
1354
1355 struct Named(&'static str);
1356
1357 impl WorkflowHandler for Named {
1358 fn name(&self) -> &str {
1359 self.0
1360 }
1361
1362 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1363 Box::pin(async { Ok(()) })
1364 }
1365 }
1366
1367 fn tagged_builder() -> WorkerBuilder {
1368 WorkerBuilder::new("http://localhost:3000", "token")
1369 .provider(Arc::new(ClaudeCodeProvider::new()))
1370 }
1371
1372 #[test]
1373 fn builder_tags_default_to_empty() {
1374 let worker = tagged_builder().build().unwrap();
1375 assert!(worker.capabilities().tags.is_empty());
1376 let engine_tags = worker.engine.worker_tags();
1377 assert!(
1378 engine_tags.is_some_and(<[String]>::is_empty),
1379 "{engine_tags:?}"
1380 );
1381 }
1382
1383 #[test]
1384 fn builder_tags_are_normalized_and_given_to_the_engine() {
1385 let worker = tagged_builder()
1386 .tags(["region:eu", " gpu ", "gpu"])
1387 .build()
1388 .unwrap();
1389 let expected = vec!["gpu".to_string(), "region:eu".to_string()];
1390 assert_eq!(worker.capabilities().tags, expected);
1391 assert_eq!(worker.engine.worker_tags(), Some(expected.as_slice()));
1392 }
1393
1394 #[test]
1395 fn builder_tags_extend_previous_ones() {
1396 let worker = tagged_builder()
1397 .tags(["gpu"])
1398 .tags(vec!["arm".to_string()])
1399 .build()
1400 .unwrap();
1401 assert_eq!(
1402 worker.capabilities().tags,
1403 vec!["arm".to_string(), "gpu".to_string()]
1404 );
1405 }
1406
1407 #[test]
1408 fn builder_tags_capabilities_list_registered_workflows_sorted() {
1409 let worker = tagged_builder()
1410 .register(Named("deploy"))
1411 .register(Named("build"))
1412 .build()
1413 .unwrap();
1414 assert_eq!(
1415 worker.capabilities().workflows,
1416 Some(vec!["build".to_string(), "deploy".to_string()])
1417 );
1418 }
1419
1420 #[test]
1421 fn builder_tags_capabilities_without_handlers_take_no_workflow() {
1422 let worker = tagged_builder().build().unwrap();
1423 assert_eq!(worker.capabilities().workflows, Some(Vec::new()));
1424 }
1425
1426 #[test]
1427 fn builder_tags_workflow_name_with_comma_disables_workflow_filter() {
1428 let worker = tagged_builder()
1429 .register(Named("deploy"))
1430 .register(Named("build,test"))
1431 .tags(["gpu"])
1432 .build()
1433 .unwrap();
1434 assert_eq!(worker.capabilities().workflows, None);
1435 assert_eq!(worker.capabilities().tags, vec!["gpu".to_string()]);
1436 }
1437
1438 #[test]
1439 fn builder_rejects_invalid_tags() {
1440 let invalid: [&[&str]; 3] = [&["bad tag"], &[""], &["gpu", " "]];
1441 for tags in invalid {
1442 let result = tagged_builder().tags(tags.iter().copied()).build();
1443 let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(_))) = result else {
1444 panic!("tags {tags:?} must be rejected");
1445 };
1446 }
1447 }
1448
1449 #[test]
1450 fn builder_rejects_too_many_tags() {
1451 let tags = (0..=MAX_WORKER_TAGS).map(|i| format!("tag-{i}"));
1452 let result = tagged_builder().tags(tags).build();
1453 let Err(WorkerError::Engine(EngineError::InvalidWorkerTag(err))) = result else {
1454 panic!("too many tags must be rejected");
1455 };
1456 assert!(matches!(err, WorkerTagError::TooMany { .. }), "{err:?}");
1457 }
1458
1459 #[test]
1460 fn builder_build_creates_worker_with_correct_concurrency() {
1461 let provider = Arc::new(ClaudeCodeProvider::new());
1462 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1463 .provider(provider)
1464 .concurrency(16);
1465 let worker = builder.build().unwrap();
1466 assert_eq!(worker.concurrency, 16);
1467 }
1468
1469 #[test]
1470 fn builder_build_creates_worker_with_correct_interval() {
1471 let provider = Arc::new(ClaudeCodeProvider::new());
1472 let interval = Duration::from_secs(10);
1473 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1474 .provider(provider)
1475 .poll_interval(interval);
1476 let worker = builder.build().unwrap();
1477 assert_eq!(worker.poll_interval, interval);
1478 }
1479
1480 #[test]
1481 fn builder_build_preserves_timeout() {
1482 let provider = Arc::new(ClaudeCodeProvider::new());
1483 let dur = Duration::from_secs(300);
1484 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1485 .provider(provider)
1486 .run_timeout(dur)
1487 .build()
1488 .unwrap();
1489 assert_eq!(worker.run_timeout, dur);
1490 }
1491
1492 #[test]
1493 fn builder_build_preserves_poison_pill_config() {
1494 let provider = Arc::new(ClaudeCodeProvider::new());
1495 let cooldown = Duration::from_secs(120);
1496 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1497 .provider(provider)
1498 .max_consecutive_panics(7)
1499 .panic_cooldown(cooldown)
1500 .build()
1501 .unwrap();
1502 assert_eq!(worker.max_consecutive_panics, 7);
1503 assert_eq!(worker.panic_cooldown, cooldown);
1504 }
1505
1506 #[test]
1507 fn builder_chaining_works() {
1508 let provider = Arc::new(ClaudeCodeProvider::new());
1509 let result = WorkerBuilder::new("http://localhost:3000", "token")
1510 .provider(provider)
1511 .concurrency(4)
1512 .poll_interval(Duration::from_secs(3))
1513 .run_timeout(Duration::from_secs(600))
1514 .max_consecutive_panics(5)
1515 .panic_cooldown(Duration::from_secs(120))
1516 .build();
1517 assert!(result.is_ok());
1518 let worker = result.unwrap();
1519 assert_eq!(worker.concurrency, 4);
1520 assert_eq!(worker.poll_interval, Duration::from_secs(3));
1521 assert_eq!(worker.run_timeout, Duration::from_secs(600));
1522 assert_eq!(worker.max_consecutive_panics, 5);
1523 assert_eq!(worker.panic_cooldown, Duration::from_secs(120));
1524 }
1525
1526 #[test]
1527 fn builder_empty_api_url_accepted() {
1528 let provider = Arc::new(ClaudeCodeProvider::new());
1529 let builder = WorkerBuilder::new("", "token").provider(provider);
1530 let result = builder.build();
1531 assert!(result.is_ok());
1532 }
1533
1534 #[test]
1535 fn builder_empty_token_accepted() {
1536 let provider = Arc::new(ClaudeCodeProvider::new());
1537 let builder = WorkerBuilder::new("http://localhost:3000", "").provider(provider);
1538 let result = builder.build();
1539 assert!(result.is_ok());
1540 }
1541
1542 #[cfg(feature = "prometheus")]
1543 #[test]
1544 fn builder_metrics_addr_is_stored_and_build_succeeds() {
1545 let addr: SocketAddr = "127.0.0.1:9100".parse().unwrap();
1546 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1547 .provider(Arc::new(ClaudeCodeProvider::new()))
1548 .metrics_addr(addr);
1549 assert_eq!(builder.metrics_addr, Some(addr));
1550 assert!(builder.build().is_ok());
1551 }
1552
1553 #[cfg(feature = "heartbeat")]
1554 #[test]
1555 fn builder_heartbeat_defaults() {
1556 let builder = WorkerBuilder::new("http://localhost:3000", "token");
1557 assert!(builder.heartbeat_url.is_none());
1558 assert_eq!(builder.heartbeat_interval, DEFAULT_HEARTBEAT_INTERVAL);
1559 }
1560
1561 #[cfg(feature = "heartbeat")]
1562 #[test]
1563 fn builder_heartbeat_url_sets_url() {
1564 let builder = WorkerBuilder::new("http://localhost:3000", "token")
1565 .heartbeat_url("https://uptime.betterstack.com/api/v1/heartbeat/abc");
1566 assert_eq!(
1567 builder.heartbeat_url.as_deref(),
1568 Some("https://uptime.betterstack.com/api/v1/heartbeat/abc")
1569 );
1570 }
1571
1572 #[cfg(feature = "heartbeat")]
1573 #[test]
1574 fn builder_heartbeat_custom_interval() {
1575 let interval = Duration::from_secs(10);
1576 let builder =
1577 WorkerBuilder::new("http://localhost:3000", "token").heartbeat_interval(interval);
1578 assert_eq!(builder.heartbeat_interval, interval);
1579 }
1580
1581 #[cfg(feature = "heartbeat")]
1582 #[test]
1583 fn builder_build_preserves_heartbeat_config() {
1584 let provider = Arc::new(ClaudeCodeProvider::new());
1585 let interval = Duration::from_secs(15);
1586 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1587 .provider(provider)
1588 .heartbeat_url("https://example.com/heartbeat")
1589 .heartbeat_interval(interval)
1590 .build()
1591 .unwrap();
1592 assert_eq!(
1593 worker.heartbeat_url.as_deref(),
1594 Some("https://example.com/heartbeat")
1595 );
1596 assert_eq!(worker.heartbeat_interval, interval);
1597 }
1598
1599 #[cfg(feature = "heartbeat")]
1600 #[test]
1601 fn builder_build_without_heartbeat_url_has_none() {
1602 let provider = Arc::new(ClaudeCodeProvider::new());
1603 let worker = WorkerBuilder::new("http://localhost:3000", "token")
1604 .provider(provider)
1605 .build()
1606 .unwrap();
1607 assert!(worker.heartbeat_url.is_none());
1608 }
1609
1610 #[test]
1613 fn poison_tracker_not_blocked_initially() {
1614 let tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1615 assert!(!tracker.is_blocked("my-workflow"));
1616 }
1617
1618 #[test]
1619 fn poison_tracker_blocked_after_max_panics() {
1620 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1621 assert!(!tracker.record_panic("wf"));
1622 assert!(!tracker.record_panic("wf"));
1623 assert!(tracker.record_panic("wf"));
1624 assert!(tracker.is_blocked("wf"));
1625 }
1626
1627 #[test]
1628 fn poison_tracker_success_resets_count() {
1629 let mut tracker = PoisonPillTracker::new(3, Duration::from_secs(300));
1630 tracker.record_panic("wf");
1631 tracker.record_panic("wf");
1632 tracker.record_success("wf");
1633 assert!(!tracker.is_blocked("wf"));
1634 assert!(!tracker.record_panic("wf"));
1636 }
1637
1638 #[test]
1639 fn poison_tracker_independent_per_workflow() {
1640 let mut tracker = PoisonPillTracker::new(2, Duration::from_secs(300));
1641 tracker.record_panic("wf-a");
1642 tracker.record_panic("wf-a");
1643 assert!(tracker.is_blocked("wf-a"));
1644 assert!(!tracker.is_blocked("wf-b"));
1645 }
1646
1647 #[test]
1648 fn poison_tracker_unblocks_after_cooldown() {
1649 let mut tracker = PoisonPillTracker::new(2, Duration::from_millis(0));
1650 tracker.record_panic("wf");
1651 tracker.record_panic("wf");
1652 assert!(!tracker.is_blocked("wf"));
1654 }
1655
1656 #[tokio::test]
1657 async fn acquire_slot_returns_a_permit_when_a_slot_is_free() {
1658 let semaphore = Arc::new(Semaphore::new(1));
1659 let shutdown = CancellationToken::new();
1660
1661 let permit = acquire_slot(&semaphore, &shutdown)
1662 .await
1663 .expect("semaphore open");
1664
1665 assert!(permit.is_some());
1666 assert_eq!(semaphore.available_permits(), 0);
1667 drop(permit);
1668 assert_eq!(semaphore.available_permits(), 1);
1669 }
1670
1671 #[tokio::test]
1672 async fn acquire_slot_gives_up_on_shutdown_while_all_slots_are_busy() {
1673 let semaphore = Arc::new(Semaphore::new(1));
1674 let _busy = semaphore.clone().acquire_owned().await.expect("permit");
1675 let shutdown = CancellationToken::new();
1676
1677 let cancel = shutdown.clone();
1678 spawn(async move {
1679 sleep(Duration::from_millis(20)).await;
1680 cancel.cancel();
1681 });
1682
1683 let permit = timeout(Duration::from_secs(5), acquire_slot(&semaphore, &shutdown))
1684 .await
1685 .expect("waiting for a slot must stop on shutdown")
1686 .expect("semaphore open");
1687
1688 assert!(permit.is_none());
1689 }
1690
1691 #[tokio::test]
1692 async fn acquire_slot_takes_no_permit_once_shutdown_is_requested() {
1693 let semaphore = Arc::new(Semaphore::new(1));
1694 let shutdown = CancellationToken::new();
1695 shutdown.cancel();
1696
1697 let permit = acquire_slot(&semaphore, &shutdown)
1698 .await
1699 .expect("semaphore open");
1700
1701 assert!(permit.is_none());
1702 assert_eq!(semaphore.available_permits(), 1);
1703 }
1704
1705 #[tokio::test]
1706 async fn acquire_slot_fails_when_the_semaphore_is_closed() {
1707 let semaphore = Arc::new(Semaphore::new(1));
1708 semaphore.close();
1709 let shutdown = CancellationToken::new();
1710
1711 let result = acquire_slot(&semaphore, &shutdown).await;
1712
1713 assert!(matches!(result, Err(WorkerError::Internal(_))));
1714 }
1715}