Scheduler
Requires Clarity 1.5.0 or newer. The skeleton pins
monad/clarity: ^1.0, which also resolves to releases without this service —
run composer update monad/clarity if Services\Scheduler is missing.
The schedule lives in app/routes/cli.php, which the
Console kernel already loads before every
dispatch. Jobs sit beside your own commands, travel with a deploy, and show up in code
review:
use Monad\Clarity\Services\{Scheduler, Session};
Scheduler::job('sessions:prune', '15 3 * * *', fn () => Session::purgeExpired());
Scheduler::job('inbox:poll', '*/10 * * * *', fn () => $inbox->fetch());
Scheduler::job('reports:build', '0 4 * * MON', $reports->rebuild(...), staleAfterMinutes: 240);
The system cron gets one line, and never a second one:
* * * * * cd /srv/app && php mitosis schedule:run
Adding a job, retiming one, or removing one is then a code change rather than an
ssh session on every node.
Do not redirect that line to /dev/null
schedule:run prints nothing on a tick where nothing happened — no job due, or every due job already claimed elsewhere. Sixty greetings an hour teach an operator to filter the mailbox, and then the one line that mattered is filtered too. So silence is the signal, and the four things worth knowing each get exactly one line: a job ran, a job failed, a job stood down because its previous run is still going, or a dead run was reaped.
The kernel writes errors to stdout like everything else. Appending
> /dev/null 2>&1 — the reflex, on a cron line that is quiet most of
the time — throws the failures away along with the quiet. Leave the redirect off and let
cron mail you the exceptions.
Install the table first
php mitosis schedule:install
Once, per database context, and before the crontab line goes in. The order is not fatal
either way: a tick that runs before the install exits 1 every minute and names
the command, which is loud rather than silently doing nothing. See
schedule:install.
Registering a job
Scheduler::job(
string $name, // identifies this job's run records — 'group:verb', like a command
string $expression, // five cron fields, or a macro
callable $work,
int $staleAfterMinutes = 60,
): void
The facade holds the registry and nothing else:
Scheduler::jobs(): array<string, ScheduledJob>
Scheduler::due(DateTimeInterface $moment): list<ScheduledJob>
Scheduler::reset(): void
Deciding what is due is a pure function of the registry and the clock, so
due() is testable without a database.
A malformed expression, a name registered twice, or a staleness window below one minute
throws Scheduler\SchedulerException at registration, not at
the tick that would have run the job. app/routes/cli.php is loaded before every
command dispatches, so a typo breaks your very next mitosis invocation in plain
sight instead of sitting quietly in a crontab.
Cron expressions
Five fields — minute, hour, day-of-month, month, day-of-week — parsed in-house, with no new
Composer dependency. Every field accepts a star, a number, a range a-b, a step
on a star or a range, and a comma-separated list of any of those:
15 3 * * * 03:15 every day
*/10 * * * * every ten minutes
0 4 * * MON 04:00 on Mondays
0 9-17 * * 1-5 on the hour, 09:00–17:00, weekdays
0 0 1,15 * * midnight on the 1st and the 15th
Months take JAN–DEC and days-of-week SUN–SAT,
case-insensitively; day-of-week accepts both 0 and 7 for Sunday. The
macros are @yearly (@annually), @monthly,
@weekly, @daily (@midnight) and @hourly.
A step on a single value — 5/15 — is refused rather than guessed at. It reads as
5-59/15 in Vixie cron and as minute 5 alone under a literal reading, and either
guess is a silently wrong schedule. The exception names the range to write instead.
Day-of-month and day-of-week are OR'd when both are restricted
This is the Vixie cron rule, and it is genuinely surprising:
0 0 13 * FRI the 13th of the month, AND ALSO every Friday
— not "Friday the 13th"
When either field is * the two are AND'd, which is why the everyday
15 3 * * * behaves as anyone would expect. It is worth checking because getting
it wrong is silent: the expression parses either way and simply fires on the wrong days.
There is deliberately no nextRunAt(). The runner only ever asks whether the
minute it is standing in matches, and a next-occurrence search is a materially harder thing
to prove correct than a predicate.
At most one run per job per minute, cluster-wide
Put the crontab line on every node. It is safe there: claiming a job is an
INSERT against a unique index on (job, due_at), so the second node
to try for a minute collides and stands down. Three nodes give three chances a due job runs,
and no chance it runs three times.
The other half of that guarantee is the half you will eventually depend on: this is at-most-once, not at-least-once. A minute in which every node is down is a minute in which the job does not run. There is no catch-up window — if every node was down from 03:10 to 03:20, the 03:15 job does not run at 03:21. Adding a catch-up would make this a queue, and the missed work is usually stale by the time anyone notices. The honest cost is that a tick starting more than 60 seconds late skips a minute entirely.
A lock file under storage/ could not do this at all — local disk is not shared,
so on a three-node cluster every due job would fire three times.
Overlap, and a run whose process died
The unique index stops two nodes running the same slot. It does nothing about a 03:00 run
still going when 03:01 arrives — that is a different due_at, so the insert
succeeds. A job with a running row therefore stands down, which is a separate,
deliberate mechanism: sessions:prune overlapping itself corrupts its own counts.
That mechanism creates one failure of its own, and staleAfterMinutes is the
answer to it. A run killed mid-flight — a deploy, an OOM kill, a machine going away — leaves
a running row behind forever, and the job would stand down on every future tick:
stopping silently, the worst way for a scheduler to fail. A later tick reaps runs older than
the job's own window, says so on stdout, and exits non-zero, because a run that died is a
failure and the exit code is the only signal some operators watch.
Scheduler::job('reports:build', '0 4 * * MON', $reports->rebuild(...), staleAfterMinutes: 240);
The window is per job rather than one scheduler-wide setting, because a four-hour report and a ten-second sweep cannot share a threshold. Set it to suit the sweep and the report gets reaped while it is still working — after which a second copy of it starts, which is the exact failure the in-flight check exists to prevent. Set it comfortably above the job's worst honest runtime.
Expressions are read on the server's local clock
Not UTC. This matches what the system cron itself reads, and the own-clock columns on
sessions and caches. Nodes in a cluster must agree on their
timezone, or they are running two different schedules and the
(job, due_at) key no longer collides.
Daylight saving then falls out of that key, correctly. On the day the clocks go back, local
02:30 happens twice and both occurrences share one due_at — so the second
collides with the first's claim and stands down. On the day they go forward, 02:30 never
happens and a job scheduled for it is skipped that day.
The run ledger
Scheduler\JobLedger is the stateful half, kept out of the facade the same way
Checkout's ledgers are: one row per attempted
run in scheduled_runs, and — through the unique index that row is inserted
against — the mutex itself.
$ledger = new JobLedger(); // ?string $context selects a DB connection
$ledger->claim(string $job, DateTimeInterface $dueAt): ?string // null: another node holds the slot
$ledger->hasRunInFlight(string $job, DateTimeInterface $staleBefore): bool
$ledger->reapStale(string $job, DateTimeInterface $staleBefore): int
$ledger->complete(string $runId, int $durationMs): void
$ledger->fail(string $runId, int $durationMs, string $reason): void
$ledger->lastRun(string $job): ?array
$ledger->prune(DateTimeInterface $before): int
Three states, and there is deliberately no fourth for "skipped":
RunState::Running | Completed | Failed
A tick that stands down writes no row at all, which keeps scheduled_runs a record
of what ran rather than a log of what didn't. Anything from claim() that is not a
unique-index collision propagates rather than being read as a lost claim — treating a missing
table as "another node got there first" would turn a forgotten install into a scheduler that
silently never runs anything.
Retention is a job like any other
prune() is deliberately not a command. Sweeping the history is itself recurring
work, so the scheduler sweeps it the way it sweeps anything else, and the mechanism proves
itself in use:
use Monad\Clarity\Services\Scheduler\JobLedger;
Scheduler::job('scheduler:prune', '@daily', fn () => (new JobLedger())->prune(new DateTimeImmutable('-30 days')));
What's deliberately not here
No retries or backoff. A failed run is recorded and the next due slot tries
again. No queue, worker pool, or async dispatch — jobs run in the tick's own
process, one after another; a scheduler is not a queue. No sub-minute schedules,
forbidden twice over: run records store DATETIME at second precision, and the
heartbeat is one minute. No daemon or supervisor — Clarity supplies the
command, and installing the crontab entry is the deploying team's job. No
Event dispatch on job outcome: the non-zero exit
code and the cron mail are the notification. And no sixth
health check — "has the scheduler run recently" is a
monitoring question, not a deployment gate.
Next steps
- schedule:install — the one table, created once.
- schedule:run — the heartbeat, and what each line of its output means.
- schedule:list — the registered schedule and how each job's last run went.
- Console Kernel — how
app/routes/cli.phpis loaded.