Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Jobs

Durable background jobs on PostgreSQL. A FOR UPDATE SKIP LOCKED queue over the application’s existing PgPool — no second connection pool, no separate broker to operate.

The queue is at-least-once. A handler must tolerate running twice.

Declaring a job

use arcature::Job;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, arcature::Job)]
struct SendVerificationEmail {
    user_id: u64,
}

The derive generates impl DxComponent (so NAME is "SendVerificationEmail") and a JOB const describing the queue identity. The defaults are the struct name in snake_case, version 1, three attempts:

assert_eq!(SendVerificationEmail::JOB.kind(), "send_verification_email");
assert_eq!(SendVerificationEmail::JOB.version(), 1);
assert_eq!(SendVerificationEmail::JOB.max_attempts(), 3);

Override them with the helper attribute:

#[derive(Debug, Clone, Serialize, Deserialize, arcature::Job)]
#[job(kind = "custom_kind", version = 2, attempts = 5)]
struct CleanupSessions {
    user_id: u64,
}

version is part of the queue identity, not decoration. A handler registers for a (kind, version) pair, so bumping the version lets a new payload shape coexist with jobs already in the table rather than deserializing into the wrong struct.

JobModel::new(kind, version, max_attempts) builds the identity by hand if you would rather not derive it.

Enqueueing

use arcature::jobs::{JobRequest, Jobs};

let jobs = Jobs::new(pool.clone());
let request = JobRequest::new(
    &SendVerificationEmail::JOB,
    &SendVerificationEmail { user_id: 42 },
)?;
jobs.enqueue(&request).await?;

JobRequest builders: .delay(Duration), .run_at(DateTime<Utc>), .max_attempts(n) to override the model’s default for this one job.

jobs.enqueue_tx(..) enqueues inside a transaction you already hold, and enqueue_with(executor, ..) takes any SQLx executor. Enqueueing in the same transaction as the state change is the only way to avoid the job that runs before its row exists.

jobs.migrate() creates the queue tables; migrate_tx does it inside a transaction you own.

Payloads are size-capped (DEFAULT_MAX_PAYLOAD_BYTES, overridable per model with with_max_payload_bytes). A queue row is not a blob store; put the bytes in Storage and the key in the payload.

Handlers

A handler is a closure registered against the job model. Registry::add takes &mut self, so the registry is mutable while you build it:

use arcature::jobs::{JobError, Registry};

pub fn registry() -> Registry {
    let mut registry = Registry::new();
    registry
        .add(&SendVerificationEmail::JOB, |job: SendVerificationEmail| async move {
            send_email(job.user_id).await.map_err(JobError::retryable)
        })
        .expect("job kind is valid and registered once");
    registry
}

Registering the same (kind, version) twice is an error, not a silent overwrite.

The handler’s error type decides what happens next. JobError::Retryable means retry per the backoff policy until max_attempts is exhausted; JobError::Permanent means dead immediately. retryable(e) and permanent(e) wrap any std::error::Error; retryable_msg(s) and permanent_msg(s) take a string. Choosing between them is the handler’s job, because the framework cannot tell a bad payload from a flaky network.

#[job_handler] validates that a handler function is pub async fn with a return type and emits it unchanged. It generates no binding const and does not register anything: a handler’s proc-macro cannot see the job’s kind and version, since those come from #[derive(Job)] on the payload struct. Registration stays explicit in application code.

Running a worker

use std::time::Duration;
use arcature::jobs::{RetryPolicy, Worker, WorkerConfig};
use tokio_util::sync::CancellationToken;

let worker = Worker::builder(pool.clone(), registry())
    .worker_id("worker-1")
    .config(WorkerConfig::default().concurrency(16))
    .retry_policy(
        RetryPolicy::exponential(Duration::from_secs(5), 2.0, Duration::from_secs(600))
            .jitter(true),
    )
    .build();

worker.run(CancellationToken::new()).await?;

Worker::new(pool, registry) skips the builder when the defaults suffice. run takes a CancellationToken and returns when it fires, so shutdown is the caller’s to sequence.

WorkerConfig defaults: concurrency 8, poll interval 200ms, lease 300s, poll batch 8, sweep every 30s in batches of 64, per-job timeout 60s, heartbeat derived as lease / 3.

Or from the CLI: arc queue work, arc queue drain, arc queue stats.

Why claims are fenced

Every claim carries a per-claim UUID claim_token, and every completion mutation fences on (id, status = 'running', claim_token).

The reason is the lease. A worker claims a job for a bounded time; if it dies or stalls past the lease, the sweep requeues the job and another worker picks it up. Without the token, the first worker waking up late would write its result over the second worker’s claim — two runs, one of them clobbering the other’s outcome. With it, the stale worker’s UPDATE matches zero rows and does nothing.

At-least-once still means at-least-once. The fence stops a stale worker committing a result, not a job body running twice.

Retries

RetryPolicy::exponential(base, multiplier, cap) computes base * multiplier^(attempts - 1), capped. RetryPolicy::fixed(delay) for a flat wait. .jitter(true) enables full jitter, which is what stops a batch of simultaneous failures retrying in lockstep forever.

A job that exhausts max_attempts is dead. arcature::jobs::admin exposes requeue_dead, cancel, and sweep_expired_leases for the operator paths.

Scheduling

use arcature::jobs::{ScheduleBinding, ScheduleCadence, Scheduler};

const NIGHTLY: ScheduleBinding = ScheduleBinding {
    job: "cleanup_sessions",
    version: 1,
    cadence: ScheduleCadence::Daily { hour: 3, minute: 0 },
};

let scheduler = Scheduler::new().schedule(&NIGHTLY, move || {
    let jobs = jobs.clone();
    async move { /* enqueue */ Ok(()) }
});

scheduler.run(CancellationToken::new()).await?;

ScheduleCadence is an interval or a daily wall-clock time. The scheduler enqueues; the worker runs. They are separate processes if you want them to be. From the CLI: arc schedule.

Observability

Observer is the seam, defaulting to NoopObserver. Implement it and pass it to WorkerBuilder::observer to see claims, completions and failures.

PostgreSQL only

The queue requires PostgreSQL. FOR UPDATE SKIP LOCKED is the whole design, and SQLite and MySQL do not have a usable equivalent. An application on db-sqlite gets the rest of the framework and no job queue.