Skip to main content

boson_runtime/worker/
loop_.rs

1//! Background worker loop and lifecycle host implementation.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use boson_core::{
8    ExecutionContextFactory, Job, JobStatus, QueueBackend, Result, Run, RunStatus, TaskConfig,
9};
10use chrono::Utc;
11use tokio::time::sleep;
12
13use super::claim::claim_next_job;
14use super::config::WorkerSettings;
15use super::execute::{execute_job, record_run_start};
16use super::lifecycle::{finish_job_execution, sleep_retry_delay, RunLifecycleHost};
17use crate::registry::TaskRegistry;
18use crate::telemetry;
19
20/// Handle to a spawned worker task (optional join).
21#[derive(Debug)]
22pub struct WorkerHandle {
23    _label: String,
24}
25
26pub struct WorkerEngine {
27    pub(crate) backend: Arc<dyn QueueBackend>,
28    pub(crate) registry: Arc<TaskRegistry>,
29    pub(crate) identity: Arc<dyn ExecutionContextFactory>,
30    pub(crate) worker: WorkerSettings,
31}
32
33impl WorkerEngine {
34    pub(crate) async fn drive_run(self: &Arc<Self>, job: Job, lease_id: Option<String>) {
35        if self.worker.skip_run_persistence && lease_id.is_none() {
36            self.drive_run_without_run_rows(job).await;
37            return;
38        }
39        let run = Run::new(&job.job_id, &job.task_name, job.attempt);
40        let run_id = run.run_id.clone();
41        if record_run_start(&self.backend, &run).await.is_err() {
42            telemetry::record_handler_error(
43                &job.task_name,
44                &job.job_id,
45                &run_id,
46                "failed to persist run start",
47            );
48            if let Some(ref lid) = lease_id {
49                let _ = self.backend.release_lease(lid).await;
50            }
51            let _ = self.backend.revert_job_to_queued(&job.job_id).await;
52            return;
53        }
54        telemetry::record_task_started(
55            &job.task_name,
56            &job.job_id,
57            &run_id,
58            &self.worker.runtime_label,
59        );
60        let heartbeat = self.spawn_lease_heartbeat(lease_id.as_deref());
61        let start = Utc::now();
62        let result = execute_job(&self.registry, &self.identity, &self.backend, &job).await;
63        if let Some(handle) = heartbeat {
64            handle.abort();
65        }
66        let result = self.apply_cancel_if_needed(&job.job_id, result).await;
67        if let Err(ref e) = result {
68            let msg = boson_core::sanitize_error_message(&e.to_string());
69            telemetry::record_handler_error(&job.task_name, &job.job_id, &run_id, &msg);
70        }
71        let duration_ms = (Utc::now() - start).num_milliseconds();
72        finish_job_execution(self.as_ref(), run_id, job, result, duration_ms).await;
73        if let Some(ref lid) = lease_id {
74            let _ = self.backend.release_lease(lid).await;
75        }
76    }
77
78    fn spawn_lease_heartbeat(&self, lease_id: Option<&str>) -> Option<tokio::task::JoinHandle<()>> {
79        let lease_id = lease_id?.to_string();
80        let ttl = self.worker.lease_ttl_secs;
81        if ttl <= 0 {
82            return None;
83        }
84        let backend = Arc::clone(&self.backend);
85        let interval_secs = u64::try_from((ttl / 3).max(1)).unwrap_or(1);
86        Some(tokio::spawn(async move {
87            loop {
88                sleep(Duration::from_secs(interval_secs)).await;
89                if backend.extend_lease(&lease_id, ttl).await.is_err() {
90                    break;
91                }
92            }
93        }))
94    }
95
96    /// If the job was canceled while running, convert Ok into a cancel error so finish
97    /// does not overwrite status to Success.
98    async fn apply_cancel_if_needed(&self, job_id: &str, result: Result<()>) -> Result<()> {
99        result?;
100        match self.backend.get_job(job_id).await {
101            Ok(Some(j)) if j.status == JobStatus::Canceled => Err(
102                boson_core::BosonError::internal("job canceled during execution"),
103            ),
104            _ => Ok(()),
105        }
106    }
107
108    async fn drive_run_without_run_rows(self: &Arc<Self>, job: Job) {
109        let run_id = format!("fast-{}", job.job_id);
110        telemetry::record_task_started(
111            &job.task_name,
112            &job.job_id,
113            &run_id,
114            &self.worker.runtime_label,
115        );
116        let start = Utc::now();
117        let result = execute_job(&self.registry, &self.identity, &self.backend, &job).await;
118        let result = self.apply_cancel_if_needed(&job.job_id, result).await;
119        let duration_ms = (Utc::now() - start).num_milliseconds();
120        match result {
121            Ok(()) => {
122                telemetry::record_task_completed(&job.task_name, &job.job_id, &run_id, duration_ms);
123                let mut finished = job;
124                finished.status = JobStatus::Success;
125                self.upsert_job(finished).await;
126            }
127            Err(e) => {
128                let msg = boson_core::sanitize_error_message(&e.to_string());
129                telemetry::record_handler_error(&job.task_name, &job.job_id, &run_id, &msg);
130                if msg.contains("job canceled") {
131                    let mut canceled = job;
132                    canceled.status = JobStatus::Canceled;
133                    self.upsert_job(canceled).await;
134                    return;
135                }
136                telemetry::record_task_failed(&job.task_name, &job.job_id, &run_id, &msg, false);
137                let _ = self.backend.revert_job_to_queued(&job.job_id).await;
138            }
139        }
140    }
141
142    async fn upsert_job(&self, job: Job) {
143        if let Err(e) = self.backend.upsert_job(&job).await {
144            telemetry::log_job_upsert_failed(&job.job_id, &job.task_name, &e.to_string());
145        }
146    }
147
148    async fn tick(self: &Arc<Self>) {
149        let discovered = self
150            .backend
151            .distinct_pools_queued()
152            .await
153            .unwrap_or_default();
154        let pools = self.worker.pools_to_poll(discovered);
155        for pool in pools {
156            if let Ok(Some((job, lease_id))) = claim_next_job(
157                &self.backend,
158                &pool,
159                &self.worker.worker_id,
160                self.worker.lease_ttl_secs,
161            )
162            .await
163            {
164                self.drive_run(job, lease_id).await;
165            }
166        }
167    }
168
169    async fn reap_expired_leases(self: Arc<Self>) {
170        if self.worker.lease_ttl_secs <= 0 {
171            return;
172        }
173        loop {
174            sleep(Duration::from_secs(15)).await;
175            let pairs = self
176                .backend
177                .expired_lease_job_pairs()
178                .await
179                .unwrap_or_default();
180            let count = pairs.len();
181            for (lease_id, job_id) in pairs {
182                let _ = self.backend.release_lease(&lease_id).await;
183                let _ = self.backend.revert_job_to_queued(&job_id).await;
184            }
185            telemetry::log_lease_reclaim(count, &self.worker.runtime_label);
186        }
187    }
188}
189
190#[async_trait]
191impl RunLifecycleHost for WorkerEngine {
192    async fn record_run_finish(
193        &self,
194        run_id: &str,
195        status: RunStatus,
196        duration_ms: Option<i64>,
197        error_message: Option<String>,
198    ) -> Result<()> {
199        self.backend
200            .finish_run(run_id, status, duration_ms, error_message)
201            .await
202    }
203
204    async fn put_job(&self, job: Job) {
205        self.upsert_job(job).await;
206    }
207
208    async fn load_task_config(&self, task_name: &str) -> Result<TaskConfig> {
209        if let Some(c) = self.backend.get_task_config(task_name).await? {
210            return Ok(c);
211        }
212        Ok(self.registry.get_or_err(task_name)?.to_task_config())
213    }
214
215    async fn schedule_retry(&self, mut job: Job, delay_ms: u64) {
216        sleep_retry_delay(delay_ms).await;
217        job.attempt += 1;
218        job.status = JobStatus::Queued;
219        self.upsert_job(job).await;
220    }
221}
222
223/// Spawn background worker loop.
224pub fn spawn_worker(
225    backend: Arc<dyn QueueBackend>,
226    registry: Arc<TaskRegistry>,
227    identity: Arc<dyn ExecutionContextFactory>,
228    worker: WorkerSettings,
229) -> WorkerHandle {
230    let label = worker.runtime_label.clone();
231    let engine = Arc::new(WorkerEngine {
232        backend,
233        registry,
234        identity,
235        worker,
236    });
237    if engine.worker.lease_ttl_secs > 0 {
238        let reaper = Arc::clone(&engine);
239        tokio::spawn(async move {
240            reaper.reap_expired_leases().await;
241        });
242    }
243    tokio::spawn(async move {
244        let poll_ms = engine.worker.worker_poll_interval_ms;
245        loop {
246            engine.tick().await;
247            if poll_ms > 0 {
248                sleep(Duration::from_millis(poll_ms)).await;
249            } else {
250                tokio::task::yield_now().await;
251            }
252        }
253    });
254    WorkerHandle { _label: label }
255}