Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Schedule-it for Craft CMS

The site's scheduled jobs, kept in the repository. The host runs one cron entry every minute, and that entry works out which jobs are due and runs them:

* * * * *  php craft schedule-it/run

The schedule itself is config/schedule-it.php, so it is reviewed like any other code, it is the same on every environment that deploys the commit, and a new environment gets every job the moment that one entry exists — instead of jobs living only in a hosting dashboard, invisible to the repository and missing wherever someone forgot to re-add them.

Built on omnilight/yii2-scheduling, with the defaults that library needs to work under Craft already applied.

Requirements

Craft CMS 5, PHP 8.2+. A shared mutex (Craft 5's default database mutex is one) unless the host can only ever run one server — see Overlap.

Install

composer require webdna/schedule-it
php craft plugin/install schedule-it

Copy vendor/webdna/schedule-it/src/config.php to config/schedule-it.php, add the jobs, then add the one cron entry on each environment (see Servd for that host).

The schedule

Each job is written either in words or as a cron expression, and the two mix freely:

<?php
// config/schedule-it.php
use craft\helpers\App;
use webdna\scheduleit\Job;

return [
    'jobs' => [
        // In words.
        Job::command('gc/run')->dailyAt('03:00'),
        Job::command('reports/send')->weekdays()->at('08:30'),
        Job::command('feeds/sync')->everyFifteenMinutes(),
        Job::command('my-module/products/import')->hourly()->background(),
        Job::exec('bin/geoip-update.sh')->weekly(),
        Job::command('my-module/reminders/send')
            ->dailyAt('08:00')
            ->description('Membership reminders — sends real email')
            ->enabled(App::env('CRAFT_ENVIRONMENT') === 'production'),

        // Or the console command, arguments included => a cron expression.
        'utils/prune-revisions --max-revisions=50' => '30 2 * * 1',
        'my-module/listings/archive --dryRun=0' => '30 2 * * *',

        // Or an array, for a description, a timezone, or a per-environment switch.
        'my-module/reminders/send' => [
            'cron' => '0 8 * * *',
            'description' => 'Membership reminders — sends real email',
            'enabled' => App::env('CRAFT_ENVIRONMENT') === 'production',
        ],

        // One command on two schedules: list entries that name their command.
        ['command' => 'feeds/sync', 'cron' => '0 6 * * 1-5'],
        ['command' => 'feeds/sync', 'cron' => '0 12 * * 6,0'],

        // A shell command is a list entry with `exec`.
        ['exec' => 'bin/rotate-backups.sh', 'cron' => '0 4 * * *'],
    ],
];

In words

Job::command('<route and arguments>'), then methods that each set part of the expression — so they compose, the way Laravel's scheduler does: ->weekdays()->at('08:30') is 30 8 * * 1-5, and so is ->at('08:30')->weekdays(). schedule-it/list shows the expression each one became.

Minutes everyMinute(), everyTwoMinutes(), everyFiveMinutes(), everyTenMinutes(), everyFifteenMinutes(), everyThirtyMinutes(), everyNMinutes(n)
Hours hourly(), hourlyAt(minute), everyTwoHours(), everySixHours(), everyNHours(n, minute = 0)
Time of day daily() (midnight), dailyAt('HH:MM'), at('HH:MM') (sets only the time, so it follows a day method), twiceDaily(1, 13, minute = 0)
Days weekdays(), weekends(), mondays() … sundays(), days('monday', 3, …) (names, or 0 = Sunday)
Longer weekly() (Sunday midnight), weeklyOn('tuesday', 'HH:MM'), monthly(), monthlyOn(day, 'HH:MM'), lastDayOfMonth('HH:MM'), quarterly(), yearly()
Raw cron('30 2 * * *') — methods after it change parts of it
Other timezone('Europe/London'), description('…'), enabled(bool or '$ENV_VAR'), background() (see Long jobs)

Job::exec('<shell line>') starts a job that is not a Craft command — a script, a binary, a pipeline — and takes the same methods. It runs through the shell from the project root, and its output, lock and exit code are handled exactly as a console command's. The whole line is logged, a && b included.

Times are 24-hour. A nonsensical schedule — at('24:00'), days('mon'), everyNMinutes(0) — is refused with a message naming the job.

As an array

Job key
cron Required. A five-field cron expression.
timezone The timezone cron is read in. Defaults to the schedule's (UTC).
description What list and the run output call the job. Defaults to the command.
enabled true, false, or a string Craft reads as a boolean ('$SCHEDULE_REMINDERS'). A disabled job is shown by list and never run.
background true to run the job as its own process; see Long jobs. The same values as enabled.
command Only for list entries; otherwise the key is the command.
exec A shell command, in place of command, in a list entry.

Anything the plugin cannot read is an error, not a skipped job. A misspelt key ('enable' => false), a bad expression or an unknown timezone stops schedule-it/run with a message and exit code 78, because the alternative is a job that silently never runs.

It never takes the site down. config/schedule-it.php is the plugin's settings file, so Craft loads it on every request, web included. A mistake in it — at('24:00'), even a misspelt method like ->daly() — is only reported by schedule-it/run and schedule-it/list; every other request carries on. (A PHP syntax error is the exception, as in any config file.)

Top-level settings:

Setting Default
timezone 'UTC' Keep UTC: in a timezone with summer time, a job at a time of day moves an hour against UTC twice a year, and a job at 01:30 on the night the clocks change runs twice or not at all. schedule-it/list shows each job's next run in Craft's timezone beside its own, so dailyAt('07:00') reads as 08:00 or 09:00 in Zagreb. Set it, or a job's own timezone, when a job must follow the local clock.
logFile storage/logs/schedule-it.log Aliases and env vars are parsed.
singleServer false See Overlap.
configure null function (\webdna\scheduleit\Schedule $schedule) {} — for anything the array cannot say. $schedule->job('a/b', '0 * * * *')->weekdays() and $schedule->execJob('bin/x.sh', '0 * * * *') get every default below; the library's call(), when(), skip() and thenPing() are all there.

Never schedule queue/run or queue/listen. The host or Craft already runs the queue, and a second consumer runs jobs twice or takes them mid-flight. queue/listen never exits, so it would also block every job after it. Leave db/backup to the host's backups too.

Commands

php craft schedule-it/run — runs every job due this minute, one after another, then exits non-zero if any of them failed. Each job is reported as it ends:

Running scheduled command: gc/run
…the job's own output…
✓ gc/run — exit 0 in 1.4s
– feeds/import — skipped: still running from an earlier start
↗ my-module/products/import — started in the background (pid 4182); its output goes to …/schedule-it.log

A skipped job is not a failure. still running from an earlier start means its lock is held; already started for this minute by another runner means a second runner, on another server or started by hand, got there first.

Most minutes nothing is due, and it prints No scheduled commands are ready to run. — that is evidence the schedule loaded, not a problem.

php craft schedule-it/list — every job, its expression, timezone and next due time — in the job's timezone and in Craft's — whether it runs in turn or in the background, the disabled ones, and whether the mutex would let run start. Where a job has a description, the command is shown beneath it, ready to copy. Runs nothing.

What it does for you

Each of these is a way the library, used directly under Craft, fails without saying so:

  • It runs through craft. The library spawns jobs through a script called yii, which a Craft project does not have — so nothing runs and nothing is reported. The path is also made absolute, because cron does not guarantee a working directory.
  • It keeps stderr. Every job's stdout and stderr go to the log file; without that, a fatal in a job leaves no trace.
  • It echoes job output to stdout. See Servd — on a host whose task runner throws its filesystem away, the log file alone is worthless. Output is read from the job's own process as it is written, so two runners sharing a log never print each other's.
  • It reports exit codes. The library discards them, so its runner exits 0 whatever happened.
  • It refuses a lock other servers cannot see. See Overlap.
  • It is console-only. The web app has no route to the runner; /actions/schedule-it/run is a 404.

Overlap

Every job takes a lock through Craft's mutex component just before it starts, and releases it the moment it ends. A job still running from an earlier minute is skipped rather than started twice, and holds back nothing but itself: the other jobs due that minute run as normal.

With the lock held, the runner also records the minute it started the job in Craft's cache. A second runner in the same minute — another server, or a run started by hand — finds the job already started and skips it, even if the first has already finished. That is only as shared as the cache: Craft's default file cache belongs to one server, so on several servers give the cache a shared driver (Redis, or the database) as well as the mutex.

That only works if the lock is somewhere every server can see. Craft 5's default is the database (yii\mutex\MysqlMutex / PgsqlMutex), which is. A FileMutex is not: it lives on one server's disk. The library's own guard tests Craft's wrapper component rather than the driver inside it, so it never catches this; the plugin checks the driver, and schedule-it/run refuses to start on a file (or null) mutex. On a host that can never run two servers, set 'singleServer' => true to run anyway.

Long jobs

Jobs run one after another, so a ten-minute import holds back the jobs listed after it in the same minute (never those of later minutes — see Overlap). Two ways round that:

  • List the long job last.
  • Run it in the background: ->background(), or 'background' => true. The runner starts it as its own process, craft schedule-it/run/job <id> <minute>, and moves straight on. That process takes the job's lock and records its start as the runner would, and writes the job's output and its ✓/✗ line to the log file — not to the runner's output, which has moved on. Its exit code is not the runner's either.

Not on Servd, or any host that runs the scheduled task in a container removed when the command exits: the background process goes with it. List the long job last there.

Servd

  1. Servd dashboard → the project → the environment → Scheduled Tasks.
  2. Add one task: php craft schedule-it/run, every minute (* * * * *). Match the form Servd's existing tasks use.
  3. Repeat per environment — environments do not share tasks. Nothing else is added there, ever; the jobs come from the repository.

Two things specific to Servd:

  • Servd already runs the queue and the backups. Do not schedule queue/* or db/backup.
  • The log file does not survive. A scheduled task runs in Servd's task runner, not the web container, with a filesystem that goes when the task ends; Servd collects stdout only. That is why the runner echoes each job's output: read it in Servd's log for the task, and do not look for storage/logs/schedule-it.log there. Locally the file is the thing to read.
  • Background jobs do not survive either, for the same reason. See Long jobs.

Think about staging before adding its task. Once the entry exists, every job runs there, including any that send email. Point staging's mailer somewhere harmless, give those jobs an enabled switch, or leave staging without the task.

Local

There is no local cron under DDEV. Run a job directly with ddev craft <command>, or the runner itself with ddev craft schedule-it/run, which runs whatever is due at that minute in UTC. ddev craft schedule-it/list shows when that is.

Moving from a hand-wired yii2-scheduling setup

If the project already maps schedule to omnilight\scheduling\ScheduleController in config/app.console.php with a config/schedule.php file:

  1. Install the plugin, and move each job into config/schedule-it.php. Chained ->cron()->timezone('UTC')->onOneServer()->appendOutputTo()->then(...) lines become one line each — Job::command('gc/run')->dailyAt('03:00'), or 'gc/run' => '0 3 * * *'.
  2. Remove the schedule component and the schedule controllerMap entry, and delete config/schedule.php.
  3. Change the host's one cron entry from schedule/run to schedule-it/run in the same deploy. Until it changes, nothing runs — and schedule/run fails with a message saying so, rather than succeeding. (The library registers that command on every console app by itself, and left alone it would run an empty schedule and exit 0 every minute.)

Moving from webdna/scheduler

Before 2.0.0 this plugin was webdna/scheduler, handle scheduler. It was renamed because the Plugin Store already lists Verbb's Scheduler under that handle, and Craft's update check matches plugins by handle — so the control panel offered every install Verbb's updates.

The plugin keeps nothing in the database, so the move is the old one out and the new one in:

php craft plugin/uninstall scheduler
composer remove webdna/scheduler
composer require webdna/schedule-it
php craft plugin/install schedule-it
git mv config/scheduler.php config/schedule-it.php
  1. In config/schedule-it.php, change use webdna\scheduler\Job; to use webdna\scheduleit\Job;.
  2. Change the host's one cron entry from scheduler/run to schedule-it/run in the same deploy. Until it changes nothing runs, and scheduler/run fails with a message saying so.
  3. Commit composer.json, composer.lock, the config file and config/project/. On each environment, applying project config uninstalls scheduler and installs schedule-it together; nothing else needs running there.

A schedule left in config/scheduler.php is never read by the new plugin, so schedule-it/run and schedule-it/list refuse to start until it is renamed, rather than running nothing.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages