Work that outlives the request.
Define typed jobs, run workers and schedules, publish notifications, and cache strings in your application’s PostgreSQL database.
| Contract | Value |
|---|---|
| Release | 0.15.0 |
| Needs | A Caramel application and its PostgreSQL database |
| Covers | Jobs, retries, status and hooks, renames, workers, schedules, PubSub, cache |
| Proved by | frappe corretto on a spec that drains the queue |
Learning it for the first time? The recipe Run a background job walks through it in order. This page lists every name and limit.
Limits at a glance
| Limit | Value |
|---|---|
Queue and channel names: a-z, digits, _ . : - | 1–63 characters |
| Default retry: exponential backoff from 1 second | 3 runs |
| Default worker fibers per queue | 4 |
| Worker fibers across all queues, at most | 31 |
| Worker connections: one per fiber, plus one | 32 |
| Maintenance runs in every process | 60 s |
| A lock older than this whose session has ended is released | 15 min |
| Releases of one lock before the job is failed as abandoned | 3 |
| Job partitions: created through that many days ahead, dropped once ended more than that many days ago | 7 days |
| Shortest schedule span | 1 s |
| Longest wait before a raising schedule block runs again | 1 minute |
| PubSub payload; publish an id instead | 8,000 bytes |
| Payload not received within this time is dropped | 1 s |
| Payloads waiting per subscriber before dropping | 10,000 |
A failed job’s last_error: class, message and backtrace lines | 4,000 characters, 12 lines |
| One drain in a spec, then it raises | 1,000 jobs |
A job is a struct with typed params
A job is a struct that extends Caramel::ColdBrew::Job and implements perform. Each param declares a typed field and its getter. The params form the JSON payload stored with the job. A default value makes the param optional when enqueueing.
struct SendInvitation < Caramel::ColdBrew::Job
queue "mailers"
retry_on IO::TimeoutError, attempts: 5, backoff: :exponential, base: 2.seconds
param invite_id : Int64
param note : String = ""
def perform
# Load the invite by invite_id and send it.
end
end| Condition | Outcome |
|---|---|
A job without queue | uses default |
A queue that is not a literal name of 1 to 63 characters from a-z, digits, _, ., : and - | fails compilation |
| Declaring the queue twice, or a param twice | fails compilation |
A param named run_at or priority | fails compilation |
| Param types | must round-trip through JSON |
A payload has no size limit of its own; store an id and load the record in perform. A PubSub payload does: 8,000 bytes, below.
Failed runs retry by rule
retry_on takes an exception class, attempts, backoff and base. attempts counts every run, the first included, except runs that maintenance released after their process died.
:exponentialwaits base * 2**(attempt - 1):linearwaits base * attemptRules on the job come first, then rules on its abstract parents, then global rules. The first matching rule wins.
Caramel::ColdBrew::Job.retry_on Socket::ConnectError,
attempts: 6, backoff: :linear, base: 3.seconds| Condition | Outcome |
|---|---|
perform raises | rolls back its transaction; Cold Brew chooses a retry rule |
| No rule matches | the job runs 3 times with exponential backoff from 1 second |
| A job whose class name no compiled job claims | fails at once, without retries |
| The last run fails | is failed for good |
| A failed row | keeps the error’s class, message and first 12 backtrace lines in last_error, up to 4,000 characters; APP jobs show ID prints it |
Status and hooks name the class, never the message
Caramel::ColdBrew.status(id) reads one job’s row through the current Repo connection; status(db, id) reads it on a handle, such as a spec’s db. statuses(ids) and statuses(db, ids) read many in one query and return a Hash(Int64, JobStatus).
status = Caramel::ColdBrew.status(db, id)
status.try(&.state) # => Caramel::ColdBrew::JobState::Queued| Name | Meaning |
|---|---|
JobStatus | id, queue, class_name, state, attempts (runs so far, one in progress included), run_at, enqueued_at, locked_at, finished_at, failed_at and error_class, the class of the newest failed run. It serializes to JSON. |
JobState::Scheduled | Waiting for a run_at in the future; it has not run. |
JobState::Queued | Due and waiting for a worker; it has not run. |
JobState::Running | Locked by a worker. A lock whose process died stays until maintenance releases it. |
JobState::Retrying | Ran and failed, and waits for its next attempt at run_at. |
JobState::Finished | Ran and succeeded. |
JobState::Failed | Failed for good; it will not run again. |
Two hooks observe failed runs. Register them at boot, before serve or work starts.
Caramel::ColdBrew.on_retry_scheduled do |event|
Caramel::ColdBrew.publish("deliveries", event.to_json)
end
Caramel::ColdBrew.on_failed do |event|
Log.error { "#{event.class_name} ##{event.id} failed: #{event.error_class}" }
end| Condition | Outcome |
|---|---|
on_retry_scheduled | runs after a failed run is rescheduled, with a RetryScheduled: id, queue, class_name, attempts, the new run_at and error_class |
on_failed | runs after a job fails for good, with a JobFailed: id, queue, class_name, attempts, failed_at and error_class |
| A hook under a worker | runs on the worker’s fiber after the transition has committed, in a transaction of its own: its publish is delivered and its enqueue committed when it returns |
| A hook that raises | rolls back only its own writes and is reported to Crema by class, with the source cold_brew.hooks on_failed or on_retry_scheduled; the job’s transition stands |
A hook under drain_queue! | runs inside the example’s transaction |
| A job released after its process died | fires no hook until its third release, when maintenance fails it with AbandonedJob and the on_failed hooks run |
status for a job maintenance dropped with its partition | returns nil |
Enqueue joins the business transaction
enqueue takes one keyword per param, plus run_at and priority. It returns the job’s Int64 id.
SugarORM::Repo.transaction do
invite = Invite.create!(email: "elena@acme.com")
SendInvitation.enqueue(invite_id: invite.id)
end
id = SendInvitation.enqueue(invite_id: 42, run_at: 10.minutes.from_now, priority: 5)
SendInvitation.enqueue(db, invite_id: 42, note: "resend")| Condition | Outcome |
|---|---|
| An unknown keyword or a mistyped value | fails compilation at the call |
enqueue inside SugarORM::Repo.transaction | commits or rolls back with the business write, because the row is written through SugarORM::Repo’s current connection |
run_at | defaults to now; a job runs once it is due |
A higher priority | runs first; the default is 0 |
| Equal priorities | run in id order |
enqueue(db, …) with a SugarORM::Handle first | writes on that handle, such as a spec’s db |
A renamed job keeps its queued rows
A row stores the job’s fully qualified class name, and a worker runs the compiled job of that name. enqueue always stores the current name, so after you rename or move a class, rows queued before the deploy still carry the old one. List the old names on the class that replaced it.
struct App::RestockTea < Caramel::ColdBrew::Job
renamed_from "App::Restock", "App::Stock::Restock"
param tea_id : Int64
def perform
# Runs rows queued as App::Restock or App::Stock::Restock, too.
end
end| Condition | Outcome |
|---|---|
A row queued under any name in renamed_from | runs that class with its stored params and its retry rules |
renamed_from that is not string literals of fully qualified class names | fails compilation |
renamed_from declared more than once for a job | fails compilation |
renamed_from on an abstract parent | fails compilation |
The job’s own class, or a name that is another job’s class or alias, in renamed_from | fails compilation |
Caramel::ColdBrew.unknown_queued_class_names(db) | returns the sorted class names of waiting rows, neither locked, finished nor failed, that this binary compiles neither as a job nor as an alias; empty when every queued job can run |
work and migrate with such names | log a warning naming them and carry on |
Keep an old name until no row carries it. Because those commands only warn, run the new binary’s migrate before the old one stops, and read the list from a deploy script that fails on it.
names = Caramel::ColdBrew.unknown_queued_class_names(db)
abort "Stranded jobs: #{names.join(", ")}" unless names.empty?serve and work run the workers
The application binary’s serve command starts Cold Brew beside the HTTP server. Its work command starts the same workers, maintenance and schedules without HTTP. Both read queue settings from the environment, and work’s flags replace them. Both need DATABASE_URL, CARAMEL_ENV and applied migrations; the deployment guide gives the environment.
| Setting | Meaning |
|---|---|
CARAMEL_WORKER_QUEUES | Comma-separated queue names, each once; defaults to default |
CARAMEL_WORKER_CONCURRENCY | Fibers per queue; defaults to 4 |
--queues=NAMES | work only: replaces CARAMEL_WORKER_QUEUES |
--concurrency=N | work only: replaces CARAMEL_WORKER_CONCURRENCY |
--no-scheduler | work only: leaves every schedule to another process |
--no-workers | serve only: keeps PubSub and maintenance, runs no jobs or schedules, and reads neither queue setting |
bin/bookshelf work --queues=default,mailers --concurrency=4Expect: Prints Bookshelf worker is ready: queues default, mailers; concurrency 4; scheduler on once the workers are claiming, with the application’s title first.
bin/bookshelf work --queues=default --no-schedulerExpect: Prints Bookshelf worker is ready: queues default; concurrency 4; scheduler off. Every schedule is left to another process; maintenance still runs here.
| Condition | Outcome |
|---|---|
work or serve with pending migrations | stops with Pending migrations. Run frappe migrate first. |
work with CARAMEL_ENV=test | stops with Specs run no workers; drain queues with Caramel::ColdBrew.drain_queue! |
serve with CARAMEL_ENV=test | starts no Cold Brew at all |
| A queue name outside the rule, a queue named twice, or a concurrency outside its range | exits 1 with the message, before Cold Brew’s pool opens |
| Concurrency per queue | is at most 31 divided by the number of queues, rounded down: 7 for 4 queues; with 8 or more queues the default 4 is outside the range |
| The workers’ connection pool | holds one connection per fiber plus one, up to 32, so jobs never wait for request connections |
| SIGTERM or SIGINT | workers stop claiming jobs; in-flight jobs and schedule blocks finish before the process exits |
Maintenance runs every 60 seconds in every process, even with --no-scheduler.
| Condition | Outcome |
|---|---|
| A lock older than 15 minutes whose database session has ended | is released, so that job runs again |
| A job released a third time | is failed with AbandonedJob and its on_failed hooks run; each release is counted apart from attempts. APP jobs retry ID runs it again |
| Expired cache entries | are deleted |
| Daily job partitions | are created from today through seven days ahead, in UTC |
| A partition that ended more than 7 days ago and holds only finished or failed jobs | is dropped; status then returns nil for those jobs |
A schedule runs once per period
Caramel::ColdBrew.every(span, name) declares a block that runs once per period. Declare schedules at boot, before serve or work starts.
Caramel::ColdBrew.every(1.hour, "nightly-cleanup") { CleanupJob.enqueue }| Condition | Outcome |
|---|---|
| Every process | ticks each schedule, but a database lease lets one process run the block per period |
| The block | runs inside the lease’s transaction, so its writes commit with the lease |
| A span under 1 second, or a blank or repeated name | raises ArgumentError |
| A block that raises | is logged and tried again after its span or one minute, whichever is shorter |
Notifications arrive at most once
publish(channel, payload) sends a string with PostgreSQL pg_notify on the current Repo connection.
SugarORM::Repo.transaction do
Caramel::ColdBrew.publish("board_#{board_id}", board_id.to_s)
end| Condition | Outcome |
|---|---|
publish inside a transaction | delivers the notification only when the transaction commits |
| A payload over 8,000 bytes, or a channel name outside 1 to 63 characters from the queue-name set | raises ArgumentError; publish an id and read the record instead |
| Form | Behaviour |
|---|---|
subscribe(channel) { |updates| … } | Creates a Channel(String) and unsubscribes when the block ends |
subscribe(channel, subscriber) | Delivers into your channel until unsubscribe, a closed channel, or the end of the fiber that subscribed |
A streaming action’s subscription therefore ends with its stream.
stream "text/event-stream" do |io|
Caramel::ColdBrew.subscribe("board_#{id}") do |updates|
loop { Caramel::SSE.write(io, event: "BoardUpdated", data: updates.receive) }
end
end| Condition | Outcome |
|---|---|
subscribe in a process without a broker (serve and work start one; elsewhere set Caramel::ColdBrew.broker = Caramel::ColdBrew::Broker.new(database_url) first) | raises Caramel::ColdBrew::ConfigurationError |
| Delivery | is at most once; each subscriber receives payloads in publish order |
| A payload not received within 1 second, a payload beyond 10,000 waiting, or a notification sent while the broker reconnects | is dropped |
The cache stores strings
Caramel::Cache stores string values in the unlogged caramel_cache table, through the current Repo connection.
report = Caramel::Cache.fetch("report:2026-10", expires_in: 10.minutes) do
"computed report"
end
Caramel::Cache.write("banner", "Maintenance at noon")
Caramel::Cache.read("banner") # => "Maintenance at noon"
Caramel::Cache.delete("banner") # => true| Call | Returns |
|---|---|
write(key, value, expires_in) | Inserts or replaces; nil expires_in keeps the entry |
read(key) | The value, or nil when missing or expired |
fetch(key, expires_in) { … } | The cached value, else the block’s value, written |
delete(key) | True when an entry was removed |
clear | Deletes every entry |
vacuum | Deletes expired entries and returns the count |
| Condition | Outcome |
|---|---|
A zero or negative expires_in | raises ArgumentError |
| Maintenance | vacuums expired entries every minute |
Keep only values you can compute again.
Prove it
Drain queues in specs
Specs run no workers. Caramel::ColdBrew.drain_queue!(db, queue) runs the queue’s due jobs synchronously on db, including jobs that those jobs enqueue. It returns the number of runs. drain_queue is the same without raising.
| Condition | Outcome |
|---|---|
| On a spec’s transaction | each job runs in a savepoint |
| A failed job | follows its retry rule and does not run again in the same drain |
A failed job in drain_queue! | raises DrainFailure, listing every failed run |
A failed job in drain_queue | does not raise |
include_scheduled: true | runs jobs that are not yet due |
| A drain that has run 1,000 jobs while more are still due | raises DrainLimitExceeded |
This spec enqueues the job from the first section on a new application’s db, drains its queue and reads its status.
require "../spec_helper"
describe SendInvitation do
it "runs once its queue is drained" do
Corretto.session do |_client, db|
id = SendInvitation.enqueue(db, invite_id: 42)
Caramel::ColdBrew.drain_queue!(db, "mailers").should eq(1)
Caramel::ColdBrew.status(db, id).not_nil!.state
.should eq(Caramel::ColdBrew::JobState::Finished)
end
end
endfrappe corretto spec/jobs/send_invitation_spec.crPassing: [w1] 1 examples, 0 failures, 0 errors, 0 pending, then Corretto: 1 of 1 workers passed. A failed run would make drain_queue! raise DrainFailure, listing every failed run.