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 |