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,Cron) plus per-class facade methods. - A built-in callback route (
POST /_jobs/run/:name) that SolidB hits when a job fires.
When you enqueue a job, Soli sends it to SolidB along with a callback URL. When SolidB is ready to run it, it calls back into your app, which dispatches to XJob.perform(args).
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_now(args) |
Runs perform inline, in the current process. No SolidB round-trip. |
XJob.perform_later(args, queue?) |
Enqueues into SolidB. Returns the job id. |
XJob.perform_in(duration, args, queue?) |
Enqueues with a relative delay ("5 minutes", "2 hours", seconds as a number). |
XJob.perform_at(datetime, args, queue?) |
Enqueues to run at an ISO-8601 timestamp. |
XJob.set(queue: ...) |
Captures options and forwards to perform_later/perform_in/perform_at. |
XJob.schedule_cron(name, expr, args?) |
Idempotently registers a cron entry that triggers this class. |
WelcomeEmailJob.perform_now({ "user_id": 42 }) # Inline, no queue
WelcomeEmailJob.perform_later({ "user_id": 42 }) # Enqueue
WelcomeEmailJob.perform_in("5 minutes", { "user_id": 42 })
WelcomeEmailJob.perform_at("2026-05-01T08:00:00Z", { "user_id": 42 })
WelcomeEmailJob.set(queue: "mailers").perform_later({ "user_id": 42 })
Job Class API
Job.enqueue(handler, args, queue?)
Enqueue a job by handler name. Returns the SolidB-issued job id.
job_id = Job.enqueue("WelcomeEmailJob", { "user_id": 42 })
Job.enqueue_in(handler, duration, args, queue?)
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?)
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")
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 job fires | http://127.0.0.1:3000/_jobs/run |
SOLI_JOBS_SECRET |
Required. HMAC-SHA256 key used to sign and verify job callbacks | unset |
The callback URL must be reachable from the SolidB server. In production, set it to your Soli app's public URL plus /_jobs/run.
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.
SOLI_JOBS_SECRETis required. If it isn't 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 must include an
X-Job-Signatureheader whose value is the lowercase hex HMAC-SHA256 of the raw request body, keyed withSOLI_JOBS_SECRET. - 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_JOBS_SECRET")); # hex HMAC-SHA256
# POST /_jobs/run/WelcomeEmailJob with body and X-Job-Signature: <sig>
Use a long, random value for SOLI_JOBS_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
fn 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
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.