Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Schedule::driver('action-scheduler')`, or `POLLORA_SCHEDULE_DRIVER`, chooses the driver of the site; `Schedule::extend()` adds one. WordPress's own scheduling functions follow it, for the core, plugins and themes, through the `pre_schedule_event`, `pre_reschedule_event`, `pre_unschedule_event`, `pre_clear_scheduled_hook`, `pre_unschedule_hook` and `pre_get_scheduled_event` filters
- What stays on WP-Cron: events already in the `cron` option (nothing moves until a migration says so, so a recurring event WP-Cron runs keeps rescheduling itself there), Action Scheduler's own queue runner (`action_scheduler_run_queue`) and the hooks given to `ScheduleEngine::protect()` or the `pollora/schedule/protected_hooks` filter, and every event when the driver is not there
- Events scheduled before Action Scheduler is ready are kept and given to it on `action_scheduler_init`, once each
- The `cron` option indexes every event, whoever keeps it: an event another driver keeps is written there with a `driver` key (an entry without it is WP-Cron's own), so `wp_next_scheduled()`, Site Health, WP Crontrol and Query Monitor see it without a query to the driver. The engine writes the entry when it schedules and cancels, and Action Scheduler's own reports (stored, completed, failed, cancelled) keep it current
- WP-Cron never runs an event another driver keeps (`pre_get_ready_cron_jobs`), and a direct write to the option that removes or moves such an entry reaches its driver (`pre_update_option_cron`). Running an event with `wp cron event run`, which reschedules and then unschedules the old time, leaves it scheduled
- `Migrator`, `ScheduleEngine::reindex()`: move pending events from one driver to another, in both directions and safe to run again (the event is created in the target before it leaves the source), keeping a recurring event's own interval; `--dry-run` shows what would move. Hooks the driver protects stay on WP-Cron
- WP-CLI: `wp cron event list` gains `type` (`once` or `recurring`) and `driver` and their filters, extending the command WP-CLI bundles without removing anything; `wp pollora schedule migrate [--from=] [--to=] [--dry-run]`
- `Schedule::boot()`: an application that starts before WordPress chooses the driver at once, and the engine hooks into WordPress as soon as `add_filter()` exists
- `Contracts\ReportsChanges`, for a driver that runs its own events and tells when one ran, failed or was cancelled; `Event::$driver`

### Changed

- `pre_get_scheduled_event` is no longer filtered: the index answers `wp_get_scheduled_event()` natively

## [0.1.0] - 2026-10-13

Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ A driver is where events are kept and who runs them. `WpCronDriver` is the defau

Another driver implements `Pollora\Schedule\Contracts\ScheduleDriver` and is given to the engine with `Schedule::useDriver()`.

## The `cron` option as an index

Whatever the driver, the `cron` option lists every scheduled event. An event another driver keeps carries a `driver` key next to `schedule` and `args`; an entry without it is WP-Cron's. WordPress reads the option without a query, so `wp_next_scheduled()`, Site Health, WP Crontrol, Query Monitor and `wp cron event list` all see the event, and WP-Cron never runs one it does not own.

A plugin that rewrites the whole array still works: removing or moving an entry reaches the driver that keeps it.

## Changing driver

Setting `Schedule::driver('action-scheduler')` (or `POLLORA_SCHEDULE_DRIVER`) sends new events to Action Scheduler; what WP-Cron holds stays there until you move it:

```bash
wp pollora schedule migrate --to=action-scheduler --dry-run
wp pollora schedule migrate --from=wp-cron --to=action-scheduler
wp pollora schedule migrate --from=action-scheduler --to=wp-cron # and back
```

An event is created in the target before it leaves the source, so a crash duplicates it instead of losing it, and running the command again does not create it twice. A recurring event keeps the interval its entry stores. Hooks the driver protects stay on WP-Cron.

`wp cron event list` shows the type and the driver of each event, and takes `--type=once|recurring` and `--driver=<name>` like its other filters.

## Testing

```php
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ parameters:
treatPhpDocTypesAsCertain: false
bootstrapFiles:
- stubs/action-scheduler.php
- stubs/wp-cli.php
paths:
- src
127 changes: 127 additions & 0 deletions src/Cli/CronEventCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Cli;

use Pollora\Schedule\Support\CronIndex;
use stdClass;
use WP_CLI\Formatter;

/**
* `wp cron event`, with what the 'cron' option now knows: the type of each event
* and the driver that keeps it.
*
* It extends the command WP-CLI bundles and takes nothing away: the columns,
* the filters and the formats it had still work, and `schedule`, `unschedule`
* and `delete` are the parent's, which go through the core functions the engine
* already follows: running an event another driver keeps reschedules it and
* unschedules the old time, which the engine reads as "it stays".
*/
class CronEventCommand extends \Cron_Event_Command
{
private const array FIELDS = ['hook', 'next_run_gmt', 'next_run_relative', 'recurrence', 'type', 'driver'];

/**
* Lists scheduled cron events.
*
* ## OPTIONS
*
* [--fields=<fields>]
* : Limit the output to specific object fields.
*
* [--<field>=<value>]
* : Filter by one or more fields.
*
* [--format=<format>]
* : Render output in a particular format.
* ---
* default: table
* options:
* - table
* - csv
* - ids
* - count
* - json
* - yaml
* ---
*
* ## AVAILABLE FIELDS
*
* These fields will be displayed by default for each cron event:
*
* * hook
* * next_run_gmt
* * next_run_relative
* * recurrence
* * type: `once` or `recurring`
* * driver: the driver that keeps the event (`wp-cron`, `action-scheduler`…)
*
* These fields are optionally available:
*
* * time
* * sig
* * args
* * schedule
* * interval
* * next_run
* * actions
*
* ## EXAMPLES
*
* $ wp cron event list --fields=hook,type,driver,next_run_relative
* $ wp cron event list --type=once --driver=action-scheduler --format=json
*
* @subcommand list
*
* @param list<string> $args
* @param array<string, mixed> $assoc_args
*/
public function list_($args, $assoc_args): void
{
$formatter = new Formatter($assoc_args, self::FIELDS, 'event');
$events = static::get_cron_events();

if (is_wp_error($events)) {
$events = [];
}

$crons = (array) _get_cron_array();

foreach ($events as $event) {
$event->driver = self::driverOf($crons, $event);
$event->type = $event->schedule === false ? 'once' : 'recurring';
}

if (in_array('actions', (array) $formatter->fields, true)) {
foreach ($events as $event) {
$event->actions = static::get_hook_actions($event->hook);
}
}

foreach ($events as $key => $event) {
foreach (['hook', 'next_run_gmt', 'next_run_relative', 'recurrence', 'type', 'driver'] as $field) {
if (! empty($assoc_args[$field]) && $event->{$field} !== $assoc_args[$field]) {
unset($events[$key]);
break;
}
}
}

if ($formatter->format === 'ids') {
echo implode(' ', wp_list_pluck($events, 'hook'));

return;
}

$formatter->display_items($events);
}

/**
* @param array<int|string, mixed> $crons
*/
private static function driverOf(array $crons, stdClass $event): string
{
return (string) ($crons[$event->time][$event->hook][$event->sig][CronIndex::MARKER] ?? CronIndex::WP_CRON);
}
}
98 changes: 98 additions & 0 deletions src/Cli/MigrateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Cli;

use Pollora\Schedule\Migration\MigrationReport;
use Pollora\Schedule\Migration\Migrator;
use Pollora\Schedule\Schedule;
use Pollora\Schedule\Support\CronIndex;

/**
* Moves the pending events of a driver to another.
*
* ## EXAMPLES
*
* # See what would move to Action Scheduler
* $ wp pollora schedule migrate --to=action-scheduler --dry-run
*
* # Move it, then back
* $ wp pollora schedule migrate --from=wp-cron --to=action-scheduler
* $ wp pollora schedule migrate --from=action-scheduler --to=wp-cron
*/
class MigrateCommand
{
/**
* Migrates pending events from one driver to another.
*
* Creates each event in the target, then takes it out of the source, so
* running it again never duplicates an event. Hooks the driver protects
* stay on WP-Cron.
*
* ## OPTIONS
*
* [--from=<driver>]
* : The driver that keeps the events now. Defaults to `wp-cron`, or to the driver that still keeps events when the target is `wp-cron`.
*
* [--to=<driver>]
* : The driver that takes them. Defaults to the driver of the site.
*
* [--dry-run]
* : Show what would move, and move nothing.
*
* @param list<string> $args
* @param array<string, mixed> $assoc_args
*/
public function __invoke($args, $assoc_args): void
{
$engine = Schedule::engine();
$to = (string) ($assoc_args['to'] ?? $engine->driverName());
$from = (string) ($assoc_args['from'] ?? $this->guessSource($to));
$dryRun = ! empty($assoc_args['dry-run']);

try {
$report = (new Migrator($engine))->migrate($from, $to, $dryRun);
} catch (\Throwable $throwable) {
\WP_CLI::error($throwable->getMessage());
}

$this->print($report);
}

private function guessSource(string $to): string
{
if ($to !== CronIndex::WP_CRON) {
return CronIndex::WP_CRON;
}

foreach (CronIndex::events() as $event) {
if ($event->driver !== null && $event->driver !== CronIndex::WP_CRON) {
return $event->driver;
}
}

\WP_CLI::error('No other driver keeps events: say which one with --from.');
}

private function print(MigrationReport $report): void
{
$verb = $report->dryRun ? 'Would move' : 'Moved';

foreach ($report->moved as $event) {
\WP_CLI::log(sprintf('%s %s (%s)', $verb, $event->hook, $event->recurrence ?? 'once'));
}

foreach ($report->skipped as $skipped) {
\WP_CLI::log(sprintf('Kept %s: %s', $skipped['event']->hook, $skipped['reason']));
}

foreach ($report->failed as $failed) {
\WP_CLI::warning(sprintf('Could not move %s: %s', $failed['event']->hook, $failed['reason']));
}

$summary = sprintf('%s %d event(s) from %s to %s%s.', $verb, count($report->moved), $report->from, $report->to, $report->dryRun ? ' (dry run)' : '');

$report->succeeded() ? \WP_CLI::success($summary) : \WP_CLI::error($summary.' Some events stayed where they were.');
}
}
30 changes: 30 additions & 0 deletions src/Cli/Registrar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Cli;

/**
* Registers the WP-CLI commands of the package: an enriched `wp cron event`
* and `wp pollora schedule migrate`. Does nothing outside WP-CLI.
*/
final class Registrar
{
private static bool $registered = false;

public static function register(): void
{
if (self::$registered || ! defined('WP_CLI') || ! class_exists('WP_CLI')) {
return;
}

self::$registered = true;

// The core's command is bundled with WP-CLI; ours extends it and takes its place
if (class_exists('Cron_Event_Command')) {
\WP_CLI::add_command('cron event', CronEventCommand::class);
}

\WP_CLI::add_command('pollora schedule migrate', MigrateCommand::class);
}
}
22 changes: 22 additions & 0 deletions src/Contracts/ReportsChanges.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Contracts;

use Closure;

/**
* A driver that runs its own events, and tells when one of them was run, failed
* or was cancelled by someone else than the engine: the 'cron' option, which
* indexes every event, follows.
*/
interface ReportsChanges
{
/**
* Call $changed with the hook and the arguments of an event whose state changed.
*
* @param Closure(string, array<int|string, mixed>): void $changed
*/
public function observe(Closure $changed): void;
}
Loading
Loading