1use std::time::Duration;
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use sbol_db_core::{DomainError, JobId};
13use serde_json::Value;
14use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
15
16use sbol_db_storage::{
17 EnqueueOutcome, JobAttempt, JobLogRecord, JobQueue, JobStatus, ListJobsFilter, NewJob,
18 OldestQueuedAge, QueueDepthRow, SbolJob, DEFAULT_QUEUE,
19};
20
21use crate::pool::db_err;
22
23const JOB_COLS: &str = "id, kind, status, priority, queue, payload, result, error, \
24 idempotency_key, attempts, max_attempts, available_at, leased_by, lease_expires_at, \
25 parent_job_id, correlation_id, created_at, started_at, finished_at";
26
27#[derive(Clone)]
28pub struct SqliteJobRepository {
29 pool: SqlitePool,
30}
31
32impl SqliteJobRepository {
33 pub fn new(pool: SqlitePool) -> Self {
34 Self { pool }
35 }
36
37 async fn find_by_idempotency(
38 &self,
39 kind: &str,
40 key: &str,
41 ) -> Result<Option<SbolJob>, DomainError> {
42 let row = sqlx::query(&format!(
43 "SELECT {JOB_COLS} FROM sbol_jobs \
44 WHERE kind = ? AND idempotency_key = ? \
45 AND status IN ('queued', 'running', 'succeeded') \
46 ORDER BY created_at DESC LIMIT 1"
47 ))
48 .bind(kind)
49 .bind(key)
50 .fetch_optional(&self.pool)
51 .await
52 .map_err(db_err)?;
53 row.map(row_to_job).transpose()
54 }
55
56 async fn record_attempt_started(
57 &self,
58 job_id: JobId,
59 attempt_no: i32,
60 worker_id: &str,
61 ) -> Result<(), DomainError> {
62 sqlx::query(
63 "INSERT OR IGNORE INTO sbol_job_attempts \
64 (job_id, attempt_no, worker_id, status, started_at) \
65 VALUES (?, ?, ?, 'running', ?)",
66 )
67 .bind(job_id.as_uuid().to_string())
68 .bind(attempt_no)
69 .bind(worker_id)
70 .bind(Utc::now())
71 .execute(&self.pool)
72 .await
73 .map_err(db_err)?;
74 Ok(())
75 }
76}
77
78#[async_trait]
79impl JobQueue for SqliteJobRepository {
80 async fn enqueue(&self, input: NewJob) -> Result<EnqueueOutcome, DomainError> {
81 if let Some(key) = &input.idempotency_key {
82 if let Some(existing) = self.find_by_idempotency(&input.kind, key).await? {
83 return Ok(EnqueueOutcome::AlreadyExists(existing));
84 }
85 }
86
87 let now = Utc::now();
88 let available_at = input.available_at.unwrap_or(now);
89 let queue = input.queue.unwrap_or_else(|| DEFAULT_QUEUE.to_owned());
90 let priority = input.priority.unwrap_or(0) as i64;
91 let max_attempts = input.max_attempts.unwrap_or(5) as i64;
92 let payload = serde_json::to_string(&input.payload).map_err(db_err)?;
93
94 let row = sqlx::query(&format!(
95 r#"
96 INSERT INTO sbol_jobs (
97 id, kind, status, priority, queue, payload, idempotency_key,
98 attempts, max_attempts, available_at, parent_job_id,
99 correlation_id, created_at
100 ) VALUES (?, ?, 'queued', ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
101 RETURNING {JOB_COLS}
102 "#
103 ))
104 .bind(uuid::Uuid::new_v4().to_string())
105 .bind(&input.kind)
106 .bind(priority)
107 .bind(&queue)
108 .bind(payload)
109 .bind(input.idempotency_key.as_deref())
110 .bind(max_attempts)
111 .bind(available_at)
112 .bind(input.parent_job_id.map(|j| j.as_uuid().to_string()))
113 .bind(input.correlation_id.map(|c| c.to_string()))
114 .bind(now)
115 .fetch_one(&self.pool)
116 .await
117 .map_err(db_err)?;
118 Ok(EnqueueOutcome::Inserted(row_to_job(row)?))
119 }
120
121 async fn append_log(
122 &self,
123 job_id: JobId,
124 attempt_no: Option<i32>,
125 level: &str,
126 message: &str,
127 fields: Value,
128 ) -> Result<JobLogRecord, DomainError> {
129 let fields_s = serde_json::to_string(&fields).map_err(db_err)?;
130 let row = sqlx::query(
131 "INSERT INTO sbol_job_logs (job_id, attempt_no, level, message, fields, created_at) \
132 VALUES (?, ?, ?, ?, ?, ?) \
133 RETURNING id, job_id, attempt_no, level, message, fields, created_at",
134 )
135 .bind(job_id.as_uuid().to_string())
136 .bind(attempt_no)
137 .bind(normalize_log_level(level))
138 .bind(message)
139 .bind(fields_s)
140 .bind(Utc::now())
141 .fetch_one(&self.pool)
142 .await
143 .map_err(db_err)?;
144 row_to_job_log(row)
145 }
146
147 async fn list_logs(
148 &self,
149 id: JobId,
150 after_id: Option<i64>,
151 limit: u32,
152 ) -> Result<Vec<JobLogRecord>, DomainError> {
153 let rows = sqlx::query(
154 "SELECT id, job_id, attempt_no, level, message, fields, created_at \
155 FROM sbol_job_logs WHERE job_id = ? AND id > ? ORDER BY id ASC LIMIT ?",
156 )
157 .bind(id.as_uuid().to_string())
158 .bind(after_id.unwrap_or(0))
159 .bind(limit.clamp(1, 1000) as i64)
160 .fetch_all(&self.pool)
161 .await
162 .map_err(db_err)?;
163 rows.into_iter().map(row_to_job_log).collect()
164 }
165
166 async fn dequeue(
167 &self,
168 queues: &[String],
169 worker_id: &str,
170 lease: Duration,
171 ) -> Result<Option<SbolJob>, DomainError> {
172 if queues.is_empty() {
173 return Ok(None);
174 }
175 let now = Utc::now();
176 let lease_expires = now
177 + chrono::Duration::from_std(lease).unwrap_or_else(|_| chrono::Duration::seconds(60));
178
179 let mut qb: QueryBuilder<Sqlite> =
180 QueryBuilder::new("UPDATE sbol_jobs SET status = 'running', leased_by = ");
181 qb.push_bind(worker_id.to_owned());
182 qb.push(", lease_expires_at = ").push_bind(lease_expires);
183 qb.push(", attempts = attempts + 1, started_at = COALESCE(started_at, ")
184 .push_bind(now)
185 .push(")");
186 qb.push(
187 " WHERE id = (SELECT id FROM sbol_jobs WHERE status = 'queued' AND available_at <= ",
188 )
189 .push_bind(now);
190 qb.push(" AND queue IN (");
191 {
192 let mut sep = qb.separated(", ");
193 for q in queues {
194 sep.push_bind(q.clone());
195 }
196 }
197 qb.push(") ORDER BY priority DESC, available_at ASC LIMIT 1) RETURNING ");
198 qb.push(JOB_COLS);
199
200 let row = qb
201 .build()
202 .fetch_optional(&self.pool)
203 .await
204 .map_err(db_err)?;
205 let job = row.map(row_to_job).transpose()?;
206 if let Some(j) = &job {
207 self.record_attempt_started(j.id, j.attempts, worker_id)
208 .await?;
209 }
210 Ok(job)
211 }
212
213 async fn renew_lease(
214 &self,
215 job_id: JobId,
216 worker_id: &str,
217 lease: Duration,
218 ) -> Result<bool, DomainError> {
219 let lease_expires = Utc::now()
220 + chrono::Duration::from_std(lease).unwrap_or_else(|_| chrono::Duration::seconds(60));
221 let res = sqlx::query(
222 "UPDATE sbol_jobs SET lease_expires_at = ? \
223 WHERE id = ? AND leased_by = ? AND status = 'running'",
224 )
225 .bind(lease_expires)
226 .bind(job_id.as_uuid().to_string())
227 .bind(worker_id)
228 .execute(&self.pool)
229 .await
230 .map_err(db_err)?;
231 Ok(res.rows_affected() == 1)
232 }
233
234 async fn mark_succeeded(
235 &self,
236 job_id: JobId,
237 worker_id: &str,
238 result: Option<Value>,
239 ) -> Result<(), DomainError> {
240 let result_s = result
241 .map(|v| serde_json::to_string(&v))
242 .transpose()
243 .map_err(db_err)?;
244 let mut tx = self.pool.begin().await.map_err(db_err)?;
245 let row = sqlx::query(
246 "UPDATE sbol_jobs SET status = 'succeeded', result = ?, error = NULL, \
247 leased_by = NULL, lease_expires_at = NULL, finished_at = ? \
248 WHERE id = ? AND leased_by = ? AND status = 'running' RETURNING attempts",
249 )
250 .bind(result_s)
251 .bind(Utc::now())
252 .bind(job_id.as_uuid().to_string())
253 .bind(worker_id)
254 .fetch_optional(&mut *tx)
255 .await
256 .map_err(db_err)?;
257
258 if let Some(r) = row {
259 let attempts: i64 = r.try_get("attempts").map_err(db_err)?;
260 finalize_attempt(&mut tx, job_id, attempts as i32, JobStatus::Succeeded, None).await?;
261 }
262 tx.commit().await.map_err(db_err)?;
263 Ok(())
264 }
265
266 async fn mark_failed(
267 &self,
268 job_id: JobId,
269 worker_id: &str,
270 error: &str,
271 ) -> Result<JobStatus, DomainError> {
272 let mut tx = self.pool.begin().await.map_err(db_err)?;
273 let row = sqlx::query(
274 "SELECT attempts, max_attempts FROM sbol_jobs \
275 WHERE id = ? AND leased_by = ? AND status = 'running'",
276 )
277 .bind(job_id.as_uuid().to_string())
278 .bind(worker_id)
279 .fetch_optional(&mut *tx)
280 .await
281 .map_err(db_err)?;
282
283 let Some(row) = row else {
284 tx.commit().await.map_err(db_err)?;
285 return Err(DomainError::Database(
286 "job lease lost before failure could be recorded".to_owned(),
287 ));
288 };
289 let attempts = row.try_get::<i64, _>("attempts").map_err(db_err)? as i32;
290 let max_attempts = row.try_get::<i64, _>("max_attempts").map_err(db_err)? as i32;
291
292 let next_status = if attempts >= max_attempts {
293 JobStatus::Dead
294 } else {
295 JobStatus::Queued
296 };
297
298 match next_status {
299 JobStatus::Queued => {
300 let available_at =
301 Utc::now() + chrono::Duration::seconds(backoff_seconds(attempts));
302 sqlx::query(
303 "UPDATE sbol_jobs SET status = 'queued', leased_by = NULL, \
304 lease_expires_at = NULL, available_at = ?, error = ? WHERE id = ?",
305 )
306 .bind(available_at)
307 .bind(error)
308 .bind(job_id.as_uuid().to_string())
309 .execute(&mut *tx)
310 .await
311 .map_err(db_err)?;
312 }
313 JobStatus::Dead => {
314 sqlx::query(
315 "UPDATE sbol_jobs SET status = 'dead', leased_by = NULL, \
316 lease_expires_at = NULL, error = ?, finished_at = ? WHERE id = ?",
317 )
318 .bind(error)
319 .bind(Utc::now())
320 .bind(job_id.as_uuid().to_string())
321 .execute(&mut *tx)
322 .await
323 .map_err(db_err)?;
324 }
325 _ => unreachable!("next_status is queued or dead"),
326 }
327
328 let attempt_status = match next_status {
329 JobStatus::Dead => JobStatus::Dead,
330 _ => JobStatus::Failed,
331 };
332 finalize_attempt(&mut tx, job_id, attempts, attempt_status, Some(error)).await?;
333 tx.commit().await.map_err(db_err)?;
334 Ok(next_status)
335 }
336
337 async fn reap_expired_leases(&self) -> Result<u64, DomainError> {
338 let res = sqlx::query(
339 "UPDATE sbol_jobs SET status = 'queued', leased_by = NULL, \
340 lease_expires_at = NULL, available_at = ?, \
341 error = COALESCE(error, 'lease expired') \
342 WHERE status = 'running' AND lease_expires_at < ?",
343 )
344 .bind(Utc::now())
345 .bind(Utc::now())
346 .execute(&self.pool)
347 .await
348 .map_err(db_err)?;
349 Ok(res.rows_affected())
350 }
351
352 async fn get(&self, id: JobId) -> Result<Option<SbolJob>, DomainError> {
353 let row = sqlx::query(&format!("SELECT {JOB_COLS} FROM sbol_jobs WHERE id = ?"))
354 .bind(id.as_uuid().to_string())
355 .fetch_optional(&self.pool)
356 .await
357 .map_err(db_err)?;
358 row.map(row_to_job).transpose()
359 }
360
361 async fn list_attempts(&self, id: JobId) -> Result<Vec<JobAttempt>, DomainError> {
362 let rows = sqlx::query(
363 "SELECT id, job_id, attempt_no, worker_id, started_at, finished_at, status, error \
364 FROM sbol_job_attempts WHERE job_id = ? ORDER BY attempt_no DESC",
365 )
366 .bind(id.as_uuid().to_string())
367 .fetch_all(&self.pool)
368 .await
369 .map_err(db_err)?;
370 rows.into_iter()
371 .map(|row| {
372 Ok(JobAttempt {
373 id: row.try_get("id").map_err(db_err)?,
374 job_id: JobId(parse_uuid(row.try_get("job_id").map_err(db_err)?)?),
375 attempt_no: row.try_get::<i64, _>("attempt_no").map_err(db_err)? as i32,
376 worker_id: row.try_get("worker_id").map_err(db_err)?,
377 started_at: row.try_get("started_at").map_err(db_err)?,
378 finished_at: row.try_get("finished_at").map_err(db_err)?,
379 status: JobStatus::from_db_str(
380 row.try_get::<String, _>("status").map_err(db_err)?.as_str(),
381 )?,
382 error: row.try_get("error").map_err(db_err)?,
383 })
384 })
385 .collect()
386 }
387
388 async fn list(&self, filter: &ListJobsFilter) -> Result<Vec<SbolJob>, DomainError> {
389 let mut qb: QueryBuilder<Sqlite> =
390 QueryBuilder::new(format!("SELECT {JOB_COLS} FROM sbol_jobs WHERE 1=1"));
391 if let Some(kind) = &filter.kind {
392 qb.push(" AND kind = ").push_bind(kind.clone());
393 }
394 if let Some(status) = filter.status {
395 qb.push(" AND status = ").push_bind(status.as_db_str());
396 }
397 if let Some(queue) = &filter.queue {
398 qb.push(" AND queue = ").push_bind(queue.clone());
399 }
400 if let Some(correlation_id) = filter.correlation_id {
401 qb.push(" AND correlation_id = ")
402 .push_bind(correlation_id.to_string());
403 }
404 if let Some(since) = filter.since {
405 qb.push(" AND created_at >= ").push_bind(since);
406 }
407 qb.push(" ORDER BY created_at DESC, id DESC LIMIT ")
408 .push_bind(filter.limit.clamp(1, 1000) as i64);
409 let rows = qb.build().fetch_all(&self.pool).await.map_err(db_err)?;
410 rows.into_iter().map(row_to_job).collect()
411 }
412
413 async fn cancel(&self, id: JobId) -> Result<bool, DomainError> {
414 let res = sqlx::query(
415 "UPDATE sbol_jobs SET status = 'cancelled', leased_by = NULL, \
416 lease_expires_at = NULL, finished_at = COALESCE(finished_at, ?) \
417 WHERE id = ? AND status IN ('queued', 'running')",
418 )
419 .bind(Utc::now())
420 .bind(id.as_uuid().to_string())
421 .execute(&self.pool)
422 .await
423 .map_err(db_err)?;
424 Ok(res.rows_affected() == 1)
425 }
426
427 async fn current_status(&self, id: JobId) -> Result<Option<JobStatus>, DomainError> {
428 let row = sqlx::query("SELECT status FROM sbol_jobs WHERE id = ?")
429 .bind(id.as_uuid().to_string())
430 .fetch_optional(&self.pool)
431 .await
432 .map_err(db_err)?;
433 row.map(|r| JobStatus::from_db_str(&r.try_get::<String, _>("status").map_err(db_err)?))
434 .transpose()
435 }
436
437 async fn queue_depth_snapshot(&self) -> Result<Vec<QueueDepthRow>, DomainError> {
438 let rows = sqlx::query(
439 "SELECT status, queue, count(*) AS n FROM sbol_jobs \
440 WHERE status IN ('queued', 'running', 'failed', 'dead') \
441 GROUP BY status, queue",
442 )
443 .fetch_all(&self.pool)
444 .await
445 .map_err(db_err)?;
446 rows.into_iter()
447 .map(|r| {
448 Ok(QueueDepthRow {
449 status: JobStatus::from_db_str(
450 r.try_get::<String, _>("status").map_err(db_err)?.as_str(),
451 )?,
452 queue: r.try_get("queue").map_err(db_err)?,
453 count: r.try_get("n").map_err(db_err)?,
454 })
455 })
456 .collect()
457 }
458
459 async fn oldest_queued_age(&self) -> Result<Vec<OldestQueuedAge>, DomainError> {
460 let now = Utc::now();
461 let rows = sqlx::query(
462 "SELECT queue, MIN(available_at) AS oldest FROM sbol_jobs \
463 WHERE status = 'queued' AND available_at <= ? GROUP BY queue",
464 )
465 .bind(now)
466 .fetch_all(&self.pool)
467 .await
468 .map_err(db_err)?;
469 rows.into_iter()
470 .map(|r| {
471 let oldest: DateTime<Utc> = r.try_get("oldest").map_err(db_err)?;
472 Ok(OldestQueuedAge {
473 queue: r.try_get("queue").map_err(db_err)?,
474 age_secs: (now - oldest).num_milliseconds() as f64 / 1000.0,
475 })
476 })
477 .collect()
478 }
479}
480
481async fn finalize_attempt(
482 tx: &mut sqlx::Transaction<'_, Sqlite>,
483 job_id: JobId,
484 attempt_no: i32,
485 status: JobStatus,
486 error: Option<&str>,
487) -> Result<(), DomainError> {
488 sqlx::query(
489 "UPDATE sbol_job_attempts SET finished_at = ?, status = ?, error = ? \
490 WHERE job_id = ? AND attempt_no = ?",
491 )
492 .bind(Utc::now())
493 .bind(status.as_db_str())
494 .bind(error)
495 .bind(job_id.as_uuid().to_string())
496 .bind(attempt_no)
497 .execute(&mut **tx)
498 .await
499 .map_err(db_err)?;
500 Ok(())
501}
502
503fn backoff_seconds(attempts: i32) -> i64 {
504 let attempts = attempts.max(1);
505 let base: i64 = 60;
506 let cap: i64 = 60 * 60;
507 let shift = attempts.saturating_sub(1).min(6) as u32;
508 base.saturating_mul(1i64 << shift).min(cap)
509}
510
511fn normalize_log_level(level: &str) -> &'static str {
512 match level {
513 "debug" => "debug",
514 "warn" => "warn",
515 "error" => "error",
516 _ => "info",
517 }
518}
519
520fn parse_uuid(s: String) -> Result<uuid::Uuid, DomainError> {
521 uuid::Uuid::parse_str(&s).map_err(db_err)
522}
523
524fn row_to_job(row: sqlx::sqlite::SqliteRow) -> Result<SbolJob, DomainError> {
525 let payload: String = row.try_get("payload").map_err(db_err)?;
526 let result: Option<String> = row.try_get("result").map_err(db_err)?;
527 let parent_job_id: Option<String> = row.try_get("parent_job_id").map_err(db_err)?;
528 let correlation_id: Option<String> = row.try_get("correlation_id").map_err(db_err)?;
529
530 Ok(SbolJob {
531 id: JobId(parse_uuid(row.try_get("id").map_err(db_err)?)?),
532 kind: row.try_get("kind").map_err(db_err)?,
533 status: JobStatus::from_db_str(
534 row.try_get::<String, _>("status").map_err(db_err)?.as_str(),
535 )?,
536 priority: row.try_get::<i64, _>("priority").map_err(db_err)? as i16,
537 queue: row.try_get("queue").map_err(db_err)?,
538 payload: serde_json::from_str(&payload).map_err(db_err)?,
539 result: result
540 .map(|s| serde_json::from_str(&s))
541 .transpose()
542 .map_err(db_err)?,
543 error: row.try_get("error").map_err(db_err)?,
544 idempotency_key: row.try_get("idempotency_key").map_err(db_err)?,
545 attempts: row.try_get::<i64, _>("attempts").map_err(db_err)? as i32,
546 max_attempts: row.try_get::<i64, _>("max_attempts").map_err(db_err)? as i32,
547 available_at: row.try_get("available_at").map_err(db_err)?,
548 leased_by: row.try_get("leased_by").map_err(db_err)?,
549 lease_expires_at: row.try_get("lease_expires_at").map_err(db_err)?,
550 parent_job_id: parent_job_id.map(parse_uuid).transpose()?.map(JobId),
551 correlation_id: correlation_id.map(parse_uuid).transpose()?,
552 created_at: row.try_get("created_at").map_err(db_err)?,
553 started_at: row.try_get("started_at").map_err(db_err)?,
554 finished_at: row.try_get("finished_at").map_err(db_err)?,
555 })
556}
557
558fn row_to_job_log(row: sqlx::sqlite::SqliteRow) -> Result<JobLogRecord, DomainError> {
559 let fields: String = row.try_get("fields").map_err(db_err)?;
560 Ok(JobLogRecord {
561 id: row.try_get("id").map_err(db_err)?,
562 job_id: JobId(parse_uuid(row.try_get("job_id").map_err(db_err)?)?),
563 attempt_no: row
564 .try_get::<Option<i64>, _>("attempt_no")
565 .map_err(db_err)?
566 .map(|n| n as i32),
567 level: row.try_get("level").map_err(db_err)?,
568 message: row.try_get("message").map_err(db_err)?,
569 fields: serde_json::from_str(&fields).map_err(db_err)?,
570 created_at: row.try_get("created_at").map_err(db_err)?,
571 })
572}