Verified against Semitexa Ultimate 2026.10.20.0340
Scheduled and Delayed Jobs
semitexa/scheduler runs work that belongs to a clock rather than to a request. There are two kinds of run:
- Recurring: a class declares a cron expression with
#[AsScheduledJob], andscheduler:planturns each due occurrence into a run. - Delayed: code calls
SchedulerInterface::dispatchAt()ordispatchAfter(), which writes one run due at a given time.
Both kinds end up as rows in scheduler_runs. A scheduler:work process claims rows for its pool, holds a lease on each one, and runs the job. Nothing here runs inside the web server.
A job class
A job implements ScheduledJobInterface and must be an #[AsService]. The worker builds it from its class name through the container, and the container only builds registered services.
namespace App\Modules\Reports\Application\Service\Job;
use Semitexa\Core\Attribute\AsService;
use Semitexa\Core\Attribute\InjectAsReadonly;
use Semitexa\Scheduler\Attribute\AsScheduledJob;
use Semitexa\Scheduler\Domain\Contract\ScheduledJobInterface;
use Semitexa\Scheduler\Domain\Model\ScheduledJobContext;
#[AsService]
#[AsScheduledJob(
key: 'reports.nightly',
cronExpression: 'env::REPORTS_NIGHTLY_CRON::0 3 * * *',
overlapPolicy: 'skip',
misfirePolicy: 'run_once',
maxAttempts: 3,
retryBackoffSeconds: 60,
)]
final class NightlyReportJob implements ScheduledJobInterface
{
#[InjectAsReadonly]
protected ReportBuilder $reports;
public function handle(ScheduledJobContext $context): void
{
foreach ($this->reports->pendingBatches() as $batch) {
$this->reports->build($batch);
$context->renewLease(); // a checkpoint: see "Leases" below
}
}
}
handle() returning means the run succeeded. Any Throwable it throws means the attempt failed, and the message is stored as last_error.
ScheduledJobContext (Semitexa\Scheduler\Domain\Model) is a readonly object:
| Property / method | What it holds |
|---|---|
runId |
the scheduler_runs id |
jobClass, pool |
what the run row says |
tenantId |
the run's tenant, or null |
scheduleKey |
the #[AsScheduledJob] key; null for a run from dispatchAt()/dispatchAfter() |
sourceType |
'recurring' or 'delayed' |
attemptNumber |
1 on the first attempt |
payload |
the array passed when the job was dispatched; always [] for recurring runs |
renewLease() |
extends this run's lease and its overlap lock. Throws LeaseLostException if this worker no longer owns them |
Recurring schedules
Semitexa\Scheduler\Attribute\AsScheduledJob takes these parameters:
| Parameter | Default | Meaning |
|---|---|---|
key |
required | unique schedule key (the row in scheduler_schedule_definitions) |
cronExpression |
required | a five-field cron expression, or env::VAR::default |
pool |
'default' |
which scheduler:work runs it |
overlapPolicy |
'skip' |
allow, skip or delay |
misfirePolicy |
'run_once' |
skip, run_once or catch_up |
tenantMode |
'global' |
global or per_tenant (explicit_tenant is refused, see Tenants) |
maxAttempts |
1 |
total attempts, the first one included |
retryBackoffSeconds |
0 |
base retry delay (see Retries) |
maxCatchUpRuns |
null |
limit for catch_up; null means no limit |
Use env::VAR::default to let each install change a schedule without changing the package's code. A value that does not resolve to a valid expression is refused: scheduler:plan prints a warning naming the job, keeps that job's previous schedule, and plans the other jobs as usual.
On every run, scheduler:plan first syncs the attribute into scheduler_schedule_definitions. That sync overwrites the key, class, cron, pool, policies, attempts, backoff and maxCatchUpRuns from the attribute, so editing those columns by hand does not last. The sync never writes enabled, timezone or payload_template_json, so a schedule you switched off by hand stays off.
Timezone. Occurrences are calculated in the row's timezone column and stored in UTC. The attribute has no timezone parameter, so new rows get UTC. The only other way to set a timezone is to edit the column by hand; the sync leaves that edit alone.
How planning works
Each definition keeps a planning cursor. A run of scheduler:plan calculates every occurrence after the cursor, up to and including now. For a new definition with no cursor yet, it starts one minute ago, so nothing older is filled in. The misfire policy then chooses which occurrences become runs. The cursor moves to the last calculated occurrence, whether or not a run was created for it.
misfirePolicy |
Occurrences that become runs |
|---|---|
skip |
only those at most 120 seconds old (MisfirePolicyResolver::ON_TIME_SECONDS), so missed ones are dropped and the current one still runs |
run_once |
only the newest one, however late it is |
catch_up |
all of them, or the newest maxCatchUpRuns |
Dropped occurrences get no row. Instead, the planner prints and logs one warning per schedule: Schedule '<key>' (misfirePolicy …) dropped N missed occurrences ….
Every run has an occurrence key, schedule:<key>:at:<ISO time> (schedule:<key>:tenant:<id>:at:<ISO time> for a tenant run), and the column is unique. Planning the same occurrence twice, for example from two worker containers, creates one row.
Overlap
Unless overlapPolicy is allow, a recurring run carries a lock key: scheduler:<key>, or scheduler:<key>:tenant:<id> for a tenant run. Before running the job, the worker inserts a row into scheduler_locks, which is unique on lock_key and expires after SCHEDULER_LOCK_TTL seconds. If another run holds a lock that has not expired:
skip: the run becomesskipped_overlap. That status is final.delay: the run becomesretry_scheduled, due again aftermax(30, retryBackoffSeconds)seconds, and its attempt count does not change.
An expired lock is taken over, and every worker loop deletes expired locks. The lock is released when the run finishes, whether it succeeded or failed.
One-off jobs: SchedulerInterface
Inject Semitexa\Scheduler\Domain\Contract\SchedulerInterface:
use Semitexa\Core\Attribute\InjectAsReadonly;
use Semitexa\Scheduler\Domain\Contract\SchedulerInterface;
#[InjectAsReadonly]
protected SchedulerInterface $scheduler;
// ...
$runId = $this->scheduler->dispatchAfter(
jobClass: SendReminderJob::class,
delay: new \DateInterval('PT15M'),
payload: ['orderId' => $order->getId()],
pool: 'mail',
tenantId: $tenantId,
lockKey: 'reminder:' . $order->getId(),
maxAttempts: 5,
retryBackoffSeconds: 30,
);
public function dispatchAt(string $jobClass, \DateTimeImmutable $runAt, array $payload = [], ?string $pool = null, ?string $tenantId = null, ?string $lockKey = null, int $maxAttempts = 1, int $retryBackoffSeconds = 0): string;
public function dispatchAfter(string $jobClass, \DateInterval $delay, array $payload = [], ?string $pool = null, ?string $tenantId = null, ?string $lockKey = null, int $maxAttempts = 1, int $retryBackoffSeconds = 0): string;
Both return the new run's id. dispatchAfter() is dispatchAt(now + $delay).
The shipped SchedulerService is also a DeduplicatingSchedulerInterface: dispatchOnceAt(string $occurrenceKey, …) takes the same arguments after a key (at most 255 characters) and schedules the run only if no run with that key exists, whatever its status; otherwise it returns null. The unique occurrence_key index decides, so two callers at once get one run. Use it for a follow-up two runs can ask for at the same time.
payloadis stored as JSON and reaches the job as$context->payload. It must be JSON-encodable, ordispatchAt()throws.pool: nullmeansSCHEDULER_DEFAULT_POOL, ordefaultif that is unset.tenantIdmakes the job run as that tenant (see Tenants).lockKeyturns on the overlap lock for this run. A dispatched run has no schedule definition: when its lock is held it waits (delay: the larger of 30 seconds and its backoff, as many times as it takes), it is never dropped.- The job class does not need
#[AsScheduledJob], but it must be an#[AsService]that implementsScheduledJobInterface. This is not checked when you dispatch: a wrong class fails each time a worker tries to run it.
The worker model
scheduler:plan exits after one pass: it syncs definitions, plans due occurrences and prints Planned N run(s).
scheduler:work [pool] runs a loop until it is killed. Each pass:
- Handles every
claimedorrunningrun whose lease has expired, in all pools, not only its own. Arunningrun that has used up its attempts becomesfailed, withlast_errorsaying its lease ran out. Any other one goes back topending. - Deletes expired locks.
- Claims the oldest due run in its pool, meaning status
pendingorretry_scheduledwithavailable_atalready past. A guarded update sets it toclaimedwithlease_owner = <host>:<pid>:<random>andlease_expires_at = now + SCHEDULER_LEASE_TTL. If there is nothing to claim, it sleeps forSCHEDULER_POLL_INTERVALseconds. - Applies the overlap lock, sets the run to
running, adds one toattempt_count, and callshandle()in the same process. A worker runs one job at a time. - Writes the outcome only if the run's
lease_owneris still this worker. If it is not, the worker logsoutcome discardedand writes nothing.
Leases
Lease and lock times are written and judged by the database's clock (NOW(6) on MySQL, in UTC), not by each worker's: a worker whose clock runs ahead does not take over leases or locks that others still hold. On SQLite, the database's clock is the clock of the host it runs on, so every worker on that host shares it. The time a run is due (available_at) is still written by the PHP process that plans or retries it, so a planner whose clock is off makes runs due early or late by that much.
The worker does not renew the lease while a job runs. A job that can take longer than SCHEDULER_LEASE_TTL (300 s by default) must call $context->renewLease() at checkpoints. If it does not, another worker reclaims the "expired" run. With attempts left, it starts the run a second time, alongside the first. With none left (the default maxAttempts: 1), it marks the run failed, and the first copy's outcome is then discarded when it finishes. When renewLease() throws LeaseLostException, let the exception propagate: the worker then discards this attempt's outcome.
Retries
maxAttempts counts every attempt. After a failed attempt n, if n < maxAttempts, the run becomes retry_scheduled with available_at = now + retryBackoffSeconds × 2^(n−1). Otherwise it becomes failed. For example, maxAttempts: 3, retryBackoffSeconds: 60 waits 60 s, then 120 s, then fails. With a backoff of 0, the retry is due immediately. The doubling stops at a day, or at retryBackoffSeconds when that is longer.
When a worker dies
Stopping the worker container sends SIGTERM, and scheduler:work lets the run in hand finish and be recorded before it exits (it needs the pcntl extension, which the scaffold image has; see Queued handlers for the grace period). A run that outlasts the grace period, a second signal, or killing the process any other way ends it mid-job: the job does not get to finish, and the worker does not release its lease or lock. Once the lease expires, the next pass of any worker sets the run back to pending, and it runs again from the start as a new attempt; a run whose attempts are used up (the dead attempt counts) becomes failed instead, so a job that crashes its worker does not run forever. Until the lock's own TTL runs out, a run of the same schedule with skip becomes skipped_overlap. Delivery is at least once, so write jobs that are safe to run twice.
Run statuses
| Status | Meaning |
|---|---|
pending |
waiting for available_at |
claimed |
leased by a worker, not started yet |
running |
handle() is executing |
retry_scheduled |
failed or delayed by overlap, due again at available_at |
succeeded, failed, skipped_overlap |
final |
RunStatus also has misfire_skipped and cancelled, but the package never writes either.
Tenants
tenantMode |
Runs per occurrence |
|---|---|
global |
one, with tenant_id null |
per_tenant |
one for each tenant the tenancy repository lists, each with its own occurrence key and lock. If no tenants are configured, one run with no tenant |
explicit_tenant |
refused: a recurring schedule has no way to name its tenant. scheduler:plan reports the schedule and does not plan it |
For a run with a tenant_id, whether it came from per_tenant, dispatchAt(tenantId: …) or scheduler:run-now --tenant, the worker makes that tenant the current tenant context for the length of handle(), then restores the previous one. Use per_tenant for any job that reads tenant-scoped data. A global run sees only the default scope.
Commands
| Command | Arguments and options |
|---|---|
scheduler:list |
none. Reads scheduler_schedule_definitions, enabled rows only, and shows key, cron, next due time (in the row's timezone, with the UTC time beside it when that is not UTC), pool, overlap, misfire, tenant mode and Last Planned, which is the planning cursor |
scheduler:plan |
none. One pass: sync, then plan |
scheduler:work |
[pool], which defaults to SCHEDULER_DEFAULT_POOL or default. Runs until killed |
scheduler:run-now |
<schedule-key>, --tenant / -t <id>, --inline |
scheduler:run-now creates a run for an existing definition that is due now, using the definition's pool, attempts, backoff and the same lock key a planned run would get. Without --inline, a worker for that pool picks it up. With --inline, the run is saved already claimed under the command's own lease, so no worker can claim it while the command holds it. The command runs it in its own process through the same overlap, executor and retry path, and exits non-zero unless a success was recorded. renewLease() works there as it does in a worker. A retry or an overlap delay it schedules goes to the workers, and so does a run whose lease ran out, because the job outlived it or the command was killed. A per_tenant schedule needs --tenant once the install has tenants, and --tenant must name one that exists; the command refuses otherwise.
Tables
All four come from ORM resource models; bin/semitexa orm:sync creates them.
| Table | Holds |
|---|---|
scheduler_schedule_definitions |
one row per #[AsScheduledJob] key, plus enabled, timezone and the planning cursor |
scheduler_runs |
every recurring and delayed run: status, available_at, attempts, lease, lock key, payload, last_error |
scheduler_locks |
overlap locks, unique on lock_key, each with an expiry |
scheduler_run_history |
an append-only event log per run (running, succeeded, retry_scheduled, failed, skipped_overlap, overlap_delayed). A reclaim after an expired lease writes no entry |
Locks are deleted when released and when they expire. Every scheduler:work process deletes runs in a final status (succeeded, failed, skipped_overlap, and the never-written misfire_skipped and cancelled) last changed more than SCHEDULER_RETENTION_DAYS ago, with their history: once when it starts, then at most once an hour, up to 10 000 runs each time, across all pools. Pending, claimed, running and retrying rows are never deleted, and definitions stay.
How the worker container runs it
In a project started with bin/semitexa server:start, the worker service runs every line that bin/semitexa worker:processes prints, and starts each command again after a pause once it exits. The scheduler adds these lines through #[AsWorkerProcessSource] (SchedulerWorkerProcesses):
scheduler:plan, started again 60 s after each run ends, so occurrences are planned up to about a minute late.available_atis the occurrence time, so a late run is claimed straight away.- one
scheduler:work <pool>(5 s pause) each fordefault, forSCHEDULER_DEFAULT_POOL, and for everypoolnamed in an#[AsScheduledJob].
A pool name must be a single plain word (A–Z a–z 0–9 . _ : / = -). Any other name gets no worker, and a warning is logged. WORKER_REPLICAS runs more containers. The unique occurrence key and the guarded claim keep the extra planners and workers from running anything twice. In development, each run also shows up in ai:observe as a scheduler process. See Workers & Scheduling.
Configuration
| Key | Default | Used for |
|---|---|---|
SCHEDULER_DEFAULT_POOL |
default |
the pool of a dispatch with no pool, scheduler:work with no argument, and one more worker line. It does not change the attribute's pool default, which is the literal 'default' |
SCHEDULER_POLL_INTERVAL |
5 | seconds a worker sleeps when nothing is due |
SCHEDULER_LEASE_TTL |
300 | seconds a claim lasts; renewLease() resets it to now plus this |
SCHEDULER_LOCK_TTL |
300 | seconds an overlap lock lasts; renewLease() resets it to now plus this |
SCHEDULER_RETENTION_DAYS |
30 | days a finished run and its history are kept; 0 keeps them forever |
WORKER_REPLICAS |
1 | worker containers |
SCHEDULER_MAX_ATTEMPTS and SCHEDULER_RETRY_BACKOFF are read into SchedulerConfig, but nothing uses them. Attempts and backoff come from the attribute or the dispatch call, both defaulting to 1 and 0.
Watch out for
- A deleted job class stops its schedule, without disabling it.
scheduler:plandoes not plan a schedule whose job class no longer exists, and reports it on every pass, about once a minute. The row staysenabled, so the schedule plans again once the class is back. If it is gone for good, setenabled = 0on the row. - A key no attribute declares any more is not planned. After renaming
keyor removing#[AsScheduledJob], the old row staysenabledbutscheduler:planpasses it over and reports it on every pass. Nothing is disabled, so rolling back to the old name plans it again. While a rolling deploy runs the old and new images side by side, each plans its own key, so the job can run twice until the old image stops. Its cursor stays put meanwhile, so it then catches up by its misfire policy. To silence the report for good, setenabled = 0or delete the row. - No
#[AsService], no job.scheduler:planwarns... is not a service ... mark it #[AsService]and does not sync the schedule. A delayed dispatch of such a class fails on every attempt. - One instance serves every run. A service is built once per worker process, so a job object is reused from run to run. Do not keep per-run state on its properties.
- A long job without
renewLease()runs twice, or is marked failed. After 300 s, another worker reclaims it. With attempts left, it starts a second copy while the first is still running. On its last attempt, the run is markedfailedand the first copy's result is thrown away. - A pool with no worker never runs. Only
default,SCHEDULER_DEFAULT_POOLand pools named in an attribute get ascheduler:work. If you dispatch to any other pool, add your own#[AsWorkerProcessSource], as the workflow package does for its timeout pools. - A mistyped policy stops that schedule.
overlapPolicy,misfirePolicyandtenantModeare not checked when synced. A value outside the enum makesscheduler:planreport that schedule and pass over it on every pass; the other schedules are planned as usual. Its planning cursor does not move, so once the value is fixed, the occurrences missed meanwhile go through the misfire policy like an outage. scheduler:listandscheduler:run-nowread the table, not the code. A new job appears only after the nextscheduler:plan.explicit_tenantis refused. Useper_tenant, or dispatch with atenantId.- A cron that never matches is refused.
0 0 31 2 *parses, but there is no 31 February. The sync refuses it as it refuses one that does not parse; a row edited by hand to such a value is reported and passed over byscheduler:plan, and the other schedules are planned as usual. - A job at a time of day runs once when the clocks go back. In a row whose
timezonehas daylight saving,30 3 * * *runs at the first 03:30 of the repeated hour, not at both. A job whose minute or hour is a wildcard or a step (0 * * * *,*/15 3 * * *) keeps every tick of the repeated hour. In spring, a time the clocks skip runs an hour late, once. The attribute writesUTC, which has no such change. - New schedules do not backfill, and
run_onceruns late. A daily 03:00 job withrun_oncewhose planner was down until 09:00 runs once at 09:00. Withskipit does not run that day. Withcatch_upand nomaxCatchUpRuns, it runs every missed occurrence.