Skip to content
carameldocs · 0.16.0
Browse documentation
Reference / Cold Brew

Work that outlives the request.

Define typed jobs, run workers and schedules, publish notifications, and cache strings in your application’s PostgreSQL database.

ContractValue
Release0.15.0
NeedsA Caramel application and its PostgreSQL database
CoversJobs, retries, status and hooks, renames, workers, schedules, PubSub, cache
Proved byfrappe 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

LimitValue
Queue and channel names: a-z, digits, _ . : -1–63 characters
Default retry: exponential backoff from 1 second3 runs
Default worker fibers per queue4
Worker fibers across all queues, at most31
Worker connections: one per fiber, plus one32
Maintenance runs in every process60 s
A lock older than this whose session has ended is released15 min
Releases of one lock before the job is failed as abandoned3
Job partitions: created through that many days ahead, dropped once ended more than that many days ago7 days
Shortest schedule span1 s
Longest wait before a raising schedule block runs again1 minute
PubSub payload; publish an id instead8,000 bytes
Payload not received within this time is dropped1 s
Payloads waiting per subscriber before dropping10,000
A failed job’s last_error: class, message and backtrace lines4,000 characters, 12 lines
One drain in a spec, then it raises1,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.

app/jobs/send_invitation.cr
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
ConditionOutcome
A job without queueuses 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 twicefails compilation
A param named run_at or priorityfails compilation
Param typesmust 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 * attempt

Rules on the job come first, then rules on its abstract parents, then global rules. The first matching rule wins.

Application boot · a rule for every job
Caramel::ColdBrew::Job.retry_on Socket::ConnectError,
  attempts: 6, backoff: :linear, base: 3.seconds
ConditionOutcome
perform raisesrolls back its transaction; Cold Brew chooses a retry rule
No rule matchesthe job runs 3 times with exponential backoff from 1 second
A job whose class name no compiled job claimsfails at once, without retries
The last run failsis failed for good
A failed rowkeeps 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).

Inside an action or a spec
status = Caramel::ColdBrew.status(db, id)
status.try(&.state) # => Caramel::ColdBrew::JobState::Queued
NameMeaning
JobStatusid, 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::ScheduledWaiting for a run_at in the future; it has not run.
JobState::QueuedDue and waiting for a worker; it has not run.
JobState::RunningLocked by a worker. A lock whose process died stays until maintenance releases it.
JobState::RetryingRan and failed, and waits for its next attempt at run_at.
JobState::FinishedRan and succeeded.
JobState::FailedFailed for good; it will not run again.

Two hooks observe failed runs. Register them at boot, before serve or work starts.

Application boot
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
ConditionOutcome
on_retry_scheduledruns after a failed run is rescheduled, with a RetryScheduled: id, queue, class_name, attempts, the new run_at and error_class
on_failedruns after a job fails for good, with a JobFailed: id, queue, class_name, attempts, failed_at and error_class
A hook under a workerruns 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 raisesrolls 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 diedfires 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 partitionreturns nil

Enqueue joins the business transaction

enqueue takes one keyword per param, plus run_at and priority. It returns the job’s Int64 id.

Inside an action or another job
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")
ConditionOutcome
An unknown keyword or a mistyped valuefails compilation at the call
enqueue inside SugarORM::Repo.transactioncommits or rolls back with the business write, because the row is written through SugarORM::Repo’s current connection
run_atdefaults to now; a job runs once it is due
A higher priorityruns first; the default is 0
Equal prioritiesrun in id order
enqueue(db, …) with a SugarORM::Handle firstwrites 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.

app/jobs/restock_tea.cr
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
ConditionOutcome
A row queued under any name in renamed_fromruns that class with its stored params and its retry rules
renamed_from that is not string literals of fully qualified class namesfails compilation
renamed_from declared more than once for a jobfails compilation
renamed_from on an abstract parentfails compilation
The job’s own class, or a name that is another job’s class or alias, in renamed_fromfails 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 nameslog 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.

Application boot or a deploy script
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.

SettingMeaning
CARAMEL_WORKER_QUEUESComma-separated queue names, each once; defaults to default
CARAMEL_WORKER_CONCURRENCYFibers per queue; defaults to 4
--queues=NAMESwork only: replaces CARAMEL_WORKER_QUEUES
--concurrency=Nwork only: replaces CARAMEL_WORKER_CONCURRENCY
--no-schedulerwork only: leaves every schedule to another process
--no-workersserve only: keeps PubSub and maintenance, runs no jobs or schedules, and reads neither queue setting
Terminal · where the unpacked artifact runs
bin/bookshelf work --queues=default,mailers --concurrency=4

Expect: Prints Bookshelf worker is ready: queues default, mailers; concurrency 4; scheduler on once the workers are claiming, with the application’s title first.

Terminal · where the unpacked artifact runs
bin/bookshelf work --queues=default --no-scheduler

Expect: Prints Bookshelf worker is ready: queues default; concurrency 4; scheduler off. Every schedule is left to another process; maintenance still runs here.

Run the process roles in production →
ConditionOutcome
work or serve with pending migrationsstops with Pending migrations. Run frappe migrate first.
work with CARAMEL_ENV=teststops with Specs run no workers; drain queues with Caramel::ColdBrew.drain_queue!
serve with CARAMEL_ENV=teststarts no Cold Brew at all
A queue name outside the rule, a queue named twice, or a concurrency outside its rangeexits 1 with the message, before Cold Brew’s pool opens
Concurrency per queueis 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 poolholds one connection per fiber plus one, up to 32, so jobs never wait for request connections
SIGTERM or SIGINTworkers 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.

ConditionOutcome
A lock older than 15 minutes whose database session has endedis released, so that job runs again
A job released a third timeis 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 entriesare deleted
Daily job partitionsare created from today through seven days ahead, in UTC
A partition that ended more than 7 days ago and holds only finished or failed jobsis 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.

Application boot
Caramel::ColdBrew.every(1.hour, "nightly-cleanup") { CleanupJob.enqueue }
ConditionOutcome
Every processticks each schedule, but a database lease lets one process run the block per period
The blockruns inside the lease’s transaction, so its writes commit with the lease
A span under 1 second, or a blank or repeated nameraises ArgumentError
A block that raisesis 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.

Inside a job’s perform method
SugarORM::Repo.transaction do
  Caramel::ColdBrew.publish("board_#{board_id}", board_id.to_s)
end
ConditionOutcome
publish inside a transactiondelivers 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 setraises ArgumentError; publish an id and read the record instead
FormBehaviour
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.

Inside an action’s handle method
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
ConditionOutcome
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
Deliveryis 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 reconnectsis dropped

The cache stores strings

Caramel::Cache stores string values in the unlogged caramel_cache table, through the current Repo connection.

Inside an action
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
CallReturns
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
clearDeletes every entry
vacuumDeletes expired entries and returns the count
ConditionOutcome
A zero or negative expires_inraises ArgumentError
Maintenancevacuums 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.

ConditionOutcome
On a spec’s transactioneach job runs in a savepoint
A failed jobfollows 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_queuedoes not raise
include_scheduled: trueruns jobs that are not yet due
A drain that has run 1,000 jobs while more are still dueraises DrainLimitExceeded

This spec enqueues the job from the first section on a new application’s db, drains its queue and reads its status.

spec/jobs/send_invitation_spec.cr
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
end
Terminal · inside the application
frappe corretto spec/jobs/send_invitation_spec.cr

Passing: [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.

Test requests with Corretto →