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