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