Skip to content

Job queue

The Queue (crates/server/src/queue.rs) is a durable, retrying job queue — a pg_boss-style worker, not a hosted service. A job can be handled from a pb_hooks/*.pb.js file with no Rust code at all, or from Rust for anything that needs to run at native speed or reach a crate JavaScript can’t.

Enqueueing always works. settings.queue.enabled (false by default) only gates whether jobs get processed — POST /api/queue/enqueue, $queue.enqueue, and the dead-letter endpoints below all work regardless, so a job you enqueue before an operator turns the setting on just sits pending until they do. Flipping the setting — either direction — takes effect on the worker’s very next tick, no restart: PATCH /api/settings {"queue": {"enabled": true}} starts processing immediately, and turning it back off stops it just as fast. _queue_jobs itself is still only ever provisioned in the database the first time it’s actually needed (the first enqueue, or the setting turning on) — an install that never touches the queue costs nothing.

onQueueJob("send-welcome-email", (e) => {
// e.payload is whatever `enqueue`/`$queue.enqueue` was called with.
sendEmail(e.payload.userId);
// returning normally marks the job `completed`.
});

Throwing inside the handler marks the job for retry with the backoff described below, failed once maxAttempts is reached — the exact same outcome a Rust handler’s Err gets, since both are routed through the one claim/retry loop. A --dev hot reload re-registers onQueueJob handlers from the reloaded files, same as routerAdd/cronAdd: a changed handler body takes effect, and a queue name a new version of the file no longer registers goes back to “no handler registered”, exactly as if it had never had one.

A Rust handler always takes precedence over a JS onQueueJob for the same queue name — crate::mails’s own queued-mail-delivery path registers under a fixed name the same way a pb_hooks file can’t accidentally shadow it.

// From a pb_hooks/*.pb.js file:
const job = $queue.enqueue("send-welcome-email", { userId: "abc123" }, {
maxAttempts: 5,
dedupeKey: `welcome-${userId}`,
});
Terminal window
curl -X POST http://localhost:8090/api/queue/enqueue \
-H "Authorization: $SUPERUSER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"queue": "send-welcome-email", "payload": {"userId": "abc123"}, "maxAttempts": 5}'

Superuser or API key — the same trust tier as _cron_jobs/_webhooks: a queued job’s payload is handed verbatim to whatever handler its queue name resolves to, with no rule enforcement in between. The response is { id, queue, status, runAfter, deduped }. The older POST /api/plugins/queue/enqueue still works, unchanged, as an alias.

From JavaScript, @cratebase/extras’s enqueue(pb, queue, payload, options) wraps the same call, and the cb SDK client exposes it as cb.queue.enqueue(queue, payload, options).

Option Meaning
maxAttempts How many attempts (including the first) before the job gives up for good. Default: 5.
runAt ISO-8601/RFC3339 timestamp; the job is not eligible to run before this. Takes precedence over delay.
delay Milliseconds from now to delay the job’s first attempt. Ignored if runAt is also given.
dedupeKey A caller-chosen idempotency key. A second enqueue with the same non-empty key, while an earlier job with it is still pending/in_progress, is a no-op: the response describes the existing job with deduped: true instead of inserting a duplicate. Enforced with a Postgres advisory lock around the check-and-insert, plus a partial unique index as the actual correctness backstop against races.
priority Higher runs first among otherwise-due jobs. Default 0.
Field Meaning
queue The job’s name — looked up in the handler registry (Rust first, then JS).
payload Arbitrary JSON passed to the handler.
status pending → in_progress → completed, or back to pending on a retry, or failed once attempts reaches maxAttempts.
attempts / maxAttempts Retry count and ceiling.
runAfter Not claimed before this timestamp — the initial schedule (runAt/delay), or a failure’s backoff.
startedAt Set when a worker claims the job; used to detect a stale in_progress row.
lastError The most recent handler failure message, if any.
dedupeKey The caller-supplied idempotency key, if any.
priority Claim order among otherwise-due jobs.

Query it like any other system collection through the ordinary Records API (GET /api/collections/_queue_jobs/records/:id) to poll a job’s status.

A job that exhausts maxAttempts stays failed with lastError set — nothing deletes it automatically. From the dashboard’s Queue tab, or directly:

Terminal window
# Reset a failed job to pending with a fresh attempts budget:
curl -X POST http://localhost:8090/api/queue/jobs/$ID/retry \
-H "Authorization: $SUPERUSER_TOKEN"
# Remove a job outright, whatever its status:
curl -X DELETE http://localhost:8090/api/queue/jobs/$ID \
-H "Authorization: $SUPERUSER_TOKEN"

retry is refused (400) unless the job is currently failed — retrying one still in_progress would race the worker already running it. delete has no such restriction. Both also exist under the older /api/plugins/queue/jobs/... prefix as aliases, and as cb.queue.retry(id)/cb.queue.delete(id) in the SDK.

There is no bytecode or script execution plane beyond the JS runtime above — a Rust handler is a plain closure registered on the plugin’s handle:

use cratebase_server::queue::QueuePlugin;
let plugin = QueuePlugin::new();
plugin.handle().register_handler("send-welcome-email", |payload| async move {
// ... send the email, return Err(message.to_string()) on failure
Ok(())
});
app.register_plugin(plugin)?;

A job whose queue has no registered handler (Rust or JS) fails immediately with a descriptive lastError, retried and eventually given up on like any other failure — it never panics the worker loop.

A failed job’s runAfter is pushed out with exponential backoff — min(2^attempts * base_delay, max_delay) — until attempts reaches maxAttempts, at which point it becomes failed for good and is never retried again automatically (use the dead-letter API above to retry it by hand).

Every worker tick first reclaims any in_progress job whose startedAt is older than a stale timeout, putting it back to pending — pg_boss’s own core guarantee: a worker that crashes mid-job never orphans that job forever.

Claiming the next due job runs inside one transaction: SELECT ... FOR UPDATE SKIP LOCKED on Postgres (so concurrent workers never race for the same row), then an UPDATE ... WHERE status = 'pending' that re-checks status inside the same transaction rather than trusting the row the SELECT just read. SQLite needs neither trick — the engine’s single writer lock already serializes the whole transaction against every other write. This is what makes a job run exactly once even when several server nodes share one Postgres database — proven with a real two-process test in this project’s own test suite, not just asserted.