Background Jobs & Cron
SolidB-backed queues and scheduled jobs. Define a handler in app/jobs/, call .perform_later, and SolidB takes care of scheduling and retries.
How it works
SolidB owns the queue, scheduling, and retries. Soli provides:
- A file-based handler convention (
app/jobs/email_job.sl↔EmailJob). - A small language-side API (
Job,Webhook,Cron) plus per-class facade methods. - A built-in callback route (
POST /_jobs/run/:name) that SolidB hits when a class-target job fires.
When you enqueue a class-target job, Soli sends it to SolidB along with a callback URL. When SolidB is ready to run it, it POSTs back into your app with an HMAC-signed payload, which dispatches to XJob.perform(args). When you enqueue via Webhook, SolidB POSTs straight to the URL you supplied — no Soli-side dispatcher in the loop.
Defining a Job
Create a file under app/jobs/. Filename and class name follow the same convention as controllers and models — welcome_email_job.sl defines class WelcomeEmailJob.
class WelcomeEmailJob
static def perform(args: Hash)
user = User.find(args["user_id"])
Mailer.send(user.email, "Welcome to the app")
end
end
Every job class must define static def perform(args: Hash). That's the entry point SolidB triggers when the job runs.
Per-class Facade
Job classes get a set of static helpers automatically — no inheritance needed.
| Method | Behavior |
|---|---|
XJob.perform_later(args, queue_or_opts?) |
Enqueues into SolidB. Returns the job id. |
XJob.perform_in(duration, args, queue_or_opts?) |
Enqueues with a relative delay ("5 minutes", "2 hours", seconds as a number). |
XJob.perform_at(datetime, args, queue_or_opts?) |
Enqueues to run at an ISO-8601 timestamp. |
XJob.schedule_cron(name, expr, args?) |
Idempotently registers a cron entry that triggers this class. |
The trailing queue_or_opts argument is either a queue-name string or an options hash { queue, priority, max_retries } — priority is an Int and higher runs first.
WelcomeEmailJob.perform_later({ "user_id": 42 }) # Default queue
WelcomeEmailJob.perform_in("5 minutes", { "user_id": 42 })
WelcomeEmailJob.perform_at("2026-05-01T08:00:00Z", { "user_id": 42 })
WelcomeEmailJob.perform_later({ "user_id": 42 }, "mailers") # Pick a queue
WelcomeEmailJob.perform_later({ "user_id": 42 }, { "queue": "mailers", "priority": 10 })
Job Class API
Job.enqueue(handler, args, queue_or_opts?)
Enqueue a job by handler name. Returns the SolidB-issued job id. The trailing argument is a queue-name string or an options hash { queue, priority, max_retries } (higher priority runs first).
job_id = Job.enqueue("WelcomeEmailJob", { "user_id": 42 })
Job.enqueue("WelcomeEmailJob", { "user_id": 42 }, { "queue": "mailers", "priority": 10 })
Job.enqueue_in(handler, duration, args, queue_or_opts?)
Enqueue with a relative delay. duration accepts "5 minutes", "1 hour", "2 days", etc., or a number of seconds.
Job.enqueue_in("WelcomeEmailJob", "30 minutes", { "user_id": 42 })
Job.enqueue_at(handler, datetime, args, queue_or_opts?)
Enqueue to run at a specific ISO-8601 timestamp.
Job.enqueue_at("WelcomeEmailJob", "2026-05-01T08:00:00Z", { "user_id": 42 })
Job.list(queue?)
List jobs in a queue. Defaults to the configured default queue.
jobs = Job.list("mailers")
Webhook Class API
For work that targets a URL rather than a Soli handler class. SolidB itself fires the HTTP request when the job runs — no Soli-side dispatcher involved.
When the job fires, SolidB POSTs the payload as JSON with X-Webhook-Event: job, X-Webhook-Delivery: <job-id>, and (when a secret is set) X-Webhook-Signature (lowercase hex HMAC-SHA256 of the body). Non-2xx responses count as failure and are retried with the same exponential backoff as script jobs.
Webhook.enqueue(url, payload, opts?)
Enqueue an HTTP POST to url with payload as the JSON body. Returns the SolidB-issued job id.
Webhook.enqueue(
"https://hooks.slack.com/services/T00/B00/abc",
{ "text": "Order #1234 shipped" }
)
Webhook.enqueue_in(url, duration, payload, opts?)
Enqueue with a relative delay. duration accepts "5 minutes", "1 hour", "2 days", etc., or a number of seconds.
Webhook.enqueue_in(
"https://api.example.com/order-completed",
"5 minutes",
{ "order_id": 1234 }
)
Webhook.enqueue_at(url, datetime, payload, opts?)
Enqueue to fire at a specific ISO-8601 timestamp.
Webhook.enqueue_at(
"https://api.example.com/daily-summary",
"2026-05-01T08:00:00Z",
{ "report": "daily" }
)
Options Hash
The optional last argument controls queue placement, retries, signing, and outgoing headers.
| Key | Type | Purpose |
|---|---|---|
queue |
String | Queue name (defaults to SOLI_JOBS_DEFAULT_QUEUE). |
priority |
Int | Higher executes first. |
max_retries |
Int | Retry budget. |
secret |
String | Per-job HMAC key (overrides SOLI_WEBHOOK_SECRET). |
headers |
Hash | Extra HTTP headers attached to the outgoing request. |
Webhook.enqueue(
"https://api.partner.test/event",
{ "kind": "user.created", "user_id": user.id },
{
"queue": "external",
"priority": 10,
"secret": getenv("PARTNER_HMAC_SECRET"),
"headers": { "Authorization": "Bearer " + getenv("PARTNER_TOKEN") }
}
)
Webhook.cancel(job_id) and Webhook.list(queue?) operate on the same underlying _jobs collection as Job — webhook and class jobs share a queue.
Cron Class API
Cron.schedule(name, expr, handler, args?)
Idempotent upsert by name. Calling twice with the same name updates the existing entry rather than creating a duplicate.
Cron.schedule("nightly_report", Cron.daily_at("03:00"), "ReportJob", {})
Cron.schedule("warm_cache", Cron.every("5 minutes"), "WarmCacheJob", {})
Cron.update(id, fields)
Update an existing cron entry. Pass a hash of fields to change.
Cron.update(cron_id, { "schedule": "0 4 * * *" })
Cron Expression Helpers
Pure string builders — they produce standard 5-token cron strings. Calling them has no side effect; only Cron.schedule writes to SolidB.
| Helper | Cron string |
|---|---|
Cron.every("5 minutes") |
*/5 * * * * |
Cron.every("1 hour") |
0 * * * * |
Cron.every("2 hours") |
0 */2 * * * |
Cron.every("1 day") |
0 0 */1 * * |
Cron.hourly() |
0 * * * * |
Cron.daily_at("03:00") |
0 3 * * * |
Cron.weekly_at("monday", "09:00") |
0 9 * * 1 |
You can always pass a raw cron string instead.
Declarative static cron
A class can declare a static cron field. On boot, worker 0 upserts a cron entry named after the class — the auto-derived name is the snake-case of the class (nightly_report_job).
class NightlyReportJob
static cron = Cron.daily_at("03:00")
static def perform(args: Hash)
Report.generate()
end
end
Re-registers idempotently on hot reload. Removing the field does not auto-delete the SolidB entry — call Cron.delete(id) explicitly to avoid surprise data loss.
Environment Variables
Configure jobs and cron via env vars (typically in .env):
| Variable | Description | Default |
|---|---|---|
SOLI_JOBS_DATABASE |
SolidB database hosting queues + cron entries | SOLIDB_DATABASE then default |
SOLI_JOBS_DEFAULT_QUEUE |
Queue name when none is supplied | default |
SOLI_JOBS_CALLBACK_URL |
URL SolidB POSTs to when a Soli Job fires |
http://127.0.0.1:3000/_jobs/run |
SOLI_WEBHOOK_SECRET |
Required. HMAC-SHA256 key used to sign and verify callbacks | unset |
SOLI_JOBS_SECRET |
Legacy alias for SOLI_WEBHOOK_SECRET; still accepted |
unset |
SOLI_JOB_WORKERS |
Size of the in-process pool for static background: Bool = true jobs; 0 disables (all jobs run inline) |
2 |
The callback URL must be reachable from the SolidB server. In production, set it to your Soli app's public URL plus /_jobs/run.
Long-Running Jobs (static background)
By default perform runs synchronously inside the callback — SolidB holds the connection open until it returns. A job that runs for minutes trips SolidB's callback timeout, which marks it failed and retries it (so it runs twice, concurrently), and it occupies a web worker the whole time.
Add static background: Bool = true to run the job on a dedicated in-process pool instead. The callback acks 200 queued immediately, so there's no timeout, no duplicate retry, and no worker held:
class MonthlyReportJob
static background: Bool = true # run on the background pool
static def perform(args: Hash)
# Heavy work — bulk queries, exports, PDF generation — runs with no
# timeout and without holding a web worker. Owns its own error handling.
generate_report(args["month"])
end
end
Delayed (perform_in/perform_at) and static cron jobs honor the flag too — it's read at callback time.
Fire-and-forget. Because Soli acks before the work runs, SolidB never sees a backgrounded job's failure and will not retry it. Backgrounded jobs must own their reliability:
- Make
performidempotent and handle/log its own errors (a raised error just prints). - Re-enqueue or checkpoint yourself if you need at-least-once semantics.
- Keep the default synchronous mode for short jobs where SolidB's automatic retry is the point.
Sized by SOLI_JOB_WORKERS (default 2); each worker holds its own loaded interpreter. SOLI_JOB_WORKERS=0 disables backgrounding entirely — every job runs inline as before.
Signed Callbacks
The POST /_jobs/run/:name route dispatches to XJob.perform(args) on whichever class the URL names — it can call any loaded class with a static perform. To stop a passing client from invoking arbitrary code, every callback must carry a valid signature.
- A webhook secret is required. If neither
SOLI_WEBHOOK_SECRETnorSOLI_JOBS_SECRETis set, Soli does not register/_jobs/run/:nameat all — workers log a warning at boot and SolidB callbacks will get 404s until a secret is configured. - SolidB sends
X-Webhook-Signature(canonical) whose value is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the configured secret. The legacyX-Job-Signatureheader is also accepted by the dispatcher. - Soli verifies the signature with
secure_compare()(constant-time), so callers can't probe the secret by timing 401 responses. - Bad signature →
401, missing class →503, handler error →500. Only a valid signature lets the request reachcls.perform(args).
Computing the signature yourself (e.g. for an integration test):
let body = json_stringify({ "args": { "user_id": 42 } });
let sig = hmac(body, getenv("SOLI_WEBHOOK_SECRET")); # hex HMAC-SHA256
# POST /_jobs/run/WelcomeEmailJob with body and X-Webhook-Signature: <sig>
Use a long, random value for the secret (32+ bytes from a CSPRNG). Rotate it the same way you'd rotate any HMAC key — both Soli and the SolidB sender need to flip together.
Common Patterns
Send an email asynchronously
class WelcomeEmailJob
static def perform(args: Hash)
user = User.find(args["user_id"])
Mailer.send(user.email, "Welcome!")
end
end
def create
user = User.create(req["json"])
WelcomeEmailJob.perform_later({ "user_id": user.id })
return redirect("/users/" + str(user.id))
end
Recurring report (declarative)
class NightlyReportJob
static cron = Cron.daily_at("03:00")
static def perform(args: Hash)
Report.generate()
AdminMailer.send_summary()
end
end
Fire-and-forget webhook to a third party
No handler class needed — SolidB POSTs the payload directly to the URL when the job is due. Retries on non-2xx with exponential backoff.
def create
order = Order.create(req["json"])
Webhook.enqueue(
"https://hooks.slack.com/services/T00/B00/abc",
{ "text": "Order #" + str(order.id) + " placed" },
{ "queue": "external", "max_retries": 5 }
)
return redirect("/orders/" + str(order.id))
end
Idempotent retries
SolidB owns retry semantics. Treat handlers as idempotent. The callback receives job_id and attempt in the request body if you need to dedupe.
class ChargeJob
static def perform(args: Hash)
charge_id = args["charge_id"]
if Charge.find(charge_id).processed
return # already done — no-op on retry
end
Charge.process(charge_id)
end
end
Notes
- Filename and class name must match.
email_job.sl↔EmailJob. A mismatch is a startup error. performmust bestatic— it's invoked on the class, never on an instance.- Job arguments round-trip through JSON via SolidB; pass plain hashes / arrays / strings / numbers, not class instances.
- If a callback arrives before
app/jobs/finishes loading, the dispatcher returns503so SolidB retries instead of failing. - In
--devmode, edits underapp/jobs/hot-reload the class without restarting the server. - Only worker 0 performs
static cronauto-registration on boot, to avoid duplicate writes from multiple workers.