pg-jobs (0.2.0)
Installation
[registries.forgejo]
index = "sparse+ " # Sparse index
# index = " " # Git
[net]
git-fetch-with-cli = truecargo add pg-jobs@0.2.0 --registry forgejoAbout this package
pg-jobs
A Postgres-native job & flow engine for Rust: DAG flows, retry/DLQ, typed
handlers, recurring scheduler, and a worker driven by LISTEN/NOTIFY (with a
polling fallback). All state lives in a dedicated jobs schema in your own
Postgres database — no external broker.
Features
- Typed handlers — implement the
Jobtrait with typedInput/Output. - DAG flows — compose jobs with dependencies (
Flow,submit_flow,rerun_flow), fan-out, and per-node dedup keys. - Retry / DLQ — configurable backoff strategies (exponential/fixed, optional
equal jitter); fatal vs. retryable errors, plus
JobError::Delayedto postpone a job (e.g. on a429) without burning an attempt, pairing withctx.update_data. - Two execution modes —
Transactional(default) commits a handler's work atomically with completion;Detachedholds no transaction (at-least-once) for long, I/O-bound jobs. See Execution modes. - Recurring scheduler — fixed-interval (
upsert_every) and cron (upsert_cron, standard 5-field UTC) schedules, keyed by name for idempotent startup declaration;remove_scheduleto drop one. Driven from the worker loop. - Per-queue tuning — concurrency cap, throughput rate limit (max starts per
sliding window), reaper lock duration, and GC retention, each overridable per
queue (
set_queue_*). - Automatic GC — opt-in retention-based cleanup driven by the worker
(
Worker::with_gc), advisory-locked so a single worker runs it under many. - Tracking — read API for flow views, queue stats, and job listing.
- Job admin — cancel or remove a job by id (cascading to its dependents), and
read a job's log lines (
cancel,remove,job_logs). - No compile-time DB — uses runtime
sqlxqueries, so it builds without aDATABASE_URLor.sqlxcache.
Install
Released versions are published to the Forgejo Cargo registry. Declare the
registry in your project's .cargo/config.toml:
[registries.forgejo]
index = "sparse+https://forge.netserv.fr/api/packages/AzSiAz/cargo/"
Then depend on it in Cargo.toml:
[dependencies]
pg-jobs = { version = "0.2", registry = "forgejo" }
Authenticate once for read access with cargo login --registry forgejo
(paste a Forgejo token in the Bearer <token> form when prompted).
Alternatively, track main directly via git:
[dependencies]
pg-jobs = { git = "ssh://git@git.netserv.fr/AzSiAz/pg-jobs.git", branch = "main" }
The library crate is imported as pg_jobs.
Migrations
The crate ships its schema migrations and exposes a Migrator. Run it once at
startup; the jobs schema is created automatically.
pg_jobs::MIGRATOR.run(&pool).await?;
Usage
use pg_jobs::{Job, JobKind, JobError, JobSpec, QueueName, Registry, Worker, WorkerConfig};
use pg_jobs::ctx::Ctx;
use pg_jobs::store::JobStore;
use std::sync::Arc;
// 0. Declare your queues once — typo-safe, autocompleted.
#[derive(Clone, Copy, QueueName)]
enum Queue {
Default,
Urgent,
}
// 1. Define a typed job. `KIND` is derived from the struct name ("send_email").
#[derive(JobKind)]
struct SendEmail;
#[async_trait::async_trait]
impl Job for SendEmail {
type Input = String; // recipient address
type Output = ();
async fn run(&self, to: String, _ctx: &mut Ctx<'_>) -> Result<(), JobError> {
println!("sending to {to}");
Ok(())
}
// Per-kind retry policy. These are associated functions (no `self`), like
// `mode()`. `JobSpec::for_job` seeds every enqueue with these defaults.
fn max_attempts() -> u32 {
3
}
fn backoff() -> pg_jobs::Backoff {
pg_jobs::Backoff::exponential(
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(30),
)
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
pg_jobs::MIGRATOR.run(&pool).await?;
// Jobs without an explicit queue land on `Queue::Default`.
let store = JobStore::with_default_queue(pool.clone(), Queue::Default);
let mut reg = Registry::new();
reg.register(SendEmail);
let worker = Worker::new(
store.clone(),
Arc::new(reg),
WorkerConfig::default().with_queues([Queue::Default, Queue::Urgent]),
);
// Enqueue on the default queue…
store.enqueue::<SendEmail>("alice@example.com".into()).await?;
// …or route per call (e.g. premium users).
store.enqueue_to::<SendEmail>("vip@example.com".into(), Queue::Urgent).await?;
// …or build a spec for finer control (priority, delay, dedup, retry overrides).
store
.enqueue_spec(
JobSpec::for_job::<SendEmail>("bob@example.com".into())?
.on_queue(Queue::Urgent)
.priority(10)
.delay(std::time::Duration::from_secs(30))
.dedup_key("welcome:bob@example.com"),
)
.await?;
let (_tx, rx) = tokio::sync::watch::channel(false);
worker.run(rx).await?;
Ok(())
}
See the crate's tests/ directory for flows, fan-out, transactional jobs,
recurring schedules, the reaper, and tracking.
Execution modes
Each job kind runs in one of two modes, chosen by Job::mode() (default
Transactional):
-
Transactional— the handler runs inside the job's transaction. Writes on it viactx.tx()commit atomically with the job's completion, so a crash leaves neither — this is what removes the dual-write races a Redis-backed queue is prone to. The trade-off: the job pins a pooled connection for its whole duration, so keep these jobs short. Best when the work is a database write. -
Detached— the handler runs with no open job transaction, while a background heartbeat keeps the lock fresh. Completion is a separate guarded step, so delivery is at-least-once: make these handlers idempotent. Best for long, I/O-bound work (HTTP scraping, S3 uploads) where holding a transaction open would cause idle-in-transaction bloat and pin a connection.
impl Job for ScrapeChapter {
// ... long HTTP fetch + S3 upload ...
fn mode() -> ExecMode { ExecMode::Detached }
}
Writing to the database from a handler
Two helpers, and only tx() is mode-specific:
ctx.with_tx(|conn| …)— works in both modes: a short transaction that commits immediately, independent of the job. Use it for incremental, idempotent writes (and it's the portable choice if a handler might switch modes).ctx.tx()—Transactionalonly: hands you the job's own transaction, so the writes commit atomically with completion. InDetachedmode there is no job transaction, so it returns an error — usewith_tx()there.
So reach for tx() only when you specifically want atomic-with-completion; otherwise
with_tx() is the uniform API across both modes.
Concurrency and connections
WorkerConfig::max_in_flight sets how many jobs one worker runs at once. In
Transactional mode each in-flight job pins a pooled connection for its whole run,
so size the sqlx pool to at least max_in_flight (+1 for claims) or jobs will
block on pool.begin(). Detached jobs only touch a connection briefly (claim,
heartbeat, completion), so a small pool serves high concurrency.
A connection pooler like PgBouncer caps total Postgres backends across many
worker processes, but won't shrink what a single worker needs for concurrent
Transactional jobs: a held transaction can't be multiplexed. Note that PgBouncer's
transaction pooling disables LISTEN/NOTIFY (the worker falls back to polling) and
needs prepared-statement-aware settings; pg-jobs' advisory locks are
transaction-scoped and stay compatible.
Development
A compose.yaml provides a local Postgres 18 (with extensions baked in) for
running the test suite:
docker compose up -d
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/pg_jobs"
cargo test
The integration tests use #[sqlx::test], which connects to the base database
and creates/drops an isolated temporary database per test — so the configured
role must be a superuser (the default postgres user is). The crate builds
without a database (cargo build / cargo check); only the tests need one.
License
MIT © Stephan Deumier
Dependencies
| ID | Version |
|---|---|
| anyhow | ^1 |
| async-trait | ^0.1 |
| chrono | ^0.4 |
| croner | ^2 |
| pg-jobs-derive | =0.2.0 |
| rand | ^0.10 |
| serde | ^1 |
| serde_json | ^1 |
| sqlx | ^0.9 |
| thiserror | ^2 |
| tokio | ^1 |
| tracing | ^0.1 |
| uuid | ^1 |
| sqlx | ^0.9 |
| tokio | ^1 |
| trybuild | ^1 |