OXYGENHUB

A durable work queue, first slice (refs #276) dd327d76062d

work_queue (migration 0091) and db::queue: enqueue with a dedupe key,
claim a leased batch (FOR UPDATE SKIP LOCKED on PostgreSQL, under the
write lock on SQLite), done, failed with backoff from 30 s to an hour, dead
after eight attempts. Every worker runs queue.worker. First tenants, each
tried at once and queued only on failure: a notification's email, and the
Git mirror export after a push.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ae4947279dff
When
· 2026-10-21 01:32 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
dd327d76062d8685bec98f4eb43ae719b4e1c55f8ffbcc87b3c4da72dda04705
Depends on
0ee5c3fb32eb 145306044896 18e381ea88c2 19bb31ff1f5c 356183b71fab 3b1bc27fe805 3f4b0ea6eabf 6b195ed3b637 a7e6e8e6094c b5668108487f c5f3bde466d1 cb7b3cd45dc0 d23464c0ac60 e57765dee61a
Depended on by
439ecd8708a1 9e8cbfc3ad00
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
28 atoms, 4 tree operations, 14 files

Browse the tree at this change

added crates/hub/migrations-pg/0091_work_queue.sql +18 −0
1 +
-- A durable work queue (oxygenhub#276): work that must not be lost on a
2 +
-- restart or a failure, claimed by any worker process (SKIP LOCKED on
3 +
-- PostgreSQL), retried with backoff, kept as dead after its last attempt.
4 +
CREATE TABLE work_queue (
5 +
    id            BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
6 +
    kind          TEXT NOT NULL,
7 +
    payload       TEXT NOT NULL DEFAULT '{}',
8 +
    -- One live item per kind and key: a second enqueue of the same is dropped.
9 +
    dedupe        TEXT NOT NULL DEFAULT '',
10 +
    run_after     BIGINT NOT NULL,
11 +
    attempts      BIGINT NOT NULL DEFAULT 0,
12 +
    last_error    TEXT NOT NULL DEFAULT '',
13 +
    claimed_by    TEXT NOT NULL DEFAULT '',
14 +
    claimed_until BIGINT NOT NULL DEFAULT 0,
15 +
    dead          BOOLEAN NOT NULL DEFAULT FALSE,
16 +
    created_ms    BIGINT NOT NULL
17 +
);
18 +
CREATE INDEX work_queue_due ON work_queue (dead, run_after);
added crates/hub/migrations/0091_work_queue.sql +18 −0
1 +
-- A durable work queue (oxygenhub#276): work that must not be lost on a
2 +
-- restart or a failure, claimed by any worker process (SKIP LOCKED on
3 +
-- PostgreSQL), retried with backoff, kept as dead after its last attempt.
4 +
CREATE TABLE work_queue (
5 +
    id            INTEGER PRIMARY KEY,
6 +
    kind          TEXT NOT NULL,
7 +
    payload       TEXT NOT NULL DEFAULT '{}',
8 +
    -- One live item per kind and key: a second enqueue of the same is dropped.
9 +
    dedupe        TEXT NOT NULL DEFAULT '',
10 +
    run_after     INTEGER NOT NULL,
11 +
    attempts      INTEGER NOT NULL DEFAULT 0,
12 +
    last_error    TEXT NOT NULL DEFAULT '',
13 +
    claimed_by    TEXT NOT NULL DEFAULT '',
14 +
    claimed_until INTEGER NOT NULL DEFAULT 0,
15 +
    dead          INTEGER NOT NULL DEFAULT 0,
16 +
    created_ms    INTEGER NOT NULL
17 +
);
18 +
CREATE INDEX work_queue_due ON work_queue (dead, run_after);
added crates/hub/src/db/queue.rs +98 −0
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.
29 +
pub async fn enqueue(pool: &Pool, kind: &str, payload: &serde_json::Value, dedupe: &str, delay_ms: i64) -> sqlx::Result<bool> {
30 +
    let now = now_ms();
31 +
    let mut tx = super::begin_write(pool).await?;
32 +
    if !dedupe.is_empty() {
33 +
        let live: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM work_queue WHERE kind = $1 AND dedupe = $2 AND dead = $3")
34 +
            .bind(kind)
35 +
            .bind(dedupe)
36 +
            .bind(false)
37 +
            .fetch_one(&mut *tx)
38 +
            .await?;
39 +
        if live > 0 {
40 +
            return Ok(false);
41 +
        }
42 +
    }
43 +
    sqlx::query("INSERT INTO work_queue (kind, payload, dedupe, run_after, created_ms) VALUES ($1, $2, $3, $4, $5)")
44 +
        .bind(kind)
45 +
        .bind(payload.to_string())
46 +
        .bind(dedupe)
47 +
        .bind(now + delay_ms.max(0))
48 +
        .bind(now)
49 +
        .execute(&mut *tx)
50 +
        .await?;
51 +
    tx.commit().await?;
52 +
    Ok(true)
53 +
}
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.
57 +
pub async fn claim(pool: &Pool, worker: &str, limit: i64, lease_ms: i64) -> sqlx::Result<Vec<Item>> {
58 +
    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"))]
62 +
    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";
63 +
    let sql =
64 +
        format!("UPDATE work_queue SET claimed_by = $1, claimed_until = $2, attempts = attempts + 1 WHERE id IN ({pick}) RETURNING *");
65 +
    let mut tx = pool.begin().await?;
66 +
    let items: Vec<Item> =
67 +
        sqlx::query_as(&sql).bind(worker).bind(now + lease_ms).bind(false).bind(now).bind(limit).fetch_all(&mut *tx).await?;
68 +
    tx.commit().await?;
69 +
    Ok(items)
70 +
}
71 +
72 +
/// The item is done: it leaves the queue.
73 +
pub async fn done(pool: &Pool, id: i64) -> sqlx::Result<()> {
74 +
    sqlx::query("DELETE FROM work_queue WHERE id = $1").bind(id).execute(pool).await?;
75 +
    Ok(())
76 +
}
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.
80 +
pub async fn failed(pool: &Pool, item: &Item, error: &str) -> sqlx::Result<()> {
81 +
    let dead = item.attempts >= MAX_ATTEMPTS;
82 +
    let backoff = (30_000i64 << (item.attempts.clamp(1, 8) - 1)).min(3_600_000);
83 +
    sqlx::query("UPDATE work_queue SET last_error = $1, dead = $2, run_after = $3, claimed_by = '', claimed_until = 0 WHERE id = $4")
84 +
        .bind(error)
85 +
        .bind(dead)
86 +
        .bind(now_ms() + backoff)
87 +
        .bind(item.id)
88 +
        .execute(pool)
89 +
        .await?;
90 +
    Ok(())
91 +
}
92 +
93 +
/// Items waiting (claimed or not) and items dead.
94 +
pub async fn counts(pool: &Pool) -> sqlx::Result<(i64, i64)> {
95 +
    let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM work_queue WHERE dead = $1").bind(false).fetch_one(pool).await?;
96 +
    let dead: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM work_queue WHERE dead = $1").bind(true).fetch_one(pool).await?;
97 +
    Ok((waiting, dead))
98 +
}
added crates/hub/src/queue.rs +121 −0
1 +
//! The worker side of the durable work queue (oxygenhub#276,
2 +
//! docs/roles.md): every worker process takes due items, a batch at a
3 +
//! time, and does them. Work that fails where it starts (a notification's
4 +
//! email, the Git mirror after a push) is queued here instead of being
5 +
//! lost, and retried with backoff.
6 +
7 +
use crate::db::{self, queue::Item};
8 +
use crate::Shared;
9 +
use std::time::Duration;
10 +
11 +
/// How often an idle worker looks for due items.
12 +
pub const POLL: Duration = Duration::from_secs(2);
13 +
/// How long a claim lasts before another worker may take the item.
14 +
const LEASE_MS: i64 = 5 * 60 * 1000;
15 +
/// Items taken at once.
16 +
const BATCH: i64 = 10;
17 +
18 +
/// An email to send again (`mail`).
19 +
pub async fn queue_mail(
20 +
    app: &Shared,
21 +
    to_name: &str,
22 +
    to_addr: &str,
23 +
    subject: &str,
24 +
    body: &str,
25 +
    action: Option<(&str, &str)>,
26 +
    unsubscribe: Option<&str>,
27 +
) {
28 +
    let payload = serde_json::json!({
29 +
        "to_name": to_name, "to_addr": to_addr, "subject": subject, "body": body,
30 +
        "action_label": action.map(|a| a.0), "action_url": action.map(|a| a.1), "unsubscribe": unsubscribe,
31 +
    });
32 +
    if let Err(e) = db::queue::enqueue(&app.db, "mail", &payload, "", 30_000).await {
33 +
        tracing::warn!("queueing an email to {to_addr}: {e}");
34 +
    }
35 +
}
36 +
37 +
/// A repository's Git mirror to refresh again (`mirror.refresh`), once.
38 +
pub async fn queue_mirror(app: &Shared, repo_id: i64) {
39 +
    let payload = serde_json::json!({ "repo_id": repo_id });
40 +
    if let Err(e) = db::queue::enqueue(&app.db, "mirror.refresh", &payload, &repo_id.to_string(), 30_000).await {
41 +
        tracing::warn!("queueing a Git mirror refresh: {e}");
42 +
    }
43 +
}
44 +
45 +
/// This process's name as a claimant: its host and pid.
46 +
fn worker_name() -> String {
47 +
    let host = std::fs::read_to_string("/etc/hostname").map(|h| h.trim().to_string()).unwrap_or_default();
48 +
    format!("{host}:{}", std::process::id())
49 +
}
50 +
51 +
/// Take and do due items until the process stops.
52 +
pub async fn worker(app: Shared) {
53 +
    let me = worker_name();
54 +
    loop {
55 +
        let items = match db::queue::claim(&app.db, &me, BATCH, LEASE_MS).await {
56 +
            Ok(items) => items,
57 +
            Err(e) => {
58 +
                tracing::warn!("work queue: claiming: {e}");
59 +
                Vec::new()
60 +
            }
61 +
        };
62 +
        let idle = items.is_empty();
63 +
        for item in items {
64 +
            run(&app, item).await;
65 +
        }
66 +
        if idle {
67 +
            tokio::time::sleep(POLL).await;
68 +
        }
69 +
    }
70 +
}
71 +
72 +
/// Do one item and record how it went.
73 +
pub async fn run(app: &Shared, item: Item) {
74 +
    let outcome = match item.kind.as_str() {
75 +
        "mail" => mail(app, &item).await,
76 +
        "mirror.refresh" => mirror(app, &item).await,
77 +
        other => Err(format!("no worker knows {other:?}")),
78 +
    };
79 +
    let recorded = match outcome {
80 +
        Ok(()) => db::queue::done(&app.db, item.id).await,
81 +
        Err(e) => {
82 +
            tracing::warn!(kind = %item.kind, attempt = item.attempts, "work queue: {e}");
83 +
            // An item nobody can do is dead at once.
84 +
            let item = if item.kind != "mail" && item.kind != "mirror.refresh" {
85 +
                Item { attempts: db::queue::MAX_ATTEMPTS, ..item }
86 +
            } else {
87 +
                item
88 +
            };
89 +
            db::queue::failed(&app.db, &item, &e).await
90 +
        }
91 +
    };
92 +
    if let Err(e) = recorded {
93 +
        tracing::warn!("work queue: recording an outcome: {e}");
94 +
    }
95 +
}
96 +
97 +
async fn mail(app: &Shared, item: &Item) -> Result<(), String> {
98 +
    let p: serde_json::Value = serde_json::from_str(&item.payload).map_err(|e| e.to_string())?;
99 +
    let s = |k: &str| p[k].as_str().unwrap_or_default().to_string();
100 +
    let (label, url) = (s("action_label"), s("action_url"));
101 +
    let action = (!url.is_empty()).then_some(crate::mail::Action { label: &label, url: &url });
102 +
    let unsubscribe = s("unsubscribe");
103 +
    crate::mail::send_letter(
104 +
        app,
105 +
        &s("to_name"),
106 +
        &s("to_addr"),
107 +
        &s("subject"),
108 +
        &s("body"),
109 +
        action,
110 +
        (!unsubscribe.is_empty()).then_some(unsubscribe.as_str()),
111 +
    )
112 +
    .await
113 +
}
114 +
115 +
async fn mirror(app: &Shared, item: &Item) -> Result<(), String> {
116 +
    let p: serde_json::Value = serde_json::from_str(&item.payload).map_err(|e| e.to_string())?;
117 +
    let id = p["repo_id"].as_i64().ok_or("no repo_id")?;
118 +
    // Gone, or its mirror turned off meanwhile: nothing to do.
119 +
    let Some(row) = db::repos::get_by_id(&app.db, id).await.map_err(|e| e.to_string())? else { return Ok(()) };
120 +
    crate::mirror::refresh(app, &row).await
121 +
}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A durable work queue (#276):** work that fails where it starts is queued and retried instead of lost: a notification's email the mail server refused, and a Git mirror export after a push. Any worker process takes due items (on PostgreSQL several at once, each skipping what another holds), retries back off from 30 seconds to an hour, and an item still failing after eight attempts is kept as dead.
7 8
- **One auto-land and one Diffusion bump at a time across hub processes (#275):** auto-land's check of a proposal, and Diffusion's bump and landing of a consumer's pin, were serialised only within a process, so two web processes could land one proposal twice. Each now holds a lock named for the proposal or the pin across every process sharing the database (a PostgreSQL advisory lock; on SQLite, one process, as before).
8 9
- **Several web processes can share a data directory (#274):** the transparency log took appends from one process only, so only one process per data directory could have the web role. Each append now holds a lock in the log's directory and starts from the log as the files hold it, readers see every process's entries, and every process serves the newest checkpoint, whichever signed it.
9 10
- **A push reuses a job's pass on the same state (#265):** when main reaches a state a proposal's run already tested (landing it), a job that passed there with the same spec and sandbox is not run again: it shows passed, reused from that run, with its receipt, and its check says so. Jobs with an approval gate, an environment, secrets or identity tokens, or whose artifacts another job takes, always run. Maintainers can turn reuse off under Settings › Runs.
…
276 unchanged lines
modified crates/hub/src/db/mod.rs +1 −0
…
10 unchanged lines
11 11
pub mod projects;
12 12
pub mod proposals;
13 13
pub mod push_protection;
14 +
pub mod queue;
14 15
pub mod releases;
15 16
pub mod repos;
16 17
pub mod runners;
…
153 unchanged lines
modified crates/hub/src/lib.rs +4 −0
…
39 unchanged lines
40 40
pub mod protocol;
41 41
pub mod provenance;
42 42
pub mod push_protection;
43 +
pub mod queue;
43 44
pub mod render;
44 45
pub mod reports;
45 46
pub mod repos;
…
161 unchanged lines
207 208
    // Each repository's history scanned for secrets (#277), a budget at a
208 209
    // time, at low priority.
209 210
    leader::run(app, "secrets.history_scan", history_scan::worker);
211 +
    // The durable work queue (#276): every worker takes its due items.
212 +
    app.leader.note("queue.worker");
213 +
    tokio::spawn(queue::worker(app.clone()));
210 214
    // What each image is for, read or learned once (oxygenhub#173): safe
211 215
    // in every worker, so it takes no lock.
212 216
    app.leader.note("images.learn_archs");
…
209 unchanged lines
modified crates/hub/src/mirror.rs +9 −5
…
18 unchanged lines
19 19
}
20 20
21 21
/// Export the default bookmark into the mirror, if the mirror is on.
22 -
pub async fn refresh(app: &Shared, row: &RepoRow) {
22 +
pub async fn refresh(app: &Shared, row: &RepoRow) -> Result<(), String> {
23 23
    if !row.git_mirror {
24 -
        return;
24 +
        return Ok(());
25 25
    }
26 -
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
26 +
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return Ok(()) };
27 27
    let dir = mirror_dir(app, &repo);
28 28
    let bookmark = row.default_bookmark.clone();
29 29
    let result = app
…
11 unchanged lines
41 41
    match result {
42 42
        Ok((_, state)) => {
43 43
            db::repos::set_git_mirror_state(&app.db, row.id, &state).await.ok();
44 +
            Ok(())
44 45
        }
45 -
        Err(e) => tracing::warn!("git mirror of {}: {e}", row.slug()),
46 +
        Err(e) => {
47 +
            tracing::warn!("git mirror of {}: {e}", row.slug());
48 +
            Err(format!("git mirror of {}: {e}", row.slug()))
49 +
        }
46 50
    }
47 51
}
48 52
…
70 unchanged lines
119 123
    }
120 124
    let dir = mirror_dir(&app, &repo);
121 125
    if !dir.join("HEAD").exists() {
122 -
        refresh(&app, &row).await;
126 +
        refresh(&app, &row).await.ok();
123 127
    }
124 128
    let root = app.config.data.join("mirrors");
125 129
    let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into());
…
70 unchanged lines
modified crates/hub/src/notify/mod.rs +4 −0
…
101 unchanged lines
102 102
    let action = crate::mail::Action { label: "Open it", url: &link };
103 103
    if let Err(e) = crate::mail::send_letter(app, to.name(), &to.email, title, &body, Some(action), Some(&unsubscribe)).await {
104 104
        tracing::warn!("email to {}: {e}", to.email);
105 +
        // Sent again from the work queue (#276), when mail is on at all.
106 +
        if crate::mail::enabled(app) {
107 +
            crate::queue::queue_mail(app, to.name(), &to.email, &subject, &body, Some(("Open it", &link)), Some(&unsubscribe)).await;
108 +
        }
105 109
    }
106 110
}
107 111
…
10 unchanged lines
modified crates/hub/src/protocol.rs +4 −1
…
564 unchanged lines
565 565
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
566 566
                crate::web::issues::references_from_messages(&app, &row, &imported, &on, &actor).await;
567 567
                if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) {
568 -
                    crate::mirror::refresh(&app, &row).await;
568 +
                    // Failed, it is done again from the work queue (#276).
569 +
                    if crate::mirror::refresh(&app, &row).await.is_err() {
570 +
                        crate::queue::queue_mirror(&app, row.id).await;
571 +
                    }
569 572
                }
570 573
                let accepted: Vec<serde_json::Value> = results
571 574
                    .iter()
…
78 unchanged lines
modified crates/hub/src/web/edit.rs +1 −1
…
269 unchanged lines
270 270
    crate::flow::on_push(app, row, &done.bookmark, &done.new_state, &done.heads, &account.username).await;
271 271
    if done.bookmark == row.default_bookmark {
272 272
        super::issues::close_from_messages(app, row, &[(done.change.to_hex(), message.to_string())]).await;
273 -
        crate::mirror::refresh(app, row).await;
273 +
        crate::mirror::refresh(app, row).await.ok();
274 274
    }
275 275
    accounts::audit(
276 276
        &app.db,
…
8 unchanged lines
modified crates/hub/src/web/repo_settings.rs +1 −1
…
1512 unchanged lines
1513 1513
    db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1514 1514
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
1515 1515
    if row.git_mirror {
1516 -
        crate::mirror::refresh(&app, &row).await;
1516 +
        crate::mirror::refresh(&app, &row).await.ok();
1517 1517
    }
1518 1518
    accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" })
1519 1519
        .await
…
126 unchanged lines
modified crates/hub/tests/roles.rs +37 −2
…
93 unchanged lines
94 94
    cfg.server.metrics_token = "m".into();
95 95
    let app = oxygenhub::build(cfg).await.unwrap();
96 96
    let mut tasks = WORKER_SINGLETONS.to_vec();
97 -
    tasks.extend(["diffusion.backfill", "images.learn_archs"]);
97 +
    tasks.extend(["diffusion.backfill", "images.learn_archs", "queue.worker"]);
98 98
    tasks.sort();
99 99
    assert_eq!(app.leader.tasks(), tasks, "every worker task, and not the log's checkpointer");
100 100
    assert!(
…
37 unchanged lines
138 138
    assert!(tokio::time::timeout(Duration::from_secs(5), rx).await.is_ok(), "and takes it once it is let go");
139 139
}
140 140
141 +
/// oxygenhub#276: the work queue. A keyed item is queued once; a claim
142 +
/// leases it (another claim skips it); a failure backs off and the last
143 +
/// attempt leaves it dead; done removes it. An item no worker knows is dead
144 +
/// at once.
141 145
#[tokio::test(flavor = "multi_thread")]
146 +
async fn the_work_queue_leases_retries_and_keeps_the_dead() {
147 +
    use oxygenhub::db::queue;
148 +
    hermetic();
149 +
    let dir = tempfile::tempdir().unwrap();
150 +
    let app = oxygenhub::build(config(dir.path(), &["web"])).await.unwrap();
151 +
    let payload = serde_json::json!({ "repo_id": 7 });
152 +
    assert!(queue::enqueue(&app.db, "mirror.refresh", &payload, "7", 0).await.unwrap());
153 +
    assert!(!queue::enqueue(&app.db, "mirror.refresh", &payload, "7", 0).await.unwrap(), "once per key");
154 +
    let got = queue::claim(&app.db, "a", 10, 60_000).await.unwrap();
155 +
    assert_eq!((got.len(), got[0].attempts, got[0].claimed_by.as_str()), (1, 1, "a"));
156 +
    assert!(queue::claim(&app.db, "b", 10, 60_000).await.unwrap().is_empty(), "leased to a");
157 +
    queue::failed(&app.db, &got[0], "the disk is full").await.unwrap();
158 +
    assert!(queue::claim(&app.db, "b", 10, 60_000).await.unwrap().is_empty(), "backing off");
159 +
    sqlx::query("UPDATE work_queue SET run_after = 0, attempts = $1").bind(queue::MAX_ATTEMPTS - 1).execute(&app.db).await.unwrap();
160 +
    let last = queue::claim(&app.db, "b", 10, 60_000).await.unwrap();
161 +
    assert_eq!(last[0].attempts, queue::MAX_ATTEMPTS);
162 +
    queue::failed(&app.db, &last[0], "still full").await.unwrap();
163 +
    assert_eq!(queue::counts(&app.db).await.unwrap(), (0, 1), "dead after the last attempt");
164 +
    // A dead item does not hold its key: the same work queues again.
165 +
    assert!(queue::enqueue(&app.db, "mirror.refresh", &payload, "7", 0).await.unwrap());
166 +
    let item = queue::claim(&app.db, "a", 10, 60_000).await.unwrap().remove(0);
167 +
    // Repository 7 does not exist: nothing to refresh, so it is done.
168 +
    oxygenhub::queue::run(&app, item).await;
169 +
    assert_eq!(queue::counts(&app.db).await.unwrap(), (0, 1));
170 +
    queue::enqueue(&app.db, "no.such.kind", &serde_json::json!({}), "", 0).await.unwrap();
171 +
    let item = queue::claim(&app.db, "a", 10, 60_000).await.unwrap().remove(0);
172 +
    oxygenhub::queue::run(&app, item).await;
173 +
    assert_eq!(queue::counts(&app.db).await.unwrap(), (0, 2), "an unknown kind is dead at once");
174 +
}
175 +
176 +
#[tokio::test(flavor = "multi_thread")]
142 177
async fn a_hub_takes_every_role_by_default() {
143 178
    hermetic();
144 179
    let dir = tempfile::tempdir().unwrap();
145 180
    let app = oxygenhub::build(config(dir.path(), &[])).await.unwrap();
146 -
    assert_eq!(app.leader.tasks().len(), 11, "{:?}", app.leader.tasks());
181 +
    assert_eq!(app.leader.tasks().len(), 12, "{:?}", app.leader.tasks());
147 182
    assert_eq!(health(&app).await["roles"], serde_json::json!(["web", "worker"]));
148 183
    assert_eq!(get(&app, "/explore", None).await.0, StatusCode::OK);
149 184
    let mut bad = config(dir.path(), &["web", "cron"]);
…
57 unchanged lines
modified docs/roles.md +16 −0
…
221 unchanged lines
222 222
mirror export, the apt rebuild, Diffusion's bumps, `oxy gc`. Runner claims
223 223
stay on their own path (oxygenflow#34 may later move them to the queue).
224 224
225 +
**Built, first slice (#276):** the `work_queue` table (migration 0091:
226 +
kind, payload, a dedupe key, run after, attempts, last error, claimed by,
227 +
claimed until, dead) and `db::queue` (enqueue, claim, done, failed,
228 +
counts). A claim leases a batch for five minutes (`UPDATE … WHERE id IN
229 +
(SELECT … FOR UPDATE SKIP LOCKED) RETURNING *` on Postgres; under the write
230 +
lock on SQLite), so any number of worker processes take items, and an item
231 +
whose worker died is due again when its lease ends. A failure backs off
232 +
(30 s, doubling, at most an hour); the eighth attempt leaves the item dead,
233 +
kept for a person to read; a kind no worker knows is dead at once. Every
234 +
worker runs `queue.worker` (no leader lock), polling every 2 s. Tenants so
235 +
far: a notification's **email** that failed to send (`mail`), and the
236 +
**Git mirror** export after a push that failed (`mirror.refresh`, one per
237 +
repository); both still try at once and queue only on failure, so the
238 +
usual path is as fast as before. Next: webhook deliveries onto it, the
239 +
apt rebuild, Diffusion's bumps, and a nudge on the event bus.
240 +
225 241
## The single-node edition
226 242
227 243
`oxygenhub serve` with no `--role` runs every role, with the in-process
…
20 unchanged lines