# Schedules

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`:

```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](stores.md)); 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](schedules.md#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](lifecycle.md) has `purge:` gets an
hourly purge schedule from the compiler, such as `Member.purge` for
`Member`. You write nothing for it
([The purge schedule](schedules.md#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](where-events-travel.md#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](background-work-and-tenants.md)). 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](background-work.md)).

## 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-native-deployable.md),
[A Workers deployable](a-workers-deployable.md)).

- **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.

```jadpo
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](a-native-deployable.md)).

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](what-an-operator-sees.md#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](what-an-operator-sees.md#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](idempotent-writes.md));
- 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](what-an-operator-sees.md));
- the handled records of those deliveries (below);
- browser sessions and API tokens that have ended, and service keys that
  have expired ([Identity](identity.md)), 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](how-a-run-is-kept.md));
- 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](delivery-at-least-once-safe-to-repeat.md)).
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](lifecycle.md) 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](testing.md)).
