Background jobs
Gebweb ships a DB-backed background-job runner: producers enqueue
work from request handlers; one or more gebweb worker processes
poll a shared gebweb_jobs table and run the registered
@Job("name") handler.
The queue lives in the same database as your application data
(SQLite, Postgres, or MySQL); there is no separate broker to
operate. Failed jobs retry with exponential backoff up to
maxAttempts, then move to the failed status with last_error
captured.
Wiring
import gebweb;
import db;
let conn = db.connect("sqlite", "./app.db");
let app = gebweb.app([HomeController()]);
gebweb.useJobs(app, conn);
gebweb.registerJobHandlers(app, [WelcomeMailer()]);
useJobs auto-creates the gebweb_jobs table if it doesn't
exist. Options:
| Option | Default | Meaning |
|---|---|---|
pollIntervalMs |
1000 |
Worker sleep between polls when the queue is empty. |
maxAttempts |
5 |
After this many failed attempts a job moves to failed. |
backoffMs |
[1000, 5000, 30000, 120000, 600000] |
Delay between retries; falls back to the last entry once exhausted. |
reclaimAfterMs |
900000 |
Reclaim a job left running by a crashed worker once its lock is this old (see Crash recovery). 0 disables. |
Pass overrides as a dict: gebweb.useJobs(app, conn, {"maxAttempts": 10}).
Enqueuing
Any code holding the app reference can enqueue a job:
class SignupController {
@Post("/signup")
func signup(SignupForm form): dict<string, any> {
let user = userRepo.create(form);
gebweb.enqueue(app, "welcome-email", {"to": user.email, "name": user.name});
return {"id": user.id};
}
}
gebweb.enqueue(app, name, payload, opts?) returns the new job
id. payload is any JSON-stringifiable dict. Options:
runAt: unix-seconds timestamp for delayed execution.priority:"high","default","low", or any string drain order you configure (see Priorities). Overrides the handler's@Job(priority: ...)default.unique: a dedupe key (see Unique jobs). Overrides the handler's@Job(unique: ...)default.
Handlers
Declare a class with one or more methods decorated with
@Job("name"). Each method receives a gebweb.Job context:
import gebweb;
class WelcomeMailer {
@Job("welcome-email")
func send(gebweb.Job job): void {
let to = job.payload["to"] as string;
let name = job.payload["name"] as string;
mailer.send(to, "Welcome, " + name + "!", "<p>Glad you're here.</p>");
}
}
Job fields:
| Field | Type | Description |
|---|---|---|
id |
string |
Row id from gebweb_jobs.id. |
name |
string |
The job name passed to enqueue. |
payload |
dict<string, any> |
The enqueued payload, JSON-decoded. |
attempts |
int |
1-based attempt counter for the current execution. |
app |
any |
The GebwebApp; useful for resolving DI dependencies via gebweb.resolve(job.app, SomeService). |
Throwing from a handler triggers a retry (or final failure once
maxAttempts is reached); returning normally marks the job
completed.
Job options
@Job takes named arguments that configure a handler's scheduling
and failure behaviour:
class Mailer {
@Job("welcome-email", priority: "high", retry: {"maxAttempts": 3, "backoff": "exponential", "baseMs": 1000}, timeoutMs: 30000)
func send(gebweb.Job job): void { ... }
}
| Argument | Meaning |
|---|---|
priority |
Drain priority for this job (see Priorities). |
unique |
Dedupe key template (see Unique jobs). |
retry |
Per-handler retry policy, overriding the queue defaults. |
timeoutMs |
Per-job deadline; a slower run is released for retry. |
Any of these can be set per-enqueue too via the enqueue opts
(priority, unique), which override the handler default.
Priorities
Each job carries a priority string ("default" if unset). The
worker drains all jobs of the highest priority before moving to the
next, so a flood of low-priority work never starves urgent jobs.
Configure the order with queues (highest first):
gebweb.runWorker(app, {"queues": ["high", "default", "low"]});
The default order is ["high", "default", "low"]. A job whose
priority is not in the list is treated as lowest. The CLI worker
takes the same default; pass a custom order from runWorker in
main.gb.
Unique jobs
A unique key dedupes a job while an equivalent one is still
pending or running: enqueuing the same key returns the existing
job id instead of inserting a duplicate. Once that job completed
or failed, a fresh enqueue is allowed again.
The key is a template. A literal string is used verbatim; a
$payload.<field> template resolves against the enqueued payload:
class Reindex {
/* one reindex per user at a time */
@Job("reindex", unique: "$payload.userId")
func run(gebweb.Job job): void { ... }
}
gebweb.enqueue(app, "reindex", {"userId": "42"});
gebweb.enqueue(app, "reindex", {"userId": "42"}); /* same id: deduped */
gebweb.enqueue(app, "reindex", {"userId": "99"}); /* distinct */
The guard is enforced at enqueue time; a partial unique index backs it where the database supports one.
Per-handler retry
retry overrides the queue-wide maxAttempts / backoffMs for one
handler. As a dict it takes maxAttempts plus a backoff curve
("fixed", "linear", or "exponential") over baseMs:
@Job("charge", retry: {"maxAttempts": 5, "backoff": "exponential", "baseMs": 1000})
For full control, pass a callable that maps the (1-based) attempt number to a delay in milliseconds:
@Job("charge", retry: func(attempt: int): int { return attempt * 2000; })
Per-job timeout
timeoutMs bounds how long a single execution may run. Past the
deadline the worker stops waiting and releases the job's claim so it
retries (or fails once attempts are exhausted). The timeout is
cooperative: the handler's own in-flight work is not forcibly
interrupted, it finishes in the background while the worker moves on.
Running a worker
The gebweb worker CLI subcommand re-runs your project's
src/main.gb with GEBWEB_RUN=worker in the environment. Branch
on it so the same binary serves HTTP and background work:
import gebweb;
import db;
import sys;
let conn = db.connect("sqlite", "./app.db");
let app = gebweb.app([HomeController()]);
gebweb.useJobs(app, conn);
gebweb.registerJobHandlers(app, [WelcomeMailer()]);
if (sys.getenv("GEBWEB_RUN") == "worker") {
gebweb.runWorker(app);
} else {
gebweb.serve(app, "0.0.0.0:3000");
}
Run multiple workers in parallel by starting gebweb worker in
multiple processes - each row is claimed atomically via a
conditional UPDATE, so the same job can't be picked up twice.
Graceful shutdown
On SIGTERM or SIGINT, a worker finishes the job it is currently
running and then stops, rather than being killed mid-job. In the
long-running mode gebweb.runWorker(app) installs the signal
handlers for you (they call gebweb.shutdown(app), which flips the
drain flag the loop checks between jobs). The gebweb worker CLI
forwards termination signals to the worker process, so a container
stop (Docker / Kubernetes SIGTERM) drains in-flight work before
exit. A job interrupted before its worker could finish is recovered
by crash recovery.
The signal handlers are installed only in the blocking production
mode; the test helpers (drainOnce / maxJobs) leave the host
process's signal handling untouched.
Concurrency within a worker
By default a worker runs one job at a time. Set maxConcurrency to
run several at once in a single process:
gebweb.runWorker(app, {"maxConcurrency": 4});
The worker keeps up to that many jobs in flight via a bounded async pool, claiming more as each finishes. This raises throughput for IO-bound work (a job waiting on the network or database lets others run) without starting more processes.
Concurrent jobs share the same handler instance, so handlers run
under maxConcurrency > 1 must be concurrency-safe: they may use
job.payload, local variables, and the database freely, but must not
mutate shared in-process state (a handler field holding a dict/list, a
module-level collection). This is the same contract as request
handlers under load. If you are unsure, leave maxConcurrency at 1
and scale out with multiple worker processes instead, which keeps each
worker fully isolated.
maxConcurrency composes with maxJobs and drainOnce (both wait
for all in-flight jobs before returning) and with graceful shutdown
(in-flight jobs finish before the worker stops).
Per-queue concurrency
queueConcurrency caps how many jobs from one queue (priority) run at
the same time, within the overall maxConcurrency. Use it to keep a
CPU-heavy queue from taking every slot:
gebweb.runWorker(app, {
"queues": ["high", "default", "reports"],
"maxConcurrency": 4,
"queueConcurrency": {"reports": 1},
});
A queue only drains when it is listed in queues, so add any custom
priority there. Jobs reach a queue through @Job(priority: "reports")
or gebweb.enqueue(app, name, payload, {"priority": "reports"}).
In-process worker
A single-binary deployment can run the worker inside the server process
instead of as a separate gebweb worker. Pass inProcessWorker to
useJobs: true, or a dict of the runWorker options above (queues,
maxConcurrency, queueConcurrency, names, workerId):
gebweb.useJobs(app, conn, {
"inProcessWorker": {"queues": ["default", "reports"], "maxConcurrency": 4, "queueConcurrency": {"reports": 1}},
});
gebweb.registerJobHandlers(app, [Reports(), Mailers()]);
gebweb.cli(app);
The worker starts the first time the app listens (gebweb.cli,
gebweb.serve or gebweb.listen) and runs as a background task
alongside request handling, so its handlers follow the same
concurrency-safe contract as maxConcurrency > 1. It ticks a registered
scheduler like a standalone worker. gebweb.shutdown(app) (called by
gebweb.cli on SIGINT/SIGTERM) stops it claiming new jobs and waits up
to the shutdown timeout for in-flight jobs; a job cut off there is
recovered by crash recovery. If the worker loop
itself fails (for example the database is briefly unavailable) it logs
the error and restarts after pollIntervalMs.
With SQLite, the server's own writes and the worker's writes come from
different connections; open the job connection with
"txLock": "immediate" (or keep the queue in its own database file) so
read-then-write transactions wait for each other instead of failing with
database is locked.
Filtering by job name
By default a worker drains every job name. To pin a worker process
to a subset of names (so one server handles email, another handles
image-resize), pass --job <name> to the CLI:
gebweb worker --job email --job sms
The flag is repeatable; the CLI populates GEBWEB_WORKER_JOBS
which gebweb.runWorker(app) reads automatically. Equivalently
in code:
gebweb.runWorker(app, {"names": ["email", "sms"]});
A worker pinned to a name list only ever claims rows whose name
column appears in the list, leaving other jobs in pending for a
differently-scoped worker (or the same worker on a later run) to
process.
Crash recovery
When a worker claims a job it sets the row to running and stamps a lock. If
that worker crashes mid-job, the row would otherwise stay running forever. The
worker periodically reclaims any job whose lock is older than reclaimAfterMs
(default 15 minutes), returning it to pending so a healthy worker picks it up.
Set reclaimAfterMs longer than your slowest job (and any per-job timeoutMs);
otherwise a job that is still legitimately running could be reclaimed and run a
second time. Set it to 0 to disable reclaiming. A reclaimed job whose attempts
have already reached maxAttempts is sent straight to the dead-letter queue
rather than retried, so a job that keeps crashing its worker cannot loop forever.
Dead-letter queue
Jobs that exhaust their retries land in the failed status with
last_error captured. The gebweb worker dlq subcommand inspects
and recovers them. Like gebweb migrate, it connects directly via
$DATABASE_URL rather than loading your app:
gebweb worker dlq list # show failed jobs
gebweb worker dlq retry <id> [<id>...] # re-queue specific jobs
gebweb worker dlq retry --all # re-queue every failed job
gebweb worker dlq purge <id> [<id>...] # delete specific jobs
gebweb worker dlq purge --all # delete every failed job
retry resets a job to pending with its attempt counter cleared
and run_at in the past, so the next worker picks it up
immediately. purge deletes the rows outright. Both report how many
rows they touched; neither affects jobs in any other status.
Inspection
For ad-hoc dashboards or tests:
gebweb.jobs.get(app.jobConfig, id): ?dict<string, any>- fetch a row by id.gebweb.jobs.stats(app.jobConfig): dict<string, int>- counts keyed by status (pending,running,completed,failed).
Test pattern
Use the in-memory SQLite driver and runWorker in drain mode:
let app = gebweb.app([]);
let conn = db.connect("sqlite", ":memory:");
gebweb.useJobs(app, conn);
gebweb.registerJobHandlers(app, [MyHandler()]);
gebweb.enqueue(app, "my-job", {"x": 1});
gebweb.runWorker(app, {"drainOnce": true});
drainOnce: true processes every pending job - including
follow-ons enqueued by handlers during the drain - and returns
when the queue is empty. maxJobs: N is the safety-capped
variant: same drain semantics, but stop after N jobs total to
guard against runaway re-enqueueing in a buggy handler.
Scheduled tasks
The same worker process can run cron-style scheduled tasks.
Multiple worker processes coordinate via leader election on a
gebweb_scheduler_leader row, so scaling out doesn't double-fire.
class Cleanup {
@Scheduled("0 * * * *") /* every hour on the hour */
func sweepStaleSessions(): void {
db.exec("DELETE FROM sessions WHERE expires_at < ?", datetime.nowUnix());
}
@Scheduled("*/15 * * * *") /* every 15 minutes */
func refreshLeaderboard(): void {
leaderboardService.rebuild();
}
}
gebweb.useJobs(app, conn);
gebweb.useScheduler(app);
gebweb.registerScheduledTasks(app, [Cleanup()]);
Cron grammar (POSIX five-field subset): minute (0-59),
hour (0-23), day-of-month (1-31), month (1-12),
day-of-week (0-6, Sun=0; 7 also accepted as Sunday).
Each field accepts: *, N, N,M,O, N-M, */N, M-N/S.
When both day-of-month and day-of-week are restricted they are
AND-ed (so 0 0 1 * 1 means "midnight on the 1st only if that
day is Monday"). This differs from Vixie cron's OR semantics; the
AND behaviour matches what users expect more often.
gebweb.useScheduler(app, opts = {}) options:
| Option | Default | Meaning |
|---|---|---|
tickIntervalMs |
30000 |
Maximum delay between scheduler checks. The worker also ticks the scheduler between job polls. |
leaseSec |
60 |
How long a leader's claim remains valid. A leader refreshes its lease on every tick; if a leader dies, another worker takes over leaseSec later. |
workerId |
<hostname>:<pid> |
Tag for the gebweb_scheduler_leader row; usually default. |
conn |
the job queue's connection | Override the DB connection used for leader election. |
Scheduler exceptions are logged to stderr and swallowed - one
broken task does not stop the rest. For at-most-once delivery
under leader-election semantics, prefer enqueueing a regular
@Job from the scheduled task body (the job retry machinery
takes over from there).
Reference
| Helper | Purpose |
|---|---|
gebweb.useJobs(app, conn, opts = {}) |
Attach a job queue to the app. |
gebweb.registerJobHandlers(app, instances) |
Discover @Job handlers on each instance. |
gebweb.enqueue(app, name, payload, opts = {}) |
Insert a job row. Returns the new id. |
gebweb.runWorker(app, opts = {}) |
Block running jobs (and ticking the scheduler when configured). opts.maxJobs for tests. |
gebweb.Job |
Handler context (id, name, payload, attempts, app). |
gebweb.jobs.get(config, id) |
Fetch a row by id (mostly for tests / dashboards). |
gebweb.jobs.stats(config) |
Count rows by status. |
gebweb.useScheduler(app, opts = {}) |
Attach the scheduler. |
gebweb.registerScheduledTasks(app, instances) |
Discover @Scheduled handlers. |
gebweb.tickScheduler(app) |
Run one scheduler tick directly (tests). |
gebweb.scheduler.parseCron(text) |
Parse a cron expression to a CronExpr. |