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:
// 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
failedwithlease_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.nowis 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 withdeadline_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.systemin their policy. A schedule reaching what onlyAccess.publicgrants ispolicy.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 buildwrites 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 anevery:under a minute isdeployable.target_unsupported, naming the schedule. jadpo devticks on its own timer, whatevertrigger: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.
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.
- It runs the schedules that are due, and any run asked for by hand (Running a schedule by hand), and delivers what they emit.
- 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
pendingpast their next attempt orrunningpast 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. - It picks up batch runs whose lease lapsed.
- 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.