1use async_trait::async_trait;
11use chrono::{DateTime, Duration, Utc};
12use mas_data_model::{Clock, UlidExt as _};
13use mas_storage::queue::{Job, QueueJobRepository, Worker};
14use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT;
15use rand::Rng;
16use sqlx::PgConnection;
17use tracing::Instrument;
18use ulid::Ulid;
19use uuid::Uuid;
20
21use crate::{DatabaseError, DatabaseInconsistencyError, ExecuteExt};
22
23pub struct PgQueueJobRepository<'c> {
25 conn: &'c mut PgConnection,
26}
27
28impl<'c> PgQueueJobRepository<'c> {
29 #[must_use]
32 pub fn new(conn: &'c mut PgConnection) -> Self {
33 Self { conn }
34 }
35}
36
37struct JobReservationResult {
38 queue_job_id: Uuid,
39 queue_name: String,
40 payload: serde_json::Value,
41 metadata: serde_json::Value,
42 attempt: i32,
43}
44
45impl TryFrom<JobReservationResult> for Job {
46 type Error = DatabaseInconsistencyError;
47
48 fn try_from(value: JobReservationResult) -> Result<Self, Self::Error> {
49 let id = value.queue_job_id.into();
50 let queue_name = value.queue_name;
51 let payload = value.payload;
52
53 let metadata = serde_json::from_value(value.metadata).map_err(|e| {
54 DatabaseInconsistencyError::on("queue_jobs")
55 .column("metadata")
56 .row(id)
57 .source(e)
58 })?;
59
60 let attempt = value.attempt.try_into().map_err(|e| {
61 DatabaseInconsistencyError::on("queue_jobs")
62 .column("attempt")
63 .row(id)
64 .source(e)
65 })?;
66
67 Ok(Self {
68 id,
69 queue_name,
70 payload,
71 metadata,
72 attempt,
73 })
74 }
75}
76
77#[async_trait]
78impl QueueJobRepository for PgQueueJobRepository<'_> {
79 type Error = DatabaseError;
80
81 #[tracing::instrument(
82 name = "db.queue_job.schedule",
83 fields(
84 queue_job.id,
85 queue_job.queue_name = queue_name,
86 db.query.text,
87 ),
88 skip_all,
89 )]
90 async fn schedule(
91 &mut self,
92 rng: &mut (dyn Rng + Send),
93 clock: &dyn Clock,
94 queue_name: &str,
95 payload: serde_json::Value,
96 metadata: serde_json::Value,
97 ) -> Result<(), Self::Error> {
98 let created_at = clock.now();
99 let id = Ulid::from_datetime_with_rng(created_at, rng);
100 tracing::Span::current().record("queue_job.id", tracing::field::display(id));
101
102 sqlx::query!(
103 r#"
104 INSERT INTO queue_jobs
105 (queue_job_id, queue_name, payload, metadata, created_at)
106 VALUES ($1, $2, $3, $4, $5)
107 "#,
108 Uuid::from(id),
109 queue_name,
110 payload,
111 metadata,
112 created_at,
113 )
114 .traced()
115 .execute(&mut *self.conn)
116 .await?;
117
118 Ok(())
119 }
120
121 #[tracing::instrument(
122 name = "db.queue_job.schedule_later",
123 fields(
124 queue_job.id,
125 queue_job.queue_name = queue_name,
126 queue_job.scheduled_at = %scheduled_at,
127 db.query.text,
128 ),
129 skip_all,
130 )]
131 async fn schedule_later(
132 &mut self,
133 rng: &mut (dyn Rng + Send),
134 clock: &dyn Clock,
135 queue_name: &str,
136 payload: serde_json::Value,
137 metadata: serde_json::Value,
138 scheduled_at: DateTime<Utc>,
139 schedule_name: Option<&str>,
140 ) -> Result<(), Self::Error> {
141 let created_at = clock.now();
142 let id = Ulid::from_datetime_with_rng(created_at, rng);
143 tracing::Span::current().record("queue_job.id", tracing::field::display(id));
144
145 sqlx::query!(
146 r#"
147 INSERT INTO queue_jobs
148 (queue_job_id, queue_name, payload, metadata, created_at, scheduled_at, schedule_name, status)
149 VALUES ($1, $2, $3, $4, $5, $6, $7, 'scheduled')
150 "#,
151 Uuid::from(id),
152 queue_name,
153 payload,
154 metadata,
155 created_at,
156 scheduled_at,
157 schedule_name,
158 )
159 .traced()
160 .execute(&mut *self.conn)
161 .await?;
162
163 if let Some(schedule_name) = schedule_name {
165 let span = tracing::info_span!(
166 "db.queue_job.schedule_later.update_schedules",
167 { DB_QUERY_TEXT } = tracing::field::Empty,
168 );
169
170 let res = sqlx::query!(
171 r#"
172 UPDATE queue_schedules
173 SET last_scheduled_at = $1,
174 last_scheduled_job_id = $2
175 WHERE schedule_name = $3
176 "#,
177 scheduled_at,
178 Uuid::from(id),
179 schedule_name,
180 )
181 .record(&span)
182 .execute(&mut *self.conn)
183 .instrument(span)
184 .await?;
185
186 DatabaseError::ensure_affected_rows(&res, 1)?;
187 }
188
189 Ok(())
190 }
191
192 #[tracing::instrument(
193 name = "db.queue_job.reserve",
194 skip_all,
195 fields(
196 db.query.text,
197 ),
198 )]
199 async fn reserve(
200 &mut self,
201 clock: &dyn Clock,
202 worker: &Worker,
203 queues: &[&str],
204 count: usize,
205 ) -> Result<Vec<Job>, Self::Error> {
206 let now = clock.now();
207 let max_count = i64::try_from(count).unwrap_or(i64::MAX);
208 let queues: Vec<String> = queues.iter().map(|&s| s.to_owned()).collect();
209 let results = sqlx::query_as!(
210 JobReservationResult,
211 r#"
212 -- We first grab a few jobs that are available,
213 -- using a FOR UPDATE SKIP LOCKED so that this can be run concurrently
214 -- and we don't get multiple workers grabbing the same jobs
215 WITH locked_jobs AS (
216 SELECT queue_job_id
217 FROM queue_jobs
218 WHERE
219 status = 'available'
220 AND queue_name = ANY($1)
221 ORDER BY queue_job_id ASC
222 LIMIT $2
223 FOR UPDATE
224 SKIP LOCKED
225 )
226 -- then we update the status of those jobs to 'running', returning the job details
227 UPDATE queue_jobs
228 SET status = 'running', started_at = $3, started_by = $4
229 FROM locked_jobs
230 WHERE queue_jobs.queue_job_id = locked_jobs.queue_job_id
231 RETURNING
232 queue_jobs.queue_job_id,
233 queue_jobs.queue_name,
234 queue_jobs.payload,
235 queue_jobs.metadata,
236 queue_jobs.attempt
237 "#,
238 &queues,
239 max_count,
240 now,
241 Uuid::from(worker.id),
242 )
243 .traced()
244 .fetch_all(&mut *self.conn)
245 .await?;
246
247 let jobs = results
248 .into_iter()
249 .map(TryFrom::try_from)
250 .collect::<Result<Vec<_>, _>>()?;
251
252 Ok(jobs)
253 }
254
255 #[tracing::instrument(
256 name = "db.queue_job.mark_as_completed",
257 skip_all,
258 fields(
259 db.query.text,
260 job.id = %id,
261 ),
262 )]
263 async fn mark_as_completed(&mut self, clock: &dyn Clock, id: Ulid) -> Result<(), Self::Error> {
264 let now = clock.now();
265 let res = sqlx::query!(
266 r#"
267 UPDATE queue_jobs
268 SET status = 'completed', completed_at = $1
269 WHERE queue_job_id = $2 AND status = 'running'
270 "#,
271 now,
272 Uuid::from(id),
273 )
274 .traced()
275 .execute(&mut *self.conn)
276 .await?;
277
278 DatabaseError::ensure_affected_rows(&res, 1)?;
279
280 Ok(())
281 }
282
283 #[tracing::instrument(
284 name = "db.queue_job.mark_as_failed",
285 skip_all,
286 fields(
287 db.query.text,
288 job.id = %id,
289 ),
290 )]
291 async fn mark_as_failed(
292 &mut self,
293 clock: &dyn Clock,
294 id: Ulid,
295 reason: &str,
296 ) -> Result<(), Self::Error> {
297 let now = clock.now();
298 let res = sqlx::query!(
299 r#"
300 UPDATE queue_jobs
301 SET
302 status = 'failed',
303 failed_at = $1,
304 failed_reason = $2
305 WHERE
306 queue_job_id = $3
307 AND status = 'running'
308 "#,
309 now,
310 reason,
311 Uuid::from(id),
312 )
313 .traced()
314 .execute(&mut *self.conn)
315 .await?;
316
317 DatabaseError::ensure_affected_rows(&res, 1)?;
318
319 Ok(())
320 }
321
322 #[tracing::instrument(
323 name = "db.queue_job.retry",
324 skip_all,
325 fields(
326 db.query.text,
327 job.id = %id,
328 ),
329 )]
330 async fn retry(
331 &mut self,
332 rng: &mut (dyn Rng + Send),
333 clock: &dyn Clock,
334 id: Ulid,
335 delay: Duration,
336 ) -> Result<(), Self::Error> {
337 let now = clock.now();
338 let scheduled_at = now + delay;
339 let new_id = Ulid::from_datetime_with_rng(now, rng);
340
341 let span = tracing::info_span!(
342 "db.queue_job.retry.insert_job",
343 { DB_QUERY_TEXT } = tracing::field::Empty
344 );
345 let res = sqlx::query!(
349 r#"
350 INSERT INTO queue_jobs
351 (queue_job_id, queue_name, payload, metadata, created_at,
352 attempt, scheduled_at, schedule_name, status)
353 SELECT $1, queue_name, payload, metadata, $2, attempt + 1, $3, schedule_name, 'scheduled'
354 FROM queue_jobs
355 WHERE queue_job_id = $4
356 AND status = 'failed'
357 "#,
358 Uuid::from(new_id),
359 now,
360 scheduled_at,
361 Uuid::from(id),
362 )
363 .record(&span)
364 .execute(&mut *self.conn)
365 .instrument(span)
366 .await?;
367
368 DatabaseError::ensure_affected_rows(&res, 1)?;
369
370 let span = tracing::info_span!(
372 "db.queue_job.retry.update_schedule",
373 { DB_QUERY_TEXT } = tracing::field::Empty
374 );
375 sqlx::query!(
376 r#"
377 UPDATE queue_schedules
378 SET last_scheduled_at = $1,
379 last_scheduled_job_id = $2
380 WHERE last_scheduled_job_id = $3
381 "#,
382 scheduled_at,
383 Uuid::from(new_id),
384 Uuid::from(id),
385 )
386 .record(&span)
387 .execute(&mut *self.conn)
388 .instrument(span)
389 .await?;
390
391 let span = tracing::info_span!(
393 "db.queue_job.retry.update_old_job",
394 { DB_QUERY_TEXT } = tracing::field::Empty
395 );
396 let res = sqlx::query!(
397 r#"
398 UPDATE queue_jobs
399 SET next_attempt_id = $1
400 WHERE queue_job_id = $2
401 "#,
402 Uuid::from(new_id),
403 Uuid::from(id),
404 )
405 .record(&span)
406 .execute(&mut *self.conn)
407 .instrument(span)
408 .await?;
409
410 DatabaseError::ensure_affected_rows(&res, 1)?;
411
412 Ok(())
413 }
414
415 #[tracing::instrument(
416 name = "db.queue_job.schedule_available_jobs",
417 skip_all,
418 fields(
419 db.query.text,
420 ),
421 )]
422 async fn schedule_available_jobs(&mut self, clock: &dyn Clock) -> Result<usize, Self::Error> {
423 let now = clock.now();
424 let res = sqlx::query!(
425 r#"
426 UPDATE queue_jobs
427 SET status = 'available'
428 WHERE
429 status = 'scheduled'
430 AND scheduled_at <= $1
431 "#,
432 now,
433 )
434 .traced()
435 .execute(&mut *self.conn)
436 .await?;
437
438 let count = res.rows_affected();
439 Ok(usize::try_from(count).unwrap_or(usize::MAX))
440 }
441
442 #[tracing::instrument(
443 name = "db.queue_job.cleanup",
444 skip_all,
445 fields(
446 db.query.text,
447 since = since.map(tracing::field::display),
448 until = %until,
449 limit = limit,
450 ),
451 )]
452 async fn cleanup(
453 &mut self,
454 since: Option<Ulid>,
455 until: Ulid,
456 limit: usize,
457 ) -> Result<(usize, Option<Ulid>), Self::Error> {
458 let res = sqlx::query_scalar!(
462 r#"
463 WITH to_delete AS (
464 SELECT queue_job_id
465 FROM queue_jobs
466 WHERE (status = 'completed' OR status = 'failed')
467 AND ($1::uuid IS NULL OR queue_job_id > $1)
468 AND queue_job_id <= $2
469 ORDER BY queue_job_id
470 LIMIT $3
471 )
472 DELETE FROM queue_jobs
473 USING to_delete
474 WHERE queue_jobs.queue_job_id = to_delete.queue_job_id
475 RETURNING queue_jobs.queue_job_id
476 "#,
477 since.map(Uuid::from),
478 Uuid::from(until),
479 i64::try_from(limit).unwrap_or(i64::MAX)
480 )
481 .traced()
482 .fetch_all(&mut *self.conn)
483 .await?;
484
485 let count = res.len();
486 let max_id = res.into_iter().max();
487
488 Ok((count, max_id.map(Ulid::from)))
489 }
490}