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