ESC
Type to search...
S
Soli Docs

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.slEmailJob).
  • 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.cancel(job_id)

Cancel an enqueued (not yet started) job.

Job.cancel(job_id)
Job.list(queue?)

List jobs in a queue. Defaults to the configured default queue.

jobs = Job.list("mailers")
Job.queues()

Return all queues defined in the SolidB database.

names = Job.queues()

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.list()

List all cron entries.

entries = Cron.list()
Cron.update(id, fields)

Update an existing cron entry. Pass a hash of fields to change.

Cron.update(cron_id, { "schedule": "0 4 * * *" })
Cron.delete(id)

Delete a cron entry by id.

Cron.delete(cron_id)

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_SECRET is required. If it isn't set, Soli does not register /_jobs/run/:name at all — workers log a warning at boot and SolidB callbacks will get 404s until a secret is configured.
  • SolidB must include an X-Job-Signature header whose value is the lowercase hex HMAC-SHA256 of the raw request body, keyed with SOLI_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 reach cls.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.slEmailJob. A mismatch is a startup error.
  • perform must be static — 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 returns 503 so SolidB retries instead of failing.
  • In --dev mode, edits under app/jobs/ hot-reload the class without restarting the server.
  • Only worker 0 performs static cron auto-registration on boot, to avoid duplicate writes from multiple workers.