Search by

ez-php / scheduler

AU9500

Cron-based job scheduler with mutex-backed overlap prevention for ez-php applications

Package info

github.com/ez-php/scheduler

pkg:composer/ez-php/scheduler

Statistics

Installs: 83

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.7.1 2026-10-30 15:41 UTC

README

Cron-based job scheduler for ez-php applications. Register commands with a fluent frequency API, prevent overlapping runs via pluggable mutex drivers (File, Database), and execute due jobs from a single cron entry.

Installation

composer require ez-php/scheduler

Quick Start

Register SchedulerServiceProvider (in an ez-php application: uncomment it in provider/modules.php). It binds one shared Scheduler with the mutex chosen by config/scheduler.php and registers scheduler:run and scheduler:list:

// config/scheduler.php
return [
    'mutex' => 'file',   // file | database | database_expiry | redis | cache | none
    'ttl' => 3600,       // database_expiry, redis, cache
    'file' => ['path' => sys_get_temp_dir() . '/ez-scheduler-locks'],
    'redis' => ['host' => '127.0.0.1', 'port' => 6379, 'database' => 0],
];

database/database_expiry use the bound DatabaseInterface's PDO, cache the bound ez-php/cache CacheInterface. Entries stay application code — add them in the boot() of a provider registered after SchedulerServiceProvider:

use EzPhp\Scheduler\Scheduler;

public function boot(): void
{
    $scheduler = $this->app->make(Scheduler::class); // shared instance

    $scheduler->command('queue:work --max-jobs=100')->everyMinute()->withoutOverlapping();
    $scheduler->command('cache:prune')->hourly();
    $scheduler->command('reports:generate')->daily();
}

Without the provider, bind a Scheduler yourself and register the commands before bootstrap ($app->registerCommand(SchedulerRunCommand::class), likewise SchedulerListCommand).

Run it from a cron entry, once per minute:

* * * * * php /var/www/html/ez scheduler:run

scheduler:run runs every due entry through the application's console — the entry string is split on whitespace, so 'queue:work --max-jobs=100' calls queue:work with --max-jobs=100 — honouring withoutOverlapping() and the optional logger. It stops at the first entry that fails (non-zero exit or exception) and exits 1.

It is deliberately named scheduler:run: the framework's own schedule:run drives the framework's simpler EzPhp\Console\Schedule\Scheduler (no overlap prevention, no cron()), not this package's. Use one or the other, not both from cron.

scheduler:list prints every entry with its frequency, overlap setting, the next due minute (searched up to 31 days ahead) and the lock state. The lock is read without being acquired, so listing never makes a running scheduler:run skip an entry; this needs a mutex implementing InspectableMutexInterface (RedisMutex, DatabaseMutex, DatabaseMutexWithExpiry) — FileMutex and CacheMutex show unknown.

Outside an ez-php application, call run() with your own executor:

$scheduler->run(new DateTimeImmutable(), static function (string $command): void {
    // dispatch $command however your application runs commands; throw on failure
});

Frequency Methods

All methods are fluent and return ScheduleEntry for chaining:

Method When due
everyMinute() Every cron invocation
everyFiveMinutes() When minute % 5 === 0
hourly() At :00 of every hour
daily() At 00:00
weekly() On Sunday at 00:00
monthly() On the 1st of the month at 00:00
cron(string $expression) Matches a five-field cron expression (minute hour dom month dow)

An entry without a frequency set is never due.

Explicit cron expressions

For schedules the predefined helpers don't cover, pass a standard five-field expression directly:

$scheduler->command('reports:weekly')->cron('30 6 * * 1'); // 06:30 every Monday
$scheduler->command('sync:external')->cron('*/15 * * * *'); // every 15 minutes

Supported field syntax per position: * (any), N (exact value), A-B (inclusive range), */N (every N steps starting from 0), A-B/N (every N steps from A up to B) and comma-separated lists of those (1,15,30, 1-5,10). Day-of-week counts Sunday as 0 or 7, as standard cron does (0 9 * * 5-7 = Fri–Sun):

$scheduler->command('reports:hourly')->cron('0 8-18/2 * * 1-5'); // every 2 hours 08–18, Mon–Fri

When both day-of-month and day-of-week are restricted, either one matching is enough (standard cron): 0 9 1,15 * 1-5 runs on the 1st, the 15th and every weekday. If either field starts with *, both must match.

Names (MON), macros (@daily) and L/W/# are not supported. A malformed expression (not exactly five space-separated fields) is simply never due, same as an entry with no frequency set at all.

Reconciling with ez-php/queue's own scheduler

ez-php/queue ships an independent job-class-based scheduler (Scheduling\Scheduler + Scheduling\ScheduledTask, driven by the queue:schedule console command) with its own cron-expression matching but no overlap prevention — nothing stops two overlapping queue:schedule cron ticks from both matching the same due task and double-pushing the same job, if a tick ever runs long enough to still be executing when the next one starts.

The two packages are not merged — ez-php/queue's job-class model (ScheduledTask::createJob()) and this package's console-command model (ScheduleEntry::getCommand()) are different enough that unifying them would be a real merge, which is explicitly out of scope. Instead, register queue:schedule itself as a single withoutOverlapping() entry here, so this package's mutex protects the entire due-job-pushing step as one atomic unit, regardless of how many individual ScheduledTasks it evaluates internally:

$scheduler->command('queue:schedule')->everyMinute()->withoutOverlapping();

Then point your system cron at this package's scheduler:run only — remove any separate * * * * * ez queue:schedule cron line, since this entry now invokes it (mutex-guarded) on your behalf:

* * * * * php /var/www/html/ez scheduler:run

This is pure integration glue: queue:schedule's own cron-matching and job dispatch logic (ez-php/queue's Scheduling\Scheduler::dueJobs()) is unchanged and still runs exactly as before — this package's mutex now simply wraps the single point where it used to be invoked directly by cron, closing the "queue:schedule takes over a minute, cron overlaps it" race window without either package depending on the other.

Overlap Prevention

Call withoutOverlapping() to skip a command if a previous invocation is still running:

$scheduler->command('queue:work')->everyMinute()->withoutOverlapping();

Requires a MutexInterface passed to the Scheduler constructor. A SchedulerException is thrown at runtime if withoutOverlapping() is used without a mutex configured.

Mutex Drivers

FileMutex

Uses PHP's flock(LOCK_EX|LOCK_NB) on per-command lock files in a configurable directory.

use EzPhp\Scheduler\Mutex\FileMutex;

$mutex = new FileMutex('/var/run/ez-php/locks');
$scheduler = new Scheduler($mutex);
  • The lock directory is created automatically if it does not exist.
  • Lock files are never deleted — their inodes remain stable across runs.
  • The lock is tied to the file handle, so a crashed process releases it automatically on the next cron run.
  • Suitable for single-server deployments.

DatabaseMutex

Uses a scheduler_locks table (created automatically via CREATE TABLE IF NOT EXISTS). Acquiring a lock inserts a row; releasing it deletes the row. A duplicate-key violation signals the lock is already held.

use EzPhp\Scheduler\Mutex\DatabaseMutex;

$mutex = new DatabaseMutex($pdo); // any PDO instance
$scheduler = new Scheduler($mutex);
  • Compatible with MySQL and SQLite.
  • No automatic TTL/expiry — stale rows from crashed processes must be cleaned manually. Use DatabaseMutexWithExpiry if that matters.
  • Suitable for multi-server deployments sharing the same database.

DatabaseMutexWithExpiry

Same idea as DatabaseMutex, but each lock carries an expiry timestamp. If the process holding a lock dies without releasing it, the next acquire() reclaims the key once the expiry has passed — the schedule recovers on its own instead of blocking until someone clears the row by hand.

use EzPhp\Scheduler\Mutex\DatabaseMutexWithExpiry;

$mutex = new DatabaseMutexWithExpiry($pdo, 600); // TTL in seconds (default 3600)
$scheduler = new Scheduler($mutex);
  • Uses its own scheduler_locks_ttl table, so it coexists with DatabaseMutex. (CREATE TABLE IF NOT EXISTS never adds a column to an existing table, so the expiry column could not be retrofitted onto scheduler_locks safely.)
  • Pick a TTL comfortably longer than the command's worst-case runtime. A TTL shorter than the actual runtime lets another process reclaim the lock while the first is still working, which defeats overlap prevention entirely.
  • Expired rows are reclaimed lazily, per key, on the next acquire. There is no background sweep — keys that stop being scheduled keep their last row.
  • Each lock row carries a random owner token: release() deletes it only while it still holds this instance's token, so a run that overruns its TTL can't remove a lock another process has reclaimed since. A table created by an older version gets its owner column added automatically.

RedisMutex

For scheduled commands running on more than one host. FileMutex is bound to a single filesystem and DatabaseMutex to a single database; Redis is usually already shared across application servers.

use EzPhp\Scheduler\Mutex\RedisMutex;

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$mutex = new RedisMutex($redis, 600); // TTL in seconds (default 3600)
$scheduler = new Scheduler($mutex);
  • Uses a single atomic SET key value NX EX ttl, so there is no read-then-write race between concurrent cron processes.
  • The value is a random owner token: release() deletes the key only while it still holds this instance's token (Lua compare-and-delete), so a run that overruns its TTL can't remove a lock another process has taken since.
  • Locks carry a TTL, so a crashed process does not block the schedule forever. Pick a TTL longer than the command's worst-case runtime.
  • Keys are namespaced with ez-php:scheduler:lock: so they cannot collide with application data in a shared Redis database.
  • Fails closed: if Redis is unreachable, acquire() returns false and the run is skipped, because the lock cannot be proven free. Running a scheduled job twice is the outcome this class exists to prevent.
  • Requires ext-redis; the constructor throws RuntimeException without it.

CacheMutex

Overlap prevention on the locks of an ez-php/cache store the application already runs — Redis, Memcached, Database (owner-token locks shared across hosts) or File. Requires ez-php/cache (composer require ez-php/cache).

use EzPhp\Scheduler\Mutex\CacheMutex;

$mutex = new CacheMutex($app->make(\EzPhp\Cache\CacheInterface::class), 600); // TTL (default 3600)
$scheduler = new Scheduler($mutex);
  • release() goes through the lock object acquire() obtained, so it never deletes a lock another process has taken after this run's TTL expired.
  • Keys are namespaced with ez-php:scheduler:lock:.
  • Don't use ArrayDriver outside tests — its locks are per process.

API Reference

Scheduler

new Scheduler(?MutexInterface $mutex = null)
Method Description
command(string $name): ScheduleEntry Register a command and return its entry for chaining
all(): list<ScheduleEntry> Return all registered entries
dueEntries(DateTimeInterface $time): list<ScheduleEntry> Return entries whose predicate matches $time
run(DateTimeInterface $time, callable $executor): void Execute all due entries via the callable
isLocked(ScheduleEntry $entry): ?bool Read-only lock state; null without withoutOverlapping() or an InspectableMutexInterface mutex

ScheduleEntry

Method Description
everyMinute(): self Due on every invocation
everyFiveMinutes(): self Due at minute :00, :05, :10, …
hourly(): self Due at minute :00
daily(): self Due at 00:00
weekly(): self Due on Sunday at 00:00
monthly(): self Due on the 1st at 00:00
cron(string $expression): self Due when the five-field cron expression matches
withoutOverlapping(bool $enabled = true): self Enable mutex-based skip
isDue(DateTimeInterface $time): bool Evaluate the frequency predicate
getCommand(): string Return the registered command name
getMutexKey(): string Return a stable sha1-derived lock key
nextRunAfter(DateTimeInterface $after, int $maxMinutes = 44640): ?DateTimeImmutable Next due minute within the window (default 31 days), or null

MutexInterface

interface MutexInterface
{
    public function acquire(string $key): bool;
    public function release(string $key): void;
}

Implement this interface to add custom mutex backends (e.g. Redis, Memcached). Implement InspectableMutexInterface (adds isLocked(string $key): bool) as well when the backend can report a held lock without acquiring it — scheduler:list then shows the lock state.

Custom Mutex

use EzPhp\Scheduler\MutexInterface;

final class MemcachedMutex implements MutexInterface
{
    /** @var array<string, string> owner token per acquired key */
    private array $tokens = [];

    public function __construct(private readonly \Memcached $memcached) {}

    public function acquire(string $key): bool
    {
        $token = bin2hex(random_bytes(16));

        if (!$this->memcached->add($key, $token, 300)) {
            return false;
        }

        $this->tokens[$key] = $token;

        return true;
    }

    public function release(string $key): void
    {
        $token = $this->tokens[$key] ?? null;
        unset($this->tokens[$key]);

        $item = $this->memcached->get($key, null, \Memcached::GET_EXTENDED);

        // Only delete the lock if it is still ours — after a TTL overrun another
        // process may hold it. cas() with a negative expiry expires it atomically.
        if ($token !== null && is_array($item) && $item['value'] === $token) {
            $this->memcached->cas($item['cas'], $key, $token, -1);
        }
    }
}

Store an owner token and release only while the lock still carries it — a plain delete lets a run that overran its TTL remove a lock another process has taken since.

Exceptions

SchedulerException (extends RuntimeException) is thrown when:

  • withoutOverlapping() is used but no MutexInterface was passed to Scheduler
  • FileMutex cannot create the lock directory or open a lock file

Exceptions from the executor callable propagate up after the mutex lock is released (guaranteed via finally).