1use crate::types::{FailureClass, QueueJobDetails, QueueJobStatus, QueuePriority, QueueStats};
2use anyhow::{Context, Result};
3use rusqlite::{params, Connection};
4use serde_json::Value;
5
6const SCHEMA: &str = r#"
7CREATE TABLE IF NOT EXISTS queue_jobs (
8 id TEXT PRIMARY KEY,
9 priority INTEGER DEFAULT 2,
10 status TEXT CHECK(status IN ('pending', 'processing', 'completed', 'failed', 'cancelled')),
11 data_json TEXT NOT NULL,
12 trace_id TEXT,
13 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
14 started_at DATETIME,
15 completed_at DATETIME,
16 error_message TEXT,
17 worker_id TEXT,
18 heartbeat_at DATETIME,
19 visibility_timeout INTEGER DEFAULT 300,
20 failure_class TEXT,
21 next_run_at DATETIME,
22 attempt_count INTEGER DEFAULT 0,
23 attempt_id TEXT,
24 trial_id TEXT
25);
26
27CREATE INDEX IF NOT EXISTS idx_queue_status_priority ON queue_jobs(status, priority);
28"#;
29
30const SCHEMA_VERSION: u32 = 4;
32
33pub fn open_database(path: Option<&std::path::Path>) -> Result<Connection> {
35 let conn = match path {
36 Some(p) => Connection::open(p).context("Failed to open queue database")?,
37 None => Connection::open_in_memory().context("Failed to open in-memory database")?,
38 };
39
40 conn.execute_batch(
41 "PRAGMA journal_mode = WAL;
42 PRAGMA foreign_keys = ON;
43 PRAGMA busy_timeout = 5000;",
44 )
45 .context("Failed to set PRAGMA options")?;
46
47 conn.execute_batch(SCHEMA)
48 .context("Failed to create queue schema")?;
49
50 run_migrations(&conn)?;
51
52 Ok(conn)
53}
54
55fn run_migrations(conn: &Connection) -> Result<()> {
57 let version: u32 = conn
58 .query_row("PRAGMA user_version", [], |row| row.get(0))
59 .unwrap_or(0);
60
61 if version < 2 {
62 let columns = [
66 "ALTER TABLE queue_jobs ADD COLUMN worker_id TEXT",
67 "ALTER TABLE queue_jobs ADD COLUMN heartbeat_at DATETIME",
68 "ALTER TABLE queue_jobs ADD COLUMN visibility_timeout INTEGER DEFAULT 300",
69 "ALTER TABLE queue_jobs ADD COLUMN failure_class TEXT",
70 "ALTER TABLE queue_jobs ADD COLUMN next_run_at DATETIME",
71 "ALTER TABLE queue_jobs ADD COLUMN attempt_count INTEGER DEFAULT 0",
72 ];
73 for sql in &columns {
74 match conn.execute_batch(sql) {
75 Ok(_) => {}
76 Err(e) if e.to_string().contains("duplicate column") => {}
77 Err(e) => return Err(e).context("Migration V2 failed"),
78 }
79 }
80 }
81
82 if version < 3 {
83 match conn.execute_batch("ALTER TABLE queue_jobs ADD COLUMN trace_id TEXT") {
84 Ok(_) => {}
85 Err(e) if e.to_string().contains("duplicate column") => {}
86 Err(e) => return Err(e).context("Migration V3 failed"),
87 }
88 }
89
90 if version < 4 {
91 let columns = [
93 "ALTER TABLE queue_jobs ADD COLUMN attempt_id TEXT",
94 "ALTER TABLE queue_jobs ADD COLUMN trial_id TEXT",
95 ];
96 for sql in &columns {
97 match conn.execute_batch(sql) {
98 Ok(_) => {}
99 Err(e) if e.to_string().contains("duplicate column") => {}
100 Err(e) => return Err(e).context("Migration V4 failed"),
101 }
102 }
103 }
104
105 conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
106 .context("Failed to set schema version")?;
107
108 Ok(())
109}
110
111pub fn insert_job(conn: &Connection, job_id: &str, priority: i32, data: &Value) -> Result<()> {
113 insert_job_with_trace(conn, job_id, priority, data, None)
114}
115
116pub fn insert_job_with_trace(
118 conn: &Connection,
119 job_id: &str,
120 priority: i32,
121 data: &Value,
122 trace_id: Option<&str>,
123) -> Result<()> {
124 insert_job_full(conn, job_id, priority, data, trace_id, None, None)
125}
126
127pub fn insert_job_full(
129 conn: &Connection,
130 job_id: &str,
131 priority: i32,
132 data: &Value,
133 trace_id: Option<&str>,
134 attempt_id: Option<&str>,
135 trial_id: Option<&str>,
136) -> Result<()> {
137 conn.execute(
138 "INSERT INTO queue_jobs (id, priority, status, data_json, trace_id, attempt_id, trial_id)
139 VALUES (?1, ?2, 'pending', ?3, ?4, ?5, ?6)",
140 params![
141 job_id,
142 priority,
143 serde_json::to_string(data)?,
144 trace_id,
145 attempt_id,
146 trial_id,
147 ],
148 )
149 .context("Failed to insert queue job")?;
150 Ok(())
151}
152
153pub fn get_next_pending(conn: &Connection) -> Result<Option<(String, Value)>> {
160 let mut stmt = conn
161 .prepare(
162 "SELECT id, data_json FROM queue_jobs
163 WHERE status = 'pending'
164 ORDER BY priority ASC, created_at ASC
165 LIMIT 1",
166 )
167 .context("Failed to prepare get_next_pending query")?;
168
169 let mut rows = stmt.query([]).context("Failed to query next pending job")?;
170
171 if let Some(row) = rows.next().context("Failed to read next pending row")? {
172 let id: String = row.get(0)?;
173 let data_json: String = row.get(1)?;
174 let data: Value =
175 serde_json::from_str(&data_json).context("Failed to parse job data JSON")?;
176 Ok(Some((id, data)))
177 } else {
178 Ok(None)
179 }
180}
181
182pub fn claim_next_job(conn: &Connection) -> Result<Option<(String, Value)>> {
188 let now = chrono::Utc::now().to_rfc3339();
189 let tx = conn
190 .unchecked_transaction()
191 .context("Failed to begin transaction")?;
192
193 let job = {
194 let mut stmt = tx
195 .prepare(
196 "SELECT id, data_json FROM queue_jobs
197 WHERE status = 'pending'
198 ORDER BY priority ASC, created_at ASC
199 LIMIT 1",
200 )
201 .context("Failed to prepare claim_next_job SELECT")?;
202
203 let mut rows = stmt.query([]).context("Failed to query next pending job")?;
204
205 if let Some(row) = rows.next().context("Failed to read next pending row")? {
206 let id: String = row.get(0)?;
207 let data_json: String = row.get(1)?;
208 Some((id, data_json))
209 } else {
210 None
211 }
212 };
213
214 let Some((id, data_json)) = job else {
215 tx.commit()
216 .context("Failed to commit empty claim transaction")?;
217 return Ok(None);
218 };
219
220 let affected = tx
221 .execute(
222 "UPDATE queue_jobs SET status = 'processing', started_at = ?1
223 WHERE id = ?2 AND status = 'pending'",
224 params![now, id],
225 )
226 .context("Failed to update job status to processing")?;
227
228 tx.commit().context("Failed to commit claim transaction")?;
229
230 if affected == 0 {
231 return Ok(None);
233 }
234
235 let data: Value = serde_json::from_str(&data_json).context("Failed to parse job data JSON")?;
236 Ok(Some((id, data)))
237}
238
239pub fn claim(conn: &Connection, worker_id: &str) -> Result<Option<(String, Value)>> {
244 claim_with_lease(conn, worker_id, 300)
245}
246
247pub fn claim_with_lease(
249 conn: &Connection,
250 worker_id: &str,
251 visibility_timeout_secs: u64,
252) -> Result<Option<(String, Value)>> {
253 let now = chrono::Utc::now().to_rfc3339();
254 let tx = conn
255 .unchecked_transaction()
256 .context("Failed to begin claim transaction")?;
257
258 let job = {
259 let mut stmt = tx
260 .prepare(
261 "SELECT id, data_json FROM queue_jobs
262 WHERE status = 'pending'
263 AND (next_run_at IS NULL OR next_run_at <= ?1)
264 ORDER BY priority ASC, created_at ASC
265 LIMIT 1",
266 )
267 .context("Failed to prepare claim SELECT")?;
268
269 let mut rows = stmt
270 .query(params![now])
271 .context("Failed to query for claimable job")?;
272
273 if let Some(row) = rows.next().context("Failed to read claim row")? {
274 let id: String = row.get(0)?;
275 let data_json: String = row.get(1)?;
276 Some((id, data_json))
277 } else {
278 None
279 }
280 };
281
282 let Some((id, data_json)) = job else {
283 tx.commit()
284 .context("Failed to commit empty claim transaction")?;
285 return Ok(None);
286 };
287
288 let affected = tx
289 .execute(
290 "UPDATE queue_jobs
291 SET status = 'processing', started_at = ?1, worker_id = ?2,
292 heartbeat_at = ?1, visibility_timeout = ?3, attempt_count = attempt_count + 1
293 WHERE id = ?4 AND status = 'pending'",
294 params![now, worker_id, visibility_timeout_secs as i64, id],
295 )
296 .context("Failed to claim job for worker")?;
297
298 tx.commit().context("Failed to commit claim transaction")?;
299
300 if affected == 0 {
301 return Ok(None);
302 }
303
304 let data: Value = serde_json::from_str(&data_json).context("Failed to parse job data JSON")?;
305 Ok(Some((id, data)))
306}
307
308pub fn heartbeat(conn: &Connection, job_id: &str, worker_id: &str) -> Result<bool> {
313 let now = chrono::Utc::now().to_rfc3339();
314 let affected = conn
315 .execute(
316 "UPDATE queue_jobs SET heartbeat_at = ?1
317 WHERE id = ?2 AND status = 'processing' AND worker_id = ?3",
318 params![now, job_id, worker_id],
319 )
320 .context("Failed to update heartbeat")?;
321 Ok(affected > 0)
322}
323
324pub fn reclaim_stale(conn: &Connection, stale_secs: u64) -> Result<u32> {
328 let cutoff = chrono::Utc::now() - chrono::Duration::seconds(stale_secs as i64);
329 let cutoff_str = cutoff.to_rfc3339();
330 let count = conn
331 .execute(
332 "UPDATE queue_jobs
333 SET status = 'pending', worker_id = NULL, heartbeat_at = NULL
334 WHERE status = 'processing'
335 AND heartbeat_at IS NOT NULL
336 AND heartbeat_at < ?1",
337 params![cutoff_str],
338 )
339 .context("Failed to reclaim stale jobs")?;
340 Ok(count as u32)
341}
342
343pub fn mark_failed_with_retry(
346 conn: &Connection,
347 job_id: &str,
348 error: &str,
349 failure_class: &FailureClass,
350) -> Result<bool> {
351 mark_failed_with_retry_owned(conn, job_id, None, error, failure_class, u32::MAX)
352}
353
354pub fn mark_failed_with_retry_owned(
356 conn: &Connection,
357 job_id: &str,
358 worker_id: Option<&str>,
359 error: &str,
360 failure_class: &FailureClass,
361 max_retries: u32,
362) -> Result<bool> {
363 let now = chrono::Utc::now();
364 let now_str = now.to_rfc3339();
365 let fc_str = failure_class.as_str();
366 let attempt_count = get_attempt_count(conn, job_id).unwrap_or(1);
367 let ownership_clause = if worker_id.is_some() {
368 " AND worker_id = ?5"
369 } else {
370 ""
371 };
372 let permanent_failure =
373 matches!(failure_class, FailureClass::Permanent) || attempt_count >= max_retries;
374
375 if permanent_failure {
376 let sql = format!(
377 "UPDATE queue_jobs
378 SET status = 'failed', completed_at = ?1, error_message = ?2,
379 failure_class = ?3
380 WHERE id = ?4 AND status = 'processing'{}",
381 ownership_clause
382 );
383 let affected = match worker_id {
384 Some(worker_id) => {
385 conn.execute(&sql, params![now_str, error, fc_str, job_id, worker_id])
386 }
387 None => conn.execute(&sql, params![now_str, error, fc_str, job_id]),
388 }
389 .context("Failed to mark job as permanently failed")?;
390 return Ok(affected > 0);
391 }
392
393 let delay_secs = match failure_class {
394 FailureClass::Transient => {
395 let exponent = attempt_count.saturating_sub(1);
397 let base = 5u64.saturating_mul(2u64.saturating_pow(exponent));
398 base.min(300)
399 }
400 FailureClass::RateLimited { retry_after_secs } => *retry_after_secs,
401 FailureClass::Permanent => unreachable!(),
402 };
403
404 let next_run = now + chrono::Duration::seconds(delay_secs as i64);
405 let next_run_str = next_run.to_rfc3339();
406 let sql = format!(
407 "UPDATE queue_jobs
408 SET status = 'pending', error_message = ?1, failure_class = ?2,
409 worker_id = NULL, heartbeat_at = NULL, next_run_at = ?3
410 WHERE id = ?4 AND status = 'processing'{}",
411 ownership_clause
412 );
413
414 let affected = match worker_id {
415 Some(worker_id) => conn.execute(
416 &sql,
417 params![error, fc_str, next_run_str, job_id, worker_id],
418 ),
419 None => conn.execute(&sql, params![error, fc_str, next_run_str, job_id]),
420 }
421 .context("Failed to mark job for retry")?;
422 Ok(affected > 0)
423}
424
425pub fn mark_processing(conn: &Connection, job_id: &str) -> Result<bool> {
433 let now = chrono::Utc::now().to_rfc3339();
434 let affected = conn
435 .execute(
436 "UPDATE queue_jobs SET status = 'processing', started_at = ?1
437 WHERE id = ?2 AND status = 'pending'",
438 params![now, job_id],
439 )
440 .context("Failed to mark job as processing")?;
441 Ok(affected > 0)
442}
443
444pub fn mark_completed(conn: &Connection, job_id: &str) -> Result<bool> {
449 mark_completed_owned(conn, job_id, None)
450}
451
452pub fn mark_completed_owned(
454 conn: &Connection,
455 job_id: &str,
456 worker_id: Option<&str>,
457) -> Result<bool> {
458 let now = chrono::Utc::now().to_rfc3339();
459 let sql = if worker_id.is_some() {
460 "UPDATE queue_jobs SET status = 'completed', completed_at = ?1
461 WHERE id = ?2 AND status = 'processing' AND worker_id = ?3"
462 } else {
463 "UPDATE queue_jobs SET status = 'completed', completed_at = ?1
464 WHERE id = ?2 AND status = 'processing'"
465 };
466 let affected = match worker_id {
467 Some(worker_id) => conn.execute(sql, params![now, job_id, worker_id]),
468 None => conn.execute(sql, params![now, job_id]),
469 }
470 .context("Failed to mark job as completed")?;
471 Ok(affected > 0)
472}
473
474pub fn mark_failed(conn: &Connection, job_id: &str, error: &str) -> Result<bool> {
479 mark_failed_owned(conn, job_id, None, error)
480}
481
482pub fn mark_failed_owned(
484 conn: &Connection,
485 job_id: &str,
486 worker_id: Option<&str>,
487 error: &str,
488) -> Result<bool> {
489 let now = chrono::Utc::now().to_rfc3339();
490 let sql = if worker_id.is_some() {
491 "UPDATE queue_jobs SET status = 'failed', completed_at = ?1, error_message = ?2
492 WHERE id = ?3 AND status = 'processing' AND worker_id = ?4"
493 } else {
494 "UPDATE queue_jobs SET status = 'failed', completed_at = ?1, error_message = ?2
495 WHERE id = ?3 AND status = 'processing'"
496 };
497 let affected = match worker_id {
498 Some(worker_id) => conn.execute(sql, params![now, error, job_id, worker_id]),
499 None => conn.execute(sql, params![now, error, job_id]),
500 }
501 .context("Failed to mark job as failed")?;
502 Ok(affected > 0)
503}
504
505pub fn is_cancelled(conn: &Connection, job_id: &str) -> Result<bool> {
507 let status: String = conn
508 .query_row(
509 "SELECT status FROM queue_jobs WHERE id = ?1",
510 params![job_id],
511 |row| row.get(0),
512 )
513 .map_err(|_| anyhow::anyhow!("Job '{}' not found", job_id))?;
514 Ok(status == "cancelled")
515}
516
517pub fn cancel_job(conn: &Connection, job_id: &str) -> Result<String> {
519 let now = chrono::Utc::now().to_rfc3339();
520
521 let prev_status: String = conn
523 .query_row(
524 "SELECT status FROM queue_jobs WHERE id = ?1",
525 params![job_id],
526 |row| row.get(0),
527 )
528 .map_err(|_| anyhow::anyhow!("Job '{}' not found", job_id))?;
529
530 let affected = conn
532 .execute(
533 "UPDATE queue_jobs SET status = 'cancelled', completed_at = ?1
534 WHERE id = ?2 AND status IN ('pending', 'processing')",
535 params![now, job_id],
536 )
537 .context("Failed to cancel job")?;
538
539 if affected == 0 {
540 let current_status: String = conn
542 .query_row(
543 "SELECT status FROM queue_jobs WHERE id = ?1",
544 params![job_id],
545 |row| row.get(0),
546 )
547 .unwrap_or_else(|_| "unknown".to_string());
548 anyhow::bail!(
549 "Job '{}' is not cancellable (current status: {})",
550 job_id,
551 current_status
552 );
553 }
554
555 Ok(prev_status)
556}
557
558pub fn requeue_interrupted(conn: &Connection) -> Result<u32> {
561 let count = conn
562 .execute(
563 "UPDATE queue_jobs SET status = 'pending' WHERE status = 'processing'",
564 [],
565 )
566 .context("Failed to requeue interrupted jobs")?;
567 Ok(count as u32)
568}
569
570pub fn update_priority(conn: &Connection, job_id: &str, priority: i32) -> Result<()> {
572 conn.execute(
573 "UPDATE queue_jobs SET priority = ?1 WHERE id = ?2",
574 params![priority, job_id],
575 )
576 .context("Failed to update job priority")?;
577 Ok(())
578}
579
580pub fn reorder_pending(conn: &Connection, job_id: &str, priority: i32) -> Result<bool> {
585 let affected = conn
586 .execute(
587 "UPDATE queue_jobs SET priority = ?1 WHERE id = ?2 AND status = 'pending'",
588 params![priority, job_id],
589 )
590 .context("Failed to reorder job")?;
591
592 if affected > 0 {
593 return Ok(true);
594 }
595
596 let exists: bool = conn
598 .query_row(
599 "SELECT COUNT(*) > 0 FROM queue_jobs WHERE id = ?1",
600 params![job_id],
601 |row| row.get(0),
602 )
603 .context("Failed to check job existence")?;
604
605 if exists {
606 Ok(false)
607 } else {
608 anyhow::bail!("Job '{}' not found", job_id)
609 }
610}
611
612pub fn list_all_jobs(conn: &Connection) -> Result<Vec<(String, String, String)>> {
615 let mut stmt = conn
616 .prepare(
617 "SELECT id, status, data_json FROM queue_jobs
618 ORDER BY
619 CASE status
620 WHEN 'processing' THEN 0
621 WHEN 'pending' THEN 1
622 WHEN 'completed' THEN 2
623 WHEN 'failed' THEN 3
624 WHEN 'cancelled' THEN 4
625 END,
626 priority ASC,
627 created_at ASC",
628 )
629 .context("Failed to prepare list_all_jobs query")?;
630
631 let rows = stmt
632 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
633 .context("Failed to execute list_all_jobs query")?;
634
635 let mut jobs = Vec::new();
636 for row in rows {
637 jobs.push(row.context("Failed to read job row")?);
638 }
639 Ok(jobs)
640}
641
642pub fn prune_old_jobs(conn: &Connection, days: u32) -> Result<u32> {
645 let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
646 let cutoff_str = cutoff.to_rfc3339();
647
648 let count = conn
649 .execute(
650 "DELETE FROM queue_jobs
651 WHERE status IN ('completed', 'failed', 'cancelled')
652 AND completed_at < ?1",
653 params![cutoff_str],
654 )
655 .context("Failed to prune old queue jobs")?;
656
657 Ok(count as u32)
658}
659
660pub fn count_by_status(conn: &Connection) -> Result<QueueStats> {
662 let mut stats = QueueStats::default();
663
664 let mut stmt = conn
665 .prepare("SELECT status, COUNT(*) FROM queue_jobs GROUP BY status")
666 .context("Failed to prepare count_by_status query")?;
667
668 let rows = stmt
669 .query_map([], |row| {
670 let status: String = row.get(0)?;
671 let count: u32 = row.get(1)?;
672 Ok((status, count))
673 })
674 .context("Failed to execute count_by_status query")?;
675
676 for row in rows {
677 let (status, count) = row.context("Failed to read status count row")?;
678 match status.as_str() {
679 "pending" => stats.pending = count,
680 "processing" => stats.processing = count,
681 "completed" => stats.completed = count,
682 "failed" => stats.failed = count,
683 "cancelled" => stats.cancelled = count,
684 _ => {}
685 }
686 }
687
688 Ok(stats)
689}
690
691pub type JobRow = (String, i32, String, String, Option<String>);
693
694pub fn get_attempt_count(conn: &Connection, job_id: &str) -> Result<u32> {
696 conn.query_row(
697 "SELECT attempt_count FROM queue_jobs WHERE id = ?1",
698 params![job_id],
699 |row| row.get(0),
700 )
701 .context("Failed to fetch attempt count")
702}
703
704pub fn get_job(conn: &Connection, job_id: &str) -> Result<Option<JobRow>> {
706 let mut stmt = conn
707 .prepare(
708 "SELECT id, priority, status, data_json, error_message
709 FROM queue_jobs WHERE id = ?1",
710 )
711 .context("Failed to prepare get_job query")?;
712
713 let mut rows = stmt.query(params![job_id])?;
714
715 if let Some(row) = rows.next()? {
716 Ok(Some((
717 row.get(0)?,
718 row.get(1)?,
719 row.get(2)?,
720 row.get(3)?,
721 row.get(4)?,
722 )))
723 } else {
724 Ok(None)
725 }
726}
727
728pub fn get_job_details(conn: &Connection, job_id: &str) -> Result<Option<QueueJobDetails>> {
730 let mut stmt = conn
731 .prepare(
732 "SELECT id, priority, status, data_json, trace_id, created_at, started_at,
733 completed_at, error_message, worker_id, heartbeat_at, visibility_timeout,
734 failure_class, next_run_at, attempt_count, attempt_id, trial_id
735 FROM queue_jobs WHERE id = ?1",
736 )
737 .context("Failed to prepare get_job_details query")?;
738
739 let mut rows = stmt.query(params![job_id])?;
740 if let Some(row) = rows.next()? {
741 let priority: i32 = row.get(1)?;
742 let status: String = row.get(2)?;
743 Ok(Some(QueueJobDetails {
744 id: row.get(0)?,
745 priority: QueuePriority::from_i32(priority),
746 status: QueueJobStatus::parse(&status).unwrap_or(QueueJobStatus::Failed),
747 data_json: row.get(3)?,
748 trace_id: row.get(4)?,
749 created_at: row.get(5)?,
750 started_at: row.get(6)?,
751 completed_at: row.get(7)?,
752 error_message: row.get(8)?,
753 worker_id: row.get(9)?,
754 heartbeat_at: row.get(10)?,
755 visibility_timeout_secs: row.get::<_, i64>(11)? as u64,
756 failure_class: row.get(12)?,
757 next_run_at: row.get(13)?,
758 attempt_count: row.get::<_, i64>(14)? as u32,
759 attempt_id: row.get(15)?,
760 trial_id: row.get(16)?,
761 }))
762 } else {
763 Ok(None)
764 }
765}
766
767pub fn update_canonical_lineage(
772 conn: &Connection,
773 job_id: &str,
774 attempt_id: Option<&str>,
775 trial_id: Option<&str>,
776) -> Result<bool> {
777 let affected = conn
778 .execute(
779 "UPDATE queue_jobs SET attempt_id = COALESCE(?1, attempt_id),
780 trial_id = COALESCE(?2, trial_id)
781 WHERE id = ?3",
782 params![attempt_id, trial_id, job_id],
783 )
784 .context("Failed to update canonical lineage")?;
785 Ok(affected > 0)
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 fn setup() -> Connection {
793 open_database(None).unwrap()
794 }
795
796 #[test]
797 fn test_open_in_memory() {
798 let conn = open_database(None);
799 assert!(conn.is_ok());
800 }
801
802 #[test]
803 fn test_insert_and_get_next_pending() {
804 let conn = setup();
805 let data = serde_json::json!({"task": "send email"});
806 insert_job(&conn, "job-1", 2, &data).unwrap();
807
808 let next = get_next_pending(&conn).unwrap();
809 assert!(next.is_some());
810 let (id, val) = next.unwrap();
811 assert_eq!(id, "job-1");
812 assert_eq!(val["task"], "send email");
813 }
814
815 #[test]
816 fn test_priority_ordering() {
817 let conn = setup();
818 insert_job(&conn, "low-1", 3, &serde_json::json!({"p": "low"})).unwrap();
819 insert_job(&conn, "high-1", 1, &serde_json::json!({"p": "high"})).unwrap();
820 insert_job(&conn, "normal-1", 2, &serde_json::json!({"p": "normal"})).unwrap();
821
822 let next = get_next_pending(&conn).unwrap().unwrap();
823 assert_eq!(next.0, "high-1");
824 }
825
826 #[test]
827 fn test_mark_processing() {
828 let conn = setup();
829 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
830 assert!(mark_processing(&conn, "job-1").unwrap());
831
832 let job = get_job(&conn, "job-1").unwrap().unwrap();
833 assert_eq!(job.2, "processing");
834
835 assert!(get_next_pending(&conn).unwrap().is_none());
837 }
838
839 #[test]
840 fn test_mark_processing_guards_status() {
841 let conn = setup();
842 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
843 cancel_job(&conn, "job-1").unwrap();
845 assert!(!mark_processing(&conn, "job-1").unwrap());
847 }
848
849 #[test]
850 fn test_mark_completed() {
851 let conn = setup();
852 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
853 mark_processing(&conn, "job-1").unwrap();
854 assert!(mark_completed(&conn, "job-1").unwrap());
855
856 let job = get_job(&conn, "job-1").unwrap().unwrap();
857 assert_eq!(job.2, "completed");
858 }
859
860 #[test]
861 fn test_mark_completed_guards_status() {
862 let conn = setup();
863 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
864 mark_processing(&conn, "job-1").unwrap();
865 cancel_job(&conn, "job-1").unwrap();
866 assert!(!mark_completed(&conn, "job-1").unwrap());
868 let job = get_job(&conn, "job-1").unwrap().unwrap();
870 assert_eq!(job.2, "cancelled");
871 }
872
873 #[test]
874 fn test_mark_failed() {
875 let conn = setup();
876 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
877 mark_processing(&conn, "job-1").unwrap();
878 assert!(mark_failed(&conn, "job-1", "something broke").unwrap());
879
880 let job = get_job(&conn, "job-1").unwrap().unwrap();
881 assert_eq!(job.2, "failed");
882 assert_eq!(job.4.as_deref(), Some("something broke"));
883 }
884
885 #[test]
886 fn test_mark_failed_guards_status() {
887 let conn = setup();
888 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
889 mark_processing(&conn, "job-1").unwrap();
890 cancel_job(&conn, "job-1").unwrap();
891 assert!(!mark_failed(&conn, "job-1", "error").unwrap());
893 let job = get_job(&conn, "job-1").unwrap().unwrap();
894 assert_eq!(job.2, "cancelled");
895 }
896
897 #[test]
898 fn test_cancel_pending() {
899 let conn = setup();
900 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
901 let prev = cancel_job(&conn, "job-1").unwrap();
902 assert_eq!(prev, "pending");
903
904 assert!(is_cancelled(&conn, "job-1").unwrap());
905 }
906
907 #[test]
908 fn test_cancel_processing() {
909 let conn = setup();
910 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
911 mark_processing(&conn, "job-1").unwrap();
912 let prev = cancel_job(&conn, "job-1").unwrap();
913 assert_eq!(prev, "processing");
914
915 assert!(is_cancelled(&conn, "job-1").unwrap());
916 }
917
918 #[test]
919 fn test_cancel_completed_fails() {
920 let conn = setup();
921 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
922 mark_processing(&conn, "job-1").unwrap();
923 mark_completed(&conn, "job-1").unwrap();
924
925 let result = cancel_job(&conn, "job-1");
926 assert!(result.is_err());
927 }
928
929 #[test]
930 fn test_requeue_interrupted() {
931 let conn = setup();
932 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
933 mark_processing(&conn, "job-1").unwrap();
934
935 let count = requeue_interrupted(&conn).unwrap();
936 assert_eq!(count, 1);
937
938 let next = get_next_pending(&conn).unwrap();
939 assert!(next.is_some());
940 assert_eq!(next.unwrap().0, "job-1");
941 }
942
943 #[test]
944 fn test_update_priority() {
945 let conn = setup();
946 insert_job(&conn, "job-1", 3, &serde_json::json!({})).unwrap();
947 update_priority(&conn, "job-1", 1).unwrap();
948
949 let job = get_job(&conn, "job-1").unwrap().unwrap();
950 assert_eq!(job.1, 1);
951 }
952
953 #[test]
954 fn test_list_all_jobs() {
955 let conn = setup();
956 insert_job(&conn, "a", 2, &serde_json::json!({"n": 1})).unwrap();
957 insert_job(&conn, "b", 1, &serde_json::json!({"n": 2})).unwrap();
958 insert_job(&conn, "c", 3, &serde_json::json!({"n": 3})).unwrap();
959
960 let jobs = list_all_jobs(&conn).unwrap();
961 assert_eq!(jobs.len(), 3);
962 assert_eq!(jobs[0].0, "b");
964 assert_eq!(jobs[1].0, "a");
965 assert_eq!(jobs[2].0, "c");
966 }
967
968 #[test]
969 fn test_prune_old_jobs() {
970 let conn = setup();
971 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
972 mark_processing(&conn, "job-1").unwrap();
973 mark_completed(&conn, "job-1").unwrap();
974
975 let count = prune_old_jobs(&conn, 30).unwrap();
977 assert_eq!(count, 0);
978
979 let old_date = (chrono::Utc::now() - chrono::Duration::days(10)).to_rfc3339();
981 conn.execute(
982 "UPDATE queue_jobs SET completed_at = ?1 WHERE id = 'job-1'",
983 params![old_date],
984 )
985 .unwrap();
986
987 let count = prune_old_jobs(&conn, 5).unwrap();
989 assert_eq!(count, 1);
990 }
991
992 #[test]
993 fn test_get_job_not_found() {
994 let conn = setup();
995 let result = get_job(&conn, "nonexistent").unwrap();
996 assert!(result.is_none());
997 }
998
999 #[test]
1000 fn test_claim_next_job() {
1001 let conn = setup();
1002 insert_job(&conn, "job-1", 2, &serde_json::json!({"task": "test"})).unwrap();
1003 insert_job(&conn, "job-2", 1, &serde_json::json!({"task": "high"})).unwrap();
1004
1005 let claimed = claim_next_job(&conn).unwrap();
1007 assert!(claimed.is_some());
1008 let (id, data) = claimed.unwrap();
1009 assert_eq!(id, "job-2");
1010 assert_eq!(data["task"], "high");
1011
1012 let job = get_job(&conn, "job-2").unwrap().unwrap();
1014 assert_eq!(job.2, "processing");
1015
1016 let claimed2 = claim_next_job(&conn).unwrap();
1018 assert!(claimed2.is_some());
1019 assert_eq!(claimed2.unwrap().0, "job-1");
1020
1021 assert!(claim_next_job(&conn).unwrap().is_none());
1023 }
1024
1025 #[test]
1026 fn test_claim_next_job_empty_queue() {
1027 let conn = setup();
1028 assert!(claim_next_job(&conn).unwrap().is_none());
1029 }
1030
1031 #[test]
1032 fn test_claim_skips_cancelled() {
1033 let conn = setup();
1034 insert_job(&conn, "job-1", 1, &serde_json::json!({})).unwrap();
1035 cancel_job(&conn, "job-1").unwrap();
1036
1037 assert!(claim_next_job(&conn).unwrap().is_none());
1039 }
1040
1041 #[test]
1042 fn test_reorder_pending() {
1043 let conn = setup();
1044 insert_job(&conn, "job-1", 3, &serde_json::json!({})).unwrap();
1045
1046 assert!(reorder_pending(&conn, "job-1", 1).unwrap());
1048 let job = get_job(&conn, "job-1").unwrap().unwrap();
1049 assert_eq!(job.1, 1);
1050 }
1051
1052 #[test]
1053 fn test_reorder_non_pending_returns_false() {
1054 let conn = setup();
1055 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1056 mark_processing(&conn, "job-1").unwrap();
1057
1058 assert!(!reorder_pending(&conn, "job-1", 1).unwrap());
1060 }
1061
1062 #[test]
1063 fn test_reorder_nonexistent_errors() {
1064 let conn = setup();
1065 assert!(reorder_pending(&conn, "nope", 1).is_err());
1066 }
1067
1068 #[test]
1071 fn test_claim_with_worker_id() {
1072 let conn = setup();
1073 insert_job(&conn, "job-1", 2, &serde_json::json!({"x": 1})).unwrap();
1074
1075 let claimed = claim(&conn, "worker-A").unwrap();
1076 assert!(claimed.is_some());
1077 let (id, _data) = claimed.unwrap();
1078 assert_eq!(id, "job-1");
1079
1080 let worker: String = conn
1082 .query_row(
1083 "SELECT worker_id FROM queue_jobs WHERE id = 'job-1'",
1084 [],
1085 |row| row.get(0),
1086 )
1087 .unwrap();
1088 assert_eq!(worker, "worker-A");
1089 }
1090
1091 #[test]
1092 fn test_heartbeat() {
1093 let conn = setup();
1094 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1095 claim(&conn, "w1").unwrap();
1096
1097 assert!(heartbeat(&conn, "job-1", "w1").unwrap());
1099 assert!(!heartbeat(&conn, "job-1", "w2").unwrap());
1101 }
1102
1103 #[test]
1104 fn test_reclaim_stale_jobs() {
1105 let conn = setup();
1106 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1107 claim(&conn, "w1").unwrap();
1108
1109 let old = (chrono::Utc::now() - chrono::Duration::minutes(10)).to_rfc3339();
1111 conn.execute(
1112 "UPDATE queue_jobs SET heartbeat_at = ?1 WHERE id = 'job-1'",
1113 params![old],
1114 )
1115 .unwrap();
1116
1117 let count = reclaim_stale(&conn, 300).unwrap();
1119 assert_eq!(count, 1);
1120
1121 let job = get_job(&conn, "job-1").unwrap().unwrap();
1123 assert_eq!(job.2, "pending");
1124 }
1125
1126 #[test]
1127 fn test_reclaim_stale_ignores_fresh() {
1128 let conn = setup();
1129 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1130 claim(&conn, "w1").unwrap();
1131 heartbeat(&conn, "job-1", "w1").unwrap();
1132
1133 let count = reclaim_stale(&conn, 300).unwrap();
1135 assert_eq!(count, 0);
1136 }
1137
1138 #[test]
1139 fn test_retry_backoff_transient() {
1140 let conn = setup();
1141 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1142 claim(&conn, "w1").unwrap();
1143
1144 let ok =
1146 mark_failed_with_retry(&conn, "job-1", "timeout", &FailureClass::Transient).unwrap();
1147 assert!(ok);
1148
1149 let job = get_job(&conn, "job-1").unwrap().unwrap();
1150 assert_eq!(job.2, "pending"); let next_run: Option<String> = conn
1154 .query_row(
1155 "SELECT next_run_at FROM queue_jobs WHERE id = 'job-1'",
1156 [],
1157 |row| row.get(0),
1158 )
1159 .unwrap();
1160 assert!(next_run.is_some());
1161 }
1162
1163 #[test]
1164 fn test_retry_backoff_permanent() {
1165 let conn = setup();
1166 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1167 claim(&conn, "w1").unwrap();
1168
1169 let ok =
1170 mark_failed_with_retry(&conn, "job-1", "bad input", &FailureClass::Permanent).unwrap();
1171 assert!(ok);
1172
1173 let job = get_job(&conn, "job-1").unwrap().unwrap();
1174 assert_eq!(job.2, "failed"); }
1176
1177 #[test]
1178 fn test_retry_rate_limited() {
1179 let conn = setup();
1180 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1181 claim(&conn, "w1").unwrap();
1182
1183 let ok = mark_failed_with_retry(
1184 &conn,
1185 "job-1",
1186 "rate limited",
1187 &FailureClass::RateLimited {
1188 retry_after_secs: 60,
1189 },
1190 )
1191 .unwrap();
1192 assert!(ok);
1193
1194 let job = get_job(&conn, "job-1").unwrap().unwrap();
1195 assert_eq!(job.2, "pending"); let fc: Option<String> = conn
1199 .query_row(
1200 "SELECT failure_class FROM queue_jobs WHERE id = 'job-1'",
1201 [],
1202 |row| row.get(0),
1203 )
1204 .unwrap();
1205 assert_eq!(fc, Some("rate_limited".to_string()));
1206 }
1207
1208 #[test]
1209 fn test_retry_exhausted_remains_failed() {
1210 let conn = setup();
1211 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1212 claim(&conn, "w1").unwrap();
1213
1214 let ok = mark_failed_with_retry_owned(
1215 &conn,
1216 "job-1",
1217 Some("w1"),
1218 "still failing",
1219 &FailureClass::Transient,
1220 1,
1221 )
1222 .unwrap();
1223 assert!(ok);
1224
1225 let job = get_job(&conn, "job-1").unwrap().unwrap();
1226 assert_eq!(job.2, "failed");
1227 }
1228
1229 #[test]
1230 fn test_worker_mismatch_rejects_complete_and_fail() {
1231 let conn = setup();
1232 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1233 claim(&conn, "worker-a").unwrap();
1234
1235 assert!(!mark_completed_owned(&conn, "job-1", Some("worker-b")).unwrap());
1236 assert!(!mark_failed_owned(&conn, "job-1", Some("worker-b"), "wrong worker").unwrap());
1237
1238 let job = get_job(&conn, "job-1").unwrap().unwrap();
1239 assert_eq!(job.2, "processing");
1240 }
1241
1242 #[test]
1243 fn test_trace_id_column_and_details_round_trip() {
1244 let conn = setup();
1245 insert_job_with_trace(
1246 &conn,
1247 "job-1",
1248 2,
1249 &serde_json::json!({"task": "trace"}),
1250 Some("trace-001"),
1251 )
1252 .unwrap();
1253
1254 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1255 assert_eq!(details.trace_id.as_deref(), Some("trace-001"));
1256 assert_eq!(details.status, QueueJobStatus::Pending);
1257 }
1258
1259 #[test]
1260 fn test_invalid_transition_completed_to_processing() {
1261 let conn = setup();
1262 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1263 mark_processing(&conn, "job-1").unwrap();
1264 mark_completed(&conn, "job-1").unwrap();
1265
1266 assert!(!mark_processing(&conn, "job-1").unwrap());
1268 }
1269
1270 #[test]
1271 fn test_claim_skips_future_next_run() {
1272 let conn = setup();
1273 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1274
1275 let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
1277 conn.execute(
1278 "UPDATE queue_jobs SET next_run_at = ?1 WHERE id = 'job-1'",
1279 params![future],
1280 )
1281 .unwrap();
1282
1283 let claimed = claim(&conn, "w1").unwrap();
1285 assert!(claimed.is_none());
1286 }
1287
1288 #[test]
1291 fn test_insert_job_full_with_canonical_fields() {
1292 let conn = setup();
1293 insert_job_full(
1294 &conn,
1295 "job-1",
1296 2,
1297 &serde_json::json!({"task": "lineage"}),
1298 Some("trace-001"),
1299 Some("attempt-abc"),
1300 Some("trial-xyz"),
1301 )
1302 .unwrap();
1303
1304 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1305 assert_eq!(details.trace_id.as_deref(), Some("trace-001"));
1306 assert_eq!(details.attempt_id.as_deref(), Some("attempt-abc"));
1307 assert_eq!(details.trial_id.as_deref(), Some("trial-xyz"));
1308 }
1309
1310 #[test]
1311 fn test_insert_job_full_with_none_canonical_fields() {
1312 let conn = setup();
1313 insert_job_full(&conn, "job-1", 2, &serde_json::json!({}), None, None, None).unwrap();
1314
1315 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1316 assert!(details.attempt_id.is_none());
1317 assert!(details.trial_id.is_none());
1318 }
1319
1320 #[test]
1321 fn test_update_canonical_lineage() {
1322 let conn = setup();
1323 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1324
1325 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1327 assert!(details.attempt_id.is_none());
1328 assert!(details.trial_id.is_none());
1329
1330 let ok = update_canonical_lineage(&conn, "job-1", Some("att-1"), Some("trial-1")).unwrap();
1332 assert!(ok);
1333
1334 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1335 assert_eq!(details.attempt_id.as_deref(), Some("att-1"));
1336 assert_eq!(details.trial_id.as_deref(), Some("trial-1"));
1337 }
1338
1339 #[test]
1340 fn test_update_canonical_lineage_partial() {
1341 let conn = setup();
1342 insert_job_full(
1343 &conn,
1344 "job-1",
1345 2,
1346 &serde_json::json!({}),
1347 None,
1348 Some("att-original"),
1349 None,
1350 )
1351 .unwrap();
1352
1353 let ok = update_canonical_lineage(&conn, "job-1", None, Some("trial-new")).unwrap();
1355 assert!(ok);
1356
1357 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1358 assert_eq!(details.attempt_id.as_deref(), Some("att-original"));
1359 assert_eq!(details.trial_id.as_deref(), Some("trial-new"));
1360 }
1361
1362 #[test]
1363 fn test_update_canonical_lineage_nonexistent_job() {
1364 let conn = setup();
1365 let ok =
1366 update_canonical_lineage(&conn, "nonexistent", Some("att-1"), Some("trial-1")).unwrap();
1367 assert!(!ok);
1368 }
1369
1370 #[test]
1371 fn test_canonical_fields_survive_retry_cycle() {
1372 let conn = setup();
1373 insert_job_full(
1374 &conn,
1375 "job-1",
1376 2,
1377 &serde_json::json!({}),
1378 None,
1379 Some("att-1"),
1380 Some("trial-1"),
1381 )
1382 .unwrap();
1383
1384 claim(&conn, "w1").unwrap();
1386
1387 mark_failed_with_retry(&conn, "job-1", "timeout", &FailureClass::Transient).unwrap();
1389
1390 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1392 assert_eq!(details.attempt_id.as_deref(), Some("att-1"));
1393 assert_eq!(details.trial_id.as_deref(), Some("trial-1"));
1394 assert_eq!(details.status, QueueJobStatus::Pending);
1395 }
1396
1397 #[test]
1398 fn test_legacy_insert_without_canonical_fields() {
1399 let conn = setup();
1400 insert_job_with_trace(
1402 &conn,
1403 "job-1",
1404 2,
1405 &serde_json::json!({}),
1406 Some("trace-legacy"),
1407 )
1408 .unwrap();
1409
1410 let details = get_job_details(&conn, "job-1").unwrap().unwrap();
1411 assert_eq!(details.trace_id.as_deref(), Some("trace-legacy"));
1412 assert!(details.attempt_id.is_none());
1414 assert!(details.trial_id.is_none());
1415 }
1416
1417 #[test]
1418 fn test_cancel_completed_returns_current_status() {
1419 let conn = setup();
1420 insert_job(&conn, "job-1", 2, &serde_json::json!({})).unwrap();
1421 mark_processing(&conn, "job-1").unwrap();
1422 mark_completed(&conn, "job-1").unwrap();
1423
1424 let result = cancel_job(&conn, "job-1");
1425 assert!(result.is_err());
1426 let err_msg = result.unwrap_err().to_string();
1427 assert!(
1428 err_msg.contains("completed"),
1429 "Error should mention current status, got: {}",
1430 err_msg
1431 );
1432 }
1433}