OXYGENHUB

Coverage / crates/hub/src/db/queue.rs 100.0%

62 of 62 lines run, 10 of 20 functions · run #520 on main · run missed

1
//! The durable work queue (oxygenhub#276, docs/roles.md): items any worker
2
//! process claims for a lease, retried with backoff, dead after the last
3
//! attempt. On PostgreSQL a claim skips rows another worker has locked
4
//! (`FOR UPDATE SKIP LOCKED`); on SQLite the one writer serialises claims.
5
6
use super::{now_ms, Pool};
7
use sqlx::FromRow;
8
9
/// Attempts before an item is kept as dead.
10
pub const MAX_ATTEMPTS: i64 = 8;
11
12
#[derive(Clone, Debug, FromRow)]
13
pub struct Item {
14
    pub id: i64,
15
    pub kind: String,
16
    pub payload: String,
17
    pub dedupe: String,
18
    pub run_after: i64,
19
    pub attempts: i64,
20
    pub last_error: String,
21
    pub claimed_by: String,
22
    pub claimed_until: i64,
23
    pub dead: bool,
24
    pub created_ms: i64,
25
}
26
27
/// Queue `kind` with its payload, due after `delay_ms`. With a `dedupe`
28
/// key, nothing is added while a live item of that kind and key waits.
294×
pub async fn enqueue(pool: &Pool, kind: &str, payload: &serde_json::Value, dedupe: &str, delay_ms: i64) -> sqlx::Result<bool> {
304×
    let now = now_ms();
314×
    let mut tx = super::begin_write(pool).await?;
324×
    if !dedupe.is_empty() {
333×
        let live: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM work_queue WHERE kind = $1 AND dedupe = $2 AND dead = $3")
343×
            .bind(kind)
353×
            .bind(dedupe)
363×
            .bind(false)
373×
            .fetch_one(&mut *tx)
383×
            .await?;
393×
        if live > 0 {
401×
            return Ok(false);
412×
        }
421×
    }
433×
    sqlx::query("INSERT INTO work_queue (kind, payload, dedupe, run_after, created_ms) VALUES ($1, $2, $3, $4, $5)")
443×
        .bind(kind)
453×
        .bind(payload.to_string())
463×
        .bind(dedupe)
473×
        .bind(now + delay_ms.max(0))
483×
        .bind(now)
493×
        .execute(&mut *tx)
503×
        .await?;
513×
    tx.commit().await?;
523×
    Ok(true)
534×
}
54
55
/// Claim up to `limit` due items for `worker`, each for `lease_ms`: an item
56
/// whose worker died is due again when its lease ends.
57254×
pub async fn claim(pool: &Pool, worker: &str, limit: i64, lease_ms: i64) -> sqlx::Result<Vec<Item>> {
58254×
    let now = now_ms();
59
    #[cfg(feature = "postgres")]
60
    let pick = "SELECT id FROM work_queue WHERE dead = $3 AND run_after <= $4 AND claimed_until <= $4 ORDER BY run_after, id LIMIT $5 FOR UPDATE SKIP LOCKED";
61
    #[cfg(not(feature = "postgres"))]
62254×
    let pick = "SELECT id FROM work_queue WHERE dead = $3 AND run_after <= $4 AND claimed_until <= $4 ORDER BY run_after, id LIMIT $5";
63254×
    let sql =
64254×
        format!("UPDATE work_queue SET claimed_by = $1, claimed_until = $2, attempts = attempts + 1 WHERE id IN ({pick}) RETURNING *");
65254×
    let mut tx = pool.begin().await?;
66254×
    let items: Vec<Item> =
67254×
        sqlx::query_as(&sql).bind(worker).bind(now + lease_ms).bind(false).bind(now).bind(limit).fetch_all(&mut *tx).await?;
68254×
    tx.commit().await?;
69254×
    Ok(items)
70254×
}
71
72
/// The item is done: it leaves the queue.
731×
pub async fn done(pool: &Pool, id: i64) -> sqlx::Result<()> {
741×
    sqlx::query("DELETE FROM work_queue WHERE id = $1").bind(id).execute(pool).await?;
751×
    Ok(())
761×
}
77
78
/// The item failed: due again after a backoff that doubles each attempt
79
/// (30 s, 1 min, 2 min, … at most an hour), or dead after the last.
803×
pub async fn failed(pool: &Pool, item: &Item, error: &str) -> sqlx::Result<()> {
813×
    let dead = item.attempts >= MAX_ATTEMPTS;
823×
    let backoff = (30_000i64 << (item.attempts.clamp(1, 8) - 1)).min(3_600_000);
833×
    sqlx::query("UPDATE work_queue SET last_error = $1, dead = $2, run_after = $3, claimed_by = '', claimed_until = 0 WHERE id = $4")
843×
        .bind(error)
853×
        .bind(dead)
863×
        .bind(now_ms() + backoff)
873×
        .bind(item.id)
883×
        .execute(pool)
893×
        .await?;
903×
    Ok(())
913×
}
92
93
/// Items waiting (claimed or not) and items dead.
947×
pub async fn counts(pool: &Pool) -> sqlx::Result<(i64, i64)> {
957×
    let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM work_queue WHERE dead = $1").bind(false).fetch_one(pool).await?;
967×
    let dead: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM work_queue WHERE dead = $1").bind(true).fetch_one(pool).await?;
977×
    Ok((waiting, dead))
987×
}