Skip to main content

agent_queue/
db.rs

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
30/// Current schema version (bumped with each migration).
31const SCHEMA_VERSION: u32 = 4;
32
33/// Open (or create) the queue database. Pass `None` for an in-memory database.
34pub 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
55/// Run schema migrations. Idempotent — safe to call multiple times.
56fn 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        // V2 adds lifecycle columns. ALTER TABLE ADD COLUMN is a no-op if the
63        // column already exists (which it will for fresh databases using the
64        // updated CREATE TABLE). We catch "duplicate column" errors silently.
65        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        // V4: canonical retry lineage columns
92        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
111/// Insert a new job into the queue.
112pub 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
116/// Insert a new job into the queue with an optional trace ID.
117pub 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
127/// Insert a new job with full canonical retry lineage.
128pub 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
153/// Get the next pending job (highest priority, oldest first).
154/// Returns the job ID and its data as a JSON value.
155///
156/// **Note:** Prefer [`claim_next_job`] for executor use — it atomically
157/// selects and marks the job as processing in a single transaction,
158/// preventing race conditions when multiple executors share a database.
159pub 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
182/// Atomically claim the next pending job by selecting and marking it as
183/// processing in a single transaction. Returns the job ID and its data,
184/// or `None` if no pending jobs are available.
185///
186/// This prevents race conditions where two executors could claim the same job.
187pub 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        // Another executor claimed it between SELECT and UPDATE
232        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
239/// Atomically claim the next eligible job for a specific worker.
240///
241/// Like [`claim_next_job`] but records the `worker_id` and sets `heartbeat_at`.
242/// Only considers jobs whose `next_run_at` is `NULL` or in the past.
243pub fn claim(conn: &Connection, worker_id: &str) -> Result<Option<(String, Value)>> {
244    claim_with_lease(conn, worker_id, 300)
245}
246
247/// Claim the next eligible job for a worker and record the configured lease timeout.
248pub 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
308/// Update the heartbeat timestamp for a processing job.
309///
310/// Returns `true` if the heartbeat was recorded, `false` if the job is no
311/// longer processing or does not belong to this worker.
312pub 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
324/// Reclaim jobs that have been processing but have not sent a heartbeat
325/// within `stale_secs` seconds. Resets them to `pending` with `worker_id`
326/// cleared. Returns the number of jobs reclaimed.
327pub 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
343/// Mark a failed job for retry with backoff. Sets `next_run_at` based on
344/// the failure class and attempt count, then resets status to `pending`.
345pub 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
354/// Mark a failed job for retry or permanent failure while validating worker ownership.
355pub 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            // Exponential backoff: 2^(attempt-1) * 5 seconds, capped at 5 minutes
396            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
425/// Mark a job as processing and set started_at.
426///
427/// Only transitions jobs that are currently `pending`. Returns `true` if the
428/// update was applied, `false` if the job was no longer pending (e.g., cancelled).
429///
430/// **Note:** Prefer [`claim_next_job`] or [`claim`] for executor use — they
431/// atomically select and mark the job as processing in a single transaction.
432pub 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
444/// Mark a job as completed and set completed_at.
445///
446/// Only transitions jobs that are currently `processing`. Returns `true` if the
447/// update was applied, `false` if the job's status had already changed (e.g., cancelled).
448pub fn mark_completed(conn: &Connection, job_id: &str) -> Result<bool> {
449    mark_completed_owned(conn, job_id, None)
450}
451
452/// Mark a job as completed, optionally validating worker ownership.
453pub 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
474/// Mark a job as failed with an error message and set completed_at.
475///
476/// Only transitions jobs that are currently `processing`. Returns `true` if the
477/// update was applied, `false` if the job's status had already changed (e.g., cancelled).
478pub fn mark_failed(conn: &Connection, job_id: &str, error: &str) -> Result<bool> {
479    mark_failed_owned(conn, job_id, None, error)
480}
481
482/// Mark a job as failed, optionally validating worker ownership.
483pub 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
505/// Check if a job has been cancelled (used by executor during execution).
506pub 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
517/// Cancel a pending or processing job. Returns the previous status.
518pub fn cancel_job(conn: &Connection, job_id: &str) -> Result<String> {
519    let now = chrono::Utc::now().to_rfc3339();
520
521    // Read the current status first for the return value
522    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    // Atomically update only if still cancellable
531    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        // Re-read to get accurate current status for error message
541        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
558/// Re-queue any jobs that were mid-processing when the app crashed.
559/// Returns the number of jobs requeued.
560pub 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
570/// Update the priority of a job.
571pub 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
580/// Atomically update the priority of a pending job.
581///
582/// Returns `Ok(true)` if the job was updated, `Ok(false)` if the job exists but
583/// is not pending, or `Err` with a not-found message if the job doesn't exist.
584pub 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    // Distinguish "not found" from "not pending"
597    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
612/// List all jobs ordered by status then priority then creation time.
613/// Returns tuples of (id, status, data_json).
614pub 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
642/// Delete completed/failed/cancelled jobs older than the specified number of days.
643/// Returns the number of jobs deleted.
644pub 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
660/// Count jobs by status.
661pub 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
691/// Row data for a single job.
692pub type JobRow = (String, i32, String, String, Option<String>);
693
694/// Get the recorded attempt count for a job.
695pub 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
704/// Get a single job by ID. Returns (id, priority, status, data_json, error_message).
705pub 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
728/// Get a structured view of a job for debugging and UI inspection.
729pub 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
767/// Persist canonical retry lineage fields for a job.
768///
769/// Updates `attempt_id` and/or `trial_id` on an existing job row.
770/// Returns `true` if the row was updated, `false` if the job was not found.
771pub 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        // No more pending jobs
836        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 the job first
844        cancel_job(&conn, "job-1").unwrap();
845        // Trying to mark a cancelled job as processing should be a no-op
846        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        // Should be a no-op because job is cancelled, not processing
867        assert!(!mark_completed(&conn, "job-1").unwrap());
868        // Status should still be cancelled
869        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        // Should be a no-op because job is cancelled, not processing
892        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        // All pending, so ordered by priority: b(1), a(2), c(3)
963        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        // Job completed just now -- pruning with 30 days should NOT remove it
976        let count = prune_old_jobs(&conn, 30).unwrap();
977        assert_eq!(count, 0);
978
979        // Set completed_at to 10 days ago manually
980        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        // Pruning with 5 days should remove it (10 days old > 5 day cutoff)
988        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        // Should claim the highest priority job
1006        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        // job-2 should now be processing
1013        let job = get_job(&conn, "job-2").unwrap().unwrap();
1014        assert_eq!(job.2, "processing");
1015
1016        // Next claim should get job-1
1017        let claimed2 = claim_next_job(&conn).unwrap();
1018        assert!(claimed2.is_some());
1019        assert_eq!(claimed2.unwrap().0, "job-1");
1020
1021        // No more pending
1022        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        // Cancelled job should not be claimed
1038        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        // Reorder a pending job — should succeed
1047        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        // Job is processing, not pending
1059        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    // ── VG-6 new tests ──────────────────────────────────────────
1069
1070    #[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        // Verify worker_id is recorded
1081        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        // Heartbeat from correct worker
1098        assert!(heartbeat(&conn, "job-1", "w1").unwrap());
1099        // Heartbeat from wrong worker
1100        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        // Set heartbeat to 10 minutes ago
1110        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        // Reclaim jobs stale for > 5 minutes (300 seconds)
1118        let count = reclaim_stale(&conn, 300).unwrap();
1119        assert_eq!(count, 1);
1120
1121        // Job should be pending again
1122        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        // Jobs with fresh heartbeat should NOT be reclaimed
1134        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        // Fail with transient error → should be requeued with next_run_at
1145        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"); // re-queued, not failed
1151
1152        // next_run_at should be set
1153        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"); // NOT re-queued
1175    }
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"); // re-queued
1196
1197        // failure_class should be recorded
1198        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        // Trying to mark a completed job as processing should return false
1267        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        // Set next_run_at to 1 hour in the future
1276        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        // claim() should skip this job
1284        let claimed = claim(&conn, "w1").unwrap();
1285        assert!(claimed.is_none());
1286    }
1287
1288    // ── V4 canonical retry lineage tests ──────────────────────────
1289
1290    #[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        // Initially NULL
1326        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        // Update both
1331        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        // Update only trial_id — attempt_id should remain
1354        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 the job (simulates executor picking it up)
1385        claim(&conn, "w1").unwrap();
1386
1387        // Fail with transient error → requeued
1388        mark_failed_with_retry(&conn, "job-1", "timeout", &FailureClass::Transient).unwrap();
1389
1390        // Canonical fields should survive the retry cycle
1391        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        // Use the legacy insert path (no canonical fields)
1401        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        // canonical fields should be None for legacy inserts
1413        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}