Give a task its own time.
Enqueue a typed job inside the transaction that needs it, drain it in a request spec, and run Cold Brew workers without a web server.
| Contract | Value |
|---|---|
| Needs | The machine setup of Your first application; the recipe generates its own Book resource |
| You will have | An ImportBook job on the imports queue, an action that enqueues it inside SugarORM::Repo.transaction, a request spec that drains the queue, reads the job’s status and rolls an enqueue back, and a worker started on both queues |
| Status | Run end to end by scripts/check page-jobs |
| Proved by | frappe check · frappe corretto · frappe lint |
Where the change goes
app/jobs/import_book.crThe typed jobapp/actions/imports/create.crEnqueues in a transactionconfig/routes.crThe import routespec/requests/imports_spec.crDrain, status, rollbackCreate the application and its Book
The application is bookshelf. The job writes books, so the Book resource comes first.
- Generate and migrate
Terminal · after machine setup frappe new bookshelf cd bookshelf frappe make resource Book title:string author:string frappe migrateExpect:
frappe newprintsCreated bookshelf., thenbookshelf is configured at https://bookshelf.caramel.make resourceprintsGenerated PATHfor each file it writes, thenRun frappe migrate to apply the new schema. Use frappe routes to see the generated URLs.frappe migrateprintsApplied 1 migrations., thenThe database matches the declared schema.
Write the job
A job is a struct under Caramel::ColdBrew::Job: queue names its queue, each param is one typed field of its payload, and perform does the work. ImportBook creates one book. A job runs at least once: a job that calls another service sends a stable identifier, so the receiver can drop a repeat.
- Declare the queue, the params and perform
app/jobs/import_book.cr module App struct ImportBook < Caramel::ColdBrew::Job queue "imports" param title : String param author : String def perform App::Book.create!(title: title, author: author) end end endExpect: The params give the job a generated
ImportBook.enqueue(title:, author:), which returns the job id asInt64;enqueuetakes noqueue:keyword, the class’squeueline decides. A job without that line is ondefault. A failed run follows the default retry rule, 3 runs with exponential backoff from 1 second, until aretry_online replaces it. The names a queue may have,retry_on,renamed_fromand the params that fail compilation are the job rules. Read the job rules →
Enqueue in the business transaction
enqueue writes the queue row through the connection SugarORM::Repo.transaction holds. The row commits with the rows the block writes and rolls back with them; a job never runs for a write that was rolled back. The action answers 202 with a fragment that names the job id.
- Write the action
app/actions/imports/create.cr module App::Imports struct Create < App::ApplicationAction contract do field title : String field author : String end def handle(contract : Contract) id = SugarORM::Repo.transaction do App::ImportBook.enqueue(title: contract.title, author: contract.author) end morph("#imports", with: "Import #{id} queued", status: 202) end end endExpect: A browser that posts a title and an author gets status 202 and an
hx-partialfor#importsthat readsImport N queued, N the idenqueuereturned. The book does not exist yet: the job creates it when a worker, or a spec’s drain, runs it. - Route it
config/routes.cr · inside Caramel::Router.draw post "/imports", App::Imports::CreateTerminal · inside the application frappe routes importsExpect: Lists
POST /imports App::Imports::Create title:String author:String.
Drain the queue in a spec
Corretto starts no workers. Caramel::ColdBrew.drain_queue!(db, "imports") runs every due job of the queue on the example’s connection and returns how many ran, so the first example asserts the book after the drain and the job’s state through Caramel::ColdBrew.status. The second example enqueues inside a transaction it rolls back: no queue row survives, and the drain returns 0.
- Write the examples
spec/requests/imports_spec.cr require "../spec_helper" describe "Importing a book" do it "queues the import, and the book exists once the queue drains" do Corretto.session do |client, db| queued = client.post("/imports", headers: {"HX-Request" => "true"}, params: {"title" => "Dune", "author" => "Frank Herbert"}) queued.should have_status(202) queued.should render_partial("#imports") db.should_not have_row(App::Book, title: "Dune") Caramel::ColdBrew.drain_queue!(db, "imports").should eq(1) db.should have_row(App::Book, title: "Dune", author: "Frank Herbert") Caramel::ColdBrew.drain_queue!(db, "imports").should eq(0) id = db.query_one("SELECT max(id) FROM caramel_jobs", as: Int64) status = Caramel::ColdBrew.status(db, id) status.try(&.state).should eq(Caramel::ColdBrew::JobState::Finished) end end it "keeps no queue row when the transaction rolls back" do Corretto.session do |_client, db| SugarORM::Repo.transaction do App::ImportBook.enqueue(title: "Dropped", author: "Nobody") SugarORM::Repo.rollback end Caramel::ColdBrew.drain_queue!(db, "imports").should eq(0) end end endExpect:
frappe correttoruns both examples; the proof below shows its output. The drain rules, whatstatusandstatusesreturn, the sixJobStatevalues, and the retry and failure hooks are listed in the reference. Read status, hooks and drain →
Run a worker-only process
The compiled application takes serve and work; work runs the queues without HTTP. frappe dev and the one-shot commands compile to .caramel/application; bin/bookshelf exists only in the tarball frappe build writes under .caramel/build/, beside public/, and the template git-ignores /bin/. Build the artifact →
work reads DATABASE_URL and CARAMEL_ENV, which is production when unset, and needs the migrations applied; it reads no APP_SECRET or APP_ORIGIN.
- Start workers on both queues
Terminal · where the artifact is unpacked bin/bookshelf work --queues=default,imports --concurrency=4Expect: Prints
Bookshelf worker is ready: queues default, imports; concurrency 4; scheduler onand runs until SIGTERM or SIGINT. With a migration pending it printsPending migrations. Run frappe migrate first.and exits; underCARAMEL_ENV=testit printsSpecs run no workers; drain queues with Caramel::ColdBrew.drain_queue!and exits.--no-schedulerends the ready line withscheduler off. The worker settings, the concurrency limit and the shutdown rules are listed in the reference. Read the worker settings →
A worker starts in the default locale. If a job produces translated output, carry Caramel.language in a locale payload field and wrap the work in Caramel::I18n.with. Carry language into background work →
Prove it
Prove the job
frappe check
frappe corretto
frappe lintPassing: frappe check and frappe lint exit 0. frappe corretto runs the import examples beside the generated book spec and ends with Corretto: 1 of 1 workers passed: the book exists only after the drain, the drained job reads Finished, and the rolled-back enqueue left nothing to drain.
scripts/check page-jobs creates bookshelf, generates and migrates the Book resource, copies the job, the action, the route and the spec at the steps above, lists the route, starts work --queues=default,imports --concurrency=4 on the compiled application until it prints its ready line, and runs frappe check, frappe corretto and frappe lint.