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 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;