Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 1 Nov 2026. What's built · Install 0.2.0
jadpo
Guides: all 11

Guides

Run background work

You end with a schedule that starts a batch, an event with its subscriber, and a test of the whole run.

On this page

Some work happens on no request: a reminder sent each day, or a follow-on step after a change. Jadpo has three pieces for it. A schedule runs an action on a timer. A batch works through many records a page at a time. An event says something happened, and a subscriber runs an action for each one, retried until it succeeds. This guide reminds borrowers two days before a book is due back, using all three, and tests the whole run.

You start from an app with a loans domain, holding a Loan entity with a due_at and a reminded_at, a Reminder entity that records each reminder, and the type a batch uses to page through loans. Store and change a record shows how to write entities.

domains/loans/entities/loan.jadpo:

jadpo
use loans/types { DueTime }/// A book lent until a time.entity Loan {    id: Uuid    title: Text {        min_length: 1        max_length: 200    }    borrower: Email    due_at: DueTime    reminded_at: Instant?    policy: {        create: [Access.system]        read: [Access.system]        update: [Access.system]    }}

domains/loans/entities/reminder.jadpo:

Background work runs as the system, with no person signed in, so each entity it reads or writes lets Access.system in. A real app lists its people beside it.

Find the loans a page at a time

The query a batch walks is an ordinary query giving a Page, in an order no change to a loan moves, so a run meets each loan once:

domains/loans/queries/loans_due.jadpo:

jadpo
use loans/entities { Loan }use loans/types { DueTime, LoanPage }/// Loans due by a time whose borrower hasn't been reminded.query loans_due(by: DueTime, page: LoanPage) -> Page<Loan> {    from: Loan    where: Loan.due_at <= by    where: Loan.reminded_at == none    order: [Loan.id asc]    page: {        size: page.page_size        after: page.after    }}

Say what happened: an event

An event is a type built on Event, in a domain's events/ folder:

domains/loans/events/loan_due_soon.jadpo:

jadpo
use loans/entities { Loan }/// A loan is due back within two days.type LoanDueSoon = Event {    loan_id: Loan.id}

The action the batch runs for each loan marks it, so no later run finds it again, and emits the event:

domains/loans/actions/flag_due_loan.jadpo:

jadpo
use loans/entities { Loan }use loans/events { LoanDueSoon }/// Marks a loan as reminded, and says it is due soon.action flag_due_loan(loan: Loan) -> Unit {    var flagged: Loan = Loan.update(loan) {        set: {            reminded_at: clock.now        }    }    emit LoanDueSoon {        loan_id: flagged.id    }}

emit writes the event in the same transaction as the update. It is delivered only once that commits, so a reminder is never sent for a change that was rolled back.

Start a batch

batches.each runs an action for each record a query gives, a page at a time, each page in its own transaction. It carries on from the page it had reached after a restart or a deploy, so however many loans fall due at once, no run holds them all:

domains/loans/actions/find_due_loans.jadpo:

jadpo
use loans/types { DueTime, LoanPage }use loans/actions { flag_due_loan }use loans/queries { loans_due }/// Starts flagging every loan due within two days.action find_due_loans() -> BatchRun {    return batches.each(        from: loans_due(DueTime(clock.now + 2d), LoanPage {}),        each: loan -> flag_due_loan(loan)    )}

The batch starts once this action commits, and BatchRun names it, for batches.status to ask about later.

Run it on a timer: a schedule

domains/loans/schedules/due_reminders.jadpo:

jadpo
use loans/actions { find_due_loans }/// Looks for loans due soon every hour.schedule due_reminders {    every: 1h    run: find_due_loans()}

Runs fall on a fixed grid in UTC that a restart doesn't move. Only one runs at a time, ticks missed while the app was down become one run, and a run that fails is tried again at the next due time. The action is passed nothing, and clock.now stays the same for the whole run.

React to the event: a subscriber

domains/loans/actions/send_reminder.jadpo:

jadpo
use loans/entities { Loan, LoanNotFound, Reminder }/// Records the reminder for a loan due soon.action send_reminder(loan_id: Loan.id) fails LoanNotFound -> Reminder {    var loan: Loan = attempt Loan.get(loan_id)    return Reminder.create {        set: {            loan_id: loan.id        }    }}

domains/loans/subscribers/remind_borrower.jadpo:

jadpo
use loans/actions { send_reminder }use loans/events { LoanDueSoon }/// Reminds the borrower of each loan due soon.subscriber remind_borrower {    on: LoanDueSoon    run: send_reminder(event.loan_id)}

event is the event being delivered. A delivery that fails is retried with a growing wait between attempts, so send_reminder may run more than once for one event: write it so that running it twice does no harm. Each event is also delivered at least once, never lost. A real app sends an email here; an email is an event of its own, sent the same way after the action commits.

Run jadpo migrate new initial and jadpo records update. The second writes domains/loans/events/events.jadpo_record, the committed record of the domain's events, so a change to one shows in review:

text
// Generated by Jadpo from the events of domain loans; update it with `jadpo records update`, never by hand.record events 1event LoanDueSoon    loan_id: Uuid

Test the whole run

A schedule test runs the schedule with due_reminders.run(), as the timer would. The batch it starts runs to its end, and every event it emits is delivered, before the test's next line, so the test sees everything the run did. clock: fixes the time, and clock.advance moves it on:

domains/loans/fixtures/loan_due.jadpo:

jadpo
use loans/entities { Loan }/// A loan of the book given, due at the time given.fixture loan_due(title: Loan.title, due_at: Loan.due_at) -> Loan {    return Loan.create {        set: {            title: title            borrower: "cara@example.com"            due_at: due_at            reminded_at: none        }    }}

domains/loans/queries/reminders_for.jadpo:

jadpo
use loans/entities { Loan, Reminder }/// The reminders sent for a loan.query reminders_for(loan_id: Loan.id) -> List<Reminder> {    from: Reminder    where: Reminder.loan_id == loan_id    order: [Reminder.sent_at asc]    limit: 10}

domains/loans/schedules/due_reminders.test.jadpo:

jadpo
use loans/entities { Loan, Reminder }use loans/queries { reminders_for }use loans/fixtures { loan_due }test "a borrower is reminded once, two days before the book is due" {    clock: "2099-11-02T10:00:00Z"    var loan: Loan = loan_due("Dune", "2099-11-05T10:00:00Z")    due_reminders.run()    var none_yet: List<Reminder> = reminders_for(loan.id)    assert list.count(none_yet) == 0    clock.advance(1d)    due_reminders.run()    var once: List<Reminder> = reminders_for(loan.id)    assert list.count(once) == 1    clock.advance(1h)    due_reminders.run()    var still_once: List<Reminder> = reminders_for(loan.id)    assert list.count(still_once) == 1}test "a loan due later isn't reminded yet" {    clock: "2099-11-02T10:00:00Z"    var later: Loan = loan_due("Emma", "2099-11-20T10:00:00Z")    due_reminders.run()    var reminders: List<Reminder> = reminders_for(later.id)    assert list.count(reminders) == 0}
shell
jadpo test
output
tested: 2 passed, 2 schedule tests

jadpo test --kind schedule runs only these.

Watch it run

jadpo dev ticks once a minute. In a second terminal in the project, jadpo schedules reads its database:

shell
jadpo schedules
output
  schedule       last run (UTC)  next  due_reminders  - none yet      -schedules: 1 schedule, no tick yet

jadpo schedules run due_reminders runs it now, pause and resume stop and start it, history lists its runs, and jadpo why due_reminders says why it didn't run when you expected.

A deployed app runs its schedules and delivers its events itself: a server program on its own timer, and a Worker on Cloudflare's once-a-minute trigger. An app that declares several deployables says which runs them with runs: [Trigger.schedules, Trigger.subscribers]; see Deploy to Cloudflare Workers.

Read more

Schedules, Batch work keeps its progress, Batches: work over many items, Declaring an event, Emitting an event, Subscribers, Delivery: at least once, safe to repeat, Retries and giving up, What an operator sees and Background work.

Markdown for agents: /guides/guide-run-background-work.md.Built from Jadpo main at e00e61f. Jadpo's CI compiles this guide's code and runs its tests.