@sigil-dev/plugin-jobs
v0.9.15
Published
Durable background jobs for Grimoire: enqueue, retry, and a `sigil jobs` CLI
Maintainers
Readme
@sigil-dev/plugin-jobs
Durable background jobs for Grimoire: enqueue, retry, recurring schedules, and a sigil jobs CLI.
Opt-in. Grimoire core knows nothing about it: not installed means no queue, no table, no worker.
Setup
The queue lives in a database @sigil-dev/plugin-sql opened — "db" unless
you say otherwise — so there is nothing to connect:
// src/lib/jobs.ts
import { jobs } from "@sigil-dev/plugin-jobs";
export const queue = jobs({
inlineWorker: true, // run the worker in the server; see "Running it"
handlers: {
"send-welcome": async (p: { email: string }) => sendEmail(p.email),
"rankings.snapshot": async () => snapshotRankings(),
},
schedules: {
"rankings.snapshot": { every: "5m" },
},
});// sigil.config.ts
import { adapter } from "@sigil-dev/plugin-sql/bun";
import { queue } from "./src/lib/jobs.ts";
export default {
plugins: [adapter(process.env.DATABASE_URL, { maxTotal: 12 }), queue],
};database: "userDb" puts the queue in another plugin-sql database. The
database is looked up when a query runs, so the order of the plugins array
does not matter.
Enqueue
From anywhere on the server — import the queue:
import { queue } from "$lib/jobs";
await queue.jobs.enqueue("send-welcome", { email: "[email protected]" });
await queue.jobs.enqueue("send-welcome", { mail: "x" }); // type error: wrong payload
await queue.jobs.enqueue("send-welcom", {}); // type error: no such handler
await queue.jobs.enqueue("rankings.snapshot"); // no payload neededIn a route it is also locals.jobs. With plugin-sql's perRequestTx, a job
enqueued during a request is part of the request's transaction: an order
that rolls back takes its confirmation email with it.
Options: { runAt: Date, maxAttempts: number } (default 5).
Handlers
async (payload, { id, name, attempt }) => { ... }id is stable across retries, so it can key idempotency. attempt is 1 on
the first run. A handler that throws is retried after a backoff (30s by
default); after maxAttempts it stays in the table as failed, for
sigil jobs retry.
Schedules
schedules: { "rankings.snapshot": { every: "5m", payload: { region: "jp" } } }every is milliseconds or "30s", "5m", "1h", "1d". Each schedule is
one row that is rescheduled when it completes, so:
- It runs once however many workers there are. Every worker makes sure the row exists at boot; the primary key admits one, and one worker claims it at a time.
- A restart keeps its clock, and a deploy does not fire every schedule at once: the first run is one interval after the row is created.
- A schedule that keeps failing moves on to its next run once its
retries are used up, with the error kept in
last_error. - A schedule removed from the config is removed from the table.
Running it
inlineWorker: true runs the worker inside the server. That is right for
one process; with sigil start --scale full=3 every worker also works jobs,
which is safe (claims are race-free) but may be more than you want.
The alternative is a separate process:
sigil jobs work # run the worker until stopped (drains on SIGTERM)
sigil jobs list # most recent jobs [--limit 20]
sigil jobs stats # counts by state
sigil jobs retry # requeue every exhausted job, or one: retry <id>
sigil jobs migrate # create the table (also done at boot)The CLI opens the database from sigil.config.ts itself, so it works
without a running server.
Other stores
Instead of database, pass sql — any object with query(sql, params)
filling ? placeholders — for a queue that owns its connection:
import { bunSqlStore } from "@sigil-dev/plugin-jobs";
jobs({ sql: bunSqlStore(process.env.JOBS_URL!), handlers });A store can declare what its dialect can do; everything defaults to false, so a store that declares nothing takes the path that is correct everywhere.
| capability | true on | what it changes |
| --- | --- | --- |
| skipLocked | postgres, MySQL | appends FOR UPDATE SKIP LOCKED, so a worker does not block behind rows another holds. Throughput only. |
| spaceSeparatedTimestamp | MySQL | writes 2026-01-01 00:00:00 rather than ISO 8601, which MySQL rejects on a TIMESTAMP column. |
Drizzle
import { drizzleStore, bunSqlRunner } from "@sigil-dev/plugin-jobs";
sql: drizzleStore(bunSqlRunner(client), "mysql"),This takes the Bun.SQL connection, not your Drizzle db: Drizzle's
execute ignores a parameter array, and its bun-sql driver emits $1 on
every dialect.
Guarantees, precisely
Delivery is at-least-once. A worker can die between your side effect and the row delete, so a job may run twice. Handlers must be idempotent. The alternative — holding a transaction open across every handler, including every HTTP call inside one — is a worse trade than a duplicate email.
Order is not guaranteed. A job that fails and retries runs after jobs enqueued later. Two jobs that write the same thing should carry what they need to be applied in any order.
Claiming is race-free without SKIP LOCKED. The claim is an UPDATE guarded on the row being unclaimed or stale, tagged with a random token, followed by a SELECT of that token — so it works on MariaDB, which has no UPDATE ... RETURNING. Two workers racing means the loser gets an empty set, not a duplicate job.
A stale claim is reclaimed after 60s by default, so a hard-killed worker does not strand its jobs.
Dialects
Tested against real SQLite, postgres and MariaDB.
License
MIT
