Files
TelegramTwitterMediaBot/crates/xmedia-bot/src/queue.rs
T
YoursFunny d540fc31e9 fix: re-fetch queued retries whose local media did not survive a restart
A queued retry that holds a local file — the ugoira MP4, a bsky remux, or a
temp file the reupload fallback downloaded — could never succeed after a
restart: those files live in the system temp dir and `send::KEEP_ALIVE`, the
registry that keeps them alive for the retry, is in memory. The row retried
into an upload error, said nothing about why, and dead-lettered the user's
link even though the payload carries the `source_url`.

`handlers::repair_lost_local_media` now runs in `main` before any worker
starts (so no row can be leased while it writes payloads, which is why it can
replace them without the lease guard a worker's write-back carries):

- `Task::local_media_paths` decides which rows are affected: any local path
  that is gone. A partially delivered album is left alone — its remaining
  batches cannot be reconciled with a fresh media list without risking a
  second copy of what the user already received.
- The post is re-fetched from `source_url` through the ordinary `site::fetch`,
  so a repaired task looks like a first send: fresh media, the chat's caption
  format, a fresh link-cache snapshot, and a new keep-alive entry when the
  re-fetch produced another local file.
- The delivery envelope (chat, reply, forward/edit settings, notify targets) is
  kept, the attempt budget restarts, and nothing counts as sent.
- A post that cannot be fetched again (gone, withheld, site down) notifies the
  user with that reason instead of letting the retry die on a missing file.

New queue plumbing: `runnable_rows()` (pending + in-progress rows, read before
the workers exist) and `replace_payload()` (rewrites the payload, resets
`attempts`, marks the row pending).

Verified: 5 new offline tests (the two decisions above against a real temp
file, the queue scan/replace, and the envelope-preserving rewrite) plus
`a_lost_local_media_row_is_refetched_from_its_post`, a live test that seeds a
row pointing at a missing file with a real bsky post as its source and asserts
the row now carries http(s) media and that nothing was sent — run against the
live API here. `cargo fmt`, `cargo clippy --workspace --all-targets --locked --
-D warnings` and `cargo test --workspace --locked` (187 passed, 15 ignored)
are clean.
2026-09-21 01:48:50 +08:00

1118 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Generic persistent task queue backed by SQLite (table `tasks`).
//!
//! Concepts kept from the Python `utils/task_queue.py` (untrusted, redesigned):
//! the table schema, the lease/lock/recovery model, and the retry→dead-letter
//! flow. The Python dict-mutation hack (attempts inside the payload) is
//! replaced by dedicated columns.
use crate::db::now_f64;
use parking_lot::Mutex;
use rusqlite::{TransactionBehavior, params};
use serde_json::Value;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::Notify;
use tokio::task::JoinHandle;
pub const MAX_RETRIES: u32 = 2;
/// Attempts for a *terminal* row write (delete / reschedule). These are not
/// like a task retry: failing them leaves the row in `in_progress`, where the
/// expiry sweep can re-run a task that already ran, so a contended DB gets a
/// few quick chances before the caller falls back to a terminal state.
const TERMINAL_WRITE_ATTEMPTS: u32 = 3;
/// 100ms, 200ms, … between terminal write attempts.
fn terminal_write_backoff(attempt: u32) -> Duration {
Duration::from_millis(100 * (1u64 << attempt.min(4)))
}
pub const LOCK_TTL_SECONDS: f64 = 120.0;
/// Number of concurrent worker loops. Tasks are independent (retries and
/// forward resumes); leases serialize row claims via SQLite transactions, so
/// extra workers drain backlogs faster. Each worker can be mid-send to
/// Telegram at the same time as handler tasks, so keep this modest.
const QUEUE_WORKERS: usize = 4;
/// What a handler returns instead of throwing. The payload it carries is the
/// (possibly updated) task state to persist for the next attempt.
pub enum QueueError {
/// Reschedule with the given delay; after `MAX_RETRIES` attempts the task
/// is dead-lettered instead.
Retryable { delay_seconds: f64, payload: Value },
/// Give up now.
Permanent { message: String, payload: Value },
}
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type Handler = dyn Fn(Value) -> BoxFuture<'static, Result<(), QueueError>> + Send + Sync;
type DeadLetter = dyn Fn(Value, String) -> BoxFuture<'static, ()> + Send + Sync;
pub struct PersistentTaskQueue {
pool: std::sync::Arc<crate::db::DbPool>,
/// Wakes the workers when a row becomes leasable. `notify_one` stores a
/// permit, so nothing else may share it: a waiter that is not a worker
/// (the sweep) can consume the permit and leave the due row pending until
/// the next enqueue.
notify: Arc<Notify>,
/// Wakes the lease-expiry sweep; `stop` is the only producer.
sweep_notify: Arc<Notify>,
stop: Arc<AtomicBool>,
worker: Mutex<Vec<JoinHandle<()>>>,
counter: AtomicU64,
}
struct LeasedRow {
id: String,
payload: String,
attempts: i32,
/// Random token for *this* lease. Every write-back the worker makes is
/// guarded by it, so a lease that expired (heartbeat starved, host
/// suspended) and was re-leased by another worker cannot be written by
/// its former holder.
lease_token: String,
}
/// The row is no longer ours: its lease expired and another worker took it.
/// The former holder must not write anything back — a `delete` would erase the
/// new holder's row (or a `reschedule` would overwrite its retry state) — so
/// the attempt stops at the next heartbeat instead.
struct LeaseLost;
/// Owned worker state so the spawned loop does not borrow the queue handle.
#[derive(Clone)]
struct QueueWorker {
pool: std::sync::Arc<crate::db::DbPool>,
notify: Arc<Notify>,
stop: Arc<AtomicBool>,
handler: Arc<Handler>,
dead_letter: Arc<DeadLetter>,
}
/// Resets rows left `in_progress` with an expired lock TTL back to `pending`
/// so they can be leased again (crash/panic recovery). Returns how many rows
/// came back, which is what decides whether a worker needs waking.
fn recover_update(conn: &rusqlite::Connection) -> rusqlite::Result<usize> {
conn.execute(
"UPDATE tasks SET status='pending', locked_until=0 WHERE status='in_progress' AND locked_until < ?1",
params![now_f64()],
)
}
/// Runs [`recover_update`] and wakes a worker when something actually came
/// back. A recovered row is due immediately, but every worker may be parked on
/// `notify` — with no pending row there is no `earliest_run_after` to sleep on
/// — so without this the recovered task waits for the next unrelated enqueue.
/// Same permit semantics as `enqueue`: `notify_one` stores a permit when no
/// worker is registered.
async fn recover_expired(pool: &std::sync::Arc<crate::db::DbPool>, notify: &Notify) {
match pool.with_conn(move |conn| recover_update(conn)).await {
Ok(recovered) if recovered > 0 => {
log::warn!("queue: recovered {recovered} row(s) from an expired lease");
notify.notify_one();
}
Ok(_) => {}
Err(e) => log::error!("queue recovery failed: {e}"),
}
}
/// Base delay × 2^attempts (attempts = retries already done), capped at 300s.
/// Applied at the queue layer so the attempt count actually reaches the
/// backoff computation. The cap only ever scales *up*: a delay the server
/// asked for (Telegram `RetryAfter`) must not be shortened, retrying earlier
/// than allowed just re-triggers the flood control it came from.
fn scaled_retry_delay(base: f64, attempts: i32) -> f64 {
(base * 2f64.powi(attempts)).min(300.0).max(base)
}
impl PersistentTaskQueue {
/// Wraps the shared DB pool; the schema is initialized once by
/// [`crate::db::open_store`] (all three stores share the pool).
pub fn new(pool: std::sync::Arc<crate::db::DbPool>) -> Self {
Self {
pool,
notify: Arc::new(Notify::new()),
sweep_notify: Arc::new(Notify::new()),
stop: Arc::new(AtomicBool::new(false)),
worker: Mutex::new(Vec::new()),
counter: AtomicU64::new(0),
}
}
/// Starts the worker loops. Also recovers rows left `in_progress` by a
/// previous process (lease expired).
pub async fn start<H, F, D, G>(&self, handler: H, dead_letter: D)
where
H: Fn(Value) -> F + Send + Sync + 'static,
F: Future<Output = Result<(), QueueError>> + Send + 'static,
D: Fn(Value, String) -> G + Send + Sync + 'static,
G: Future<Output = ()> + Send + 'static,
{
let handler: Arc<Handler> = Arc::new(move |payload| Box::pin(handler(payload)));
let dead_letter: Arc<DeadLetter> =
Arc::new(move |payload, message| Box::pin(dead_letter(payload, message)));
recover_expired(&self.pool, &self.notify).await;
let mut handles = Vec::with_capacity(QUEUE_WORKERS + 1);
for _ in 0..QUEUE_WORKERS {
let worker = QueueWorker {
pool: std::sync::Arc::clone(&self.pool),
notify: Arc::clone(&self.notify),
stop: Arc::clone(&self.stop),
handler: Arc::clone(&handler),
dead_letter: Arc::clone(&dead_letter),
};
handles.push(tokio::spawn(worker.run_loop_supervised()));
}
// Periodic lease-expiry sweep: recovers rows a crashed/panicked
// worker left `in_progress` (the lock TTL bounds the wait). Its own
// notify (not the workers'): sharing that one let this task consume a
// `notify_one` permit meant for a worker, which then slept through a
// due row until some later event. Only `stop` wakes it.
let sweep_pool = std::sync::Arc::clone(&self.pool);
let sweep_notify = Arc::clone(&self.sweep_notify);
let sweep_stop = Arc::clone(&self.stop);
let sweep_workers = Arc::clone(&self.notify);
handles.push(tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
let notified = sweep_notify.notified();
tokio::pin!(notified);
tokio::select! {
_ = &mut notified => {}
_ = interval.tick() => {}
}
if sweep_stop.load(Ordering::Relaxed) {
break;
}
recover_expired(&sweep_pool, &sweep_workers).await;
}
}));
*self.worker.lock() = handles;
}
pub async fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
self.notify.notify_waiters();
self.sweep_notify.notify_waiters();
let handles = std::mem::take(&mut *self.worker.lock());
for handle in handles {
let _ = handle.await;
}
}
/// Persists a task. `run_after` is an absolute unix timestamp (seconds).
/// Notifies the worker only after the insert has committed, so the worker
/// never wakes to an invisible row.
pub async fn enqueue(&self, payload: Value, run_after: f64) -> rusqlite::Result<()> {
let id = format!(
"task_{}_{}",
(now_f64() * 1000.0) as u64,
self.counter.fetch_add(1, Ordering::Relaxed)
);
let payload = payload.to_string();
log::debug!("enqueued {id} (run_after {run_after:.1})");
self.pool.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES (?1, ?2, ?3, 0, 'pending', 0, ?4)",
params![id, payload, run_after, now_f64()],
)?;
Ok(())
})
.await?;
// `notify_one` stores a permit when no worker is registered, so a
// notification fired between a worker's DB reads and its `notified()`
// registration is not lost (notify_waiters would drop it). The
// awakened worker re-leases and finds the new row.
self.notify.notify_one();
Ok(())
}
/// Pending task count and the oldest `run_after`, for the periodic sweep's
/// health line. Deliberately separate from the worker's own
/// `earliest_run_after`: that one runs on every idle worker cycle and must
/// stay a single indexed `MIN`, while the count is only asked for once per
/// sweep.
/// `(id, payload)` of every row that can still run (`pending`,
/// `in_progress`). The startup repair reads these before the workers start:
/// with no worker running, no row can be leased while it writes.
pub async fn runnable_rows(&self) -> Vec<(String, String)> {
let result = self
.pool
.with_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT id, payload FROM tasks WHERE status IN ('pending', 'in_progress') ORDER BY run_after",
)?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
rows.collect::<rusqlite::Result<Vec<(String, String)>>>()
})
.await;
match result {
Ok(rows) => rows,
Err(e) => {
log::error!("queue row scan failed: {e}");
Vec::new()
}
}
}
/// Replaces a runnable row's payload and restarts its attempt budget: the
/// new payload is a fresh delivery of the same task, so the retries it has
/// already spent do not carry over. Startup repair only — a worker's
/// write-back is lease-token guarded instead (`replace_payload` cannot race
/// one: it runs before any worker does).
pub async fn replace_payload(&self, id: &str, payload: &Value) -> bool {
let logged_id = id.to_string();
let (id, payload) = (id.to_string(), payload.to_string());
let result = self
.pool
.with_conn(move |conn| {
let affected = conn.execute(
"UPDATE tasks SET payload=?1, attempts=0, run_after=?2, status='pending', locked_until=0 \
WHERE id=?3 AND status IN ('pending', 'in_progress')",
params![payload, now_f64(), id],
)?;
Ok(affected == 1)
})
.await;
match result {
Ok(true) => true,
Ok(false) => {
log::warn!("queue: row {logged_id} vanished before its payload could be replaced");
false
}
Err(e) => {
log::error!("queue payload replace failed for {logged_id}: {e}");
false
}
}
}
pub async fn pending_backlog(&self) -> Option<(i64, f64)> {
let result = self
.pool
.with_conn(|conn| {
let mut stmt = conn
.prepare("SELECT COUNT(*), MIN(run_after) FROM tasks WHERE status='pending'")?;
let mut rows = stmt.query([])?;
match rows.next()? {
Some(row) => {
let count = row.get::<_, i64>(0)?;
match row.get::<_, Option<f64>>(1)? {
Some(oldest) if count > 0 => Ok(Some((count, oldest))),
_ => Ok(None),
}
}
None => Ok(None),
}
})
.await;
match result {
Ok(v) => v,
Err(e) => {
log::error!("queue backlog query failed: {e}");
None
}
}
}
}
/// Last-resort terminal state for a row whose `DELETE` would not go through:
/// `done` is invisible to `lease_next` (`status='pending'`), to the expiry
/// sweep (`status='in_progress'`) and to the backlog line, so a task that
/// already ran cannot be leased and run again. Token-guarded like every other
/// write-back: `Ok(false)` means the row was re-leased and is not ours to
/// tombstone.
async fn mark_done(
pool: &std::sync::Arc<crate::db::DbPool>,
id: &str,
lease_token: &str,
) -> rusqlite::Result<bool> {
let id = id.to_string();
let lease_token = lease_token.to_string();
pool.with_conn(move |conn| {
let affected = conn.execute(
"UPDATE tasks SET status='done', locked_until=0 WHERE id = ?1 AND lease_token = ?2",
params![id, lease_token],
)?;
Ok(affected == 1)
})
.await
}
/// Which task a lease/retry/dead-letter line is about: the chat from the
/// stored payload, plus the post's normalized cache key when the payload
/// carries one (`ForwardMessages` has no source URL). Without these a queue
/// line named only a row id, which is useless to whoever reads the log — the
/// row id is assigned at insert time and appears nowhere else.
///
/// Built only when the line is actually logged (log arguments are lazy).
fn row_fields(payload: &Value) -> String {
let chat = payload
.get("chat_id")
.or_else(|| payload.get("from_chat_id"))
.and_then(Value::as_i64);
let key = payload
.get("source_url")
.and_then(Value::as_str)
.map(crate::handlers::log_key);
match (chat, key) {
(Some(chat), Some(key)) => format!("chat={chat} [key={key}]"),
(Some(chat), None) => format!("chat={chat}"),
(None, Some(key)) => format!("[key={key}]"),
(None, None) => String::new(),
}
}
impl QueueWorker {
/// Supervised worker: the inner loop runs in its own task so a panic
/// (e.g. inside a handler or a DB closure) kills only that task; the
/// supervisor respawns it until stop is set. The row a dead worker had
/// leased is recovered by the periodic sweep once its lock TTL expires.
async fn run_loop_supervised(self) {
while !self.stop.load(Ordering::Relaxed) {
let worker = self.clone();
if let Err(e) = tokio::spawn(async move { worker.run_loop().await }).await {
log::error!("queue worker panicked, restarting: {e}");
}
}
}
async fn run_loop(self) {
while !self.stop.load(Ordering::Relaxed) {
match self.lease_next().await {
Ok(Some(row)) => self.process(row).await,
Ok(None) => {
let wait_until = self.earliest_run_after().await;
let notified = self.notify.notified();
tokio::pin!(notified);
match wait_until {
Some(until) => {
let delay = (until - now_f64()).max(0.0);
tokio::select! {
_ = &mut notified => {}
_ = tokio::time::sleep(Duration::from_secs_f64(delay)) => {}
}
}
None => {
notified.await;
}
}
}
// A lease failure while rows are due would otherwise loop
// with sleep(0) and hammer SQLite; back off briefly.
Err(e) => {
log::error!("queue lease failed: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
/// Leases the oldest due row (sets it `in_progress` with a lock TTL).
/// Errors are surfaced so the caller can back off instead of spinning.
async fn lease_next(&self) -> Result<Option<LeasedRow>, rusqlite::Error> {
self.pool.with_conn(|conn| {
// BEGIN IMMEDIATE: with several workers, a deferred transaction
// that read before another worker's lease commit would fail with
// SQLITE_BUSY_SNAPSHOT. Taking the write lock up front serializes
// leases and re-reads the freshest committed state.
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let now = now_f64();
let row = tx.query_row(
"SELECT id, payload, attempts FROM tasks WHERE status='pending' AND run_after <= ?1 AND locked_until <= ?1 \
ORDER BY run_after LIMIT 1",
params![now],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i32>(2)?,
))
},
);
let (id, payload, attempts) = match row {
Ok(row) => row,
Err(rusqlite::Error::QueryReturnedNoRows) => {
tx.commit()?;
return Ok(None);
}
Err(e) => return Err(e),
};
let lease_token = format!("{:016x}", rand::random::<u64>());
tx.execute(
"UPDATE tasks SET status='in_progress', locked_until=?1, lease_token=?2 WHERE id=?3",
params![now + LOCK_TTL_SECONDS, lease_token, id],
)?;
tx.commit()?;
Ok(Some(LeasedRow {
id,
payload,
attempts,
lease_token,
}))
})
.await
}
async fn earliest_run_after(&self) -> Option<f64> {
let result = self
.pool
.with_conn(|conn| {
let mut stmt =
conn.prepare("SELECT MIN(run_after) FROM tasks WHERE status='pending'")?;
let mut rows = stmt.query([])?;
match rows.next()? {
Some(row) => Ok(row.get::<_, Option<f64>>(0)?),
None => Ok(None),
}
})
.await;
match result {
Ok(v) => v,
Err(e) => {
log::error!("queue timing query failed: {e}");
None
}
}
}
/// Processes one leased row, keeping the lease alive while the handler
/// runs. Without the heartbeat a task longer than [`LOCK_TTL_SECONDS`]
/// (slow download, ugoira encode, rate-limited batch forward) would have
/// its lease expire mid-run; the expiry sweep would flip the row back to
/// `pending` and another worker would process it again — duplicate sends.
async fn process(&self, row: LeasedRow) {
let payload: Value = match serde_json::from_str(&row.payload) {
Ok(value) => value,
Err(e) => {
log::error!("queue: unparseable payload for {}: {e}", row.id);
self.delete_row(&row.id, &row.lease_token).await;
(self.dead_letter)(Value::Null, format!("invalid stored payload: {e}")).await;
return;
}
};
let fields = row_fields(&payload);
log::debug!(
"processing {} {fields} (attempt {})",
row.id,
row.attempts + 1
);
let attempt_started = std::time::Instant::now();
let outcome = match self
.run_with_lease(&row.id, &row.lease_token, payload)
.await
{
Ok(outcome) => outcome,
Err(LeaseLost) => {
// Another worker owns this row now and is delivering the same
// task: write nothing (no delete, no reschedule, no
// dead-letter) and leave it to them.
log::warn!(
"queue: lost the lease on {} {fields} (attempt {}); abandoning this attempt",
row.id,
row.attempts + 1
);
return;
}
};
let attempt_ms = attempt_started.elapsed().as_millis();
match outcome {
Ok(()) => {
log::debug!("task {} {fields} completed in {attempt_ms}ms", row.id);
self.delete_row(&row.id, &row.lease_token).await;
}
Err(QueueError::Retryable {
delay_seconds,
payload,
}) => {
if row.attempts as u32 >= MAX_RETRIES {
// The queue keeps only the payload, not the last error, so
// the cause of an exhausted retry is just that: exhausted.
// (The dead-letter message is read by the user, so it must
// not restate its own wrapper — see `failure_text`.)
let message = "retries exhausted".to_string();
log::error!(
"dead-lettering {} {fields}: {message} after {} attempt(s)",
row.id,
row.attempts + 1
);
self.delete_row(&row.id, &row.lease_token).await;
(self.dead_letter)(payload, message).await;
} else {
let delay = scaled_retry_delay(delay_seconds, row.attempts);
log::debug!(
"task {} {fields} attempt {} took {attempt_ms}ms, rescheduled in {delay:.1}s",
row.id,
row.attempts + 1
);
self.reschedule(&row.id, &row.lease_token, payload, delay, row.attempts + 1)
.await;
}
}
Err(QueueError::Permanent { message, payload }) => {
log::error!("dead-lettering {} {fields}: {message}", row.id);
self.delete_row(&row.id, &row.lease_token).await;
(self.dead_letter)(payload, message).await;
}
}
}
/// Drives the handler to completion, refreshing the row's `locked_until`
/// every 30 s so the expiry sweep never re-leases a still-running task.
/// The heartbeat is part of this future, not a separate spawned task: if
/// the worker task dies (panic) the heartbeat dies with it and the sweep
/// recovers the row exactly as before.
async fn run_with_lease(
&self,
id: &str,
lease_token: &str,
payload: Value,
) -> Result<Result<(), QueueError>, LeaseLost> {
let fut = (self.handler)(payload);
tokio::pin!(fut);
let mut interval = tokio::time::interval(Duration::from_secs(30));
// The first interval tick fires immediately; skip it (the lease was
// just set by lease_next).
interval.tick().await;
let id_owned = id.to_string();
let token_owned = lease_token.to_string();
loop {
tokio::select! {
result = &mut fut => return Ok(result),
_ = interval.tick() => {
let now = now_f64();
let id = id_owned.clone();
let token = token_owned.clone();
let result = self
.pool
.with_conn(move |conn| {
conn.execute(
"UPDATE tasks SET locked_until=?1 \
WHERE id=?2 AND status='in_progress' AND lease_token=?3",
params![now + LOCK_TTL_SECONDS, id, token],
)
})
.await;
match result {
// Still ours: the lease is extended.
Ok(1) => {}
// The row is no longer leased to us (another worker
// re-leased it, or it is gone): dropping the handler
// future here stops this attempt instead of racing the
// new holder through the same send.
Ok(_) => return Err(LeaseLost),
Err(e) => log::error!("queue lease heartbeat failed: {e}"),
}
}
}
}
}
/// Deletes a finished row. A failure here is not cosmetic: the row would
/// stay `in_progress` with a live lease, the next sweep would flip it back
/// to `pending`, and the *completed* task would run again — a second album,
/// a second edit prompt, a second channel copy. So the delete is retried
/// (a busy/contended DB is the usual cause and clears), and if the DB still
/// refuses, the row is marked `done` — a status neither the lease query
/// (`pending`) nor the sweep (`in_progress`) looks at — so a task that
/// already ran can never be re-leased. Both writes failing is logged at
/// error level with the row id, since that is the one case where a
/// duplicate send stays possible.
async fn delete_row(&self, id: &str, lease_token: &str) {
for attempt in 0..TERMINAL_WRITE_ATTEMPTS {
match self.try_delete_row(id, lease_token).await {
Ok(true) => return,
// The row is not ours any more (re-leased while we worked):
// leaving it alone *is* the clean outcome — retrying or
// tombstoning here would erase the new holder's work.
Ok(false) => {
log::warn!("queue: row {id} was re-leased; not deleting it");
return;
}
Err(e) => {
log::error!("queue delete failed (attempt {}): {e}", attempt + 1);
tokio::time::sleep(terminal_write_backoff(attempt)).await;
}
}
}
match mark_done(&self.pool, id, lease_token).await {
Ok(true) => log::warn!("queue: row {id} marked done instead of deleted"),
Ok(false) => log::warn!("queue: row {id} was re-leased; nothing to tombstone"),
Err(e) => log::error!(
"queue: row {id} could not be deleted or marked done ({e}); \
the expiry sweep may run this finished task again"
),
}
}
/// `Ok(false)` when the `WHERE` matched no row — the lease is not ours.
async fn try_delete_row(&self, id: &str, lease_token: &str) -> rusqlite::Result<bool> {
let id = id.to_string();
let lease_token = lease_token.to_string();
self.pool
.with_conn(move |conn| {
let affected = conn.execute(
"DELETE FROM tasks WHERE id = ?1 AND lease_token = ?2 AND status='in_progress'",
params![id, lease_token],
)?;
Ok(affected == 1)
})
.await
}
/// Writes back a retryable attempt's state. A failure is retried: the row
/// would otherwise stay `in_progress`, and the expiry sweep would re-run
/// the attempt from its *previous* payload — re-sending batches the last
/// attempt had already delivered. Unlike [`Self::delete_row`] there is no
/// safe terminal fallback here (marking it done would drop the retry
/// without telling anyone), so a persistent failure is logged loudly and
/// the sweep's re-run — at-least-once, the documented trade — is named.
async fn reschedule(
&self,
id: &str,
lease_token: &str,
payload: Value,
delay_seconds: f64,
attempts: i32,
) {
let row_id = id.to_string();
let lease_token = lease_token.to_string();
let payload = payload.to_string();
let run_after = now_f64() + delay_seconds;
let mut last_error = None;
for attempt in 0..TERMINAL_WRITE_ATTEMPTS {
let id = row_id.clone();
let lease_token = lease_token.clone();
let payload = payload.clone();
let result = self
.pool
.with_conn(move |conn| {
let affected = conn.execute(
"UPDATE tasks SET payload=?1, run_after=?2, attempts=?3, status='pending', locked_until=0 \
WHERE id=?4 AND lease_token=?5 AND status='in_progress'",
params![payload, run_after, attempts, id, lease_token],
)?;
Ok(affected == 1)
})
.await;
match result {
Ok(true) => {
// Same permit semantics as enqueue: never lose the wakeup.
self.notify.notify_one();
return;
}
// Re-leased while we worked: the new holder owns the row and
// its retry, so writing our payload would overwrite progress.
Ok(false) => {
log::warn!(
"queue: row {row_id} was re-leased; not rescheduling it (the new holder decides)"
);
return;
}
Err(e) => {
log::error!("queue reschedule failed (attempt {}): {e}", attempt + 1);
last_error = Some(e.to_string());
tokio::time::sleep(terminal_write_backoff(attempt)).await;
}
}
}
log::error!(
"queue: row {row_id} could not be rescheduled ({}); the expiry sweep will \
re-run this attempt from its previous state",
last_error.unwrap_or_default()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
#[test]
fn scaled_retry_delay_scales_and_caps() {
assert_eq!(scaled_retry_delay(1.0, 0), 1.0);
assert_eq!(scaled_retry_delay(1.0, 1), 2.0);
assert_eq!(scaled_retry_delay(1.0, 2), 4.0);
assert_eq!(scaled_retry_delay(1.5, 1), 3.0);
assert_eq!(scaled_retry_delay(1.0, 10), 300.0, "capped at 300s");
assert_eq!(scaled_retry_delay(300.0, 0), 300.0);
// A server-asked delay above the cap is honoured, not truncated: a
// 1800s flood-control wait used to become 300s and earn another 429.
assert_eq!(scaled_retry_delay(1800.0, 0), 1800.0);
assert_eq!(scaled_retry_delay(1800.0, 1), 1800.0);
}
/// Puts a row into the state a worker holds while running it.
async fn set_lease(queue: &PersistentTaskQueue, id: &str, token: &str) {
let (id, token) = (id.to_string(), token.to_string());
queue
.pool
.with_conn(move |conn| {
conn.execute(
"UPDATE tasks SET status='in_progress', lease_token=?1 WHERE id=?2",
params![token, id],
)?;
Ok(())
})
.await
.unwrap();
}
async fn new_queue() -> (PersistentTaskQueue, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("queue.db");
let pool = crate::db::open_store(path.to_str().unwrap()).unwrap();
let queue = PersistentTaskQueue::new(pool);
(queue, dir)
}
#[tokio::test]
async fn enqueue_runs_handler_once() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let calls_worker = calls.clone();
queue
.start(
move |payload| {
assert_eq!(payload["n"], 42);
calls_worker.fetch_add(1, AtomicOrdering::SeqCst);
async { Ok(()) }
},
|_payload, _message| async {},
)
.await;
queue
.enqueue(serde_json::json!({"n": 42}), now_f64())
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
#[test]
fn row_fields_name_the_chat_and_the_post() {
// The payload shapes the three task variants store.
assert_eq!(
row_fields(&serde_json::json!({
"chat_id": 111,
"source_url": "https://x.com/u/status/1"
})),
"chat=111 [key=twitter:1]"
);
// A forward has no source URL; a chat id alone must still name the line.
assert_eq!(
row_fields(&serde_json::json!({"from_chat_id": 111, "to_chat_id": 222})),
"chat=111"
);
// Garbage in the payload must not panic a log line.
assert_eq!(row_fields(&serde_json::json!({"chat_id": "111"})), "");
assert_eq!(row_fields(&serde_json::Value::Null), "");
}
/// The row a leaked deletion would resurrect: `done` is invisible to the
/// lease query, so a task that already ran cannot be run again.
#[tokio::test]
async fn done_rows_are_never_leased() {
let (queue, _dir) = new_queue().await;
let runs = Arc::new(AtomicUsize::new(0));
queue
.enqueue(serde_json::json!({"chat_id": 1}), now_f64())
.await
.unwrap();
let id: String = queue
.pool
.with_conn(|conn| conn.query_row("SELECT id FROM tasks", [], |r| r.get(0)))
.await
.unwrap();
// A token that is not the row's is refused: only the lease holder can
// write the row back.
assert!(
!mark_done(&queue.pool, &id, "someone-elses-token")
.await
.unwrap(),
"a foreign lease must not be able to tombstone the row"
);
set_lease(&queue, &id, "ours").await;
assert!(mark_done(&queue.pool, &id, "ours").await.unwrap());
assert_eq!(
queue.pending_backlog().await,
None,
"a done row is not pending work"
);
let runs_worker = runs.clone();
queue
.start(
move |_payload| {
runs_worker.fetch_add(1, AtomicOrdering::SeqCst);
async { Ok(()) }
},
|_payload, _message| async {},
)
.await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
runs.load(AtomicOrdering::SeqCst),
0,
"the finished row must not run again"
);
queue.stop().await;
}
#[tokio::test]
async fn pending_backlog_counts_only_unleased_rows() {
let (queue, _dir) = new_queue().await;
assert_eq!(queue.pending_backlog().await, None, "empty queue");
let due = now_f64();
queue
.enqueue(serde_json::json!({"chat_id": 1}), due)
.await
.unwrap();
queue
.enqueue(serde_json::json!({"chat_id": 2}), due + 600.0)
.await
.unwrap();
// Hold the first row in the handler so it is leased, not pending: a
// health line that reported work already in flight as backlog would be
// lying about the queue.
let release = Arc::new(tokio::sync::Notify::new());
let held = release.clone();
queue
.start(
move |_payload| {
let held = held.clone();
async move {
held.notified().await;
Ok(())
}
},
|_payload, _message| async {},
)
.await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
queue.pending_backlog().await.map(|(n, _)| n),
Some(1),
"the leased row is not pending"
);
let (_, oldest) = queue.pending_backlog().await.unwrap();
assert!(
(oldest - (due + 600.0)).abs() < 1.0,
"oldest is the earliest run_after: {oldest}"
);
release.notify_one();
queue.stop().await;
}
#[tokio::test]
async fn retryable_reschedules_then_dead_letters() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let dead_calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let d = dead_calls.clone();
queue
.start(
move |payload| {
c.fetch_add(1, AtomicOrdering::SeqCst);
let payload = payload.clone();
async move {
Err(QueueError::Retryable {
delay_seconds: 0.001,
payload,
})
}
},
move |_payload, _message| {
d.fetch_add(1, AtomicOrdering::SeqCst);
async {}
},
)
.await;
queue
.enqueue(serde_json::json!({"a": 1}), now_f64())
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(600)).await;
assert_eq!(
calls.load(AtomicOrdering::SeqCst),
MAX_RETRIES as usize + 1,
"handler should run once per attempt"
);
assert_eq!(dead_calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
#[tokio::test]
async fn runnable_rows_and_payload_replacement() {
let (queue, _dir) = new_queue().await;
queue
.enqueue(serde_json::json!({"s": 1}), now_f64())
.await
.unwrap();
let rows = queue.runnable_rows().await;
assert_eq!(rows.len(), 1);
let (id, payload) = rows[0].clone();
assert_eq!(payload, "{\"s\":1}");
// A replacement restarts the attempt budget (the new payload is a fresh
// delivery, not the continuation of the old one).
{
let id_owned = id.clone();
queue
.pool
.with_conn(move |conn| {
conn.execute("UPDATE tasks SET attempts=2 WHERE id=?1", params![id_owned])?;
Ok(())
})
.await
.unwrap();
}
assert!(
queue
.replace_payload(&id, &serde_json::json!({"s": 2}))
.await
);
let rows = queue.runnable_rows().await;
assert_eq!(rows[0].1, "{\"s\":2}");
assert_eq!(
queue.pending_backlog().await.map(|(n, _)| n),
Some(1),
"a repaired row is pending work again"
);
// A row that is gone (or done) is not rewritten.
assert!(
!queue
.replace_payload("task_missing", &serde_json::json!({}))
.await
);
}
#[tokio::test]
async fn permanent_error_dead_letters_immediately() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let dead_calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
let d = dead_calls.clone();
queue
.start(
move |payload| {
c.fetch_add(1, AtomicOrdering::SeqCst);
let payload = payload.clone();
async move {
Err(QueueError::Permanent {
message: "nope".into(),
payload,
})
}
},
move |_payload, message| {
assert_eq!(message, "nope");
d.fetch_add(1, AtomicOrdering::SeqCst);
async {}
},
)
.await;
queue
.enqueue(serde_json::json!({"a": 1}), now_f64())
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
assert_eq!(dead_calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
#[tokio::test]
async fn stale_in_progress_row_is_recovered_on_start() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("queue.db");
// Insert a stale leased row directly (lease expired). open_store runs
// the schema; the queue below shares the same pool.
let pool = crate::db::open_store(path.to_str().unwrap()).unwrap();
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute(
"INSERT INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES ('task_stale', '{\"s\":1}', 0, 0, 'in_progress', ?1, 0)",
params![now_f64() - 10.0],
)
.unwrap();
}
let queue = PersistentTaskQueue::new(pool);
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
queue
.start(
move |payload| {
assert_eq!(payload["s"], 1);
c.fetch_add(1, AtomicOrdering::SeqCst);
async { Ok(()) }
},
|_payload, _message| async {},
)
.await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
queue.stop().await;
}
/// A row that goes stale *after* startup is picked up by the periodic
/// sweep — the spawned task, its 30 s interval included — and the worker
/// that sweeps it gets woken. The paused clock is what makes this the real
/// test: this used to call the recovery by hand, which proved the SQL but
/// left the wiring free to be deleted.
#[tokio::test(start_paused = true)]
async fn runtime_sweep_recovers_expired_lease() {
let (queue, _dir) = new_queue().await;
let calls = Arc::new(AtomicUsize::new(0));
let c = calls.clone();
queue
.start(
move |payload| {
assert_eq!(payload["s"], 1);
c.fetch_add(1, AtomicOrdering::SeqCst);
async { Ok(()) }
},
|_payload, _message| async {},
)
.await;
// Let every worker park and the sweep consume its immediate first tick,
// so only a later tick can see the row. The clock is paused: yields do
// not advance it, and the sleep below does.
for _ in 0..16 {
tokio::task::yield_now().await;
}
{
let conn = rusqlite::Connection::open(queue.pool.path()).unwrap();
conn.execute(
"INSERT INTO tasks (id, payload, run_after, attempts, status, locked_until, created_at) \
VALUES ('task_stale_runtime', '{\"s\":1}', 0, 0, 'in_progress', ?1, 0)",
params![now_f64() - 1000.0],
)
.unwrap();
}
assert_eq!(
calls.load(AtomicOrdering::SeqCst),
0,
"the row is stale but no sweep has run since it appeared"
);
tokio::time::sleep(Duration::from_secs(31)).await;
assert_eq!(
calls.load(AtomicOrdering::SeqCst),
1,
"the periodic sweep must recover the row and wake a worker"
);
queue.stop().await;
}
}