Stampeded
A job processor on Postgres 18+. A Rust daemon (scheduler, reaper, pruner, dashboard, HTTP API), a Rust worker, a Ruby gem with an ActiveJob adapter, and an MCP control plane. BSD-3.
How it works
Jobs are rows. Enqueueing is a plain INSERT, from any language, inside the
same transaction as the data that caused it:
INSERT INTO stampeded_jobs (queue, task, args)
VALUES ('mail', 'send_welcome', '{"user_id": 42}'::jsonb);args is always a JSON object of named arguments, so any language decodes it
into a struct. meta holds one runtime's context under its name, e.g.
ActiveJob's job_class and job_id under "activejob"; other workers ignore it.
Claiming, completing and failing are SQL functions, so a worker in any language needs only a Postgres driver:
require 'pg'
require 'json'
db = PG.connect(ENV['DATABASE_URL'])
worker = db.exec_params(
"INSERT INTO stampeded_workers (hostname, pid, queues) VALUES ($1,$2,$3) RETURNING id",
['ruby-worker', Process.pid, '{default}']
).getvalue(0, 0)
loop do
db.exec_params("SELECT stampeded_heartbeat($1)", [worker])
db.exec_params("SELECT * FROM stampeded_claim($1,$2,$3)", ['default', worker, 5]).each do |job|
handle(JSON.parse(job['args']))
db.exec_params("SELECT stampeded_complete($1,$2,$3,$4)", ['default', job['id'], worker, job['attempt']])
end
sleep 0.5
endRunning it
cargo install stampeded
createdb stampeded
stampeded --migrate --database-url postgres://localhost/stampeded \
--queue default --http 127.0.0.1:7878--migrate installs the schema on a fresh database and upgrades an older one
in place, so it is safe on every start; several daemons starting at once take
turns. An app embedding the library calls stampeded::schema::migrate the same
way. schema.sql is a fresh install at the current version, for psql -f.
One process runs the worker, the reaper, the pruner, the scheduler, the HTTP API
and the dashboard. Split roles with --no-worker and --no-maintenance;
stampeded --help lists every flag. --queue takes one queue, a list
(default,mail), or '*' for every queue with work (--except-queues dlq).
The URL takes libpq's sslmode and sslrootcert. TLS is rustls with Mozilla's
roots built in; a private CA goes in sslrootcert, so on RDS:
?sslmode=verify-full&sslrootcert=/path/to/global-bundle.pem.
-
Dashboard --
http://127.0.0.1:7878/dashboard, live over SSE. Only served with a token: setSTAMPEDED_DASHBOARD_TOKEN, or pass--dashboard-token, whichpsshows to everyone on the host. -
MCP --
stampeded-mcpover stdio. -
HTTP API -- for callers that cannot reach Postgres:
POST /jobsenqueue GET /queues/{queue}/jobs/{id}job state GET /queues/{queue}/jobs/{id}/eventsstate changes over SSE, until the job finishes GET /queues/{queue}/depthdepth by state GET /health
Rust handlers are registered in-process (crates/stampeded/examples/worker.rs).
Or pull jobs and run them yourself: Puller::next hands out a Lease, which
you complete, fail or discard; claiming, heartbeats and queue policies stay
in the library. To embed only the queue in a Rust app:
cargo add stampeded --no-default-features. The library then brings no
Postgres TLS of its own: keep your app's sqlx TLS feature, or turn on
stampeded's tls-aws-lc (the binaries' default) or tls-ring.
Rails
gem "stampeded"
config.active_job.queue_adapter = :stampededbin/rails stampeded:work runs your jobs; bin/rails stampeded:daemon runs the
scheduler, reaper, pruner and dashboard. ActiveJob's retry_on is refused --
use stampeded max_attempts: or Stampeded::RetryIn.
A job with stampeded task: "invoice.parse" can be enqueued from Rails and run
by any worker that handles invoice.parse, in any language; its keyword
arguments become args. See ruby/README.md.
Other languages
libstampeded_ffi exposes enqueueing and pulling over a C ABI, for any
language with a C FFI. The header is
crates/stampeded-ffi/include/stampeded.h.
stampeded_client *client = stampeded_connect("postgres://localhost/app");
stampeded_puller_options options = {.queues = "invoice"};
stampeded_puller *puller = stampeded_puller_start(client, &options);
stampeded_lease *lease = nullptr;
while (stampeded_puller_next(puller, 5000, &lease) != STAMPEDED_ERROR) {
if (lease == nullptr) continue;
const char *args = stampeded_lease_args(lease); /* a JSON object */
if (parse_invoice(args)) (void)stampeded_lease_complete(lease);
else (void)stampeded_lease_fail(lease, "unreadable invoice", -1);
lease = nullptr;
}The Ruby gem's worker is built on it. Calls block the calling thread, so call
stampeded_puller_next from as many threads as you want jobs in flight. A
lease freed without an outcome goes back to the queue as an interruption. On
STAMPEDED_ERROR, stampeded_last_error() says why.
Go
github.com/seuros/stampeded/go, linked with
CGO_LDFLAGS="-L<dir> -Wl,-rpath,<dir>" where <dir> holds the library:
client, err := stampeded.Connect("postgres://localhost/app")
puller, err := client.Pull(stampeded.PullerOptions{Queues: "invoice", MaxLeases: 8})
for range 8 {
go func() {
for {
lease, err := puller.Next(5 * time.Second)
if err != nil || lease == nil {
continue
}
if err := parseInvoice(lease.Args); err != nil {
lease.Fail(err.Error(), stampeded.Backoff)
} else {
lease.Complete()
}
}
}()
}A lease dropped without an outcome goes back to the queue once it is garbage
collected; Release gives it back at once.
Retries and failures
-
Attempts are counted when a job is claimed, so a job that crashes its worker still uses up its budget. Default
max_attemptsis 25. -
Backoff is exponential with full jitter, from
backoff_base_ms(1 s) up tobackoff_max_ms(1 h). A handler can pick its own delay instead:rescue RateLimited => e raise Stampeded::RetryIn.new(e.retry_after)
Err(Box::new(RetryIn::secs(3600)))
RetryInchooses when, not whether:max_attemptsstill applies. -
Worker deaths have their own budget (
max_interruptions, default 1). A job that keeps killing its worker is discarded after the second claim. -
Failures are recorded by kind --
raised,timed_out,worker_missing,breaker_open, ... -- and fingerprinted with the numbers and ids stripped, so the same error repeated is one cluster. -
Dead-letter queues: set
dead_letter_queueand a discarded job is copied there.
Recurring schedules
Cron expressions or fixed intervals, stored as rows and editable at runtime:
INSERT INTO stampeded_schedules (key, queue, task, cron_expr, timezone, dst_policy)
VALUES ('nightly', 'reports', 'daily_summary', '0 3 * * *', 'Africa/Casablanca', 'wall_clock');Any number of daemons can run the scheduler; each occurrence fires once. A missed occurrence (daemon down) runs once, late. A new schedule first fires at its next occurrence, not on creation.
DST is chosen per schedule:
| fall-back (01:30 happens twice) | spring-forward (02:30 does not exist) | |
|---|---|---|
wall_clock |
runs once | runs at 03:30 |
instant |
runs twice, an hour apart | -- |
Omit dst_policy and the expression decides: one that names its hours
(0 3 * * *) gets wall_clock, one with * or a step in the hour gets
instant.
Unique jobs
INSERT INTO stampeded_jobs (queue, task, dedup_scope)
VALUES ('default', 'SyncAccount', 'acct:42');A second job with the same dedup_scope in the same queue is refused until
the first finishes. dedup_period (minute, hour, day, week, month)
allows one per time bucket instead.
Claim order
Jobs are claimed by priority, then scheduled_at. Ties -- such as a batch
inserted in one statement -- come out in no defined order, and with concurrent
workers there is no strict FIFO. Encode ordering you depend on in priority or
scheduled_at.
Queue policies
Per-queue limits, judged by the daemon every second:
INSERT INTO stampeded_queue_policies (queue, max_running) VALUES ('extraction', 2);
INSERT INTO stampeded_queue_policies (queue, max_wait_secs) VALUES ('mail', 60);Each queue with a policy gets a verdict, with a reason, in the same row:
hold at max_running ("2 running, max 2"), rush once its next job has
waited past max_wait_secs ("next job waited 122s, max 60s"), otherwise
steady. Workers claim rushed queues first and skip held ones.
- A verdict older than 30 seconds is ignored, so without a daemon workers fall back to plain round robin.
-
max_runningis approximate across processes. Each worker also enforces it for what it holds itself, daemon or not.
The policy is a behavior tree from behavior-machines.
Fairness and circuit breaking
On by default in the Rust worker:
-
Per-tenant admission (
tenant_id): one tenant enqueueing 100k jobs does not starve the others. -
A circuit breaker per task: a failing downstream is not hammered by every
retry. Jobs refused by an open circuit or by tenant throttling are recorded
as
breaker_open/throttledand get their attempt back.
Resumable jobs
Save a cursor as the job progresses; a retry picks up from it:
def perform(file)
cursor = stampeded_checkpoint&.dig("cursor") || 0
rows[cursor..].each_slice(1_000) do |chunk|
import(chunk) # do the work first
cursor += chunk.size
stampeded_checkpoint!(cursor: cursor) # then record it
end
endOr split a job into named steps; completed steps are skipped on retry and their return values are kept:
def perform(order)
stampeded_step("charge") { Stripe.charge(order.total) } # never charged twice
path = stampeded_step("render") { render_pdf(order) } # value kept on resume
stampeded_step("email", cursor: 0) do |step|
recipients[step.cursor..].each do |to|
Mailer.receipt(to, path).deliver_now
step.advance!(step.cursor + 1)
end
end
endctx.step("charge", async { charge(&order).await }).await?;
let path: String = ctx.step("render", async { render(&order).await }).await?;- Every runtime reads and writes the step record through the same Rust code, so any worker can resume another's job.
- Checkpoint inside your transaction and the cursor commits with the work. Under
Rails that happens automatically; in Rust use
checkpoint_on. - A checkpoint is a round trip (about 1.7 ms locally). For large loops write
every N units:
step.advance!(i, every: 1000). A hard kill then loses up to N. - A checkpoint counts as a heartbeat, so a job making progress is not expired.
- Step results are limited to 8 KB; store anything bigger yourself and return a reference.
Timeouts
| where | default | fires when | |
|---|---|---|---|
job_timeout |
in the worker | 15 min | the handler runs too long |
expire_seconds |
in the database, per job | 1 h | nothing heard from the job |
dead_after |
in the database | 5 min | the worker stopped heartbeating |
Keep job_timeout shorter than expire_seconds, or an expired job can be
claimed by a second worker while the first is still running it. Workers warn
when a job's settings are the wrong way round. job_timeout cannot interrupt a
blocking C call (Ruby) or a blocking task (tokio).
Connection pooling
Behind PgBouncer in transaction mode, LISTEN/NOTIFY does not work, so jobs
start on the poll interval (1 s) instead of immediately; the dashboard shows
"polling only". Give the daemon a direct or session-mode connection to get
instant wakeups. Workers can stay pooled.
Operational visibility
The dashboard and the system_health MCP tool report:
- autovacuum falling behind on the queue tables,
-
LISTEN/NOTIFYnot getting through, - backlog age per queue -- whether work is being done, not just how much is waiting.
The dashboard also draws the last 10 minutes of throughput, depth and backlog
age per queue, and live jobs per tenant with how many are throttled.
Retention
Completed jobs are kept 14 days (--retain-for), discarded ones 7 days. A
completed job costs about 330 bytes; finished jobs never slow down claiming.
| jobs/sec | 1 hour | 24 hours | 7 days |
|---|---|---|---|
| 10 | 12 MB | 285 MB | 2.0 GB |
| 100 | 119 MB | 2.9 GB | 20 GB |
| 1,000 | 1.2 GB | 29 GB | 200 GB |
Performance
One million jobs against a real 8.7 GB database:
| job body | threads | durable commits | jobs/sec |
|---|---|---|---|
| nothing | 8 | no | 5,887 |
one SELECT
|
8 | no | 3,971 |
| 10 ms of IO | 64 | no | 4,890 |
| 10 ms of IO | 64 | yes | 825 |
| jobs/sec | |
|---|---|
| 1 worker × 8 threads | 3,472 |
| 4 workers × 8 threads | 8,849 |
- Queue overhead is about 0.17 ms per job.
-
With durable commits, your storage's commit rate is the ceiling, not the
worker. To see what one commit costs, time a single
INSERTinpsqlwith\timing on. Faster storage,synchronous_commit = off, or an unlogged queue (SELECT stampeded_create_queue('metrics', p_unlogged => true)) raise it. - IO-bound jobs scale with threads; CPU-bound jobs need more processes.
Limitations
- Every worker needs database credentials; there is no broker for workers outside your network.
- No strict FIFO (see Claim order).
- No TLS and no auth on the HTTP API -- put it behind something that has both.
- No claiming over HTTP yet, for workers across a trust boundary.
Requirements
- Postgres 18 or newer.
- Building on Linux needs
libclang(upkg install libclang-dev). - The C ABI test compiles C23: GCC 14 or newer (
STAMPEDED_CCpicks the compiler;gcc-16is used when onPATH).
Testing
cargo test # unit only
createdb stampeded_test
STAMPEDED_TEST_DATABASE_URL=postgres://localhost/stampeded_test cargo testThe database tests skip without STAMPEDED_TEST_DATABASE_URL, except under
CI, where they fail instead. STAMPEDED_TEST_TLS_DATABASE_URL names a server
with a certificate for the end-to-end TLS test, which skips without one, CI
included. The Ruby suite lives in ruby/.
Layout
crates/stampeded/ queue, worker, scheduler, maintenance, planner
schema/ schema.sql, NOTES.md (design notes)
src/http/ HTTP API and dashboard [feature: http]
src/mcp/ MCP control plane [feature: mcp]
src/bin/ stampeded, stampeded-mcp
crates/stampeded-ffi/ C ABI: libstampeded_ffi and stampeded.h
ruby/ the stampeded gem
go/ the Go package
FAQ
Can I use another database? No. Stampeded is a PostgreSQL application; there will be no SQLite, MySQL or Redis backend. If you want Redis, use Sidekiq, which I use for other kinds of workloads.
Can I use Ruby 3.4? Upgrade, or fork. It might work on 3.4, but I don't test it.
Can I use Rails 7.2? Same: upgrade, or fork.
I don't like the dashboard. Consume the HTTP API with whatever you want.
Will there be PHP or JS bindings?
Build one on the C ABI (stampeded.h)
and open an issue linking to it; you can be listed as its maintainer. I can't
maintain bindings for languages I don't use, even with AI in the loop.
Credits
The dashboard palette is from cybercore-css v0.3.0 (MIT, © sebyx07) -- the design tokens only.
Licence
BSD-3-Clause.