Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Jobs and events

Jobs and events

Schedules

On this page

Use a schedule for work that should happen on a timer rather than when someone asks, such as sending reminders every 15 minutes or tidying up once a day. A schedule runs one action at a fixed interval. It has a name, an every: saying how often, and a run: that calls an action in the same domain with no arguments.

domains/planning/schedules/chore_census.jadpo:

example
// domains/planning/schedules/chore_census.jadpo/// Queues a reminder for each overdue todo.schedule chore_census {    every: 15m    run: queue_chore_census()}

Here queue_chore_census runs every 15 minutes. You don't start it, and a restart or a deploy doesn't upset its timing.

every: and run:

every: takes a whole number of seconds, minutes, hours or days above zero: 30s, 15m, 1h or 1d. Anything else is schedule.every_invalid.

run: calls an action of the schedule's own domain. The call passes no arguments, and the action takes no parameters. Anything else is schedule.run_invalid. A schedule holds nothing but every: and run:; anything more is schedule.item_invalid.

When runs happen

A schedule's runs fall on a grid of times, counted in UTC from the first tick that sees the schedule. Its first run is due one every: after that tick, and each run after it one every: later. The grid is kept in the schedule's state row, a row the runtime keeps for each schedule, so a restart or a deploy neither moves it nor adds a run. When you change every:, the new interval takes effect from whichever comes first: the due time already kept, or one new interval after the tick.

Only one run of a schedule happens at a time. Ticks missed while the app was down, or while a run was still going, become one run, not a backlog. A tick at or past the due time runs the schedule once, and moves its next due time to the first grid time after the tick. A due tick that finds the schedule paused, or still held by a run that hasn't finished, skips it rather than queueing it. The skip is recorded with its reason, and the due time moves on in the same way.

The time inside a run

Inside a run, clock.now is fixed: every read gives the time the run started, which is the tick's time. The action doesn't need the time passed in.

When a run fails

A failed run is logged and shown to the operator, and tried again at its next due time, not at every tick. A schedule has no retry settings, because its next due time is its retry.

Where a schedule's state is kept

Each schedule's state and its lease (the claim that stops two copies of your app running it at once) live in a database. By default that is the app's only database. With several databases, name one in application { schedules: { store: main } } (Stores); with several and none named, or none at all, each schedule is schedule.store_required. schedules: also holds trigger:, which says what ticks a native deployable (The tick). The tables for schedule state and runs are in the migration snapshot only when a schedule is declared or an entity purges old records.

Schedules Jadpo writes for you

An entity whose lifecycle has purge: gets an hourly purge schedule from the compiler, such as Member.purge for Member. You write nothing for it (The purge schedule).

How a schedule runs

  • The lease. A run first takes the schedule's lease, in one conditional statement on the state row: it succeeds only while the due time is unchanged, the schedule isn't paused and no live lease holds it. So of two instances ticking at once, one runs the schedule and the other does nothing. The lease lasts what is left of the tick's time budget plus a minute (Running background work), at most 16 minutes on a native server. A run that held the lease past then was lost with its worker, and its record ends failed with lease_expired.
  • A run. The action is called with nothing, as the system, reaching every tenant's records (Background work and tenants). It runs in a context of its own whose clock.now is the tick's time throughout, and in a new trace. It must finish within the 15-minute limit of a run and within its tick's budget. A run stopped by either is recorded failed with deadline_exceeded, and its lease is given back. What the run emits is delivered after it. A failure is recorded with its code and kind. Due schedules run side by side, so one long run holds no other up.
  • Grants. A schedule runs as the system, so the records it reaches must name Access.system in their policy. A schedule reaching what only Access.public grants is policy.system_grant_missing, as a subscriber or a batch is (Background work).

The tick

The tick is how background work gets started when no request is running. It happens once a minute, or as often as the shortest every: when that is shorter, but at most once a second. Every host calls the same entry point for it, jadpo_runtime::schedules::tick.

What does the ticking depends on where the app runs. A deployable is one program you build and run: a native server, or a Cloudflare Worker (A native deployable, A Workers deployable).

  • A native server ticks on its own timer, on its first worker's thread, at each whole period of UTC. You don't write anything for that.
  • A Worker ticks on a cron trigger that runs every minute, which jadpo build writes into the Wrangler file. While the app receives a queue, each cron run ticks again at each 10-second mark of its minute, so a queue is read as often as on a native server. A minute's cron can't run anything more often than once a minute, so on a Worker an every: under a minute is deployable.target_unsupported, naming the schedule.
  • jadpo dev ticks on its own timer, whatever trigger: says.
  • A test's or a tool's host doesn't tick.

Each state row keeps the last tick that reached it.

Ticking from another scheduler: ScheduleTrigger.external

Use it when your platform already has a scheduler you want to run the work, such as a Kubernetes CronJob, an ECS scheduled task or a systemd timer.

example
application {    schedules: {        trigger: ScheduleTrigger.external    }}

The server then never ticks. Instead, the scheduler runs the deployable's tick command, <deployable> tick. The command reads the app's settings and checks its stores' migrations as serving does, runs one tick, and ends (A native deployable).

There is no HTTP endpoint for the tick and no tick secret. A scheduler that can only call a URL needs a wrapper of its own.

Run tick as often as the server's own timer would tick, which jadpo build prints beside the binary. A scheduler that runs it less often runs schedules and reads queues late, and the ticks-stopped alert says so (Watching schedules). Two ticks at once, or a tick beside a server's own timer, do no harm, because the lease decides which one runs each schedule.

trigger: changes only a native deployable. Without it, the value is ScheduleTrigger.native, the server's own timer. A Worker always ticks on its cron, whichever is written.

What each tick does

Each tick does the following, in order. Each step starts only while the tick's time budget lasts.

  1. It runs the schedules that are due, and any run asked for by hand (Running a schedule by hand), and delivers what they emit.
  2. In every store holding the runtime's tables, it delivers what is left to deliver. This is the outbox's sweep: up to 100 deliveries, oldest first, that are pending past their next attempt or running past their lease. A crash between a commit and its delivery, a host that dropped work, or a worker that died holding a lease leaves these behind.
  3. It picks up batch runs whose lease lapsed.
  4. It deletes what is no longer needed (below).

What the tick deletes

The tick deletes:

  • expired idempotency keys (Idempotent writes);
  • an event with its delivery records once every delivery succeeded, 24 hours after it was emitted, and an event nothing subscribes to after the same time. An event with a failed delivery stays for an operator;
  • a delivery an operator dismissed or cancelled, 30 days after the operator's last action on it (What an operator sees);
  • the handled records of those deliveries (below);
  • browser sessions and API tokens that have ended, and service keys that have expired (Identity), which a lookup already ignores. Each is deleted in the store it is kept in, beside its principal's users or services;
  • schedule run records and ended batch runs 30 days old (How a run is kept);
  • an operator's log rows 30 days old whose delivery or run is gone.

This cleanup runs after the work that can't wait (due schedules, delivery sweeps, queues, polls, alerts and expired batch leases), at most once an hour for each store once a pass completes. Each store's jadpo_housekeeping lease lets one instance in at a time. Stores holding only schedule state or AI usage are included. What to clean is worked out from the whole application's schema, so a deployable that runs no schedules or batches still cleans their history. A pass that failed, hit its cap or was interrupted runs again on the next tick. Finishing and giving back the lease are checked against the owner's identity, so only the owner can do either. A crashed owner's lease ends after the rest of its tick's budget and the usual minute. Before each batch of deletes, the tick checks its budget and whether the app is stopping.

A handled record is the note a subscriber leaves to say it has dealt with a delivery (Delivery: at least once, safe to repeat). It goes only once its delivery can no longer repeat, which is once the delivery's own record is gone: with its event deleted, nothing delivers it again, whatever an operator does later. In the event's store, the statement that deletes the event also deletes the handled records kept there for its deliveries, so an app with a single store never keeps one after its event. A subscriber that writes another store keeps its record there, and the tick deletes it by the delivery's id once the event's statement has committed. If the tick can't reach that store then, the records stay. They only cost space, and none is ever lost early.

What an operator dealt with is kept as long as a run record, then goes. A delivery dismissed or cancelled never runs again, so the tick deletes it 30 days after the last action logged on it, with its handled records. Nothing else is deleted while its event stays. A delivery that could still run (pending, running, failed or outcome_unknown) is never deleted, however old.

An event goes once every delivery of it that is left has succeeded. An event with a delivery that could still run, or one set aside within the last 30 days, stays, so jadpo deliveries shows that delivery with its event. The tick deletes those deliveries before it deletes events, so an event whose last such delivery goes is deleted on the same tick. The 30 days count from the operator's log, not from a column of the delivery: each operator action writes its log row together with its change, and a log row stays while its delivery does. So the window counts from the operator's last action without changing the delivery's table.

Each delete takes 1,000 rows at a time, at most ten times a tick: 1,000 events with their delivery and handled records, or 1,000 deliveries an operator dealt with, with their handled records. The tick hands nothing on. What it doesn't reach, the next tick carries on with.

The purge schedule

An entity whose lifecycle has purge:, and which is kept in a store, gets a schedule of its own that deletes its old records every hour. You don't write it. It is named after the entity and its operation, Member.purge, as Member.delete is. It is keyed by the entity's born name, the name it was first declared with, so renaming the entity keeps its state and run records. Its .jadpo place, which jadpo why gives, is the lifecycle's purge:.

It runs every hour on the same kind of grid as any schedule, and is recorded and watched like any schedule. Its state row, lease and run records are a schedule's, kept in the store schedules: { store: } names, or in the only database. With several databases and none named, the lifecycle's purge: is schedule.store_required, which suggests the entity's own store, and migration.out_of_date waits until one is named. jadpo schedules lists it; history, pause, resume, run and jadpo why take its name; and jadpo dev prints each run.

Each run calls the runtime's purge at the tick's time, outside every action and every policy. It deletes at most 20 batches of 500 records within the run's time limit, and the next hour's run carries on with what it leaves. How old a record must be is the lifecycle's after:, or the value the app started with for the setting after: reads. The run emits nothing.

A store that can't be reached makes a failed run with unavailable, as for an action. Anything else the store refuses is internal_fault, and the batches deleted before it stay deleted. The run records count the records deleted (items). The screens show 2 records purged where an action's run shows its events, and jadpo dev's --json line carries items and batches, how many each batch deleted. No test runs the purge schedule (Testing).

Markdown for agents: /docs/schedules.md.Built from Jadpo main at a707efb.