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

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.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 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 perform idempotent 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_SECRET nor SOLI_JOBS_SECRET is 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 sends X-Webhook-Signature (canonical) whose value is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the configured secret. The legacy X-Job-Signature header 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 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_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.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.