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 JANDEC and days-of-week SUNSAT, 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