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::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    claimable_job_types: Vec<JobType<'static>>,
62    registry: Arc<JobRegistry>,
63    observers: JobLifecycleObservers,
64    shutdown: watch::Receiver<bool>,
65    config: JobsConfig,
66    pool: runledger_postgres::DbPool,
67}
68
69enum WorkerLoopControl {
70    Continue,
71    Drain(RuntimeLoopExit),
72}
73
74impl WorkerLoop {
75    fn new(
76        pool: runledger_postgres::DbPool,
77        registry: JobRegistry,
78        config: JobsConfig,
79        shutdown: watch::Receiver<bool>,
80        observers: JobLifecycleObservers,
81    ) -> Self {
82        let registry = Arc::new(registry);
83        let claimable_job_types = registry.registered_static_types();
84        let join_set = JoinSet::new();
85        let terminal_observer_tasks = TerminalObserverTasks::owned();
86
87        Self {
88            terminal_observer_tasks,
89            join_set,
90            claimable_job_types,
91            registry,
92            observers,
93            shutdown,
94            config,
95            pool,
96        }
97    }
98
99    async fn run(mut self) -> RuntimeLoopExit {
100        loop {
101            match self.iteration().await {
102                WorkerLoopControl::Continue => {}
103                WorkerLoopControl::Drain(exit) => return self.drain(exit).await,
104            }
105        }
106    }
107
108    async fn iteration(&mut self) -> WorkerLoopControl {
109        self.drain_finished_tasks().await;
110        self.terminal_observer_tasks.drain_finished().await;
111
112        if shutdown::is_requested_or_closed(&self.shutdown) {
113            return WorkerLoopControl::Drain(RuntimeLoopExit::Shutdown);
114        }
115
116        if self.claimable_job_types.is_empty() {
117            if self.wait_for_shutdown_or_poll_interval().await {
118                return WorkerLoopControl::Drain(RuntimeLoopExit::Shutdown);
119            }
120            return WorkerLoopControl::Continue;
121        }
122
123        let available = self.available_capacity();
124        if available == 0 {
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 claim_limit = min(available, self.config.claim_batch_size as usize);
132        let claimed = self.claim(claim_limit).await;
133
134        if claimed.is_empty() {
135            self.wait_for_shutdown_or_poll_interval().await;
136            return WorkerLoopControl::Continue;
137        }
138
139        let claimed_len = claimed.len();
140        self.spawn_claimed_jobs(claimed);
141
142        if claimed_len == claim_limit {
143            return WorkerLoopControl::Continue;
144        }
145
146        if self.wait_for_shutdown_or_poll_interval().await {
147            return WorkerLoopControl::Drain(RuntimeLoopExit::Shutdown);
148        }
149
150        WorkerLoopControl::Continue
151    }
152
153    async fn claim(&self, claim_limit: usize) -> Vec<jobs::JobQueueRecord> {
154        match jobs::claim_prestart_jobs_for_types(
155            &self.pool,
156            &self.config.worker_id,
157            self.config.lease_ttl_seconds,
158            claim_limit as i64,
159            &self.claimable_job_types,
160        )
161        .await
162        {
163            Ok(claimed) => claimed,
164            Err(error) => {
165                let error = WorkerError::ClaimJobs {
166                    worker_id: self.config.worker_id.clone(),
167                    source: error,
168                };
169                warn!(%error, "worker claim failed");
170                Vec::new()
171            }
172        }
173    }
174
175    fn available_capacity(&self) -> usize {
176        self.config
177            .max_global_concurrency
178            .saturating_sub(self.join_set.len())
179    }
180
181    fn spawn_claimed_jobs(&mut self, claimed: Vec<jobs::JobQueueRecord>) {
182        debug_assert!(claimed.len() <= self.available_capacity());
183        for job in claimed {
184            let pool_clone = self.pool.clone();
185            let registry_clone = Arc::clone(&self.registry);
186            let lease_ttl_seconds = self.config.lease_ttl_seconds;
187            let observers = self.observers.clone();
188            let terminal_observer_tasks = self.terminal_observer_tasks.clone();
189            self.join_set.spawn(async move {
190                process_claimed_job_with_terminal_observers(
191                    pool_clone,
192                    registry_clone,
193                    job,
194                    lease_ttl_seconds,
195                    observers,
196                    terminal_observer_tasks,
197                )
198                .await;
199            });
200        }
201    }
202
203    async fn wait_for_shutdown_or_poll_interval(&mut self) -> bool {
204        shutdown::wait_for_request_or_timeout(&mut self.shutdown, self.config.poll_interval).await
205    }
206
207    async fn drain(mut self, exit: RuntimeLoopExit) -> RuntimeLoopExit {
208        if !self.join_set.is_empty() {
209            match exit {
210                RuntimeLoopExit::Shutdown => {
211                    info!("worker shutdown requested; draining in-flight jobs")
212                }
213                RuntimeLoopExit::InvalidConfig(_) => {
214                    warn!("worker loop rejected invalid config; draining in-flight jobs");
215                }
216                RuntimeLoopExit::Completed => {
217                    warn!("worker loop completed before shutdown; draining in-flight jobs");
218                }
219            }
220        }
221        while let Some(result) = self.join_set.join_next().await {
222            if let Err(error) = result {
223                error!(%error, "job task crashed while draining in-flight jobs");
224            }
225        }
226        self.terminal_observer_tasks.drain_for_shutdown().await;
227        exit
228    }
229
230    async fn drain_finished_tasks(&mut self) {
231        while let Some(result) = self.join_set.try_join_next() {
232            if let Err(error) = result {
233                error!(%error, "job task crashed");
234            }
235        }
236    }
237}
238
239#[cfg(test)]
240async fn process_claimed_job(
241    pool: runledger_postgres::DbPool,
242    registry: Arc<JobRegistry>,
243    job: jobs::JobQueueRecord,
244    lease_ttl_seconds: i32,
245) {
246    process_claimed_job_with_observer(
247        pool,
248        registry,
249        job,
250        lease_ttl_seconds,
251        JobLifecycleObservers::empty(),
252    )
253    .await;
254}
255
256#[cfg(test)]
257async fn process_claimed_job_with_observer(
258    pool: runledger_postgres::DbPool,
259    registry: Arc<JobRegistry>,
260    job: jobs::JobQueueRecord,
261    lease_ttl_seconds: i32,
262    observers: JobLifecycleObservers,
263) {
264    process_claimed_job_with_terminal_observers(
265        pool,
266        registry,
267        job,
268        lease_ttl_seconds,
269        observers,
270        TerminalObserverTasks::detached(),
271    )
272    .await;
273}
274
275async fn process_claimed_job_with_terminal_observers(
276    pool: runledger_postgres::DbPool,
277    registry: Arc<JobRegistry>,
278    job: jobs::JobQueueRecord,
279    lease_ttl_seconds: i32,
280    observers: JobLifecycleObservers,
281    terminal_observer_tasks: TerminalObserverTasks,
282) {
283    let Some(execution) = ClaimedJobExecution::new(
284        pool,
285        registry,
286        job,
287        lease_ttl_seconds,
288        observers,
289        terminal_observer_tasks,
290    ) else {
291        return;
292    };
293    execution.execute().await;
294}
295
296#[cfg(test)]
297mod tests;