Skip to main content

runledger_runtime/
worker.rs

1use std::cmp::min;
2use std::sync::Arc;
3
4use runledger_core::jobs::JobType;
5use runledger_postgres::jobs;
6use tokio::sync::{Semaphore, watch};
7use tokio::task::JoinSet;
8use tracing::{error, info, warn};
9
10mod completion;
11mod dead_letter;
12mod execution;
13mod observers;
14
15use self::execution::ClaimedJobExecution;
16use self::observers::TerminalObserverTasks;
17use crate::RuntimeLoopExit;
18use crate::WorkerError;
19use crate::config::JobsConfig;
20use crate::observer::JobLifecycleObservers;
21use crate::registry::JobRegistry;
22use crate::shutdown;
23
24pub async fn run_worker_loop(
25    pool: runledger_postgres::DbPool,
26    registry: JobRegistry,
27    config: JobsConfig,
28    shutdown: watch::Receiver<bool>,
29) -> RuntimeLoopExit {
30    run_worker_loop_with_observer(
31        pool,
32        registry,
33        config,
34        shutdown,
35        JobLifecycleObservers::empty(),
36    )
37    .await
38}
39
40pub async fn run_worker_loop_with_observer(
41    pool: runledger_postgres::DbPool,
42    registry: JobRegistry,
43    config: JobsConfig,
44    shutdown: watch::Receiver<bool>,
45    observers: JobLifecycleObservers,
46) -> RuntimeLoopExit {
47    if let Err(error) = config.validate_worker_loop() {
48        warn!(%error, "invalid jobs config; stopping worker loop");
49        return RuntimeLoopExit::InvalidConfig(error);
50    }
51
52    WorkerLoop::new(pool, registry, config, shutdown, observers)
53        .run()
54        .await
55}
56
57struct WorkerLoop {
58    // Field order mirrors the former function locals' drop order; do not reorder.
59    terminal_observer_tasks: TerminalObserverTasks,
60    join_set: JoinSet<()>,
61    semaphore: Arc<Semaphore>,
62    claimable_job_types: Vec<JobType<'static>>,
63    registry: Arc<JobRegistry>,
64    observers: JobLifecycleObservers,
65    shutdown: watch::Receiver<bool>,
66    config: JobsConfig,
67    pool: runledger_postgres::DbPool,
68}
69
70enum WorkerLoopControl {
71    Continue,
72    Drain(RuntimeLoopExit),
73}
74
75enum SpawnClaimedJobsOutcome {
76    Spawned,
77    SemaphoreClosed,
78}
79
80impl WorkerLoop {
81    fn new(
82        pool: runledger_postgres::DbPool,
83        registry: JobRegistry,
84        config: JobsConfig,
85        shutdown: watch::Receiver<bool>,
86        observers: JobLifecycleObservers,
87    ) -> Self {
88        let registry = Arc::new(registry);
89        let claimable_job_types = registry.registered_static_types();
90        let semaphore = Arc::new(Semaphore::new(config.max_global_concurrency));
91        let join_set = JoinSet::new();
92        let terminal_observer_tasks = TerminalObserverTasks::owned();
93
94        Self {
95            terminal_observer_tasks,
96            join_set,
97            semaphore,
98            claimable_job_types,
99            registry,
100            observers,
101            shutdown,
102            config,
103            pool,
104        }
105    }
106
107    async fn run(mut self) -> RuntimeLoopExit {
108        loop {
109            match self.iteration().await {
110                WorkerLoopControl::Continue => {}
111                WorkerLoopControl::Drain(exit) => return self.drain(exit).await,
112            }
113        }
114    }
115
116    async fn iteration(&mut self) -> WorkerLoopControl {
117        self.drain_finished_tasks().await;
118        self.terminal_observer_tasks.drain_finished().await;
119
120        if shutdown::is_requested_or_closed(&self.shutdown) {
121            return WorkerLoopControl::Drain(RuntimeLoopExit::Shutdown);
122        }
123
124        if self.claimable_job_types.is_empty() {
125            if self.wait_for_shutdown_or_poll_interval().await {
126                return WorkerLoopControl::Drain(RuntimeLoopExit::Shutdown);
127            }
128            return WorkerLoopControl::Continue;
129        }
130
131        let available = self.semaphore.available_permits();
132        if available == 0 {
133            if self.wait_for_shutdown_or_poll_interval().await {
134                return WorkerLoopControl::Drain(RuntimeLoopExit::Shutdown);
135            }
136            return WorkerLoopControl::Continue;
137        }
138
139        let claim_limit = min(available, self.config.claim_batch_size as usize);
140        let claimed = self.claim(claim_limit).await;
141
142        if claimed.is_empty() {
143            self.wait_for_shutdown_or_poll_interval().await;
144            return WorkerLoopControl::Continue;
145        }
146
147        let claimed_len = claimed.len();
148        match self.spawn_claimed_jobs(claimed).await {
149            SpawnClaimedJobsOutcome::Spawned => {}
150            SpawnClaimedJobsOutcome::SemaphoreClosed => {
151                return WorkerLoopControl::Drain(RuntimeLoopExit::Completed);
152            }
153        }
154
155        if claimed_len == claim_limit {
156            return WorkerLoopControl::Continue;
157        }
158
159        if self.wait_for_shutdown_or_poll_interval().await {
160            return WorkerLoopControl::Drain(RuntimeLoopExit::Shutdown);
161        }
162
163        WorkerLoopControl::Continue
164    }
165
166    async fn claim(&self, claim_limit: usize) -> Vec<jobs::JobQueueRecord> {
167        match jobs::claim_prestart_jobs_for_types(
168            &self.pool,
169            &self.config.worker_id,
170            self.config.lease_ttl_seconds,
171            claim_limit as i64,
172            &self.claimable_job_types,
173        )
174        .await
175        {
176            Ok(claimed) => claimed,
177            Err(error) => {
178                let error = WorkerError::ClaimJobs {
179                    worker_id: self.config.worker_id.clone(),
180                    source: error,
181                };
182                warn!(%error, "worker claim failed");
183                Vec::new()
184            }
185        }
186    }
187
188    async fn spawn_claimed_jobs(
189        &mut self,
190        claimed: Vec<jobs::JobQueueRecord>,
191    ) -> SpawnClaimedJobsOutcome {
192        for job in claimed {
193            let permit = match Arc::clone(&self.semaphore).acquire_owned().await {
194                Ok(permit) => permit,
195                Err(_) => {
196                    // The worker owns this semaphore and never closes it. If
197                    // this defensive branch fires, surface it as an unexpected
198                    // loop completion rather than graceful shutdown.
199                    warn!("worker semaphore closed; stopping worker loop");
200                    return SpawnClaimedJobsOutcome::SemaphoreClosed;
201                }
202            };
203            let pool_clone = self.pool.clone();
204            let registry_clone = Arc::clone(&self.registry);
205            let lease_ttl_seconds = self.config.lease_ttl_seconds;
206            let observers = self.observers.clone();
207            let terminal_observer_tasks = self.terminal_observer_tasks.clone();
208            self.join_set.spawn(async move {
209                let _permit = permit;
210                process_claimed_job_with_terminal_observers(
211                    pool_clone,
212                    registry_clone,
213                    job,
214                    lease_ttl_seconds,
215                    observers,
216                    terminal_observer_tasks,
217                )
218                .await;
219            });
220        }
221
222        SpawnClaimedJobsOutcome::Spawned
223    }
224
225    async fn wait_for_shutdown_or_poll_interval(&mut self) -> bool {
226        shutdown::wait_for_request_or_timeout(&mut self.shutdown, self.config.poll_interval).await
227    }
228
229    async fn drain(mut self, exit: RuntimeLoopExit) -> RuntimeLoopExit {
230        if !self.join_set.is_empty() {
231            match exit {
232                RuntimeLoopExit::Shutdown => {
233                    info!("worker shutdown requested; draining in-flight jobs")
234                }
235                RuntimeLoopExit::InvalidConfig(_) => {
236                    warn!("worker loop rejected invalid config; draining in-flight jobs");
237                }
238                RuntimeLoopExit::Completed => {
239                    warn!("worker loop completed before shutdown; draining in-flight jobs");
240                }
241            }
242        }
243        while let Some(result) = self.join_set.join_next().await {
244            if let Err(error) = result {
245                error!(%error, "job task crashed while draining in-flight jobs");
246            }
247        }
248        self.terminal_observer_tasks.drain_for_shutdown().await;
249        exit
250    }
251
252    async fn drain_finished_tasks(&mut self) {
253        while let Some(result) = self.join_set.try_join_next() {
254            if let Err(error) = result {
255                error!(%error, "job task crashed");
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262async fn process_claimed_job(
263    pool: runledger_postgres::DbPool,
264    registry: Arc<JobRegistry>,
265    job: jobs::JobQueueRecord,
266    lease_ttl_seconds: i32,
267) {
268    process_claimed_job_with_observer(
269        pool,
270        registry,
271        job,
272        lease_ttl_seconds,
273        JobLifecycleObservers::empty(),
274    )
275    .await;
276}
277
278#[cfg(test)]
279async fn process_claimed_job_with_observer(
280    pool: runledger_postgres::DbPool,
281    registry: Arc<JobRegistry>,
282    job: jobs::JobQueueRecord,
283    lease_ttl_seconds: i32,
284    observers: JobLifecycleObservers,
285) {
286    process_claimed_job_with_terminal_observers(
287        pool,
288        registry,
289        job,
290        lease_ttl_seconds,
291        observers,
292        TerminalObserverTasks::detached(),
293    )
294    .await;
295}
296
297async fn process_claimed_job_with_terminal_observers(
298    pool: runledger_postgres::DbPool,
299    registry: Arc<JobRegistry>,
300    job: jobs::JobQueueRecord,
301    lease_ttl_seconds: i32,
302    observers: JobLifecycleObservers,
303    terminal_observer_tasks: TerminalObserverTasks,
304) {
305    ClaimedJobExecution::new(
306        pool,
307        registry,
308        job,
309        lease_ttl_seconds,
310        observers,
311        terminal_observer_tasks,
312    )
313    .execute()
314    .await;
315}
316
317#[cfg(test)]
318mod tests;