diff --git a/CHANGELOG.md b/CHANGELOG.md index 08af852..5b952c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 45ac2e0..90e5a77 100644 --- a/README.md +++ b/README.md @@ -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=` like its other filters. + ## Testing ```php diff --git a/phpstan.neon b/phpstan.neon index 000e9e1..fd3aaa8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,5 +6,6 @@ parameters: treatPhpDocTypesAsCertain: false bootstrapFiles: - stubs/action-scheduler.php + - stubs/wp-cli.php paths: - src diff --git a/src/Cli/CronEventCommand.php b/src/Cli/CronEventCommand.php new file mode 100644 index 0000000..06b298d --- /dev/null +++ b/src/Cli/CronEventCommand.php @@ -0,0 +1,127 @@ +] + * : Limit the output to specific object fields. + * + * [--=] + * : Filter by one or more fields. + * + * [--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 $args + * @param array $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 $crons + */ + private static function driverOf(array $crons, stdClass $event): string + { + return (string) ($crons[$event->time][$event->hook][$event->sig][CronIndex::MARKER] ?? CronIndex::WP_CRON); + } +} diff --git a/src/Cli/MigrateCommand.php b/src/Cli/MigrateCommand.php new file mode 100644 index 0000000..ce215d0 --- /dev/null +++ b/src/Cli/MigrateCommand.php @@ -0,0 +1,98 @@ +] + * : 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=] + * : The driver that takes them. Defaults to the driver of the site. + * + * [--dry-run] + * : Show what would move, and move nothing. + * + * @param list $args + * @param array $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.'); + } +} diff --git a/src/Cli/Registrar.php b/src/Cli/Registrar.php new file mode 100644 index 0000000..e888f05 --- /dev/null +++ b/src/Cli/Registrar.php @@ -0,0 +1,30 @@ +): void $changed + */ + public function observe(Closure $changed): void; +} diff --git a/src/Drivers/ActionSchedulerDriver.php b/src/Drivers/ActionSchedulerDriver.php index 8fb2211..74f64c7 100644 --- a/src/Drivers/ActionSchedulerDriver.php +++ b/src/Drivers/ActionSchedulerDriver.php @@ -4,8 +4,10 @@ namespace Pollora\Schedule\Drivers; +use Closure; use Pollora\Schedule\Contracts\DefersUntilReady; use Pollora\Schedule\Contracts\FindsEvent; +use Pollora\Schedule\Contracts\ReportsChanges; use Pollora\Schedule\Contracts\ScheduleDriver; use Pollora\Schedule\Event; use Pollora\Schedule\Exceptions\ScheduleException; @@ -19,7 +21,7 @@ * own and is only looked up in that group, so cancelling a hook never reaches * the actions WooCommerce or another plugin keeps on the same hook. */ -final class ActionSchedulerDriver implements DefersUntilReady, FindsEvent, ScheduleDriver +final class ActionSchedulerDriver implements DefersUntilReady, FindsEvent, ReportsChanges, ScheduleDriver { public const string GROUP = 'pollora/schedule'; @@ -52,6 +54,36 @@ public function readyAction(): string return 'action_scheduler_init'; } + /** + * An action of the group is stored, completed, failed or cancelled. + * + * A recurring action is stored again after it completed, so the index sees + * the removal and then the next occurrence. + */ + public function observe(Closure $changed): void + { + $report = function (mixed $id) use ($changed): void { + $action = $this->fetch($id); + + if ($action !== null) { + $changed((string) $action->get_hook(), (array) $action->get_args()); + } + }; + + // A deleted action cannot be read any more, so what it was is unknown: reconcile once, when the request ends + $reconcile = false; + add_action('action_scheduler_deleted_action', static function () use (&$reconcile, $changed): void { + if (! $reconcile) { + $reconcile = true; + add_action('shutdown', static fn () => $changed('', [])); + } + }, 10, 1); + + foreach (['action_scheduler_stored_action', 'action_scheduler_completed_action', 'action_scheduler_failed_action', 'action_scheduler_canceled_action'] as $hook) { + add_action($hook, $report, 10, 1); + } + } + public function once(string $hook, int $timestamp, array $args = []): bool { if ($this->hasDuplicate($hook, $timestamp, $args)) { @@ -117,6 +149,20 @@ public function events(): array return $events; } + /** + * The action of the group with this identifier, null for another group's or a missing one. + */ + private function fetch(mixed $id): ?object + { + if (! is_numeric($id) || ! class_exists('ActionScheduler', false)) { + return null; + } + + $action = \ActionScheduler::store()->fetch_action((string) $id); + + return is_object($action) && method_exists($action, 'get_group') && $action->get_group() === self::GROUP ? $action : null; + } + /** * Pending actions of the group, soonest first. * diff --git a/src/Drivers/WpCronDriver.php b/src/Drivers/WpCronDriver.php index 0002d17..91d82fd 100644 --- a/src/Drivers/WpCronDriver.php +++ b/src/Drivers/WpCronDriver.php @@ -5,8 +5,8 @@ namespace Pollora\Schedule\Drivers; use Pollora\Schedule\Contracts\ScheduleDriver; -use Pollora\Schedule\Event; use Pollora\Schedule\Exceptions\ScheduleException; +use Pollora\Schedule\Support\CronIndex; /** * Schedules through WP-Cron. @@ -65,31 +65,12 @@ public function next(string $hook, array $args = []): ?int return $next === false ? null : (int) $next; } + /** + * The events WP-Cron keeps: those of the 'cron' option that no other driver claims. + */ public function events(): array { - $events = []; - - foreach ((array) _get_cron_array() as $timestamp => $hooks) { - if (! is_array($hooks)) { - continue; - } - - foreach ($hooks as $hook => $entries) { - foreach ((array) $entries as $entry) { - $events[] = new Event( - hook: (string) $hook, - timestamp: (int) $timestamp, - recurrence: ! empty($entry['schedule']) ? (string) $entry['schedule'] : null, - args: (array) ($entry['args'] ?? []), - interval: isset($entry['interval']) ? (int) $entry['interval'] : null, - ); - } - } - } - - usort($events, static fn (Event $a, Event $b): int => $a->timestamp <=> $b->timestamp); - - return $events; + return CronIndex::events(CronIndex::WP_CRON); } /** diff --git a/src/Event.php b/src/Event.php index a0ce57e..d89e685 100644 --- a/src/Event.php +++ b/src/Event.php @@ -16,6 +16,7 @@ * @param string|null $recurrence Key of the WordPress schedule, null for a single event * @param array $args Arguments the hook receives * @param int|null $interval Seconds between two runs, null for a single event + * @param string|null $driver Name of the driver that keeps the event, known when it comes from the index */ public function __construct( public string $hook, @@ -23,6 +24,7 @@ public function __construct( public ?string $recurrence = null, public array $args = [], public ?int $interval = null, + public ?string $driver = null, ) {} public function isRecurring(): bool diff --git a/src/Migration/MigrationReport.php b/src/Migration/MigrationReport.php new file mode 100644 index 0000000..fede5be --- /dev/null +++ b/src/Migration/MigrationReport.php @@ -0,0 +1,32 @@ + $moved Events now kept by the target + * @param list $skipped Events left where they are, on purpose + * @param list $failed Events that could not be moved: still where they were + */ + public function __construct( + public string $from, + public string $to, + public bool $dryRun, + public array $moved = [], + public array $skipped = [], + public array $failed = [], + ) {} + + public function succeeded(): bool + { + return $this->failed === []; + } +} diff --git a/src/Migration/Migrator.php b/src/Migration/Migrator.php new file mode 100644 index 0000000..76f8663 --- /dev/null +++ b/src/Migration/Migrator.php @@ -0,0 +1,164 @@ + + */ + public function pending(string $from): array + { + $events = CronIndex::events($from); + + if ($from !== CronIndex::WP_CRON) { + $seen = []; + + foreach ($events as $event) { + $seen[$this->identity($event)] = true; + } + + foreach ($this->engine->driverNamed($from)->events() as $event) { + if (! isset($seen[$this->identity($event)])) { + $events[] = new Event($event->hook, $event->timestamp, $event->recurrence, $event->args, $event->interval, $from); + } + } + + usort($events, static fn (Event $a, Event $b): int => $a->timestamp <=> $b->timestamp); + } + + return $events; + } + + /** + * @throws ScheduleException When the drivers are the same, or the target cannot take events now + */ + public function migrate(string $from, string $to, bool $dryRun = false): MigrationReport + { + if ($from === $to) { + throw new ScheduleException(sprintf("Nothing to migrate: '%s' is both the source and the target.", $from)); + } + + $this->engine->driverNamed($from); + $target = $this->engine->driverNamed($to); + + if (! $target->available()) { + throw new ScheduleException(sprintf("The '%s' driver is not available here.", $to)); + } + + if ($target instanceof DefersUntilReady && ! $dryRun && ! $target->ready()) { + throw new ScheduleException(sprintf("The '%s' driver is not ready yet: run the migration once WordPress has loaded it.", $to)); + } + + $moved = []; + $skipped = []; + $failed = []; + $protected = $to === CronIndex::WP_CRON ? [] : $this->engine->protectedHooks(); + + foreach ($this->pending($from) as $event) { + if (in_array($event->hook, $protected, true)) { + $skipped[] = ['event' => $event, 'reason' => 'protected hook, it stays on WP-Cron']; + + continue; + } + + if ($dryRun) { + $moved[] = $event; + + continue; + } + + try { + $this->move($event, $from, $to, $target); + $moved[] = $event; + } catch (\Throwable $throwable) { + $failed[] = ['event' => $event, 'reason' => $throwable->getMessage()]; + } + } + + return new MigrationReport($from, $to, $dryRun, $moved, $skipped, $failed); + } + + private function move(Event $event, string $from, string $to, ScheduleDriver $target): void + { + $this->knowingTheSchedule($event, function () use ($event, $to, $target): void { + if ($to === CronIndex::WP_CRON) { + CronIndex::record($event, $to); + + return; + } + + // Already there, from an earlier run: only the index and the source are left to settle + if ($target->next($event->hook, $event->args) === null) { + $this->engine->quietly(fn (): bool => $event->recurrence !== null + ? $target->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args) + : $target->once($event->hook, $event->timestamp, $event->args)); + } + + CronIndex::record($event, $to); + }); + + if ($from === CronIndex::WP_CRON) { + CronIndex::forget($event->hook, $event->args, $from); + + return; + } + + $this->engine->cancelOwned($from, $event->hook, $event->args); + } + + /** + * Run a change with the event's schedule registered, so a driver that looks the interval up by name finds it. + */ + private function knowingTheSchedule(Event $event, \Closure $change): void + { + if ($event->recurrence === null || $event->interval === null || ! function_exists('add_filter')) { + $change(); + + return; + } + + $register = static function (mixed $schedules) use ($event): array { + $schedules = (array) $schedules; + $schedules[$event->recurrence] ??= ['interval' => $event->interval, 'display' => $event->recurrence]; + + return $schedules; + }; + + add_filter('cron_schedules', $register); + + try { + $change(); + } finally { + remove_filter('cron_schedules', $register); + } + } + + private function identity(Event $event): string + { + return $event->hook.'|'.md5(serialize($event->args)).'|'.$event->timestamp; + } +} diff --git a/src/Schedule.php b/src/Schedule.php index 68fa487..4acb12c 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use InvalidArgumentException; use LogicException; +use Pollora\Schedule\Cli\Registrar; +use Pollora\Schedule\Contracts\ReportsChanges; use Pollora\Schedule\Contracts\ScheduleDriver; use Pollora\Schedule\Drivers\ActionSchedulerDriver; use Pollora\Schedule\Drivers\WpCronDriver; @@ -31,6 +33,11 @@ final class Schedule private static ?ScheduleEngine $engine = null; + /** + * The driver chosen before WordPress could take hooks, to wire once it can. + */ + private static ?string $wanted = null; + /** * @var array */ @@ -104,14 +111,33 @@ public static function engine(): ScheduleEngine $name = getenv('POLLORA_SCHEDULE_DRIVER'); - if (is_string($name) && $name !== '') { - self::driver($name); - } + // WP-Cron by default still sets the guard: it must not run what another driver kept + self::driver(is_string($name) && $name !== '' ? $name : 'wp-cron'); } + self::boot(); + return self::$engine; } + /** + * Hook the chosen driver into WordPress, once WordPress can take hooks. + * + * An application that starts before WordPress (Pollora does) creates the + * engine and chooses the driver first; this finishes the job as soon as + * add_filter() exists, and is safe to call again. + */ + public static function boot(): void + { + if (self::$wanted === null || ! function_exists('add_filter')) { + return; + } + + $name = self::$wanted; + self::$wanted = null; + self::driver($name); + } + /** * Choose the driver of the site by name: 'wp-cron' (the default) or 'action-scheduler', or one added with extend(). * @@ -124,17 +150,61 @@ public static function engine(): ScheduleEngine */ public static function driver(string $name): void { - $factory = self::$drivers[$name] ?? (isset(self::DRIVERS[$name]) ? static fn (): ScheduleDriver => new (self::DRIVERS[$name]) : null) ?? throw new InvalidArgumentException(sprintf('Unknown schedule driver "%s".', $name)); - $driver = $factory instanceof ScheduleDriver ? $factory : $factory(); + $driver = self::build($name); $engine = self::$engine ?? self::$engine = new ScheduleEngine; + if (! function_exists('add_filter')) { + self::$wanted = $name; + + return; + } + + $engine->resolveDriversWith(static fn (string $driverName): ?ScheduleDriver => self::tryBuild($driverName)); + Registrar::register(); + if ($driver instanceof WpCronDriver) { $engine->useDriver($driver); + // Events a driver kept before it was set aside still need the guard, and their driver reports to the index + $engine->guardIfNeeded(static function (): iterable { + foreach (self::DRIVERS as $driverName => $class) { + if ($driverName !== 'wp-cron' && is_subclass_of($class, ReportsChanges::class)) { + yield $driverName => self::build($driverName); + } + } + }); + return; } - $engine->selectDriver($driver); + $engine->selectDriver($driver, $name); + } + + /** + * @throws InvalidArgumentException When no driver has this name + */ + private static function build(string $name): ScheduleDriver + { + $factory = self::$drivers[$name] ?? (isset(self::DRIVERS[$name]) ? static fn (): ScheduleDriver => new (self::DRIVERS[$name]) : null) ?? throw new InvalidArgumentException(sprintf('Unknown schedule driver "%s".', $name)); + + return $factory instanceof ScheduleDriver ? $factory : $factory(); + } + + private static function tryBuild(string $name): ?ScheduleDriver + { + try { + return self::build($name); + } catch (InvalidArgumentException) { + return null; + } + } + + /** + * Whether a driver of this name can be chosen. + */ + public static function knows(string $name): bool + { + return isset(self::$drivers[$name]) || isset(self::DRIVERS[$name]); } /** diff --git a/src/ScheduleEngine.php b/src/ScheduleEngine.php index bbc0f0c..5170543 100644 --- a/src/ScheduleEngine.php +++ b/src/ScheduleEngine.php @@ -6,8 +6,13 @@ use Closure; use Pollora\Schedule\Contracts\DefersUntilReady; +use Pollora\Schedule\Contracts\FindsEvent; +use Pollora\Schedule\Contracts\ReportsChanges; use Pollora\Schedule\Contracts\ScheduleDriver; +use Pollora\Schedule\Drivers\ActionSchedulerDriver; use Pollora\Schedule\Drivers\WpCronDriver; +use Pollora\Schedule\Exceptions\ScheduleException; +use Pollora\Schedule\Support\CronIndex; use Pollora\Schedule\Support\CronInterceptor; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -29,6 +34,16 @@ final class ScheduleEngine */ public const array PROTECTED_HOOKS = ['action_scheduler_run_queue']; + /** + * Names of the drivers that come with the package, which the index writes next to the events they keep. + * + * @var array, string> + */ + public const array NAMES = [ + WpCronDriver::class => CronIndex::WP_CRON, + ActionSchedulerDriver::class => 'action-scheduler', + ]; + private ScheduleDriver $driver; /** @@ -36,8 +51,32 @@ final class ScheduleEngine */ private ?ScheduleDriver $fallback = null; + private string $driverName = CronIndex::WP_CRON; + private ?CronInterceptor $interceptor = null; + /** + * Builds a driver from its name, for events a driver other than the chosen one still keeps. + * + * @var (Closure(string): ?ScheduleDriver)|null + */ + private ?Closure $resolver = null; + + /** + * @var array + */ + private array $named = []; + + /** + * @var array + */ + private array $watched = []; + + /** + * Set while the engine changes a driver's events: what the driver reports then is the engine's own doing. + */ + private bool $busy = false; + /** * @var list */ @@ -76,12 +115,14 @@ public function driver(): ScheduleDriver /** * Use a driver as the only place events are kept. */ - public function useDriver(ScheduleDriver $driver): void + public function useDriver(ScheduleDriver $driver, ?string $name = null): void { $this->interceptor?->uninstall(); $this->interceptor = null; $this->fallback = null; $this->driver = $driver; + $this->driverName = $name ?? self::NAMES[$driver::class] ?? $driver::class; + $this->named = []; } /** @@ -90,14 +131,189 @@ public function useDriver(ScheduleDriver $driver): void * Events already in the 'cron' option stay there, and WP-Cron keeps running * them: nothing moves until a migration says so. A call to wp_schedule_event() * by the core, a plugin or a theme reaches the driver through the 'pre_*' - * filters of wp-includes/cron.php. + * filters of wp-includes/cron.php. Each event the driver keeps is written to + * the 'cron' option too, marked with the driver's name. */ - public function selectDriver(ScheduleDriver $driver): void + public function selectDriver(ScheduleDriver $driver, ?string $name = null): void { - $this->useDriver($driver); + $this->useDriver($driver, $name); $this->fallback = new WpCronDriver; $this->interceptor = new CronInterceptor($this); $this->interceptor->install(); + $this->watch($this->driverName, $driver); + } + + /** + * With WP-Cron as the driver: leave the events another driver still keeps to it. + * + * WP-Cron does not run an event of the index that is not its own, and a + * change made to such an event in the option reaches its driver. + */ + public function guard(): void + { + $this->interceptor?->uninstall(); + $this->interceptor = new CronInterceptor($this); + $this->interceptor->install(routing: false); + } + + /** + * With WP-Cron as the driver, hook into WordPress only when another driver still keeps events. + * + * A site that never left WP-Cron pays nothing: no filter, no watcher on + * Action Scheduler's actions. The index tells at init, once the options are readable. + * + * @param callable(): iterable $leftovers The drivers that may still keep events, by name + */ + public function guardIfNeeded(callable $leftovers): void + { + $run = function () use ($leftovers): void { + if (! CronIndex::hasMarked()) { + return; + } + + $this->guard(); + + foreach ($leftovers() as $name => $driver) { + $this->watch($name, $driver); + } + }; + + if (function_exists('did_action') && did_action('init') > 0) { + $run(); + + return; + } + + add_action('init', $run, 0); + } + + /** + * @param (callable(string): ?ScheduleDriver)|null $resolver + */ + public function resolveDriversWith(?callable $resolver): void + { + $this->resolver = $resolver === null ? null : Closure::fromCallable($resolver); + } + + public function driverName(): string + { + return $this->driverName; + } + + /** + * The driver of this name: the chosen one, WP-Cron, or one built on demand. + * + * @throws ScheduleException When no driver has this name + */ + public function driverNamed(string $name): ScheduleDriver + { + if ($name === CronIndex::WP_CRON) { + return $this->fallback ?? ($this->driver instanceof WpCronDriver ? $this->driver : ($this->named[$name] ??= new WpCronDriver)); + } + + if ($name === $this->driverName) { + return $this->driver; + } + + $driver = $this->named[$name] ?? ($this->resolver instanceof Closure ? ($this->resolver)($name) : null); + + if (! $driver instanceof ScheduleDriver) { + throw new ScheduleException(sprintf("No schedule driver is named '%s'.", $name)); + } + + $this->watch($name, $driver); + + return $this->named[$name] = $driver; + } + + /** + * Keep the index in step with a driver that runs its own events. + */ + public function watch(string $name, ScheduleDriver $driver): void + { + if (! $driver instanceof ReportsChanges || isset($this->watched[$name])) { + return; + } + + $this->watched[$name] = true; + $driver->observe(fn (string $hook, array $args) => $this->refresh($name, $hook, $args)); + } + + /** + * Write to the index what a driver says about an event, or take the event out of it. + * + * @param array $args + */ + public function refresh(string $name, string $hook, array $args = []): void + { + if ($this->busy) { + return; + } + + if ($hook === '') { + // The driver says something changed, not what: reconcile the index with it + $this->reindex($name); + + return; + } + + try { + $driver = $this->driverNamed($name); + } catch (ScheduleException) { + return; + } + + $found = $driver instanceof FindsEvent ? $driver->find($hook, $args) : null; + + if ($found instanceof Event) { + CronIndex::record($found, $name); + + return; + } + + CronIndex::forget($hook, $args, $name); + } + + /** + * Rebuild the index from a driver: what it keeps is written, what the index claims it keeps and it does not is taken out. + * + * @return int Number of entries written or removed + */ + public function reindex(?string $name = null): int + { + $name ??= $this->driverName; + + if ($name === CronIndex::WP_CRON) { + return 0; + } + + $driver = $this->driverNamed($name); + $actual = []; + $changed = 0; + $known = []; + + foreach (CronIndex::events($name) as $entry) { + $known[self::identity($entry->hook, $entry->args).'|'.$entry->timestamp] = $entry; + } + + foreach ($driver->events() as $event) { + $identity = self::identity($event->hook, $event->args).'|'.$event->timestamp; + $actual[$identity] = true; + + if (! isset($known[$identity])) { + CronIndex::record($event, $name); + $changed++; + } + } + + foreach ($known as $identity => $entry) { + if (! isset($actual[$identity])) { + CronIndex::forget($entry->hook, $entry->args, $name); + $changed++; + } + } + + return $changed; } /** @@ -124,10 +340,10 @@ public function protect(string ...$hooks): void /** * The driver an event goes to, or already is on. * - * A new event goes to the chosen driver, unless its hook is protected or - * the driver is not there. An event whose hook and arguments are already in - * the 'cron' option stays on WP-Cron, so a recurring event WP-Cron runs - * keeps rescheduling itself there. + * An event already in the 'cron' option is on the driver its entry names, + * WP-Cron when it names none: a recurring event WP-Cron runs keeps + * rescheduling itself there. A new event goes to the chosen driver, unless + * its hook is protected or the driver is not there. * * @param array $args */ @@ -137,7 +353,17 @@ public function driverFor(string $hook, array $args = []): ScheduleDriver return $this->driver; } - if (! $this->driver->available() || in_array($hook, $this->protectedHooks(), true) || $this->inCronOption($hook, $args)) { + $owner = CronIndex::ownerOf($hook, $args); + + if ($owner !== null) { + try { + return $this->driverNamed($owner); + } catch (ScheduleException) { + return $this->fallback; + } + } + + if (! $this->driver->available() || in_array($hook, $this->protectedHooks(), true)) { return $this->fallback; } @@ -145,21 +371,71 @@ public function driverFor(string $hook, array $args = []): ScheduleDriver } /** - * Whether the 'cron' option holds an event of the hook with exactly these arguments. + * Cancel an event the index shows as a driver's: the driver drops it, then the index. * * @param array $args */ - public function inCronOption(string $hook, array $args = []): bool + public function cancelOwned(string $owner, string $hook, array $args): int + { + $this->busy = true; + + try { + $count = $this->driverNamed($owner)->cancel($hook, $args); + } finally { + $this->busy = false; + } + + CronIndex::forget($hook, $args, $owner); + + return $count; + } + + /** + * Give the drivers what a direct write to the 'cron' option did to the events they keep: a removed entry is cancelled, a moved one scheduled at its new time. + * + * @param array{removed: list, moved: list} $changes + */ + public function applyChanges(array $changes): void { - $key = md5(serialize($args)); + $this->busy = true; - foreach ((array) _get_cron_array() as $hooks) { - if (is_array($hooks) && isset($hooks[$hook][$key])) { - return true; + try { + foreach ([...$changes['removed'], ...$changes['moved']] as $event) { + $this->driverNamed((string) $event->driver)->cancel($event->hook, $event->args); } + + foreach ($changes['moved'] as $event) { + $driver = $this->driverNamed((string) $event->driver); + + $event->recurrence !== null + ? $driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args) + : $driver->once($event->hook, $event->timestamp, $event->args); + } + } catch (\Throwable) { + // A driver that is gone or cannot take the event: the option keeps what was written, and the doctor reports the drift + } finally { + $this->busy = false; } + } + + /** + * Run something that changes a driver's events, without the index following the driver's reports: the caller writes the index itself. + * + * @template T + * + * @param Closure(): T $change + * @return T + */ + public function quietly(Closure $change): mixed + { + $busy = $this->busy; + $this->busy = true; - return false; + try { + return $change(); + } finally { + $this->busy = $busy; + } } /** @@ -206,7 +482,7 @@ public function now(): int * * @return bool Whether the event was scheduled by this call * - * @throws Exceptions\ScheduleException When the driver cannot schedule it + * @throws ScheduleException When the driver cannot schedule it */ public function schedule(Event $event): bool { @@ -222,7 +498,7 @@ public function schedule(Event $event): bool * * @return bool False when an identical single event is already due * - * @throws Exceptions\ScheduleException When the driver cannot schedule it + * @throws ScheduleException When the driver cannot schedule it */ public function place(Event $event): bool { @@ -242,6 +518,12 @@ public function place(Event $event): bool */ public function next(string $hook, array $args = []): ?int { + $owned = $this->askOwner($hook, $args); + + if ($owned !== null) { + return $owned; + } + $times = []; foreach ($this->stores() as $store) { @@ -255,6 +537,44 @@ public function next(string $hook, array $args = []): ?int return $times === [] ? null : min($times); } + /** + * The time of an event the index shows as a driver's, as the driver says it. + * + * The index is a mirror: an action deleted from Tools › Scheduled Actions, or + * a driver that lost the event, would leave an entry that WordPress's own + * wp_next_scheduled() keeps believing, and that stops a recurring event from + * ever being scheduled again. When the driver has no such event, the entry + * goes. A driver that cannot answer yet is trusted with what the index says. + * + * @param array $args + */ + private function askOwner(string $hook, array $args): ?int + { + $owner = CronIndex::ownerOf($hook, $args); + + if ($owner === null || $owner === CronIndex::WP_CRON) { + return null; + } + + try { + $driver = $this->driverNamed($owner); + } catch (ScheduleException) { + return null; + } + + if ($driver instanceof DefersUntilReady && ! $driver->ready()) { + return array_values(array_filter(CronIndex::events($owner), static fn (Event $event): bool => $event->hook === $hook && $event->args === $args))[0]->timestamp ?? null; + } + + $time = $driver->next($hook, $args); + + if ($time === null) { + CronIndex::forget($hook, $args, $owner); + } + + return $time; + } + /** * Cancel the events of a hook with these arguments, in every store. * @@ -264,9 +584,18 @@ public function next(string $hook, array $args = []): ?int public function cancel(string $hook, array $args = []): int { $count = 0; + $this->busy = true; - foreach ($this->stores() as $store) { - $count += $store->cancel($hook, $args); + try { + foreach ($this->stores() as $store) { + $count += $store->cancel($hook, $args); + } + } finally { + $this->busy = false; + } + + if ($this->indexes()) { + CronIndex::forget($hook, $args, $this->driverName); } if (isset($this->buffer[self::identity($hook, $args)])) { @@ -283,9 +612,18 @@ public function cancel(string $hook, array $args = []): int public function clear(string $hook): int { $count = 0; + $this->busy = true; - foreach ($this->stores() as $store) { - $count += $store->clear($hook); + try { + foreach ($this->stores() as $store) { + $count += $store->clear($hook); + } + } finally { + $this->busy = false; + } + + if ($this->indexes()) { + CronIndex::forgetHook($hook, $this->driverName); } foreach ($this->buffer as $identity => $event) { @@ -299,16 +637,28 @@ public function clear(string $hook): int } /** - * Everything that is scheduled, in every store, soonest first. + * Everything that is scheduled, whoever keeps it, soonest first: the index, then what the stores hold that it does not show yet. * * @return list */ public function events(): array { $events = array_values($this->buffer); + $seen = []; + + if ($this->fallback instanceof ScheduleDriver || $this->driver instanceof WpCronDriver) { + foreach (CronIndex::events() as $event) { + $events[] = $event; + $seen[self::identity($event->hook, $event->args).'|'.$event->timestamp] = true; + } + } foreach ($this->stores() as $store) { - array_push($events, ...$store->events()); + foreach ($store->events() as $event) { + if (! isset($seen[self::identity($event->hook, $event->args).'|'.$event->timestamp])) { + $events[] = $event; + } + } } usort($events, static fn (Event $a, Event $b): int => $a->timestamp <=> $b->timestamp); @@ -316,6 +666,16 @@ public function events(): array return $events; } + /** + * What the 'cron' option shows: every event with the driver that keeps it, soonest first. No query to a driver. + * + * @return list + */ + public function index(): array + { + return CronIndex::events(); + } + /** * Give the driver what was scheduled while it could not store anything. * @@ -339,11 +699,29 @@ public function flush(): void private function give(ScheduleDriver $driver, Event $event): bool { - if ($event->recurrence !== null) { - return $driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args); + $this->busy = true; + + try { + $placed = $event->recurrence !== null + ? $driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args) + : $driver->once($event->hook, $event->timestamp, $event->args); + } finally { + $this->busy = false; + } + + if ($placed && $this->indexes() && $driver === $this->driver) { + CronIndex::record($event, $this->driverName); } - return $driver->once($event->hook, $event->timestamp, $event->args); + return $placed; + } + + /** + * Whether the chosen driver is one the index follows: WP-Cron's own events are written by the core. + */ + private function indexes(): bool + { + return $this->fallback instanceof ScheduleDriver && $this->driverName !== CronIndex::WP_CRON; } private function buffer(DefersUntilReady $driver, Event $event): bool diff --git a/src/Support/CronIndex.php b/src/Support/CronIndex.php new file mode 100644 index 0000000..e1d59e2 --- /dev/null +++ b/src/Support/CronIndex.php @@ -0,0 +1,402 @@ + $args + */ + public static function ownerOf(string $hook, array $args = []): ?string + { + $key = self::key($args); + + foreach (self::read() as $hooks) { + if (is_array($hooks) && isset($hooks[$hook][$key]) && is_array($hooks[$hook][$key])) { + return self::owner($hooks[$hook][$key]); + } + } + + return null; + } + + /** + * Drivers that keep events of a hook, each once. + * + * @param array|null $args Exact arguments, null for any + * @return list + */ + public static function ownersOf(string $hook, ?array $args = null): array + { + $key = $args === null ? null : self::key($args); + $owners = []; + + foreach (self::read() as $hooks) { + foreach (is_array($hooks) ? (array) ($hooks[$hook] ?? []) : [] as $signature => $entry) { + if (is_array($entry) && ($key === null || $key === $signature)) { + $owners[self::owner($entry)] = true; + } + } + } + + return array_keys($owners); + } + + /** + * Whether the index holds an event that a driver other than WP-Cron keeps. + */ + public static function hasMarked(): bool + { + foreach (self::read() as $hooks) { + foreach (is_array($hooks) ? $hooks : [] as $entries) { + foreach ((array) $entries as $entry) { + if (is_array($entry) && self::owner($entry) !== self::WP_CRON) { + return true; + } + } + } + } + + return false; + } + + /** + * Number of events a driver other than WP-Cron keeps, for a hook and optionally exact arguments. + * + * @param array|null $args + */ + public static function countMarked(string $hook, ?array $args = null): int + { + return count(array_filter(self::events(), static fn (Event $event): bool => $event->driver !== self::WP_CRON && $event->hook === $hook && ($args === null || $event->args === $args))); + } + + /** + * The events of the index, soonest first. + * + * @param string|null $owner Only those a driver keeps, all by default + * @return list + */ + public static function events(?string $owner = null): array + { + $events = []; + + foreach (self::read() as $timestamp => $hooks) { + if (! is_array($hooks) || ! is_int($timestamp)) { + continue; + } + + foreach ($hooks as $hook => $entries) { + foreach ((array) $entries as $entry) { + if (! is_array($entry) || ($owner !== null && self::owner($entry) !== $owner)) { + continue; + } + + $events[] = new Event( + hook: (string) $hook, + timestamp: $timestamp, + recurrence: ! empty($entry['schedule']) ? (string) $entry['schedule'] : null, + args: (array) ($entry['args'] ?? []), + interval: isset($entry['interval']) ? (int) $entry['interval'] : null, + driver: self::owner($entry), + ); + } + } + } + + usort($events, static fn (Event $a, Event $b): int => $a->timestamp <=> $b->timestamp); + + return $events; + } + + /** + * Write the event as kept by a driver; the one it replaces, if any, goes. + * + * A recurring event keeps the name of its schedule when the interval is the same: a driver only knows the interval. + */ + public static function record(Event $event, string $owner): void + { + $crons = self::read(fresh: true); + $key = self::key($event->args); + $schedule = $event->recurrence; + $interval = $event->interval; + + if ($event->recurrence !== null) { + $interval ??= (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$event->recurrence]['interval'] ?? 0); + } + + foreach (self::strip($crons, $event->hook, $key, $owner) as $previous) { + if ($schedule !== null && ($previous['interval'] ?? null) === $interval && ! empty($previous['schedule'])) { + $schedule = (string) $previous['schedule']; + } + } + + $entry = ['schedule' => $schedule ?? false, 'args' => $event->args]; + + if ($schedule !== null) { + $entry['interval'] = $interval; + } + + if ($owner !== self::WP_CRON) { + $entry[self::MARKER] = $owner; + } + + $crons[$event->timestamp][$event->hook][$key] = $entry; + ksort($crons); + + self::write($crons); + } + + /** + * Take the events of a hook with these arguments, a driver keeps, out of the index. + * + * @param array $args + */ + public static function forget(string $hook, array $args, string $owner): void + { + $crons = self::read(fresh: true); + + if (self::strip($crons, $hook, self::key($args), $owner) !== []) { + self::write($crons); + } + } + + /** + * Take every event of a hook a driver keeps out of the index. + */ + public static function forgetHook(string $hook, string $owner): void + { + $crons = self::read(fresh: true); + $found = false; + + foreach ($crons as $timestamp => $hooks) { + foreach (is_array($hooks) ? (array) ($hooks[$hook] ?? []) : [] as $key => $entry) { + if (is_array($entry) && self::owner($entry) === $owner) { + unset($crons[$timestamp][$hook][$key]); + $found = true; + } + } + + self::prune($crons, $timestamp, $hook); + } + + if ($found) { + self::write($crons); + } + } + + /** + * What is ready to run: the entries a driver other than WP-Cron keeps are not WP-Cron's to run. + * + * @return array>>>|null Null when nothing is marked, so the core reads the option itself + */ + public static function ready(): ?array + { + $crons = self::read(); + $marked = false; + + foreach ($crons as $timestamp => $hooks) { + foreach (is_array($hooks) ? $hooks : [] as $hook => $entries) { + foreach ((array) $entries as $key => $entry) { + if (is_array($entry) && self::owner($entry) !== self::WP_CRON) { + unset($crons[$timestamp][$hook][$key]); + $marked = true; + } + } + + self::prune($crons, $timestamp, $hook); + } + } + + if (! $marked) { + return null; + } + + $now = microtime(true); + $ready = []; + + foreach ($crons as $timestamp => $hooks) { + if (is_int($timestamp) && $timestamp <= $now) { + $ready[$timestamp] = $hooks; + } + } + + return $ready; + } + + /** + * What a direct write to the option does to the events a driver keeps. + * + * @param array $old + * @param array $new + * @return array{removed: list, moved: list} The events that left the index, and those that are now at another time (at their new time) + */ + public static function changes(array $old, array $new): array + { + $before = self::marked($old); + $after = self::marked($new); + $changes = ['removed' => [], 'moved' => []]; + + foreach ($before as $identity => $event) { + if (! isset($after[$identity])) { + $changes['removed'][] = $event; + } elseif ($after[$identity]->timestamp !== $event->timestamp) { + $changes['moved'][] = $after[$identity]; + } + } + + return $changes; + } + + /** + * @param array $crons + * @return array + */ + private static function marked(array $crons): array + { + $marked = []; + + foreach ($crons as $timestamp => $hooks) { + if (! is_array($hooks) || ! is_int($timestamp)) { + continue; + } + + foreach ($hooks as $hook => $entries) { + foreach ((array) $entries as $key => $entry) { + if (! is_array($entry) || self::owner($entry) === self::WP_CRON) { + continue; + } + + $marked[self::owner($entry).'|'.$hook.'|'.$key] = new Event( + hook: (string) $hook, + timestamp: $timestamp, + recurrence: ! empty($entry['schedule']) ? (string) $entry['schedule'] : null, + args: (array) ($entry['args'] ?? []), + interval: isset($entry['interval']) ? (int) $entry['interval'] : null, + driver: self::owner($entry), + ); + } + } + } + + return $marked; + } + + /** + * Remove the entries a driver keeps for a hook and an argument key; returns them. + * + * @param array $crons + * @return list> + */ + private static function strip(array &$crons, string $hook, string $key, string $owner): array + { + $removed = []; + + foreach ($crons as $timestamp => $hooks) { + if (is_array($hooks) && isset($hooks[$hook][$key]) && is_array($hooks[$hook][$key]) && self::owner($hooks[$hook][$key]) === $owner) { + $removed[] = $hooks[$hook][$key]; + unset($crons[$timestamp][$hook][$key]); + self::prune($crons, $timestamp, $hook); + } + } + + return $removed; + } + + /** + * @param array $crons + */ + private static function prune(array &$crons, int|string $timestamp, int|string $hook): void + { + if (isset($crons[$timestamp][$hook]) && $crons[$timestamp][$hook] === []) { + unset($crons[$timestamp][$hook]); + } + + if (isset($crons[$timestamp]) && $crons[$timestamp] === []) { + unset($crons[$timestamp]); + } + } + + /** + * @param array $entry + */ + private static function owner(array $entry): string + { + return (string) ($entry[self::MARKER] ?? self::WP_CRON); + } + + /** + * @param array $args + */ + private static function key(array $args): string + { + return md5(serialize($args)); + } + + /** + * The option's entries. + * + * Without a persistent object cache, the copy of an autoloaded option is the + * one taken when the request started: a long request (Action Scheduler's + * queue runner) that wrote from it would undo what other requests scheduled + * since. A write reads again from the database first, which leaves the + * window of the write itself. + * + * @param bool $fresh Read from the database, before a change + * @return array + */ + private static function read(bool $fresh = false): array + { + if ($fresh && function_exists('wp_cache_delete')) { + wp_cache_delete('alloptions', 'options'); + wp_cache_delete('cron', 'options'); + } + + $crons = _get_cron_array(); + + return is_array($crons) ? $crons : []; + } + + /** + * @param array $crons + */ + private static function write(array $crons): void + { + self::$writing = true; + + try { + _set_cron_array($crons); + } finally { + self::$writing = false; + } + } +} diff --git a/src/Support/CronInterceptor.php b/src/Support/CronInterceptor.php index 610dbaa..33d32d1 100644 --- a/src/Support/CronInterceptor.php +++ b/src/Support/CronInterceptor.php @@ -4,8 +4,8 @@ namespace Pollora\Schedule\Support; -use Pollora\Schedule\Contracts\FindsEvent; use Pollora\Schedule\Event; +use Pollora\Schedule\Exceptions\ScheduleException; use Pollora\Schedule\ScheduleEngine; /** @@ -13,26 +13,44 @@ * 'pre_*' filters of wp-includes/cron.php, so the core, plugins and themes * that call wp_schedule_event() need no change. * - * Routing is the engine's: what is already in the 'cron' option stays on - * WP-Cron (the filter returns null and the core carries on), what is new goes - * to the driver. The interceptor never calls WP-Cron's own driver, and where it - * has to call a core function again it raises a flag so its own filters step aside. + * Routing is the engine's, from the 'cron' option, which indexes every event + * with the driver that keeps it: an event WP-Cron keeps stays on it (the filter + * returns null and the core carries on), what is new goes to the chosen driver. + * The interceptor never calls WP-Cron's own driver, and where it has to call a + * core function again it raises a flag so its own filters step aside. + * + * Two filters are not about routing and stay whatever the driver: WP-Cron never + * runs an event another driver keeps, and a direct write to the option reaches + * the driver of the events it changes. */ final class CronInterceptor { - private const array FILTERS = [ + private const array ROUTING = [ 'pre_schedule_event' => ['scheduleEvent', 3], - 'pre_reschedule_event' => ['rescheduleEvent', 3], - 'pre_unschedule_event' => ['unscheduleEvent', 5], 'pre_clear_scheduled_hook' => ['clearScheduledHook', 4], 'pre_unschedule_hook' => ['unscheduleHook', 3], - 'pre_get_scheduled_event' => ['getScheduledEvent', 4], + ]; + + private const array GUARD = [ + 'pre_get_ready_cron_jobs' => ['readyJobs', 1], + 'pre_update_option_cron' => ['updateOption', 3], + 'pre_reschedule_event' => ['rescheduleEvent', 3], + 'pre_unschedule_event' => ['unscheduleEvent', 5], ]; private bool $installed = false; private bool $stepsAside = false; + private bool $routing = true; + + /** + * Events a runner rescheduled and has yet to unschedule at their old time, by hook and arguments. + * + * @var array + */ + private array $rescheduled = []; + /** * @var array */ @@ -40,15 +58,16 @@ final class CronInterceptor public function __construct(private readonly ScheduleEngine $engine) {} - public function install(): void + public function install(bool $routing = true): void { if ($this->installed) { return; } $this->installed = true; + $this->routing = $routing; - foreach (self::FILTERS as $filter => [$method, $arguments]) { + foreach ([...($routing ? self::ROUTING : []), ...self::GUARD] as $filter => [$method, $arguments]) { $this->callbacks[$filter] = $this->{$method}(...); add_filter($filter, $this->callbacks[$filter], 10, $arguments); } @@ -64,6 +83,36 @@ public function uninstall(): void $this->installed = false; } + /** + * What WP-Cron may run: not the events another driver keeps. + * + * @return array|null + */ + private function readyJobs(mixed $pre): mixed + { + return $pre ?? CronIndex::ready(); + } + + /** + * A direct write to the option (a plugin that rebuilds the array, WP Crontrol) changes events a driver keeps: the driver follows. + * + * @return mixed The value, untouched + */ + private function updateOption(mixed $value, mixed $old = null, mixed $option = null): mixed + { + if (CronIndex::writing() || ! is_array($value) || ! is_array($old)) { + return $value; + } + + $changes = CronIndex::changes($old, $value); + + if ($changes['removed'] !== [] || $changes['moved'] !== []) { + $this->engine->applyChanges($changes); + } + + return $value; + } + /** * @param mixed $pre What an earlier filter returned * @return bool|\WP_Error|null @@ -102,6 +151,11 @@ private function scheduleEvent(mixed $pre, object $event, bool $wpError = false) /** * Rescheduling is the runner's job: WP-Cron reschedules what it runs, Action Scheduler its recurring actions. * + * A runner that follows WP-Cron's way (wp cron event run, WP Crontrol) + * reschedules the event, then unschedules it at its old time: for an event + * another driver keeps, the second step must not cancel what the first said + * was kept, so the pair is remembered. + * * @return bool|\WP_Error|null */ private function rescheduleEvent(mixed $pre, object $event, bool $wpError = false): mixed @@ -111,6 +165,17 @@ private function rescheduleEvent(mixed $pre, object $event, bool $wpError = fals } $args = (array) $event->args; + $owner = CronIndex::ownerOf((string) $event->hook, $args); + + if ($owner !== null && $owner !== CronIndex::WP_CRON) { + $this->rescheduled[self::identity((string) $event->hook, $args)] = (int) $event->timestamp; + + return true; + } + + if (! $this->routing) { + return null; + } return $this->engine->driverFor($event->hook, $args) === $this->engine->stores()[1] ? null : true; } @@ -124,13 +189,42 @@ private function unscheduleEvent(mixed $pre, int $timestamp, string $hook, array return $pre; } - $driver = $this->engine->stores()[0]; + $owner = CronIndex::ownerOf($hook, $args); - if ($this->engine->inCronOption($hook, $args) || $driver->next($hook, $args) !== $timestamp) { + if ($owner === CronIndex::WP_CRON || ($owner === null && ! $this->routing)) { return null; } - $driver->cancel($hook, $args); + if (($this->rescheduled[self::identity($hook, $args)] ?? null) === $timestamp) { + unset($this->rescheduled[self::identity($hook, $args)]); + + return true; + } + + if ($owner === null) { + // Not in the index: an event the chosen driver holds without having written it + $driver = $this->engine->stores()[0]; + + if ($driver->next($hook, $args) !== $timestamp) { + return null; + } + + $driver->cancel($hook, $args); + + return true; + } + + $indexed = array_filter(CronIndex::events($owner), static fn (Event $event): bool => $event->hook === $hook && $event->args === $args && $event->timestamp === $timestamp); + + if ($indexed === []) { + return null; + } + + try { + $this->engine->cancelOwned($owner, $hook, $args); + } catch (ScheduleException) { + return null; + } return true; } @@ -144,9 +238,10 @@ private function clearScheduledHook(mixed $pre, string $hook, array $args, bool return $pre; } - $cancelled = $this->engine->stores()[0]->cancel($hook, $args); + $indexed = CronIndex::countMarked($hook, $args); + $cancelled = $this->engine->quietly(fn (): int => $this->engine->stores()[0]->cancel($hook, $args)); - return $this->withCore(static fn () => wp_clear_scheduled_hook($hook, $args, $wpError), $cancelled); + return $this->withCore(static fn () => wp_clear_scheduled_hook($hook, $args, $wpError), max(0, $cancelled - $indexed)); } /** @@ -158,41 +253,22 @@ private function unscheduleHook(mixed $pre, string $hook, bool $wpError = false) return $pre; } - $cancelled = $this->engine->stores()[0]->clear($hook); + $indexed = CronIndex::countMarked($hook); + $cancelled = $this->engine->quietly(fn (): int => $this->engine->stores()[0]->clear($hook)); - return $this->withCore(static fn () => wp_unschedule_hook($hook, $wpError), $cancelled); + return $this->withCore(static fn () => wp_unschedule_hook($hook, $wpError), max(0, $cancelled - $indexed)); } /** - * An event in the 'cron' option is the core's to describe; null, not false, when the driver has none, or the core would never read the option. - * - * @return object|null + * @param array $args */ - private function getScheduledEvent(mixed $pre, string $hook, array $args, mixed $timestamp = null): mixed + private static function identity(string $hook, array $args): string { - $driver = $this->engine->stores()[0]; - - if ($pre !== null || $this->stepsAside || ! $driver instanceof FindsEvent || $this->engine->inCronOption($hook, $args)) { - return $pre; - } - - $found = $driver->find($hook, $args); - - if (! $found instanceof Event || ($timestamp !== null && (int) $timestamp !== $found->timestamp)) { - return null; - } - - $event = (object) ['hook' => $found->hook, 'timestamp' => $found->timestamp, 'schedule' => $found->recurrence ?? false, 'args' => $found->args]; - - if ($found->interval !== null) { - $event->interval = $found->interval; - } - - return $event; + return $hook.'|'.md5(serialize($args)); } /** - * Run the core function for what is in the 'cron' option and add what the driver cancelled. + * Run the core function for what is in the 'cron' option and add what the driver cancelled and the option does not show. */ private function withCore(\Closure $core, int $cancelled): mixed { diff --git a/stubs/action-scheduler.php b/stubs/action-scheduler.php index d514548..76ca997 100644 --- a/stubs/action-scheduler.php +++ b/stubs/action-scheduler.php @@ -17,6 +17,8 @@ abstract class ActionScheduler_Store public const STATUS_PENDING = 'pending'; abstract public function cancel_action($action_id); + + abstract public function fetch_action($action_id); } /** diff --git a/stubs/wp-cli.php b/stubs/wp-cli.php new file mode 100644 index 0000000..b72d63b --- /dev/null +++ b/stubs/wp-cli.php @@ -0,0 +1,72 @@ +|WP_Error + */ + protected static function get_cron_events(bool $is_due_now = false): array|WP_Error + { + return []; + } + + protected static function get_hook_actions(string $hook_name): string + { + return ''; + } + } +} + +namespace WP_CLI { + class Formatter + { + public string $format = 'table'; + + /** + * @var list + */ + public array $fields = []; + + /** + * @param array $assoc_args + * @param list $fields + */ + public function __construct(array &$assoc_args, array $fields, string $prefix) {} + + /** + * @param iterable $items + */ + public function display_items(iterable $items): void {} + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 3775f59..82d6590 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,6 +17,9 @@ pest()->beforeEach(function (): void { Monkey\setUp(); + + // Once a test defines a WordPress function, it exists for the rest of the process: give it a default + Monkey\Functions\when('wp_cache_delete')->justReturn(true); })->afterEach(function (): void { if ($container = Mockery::getContainer()) { $this->addToAssertionCount($container->mockery_getExpectationCount()); @@ -24,7 +27,7 @@ // Schedule keeps one engine for the process: start each test from the default one $schedule = new ReflectionClass(Schedule::class); - foreach (['engine', 'fake', 'driverBeforeFake'] as $property) { + foreach (['engine', 'fake', 'driverBeforeFake', 'wanted'] as $property) { $schedule->setStaticPropertyValue($property, null); } diff --git a/tests/Support/ActionSchedulerFake.php b/tests/Support/ActionSchedulerFake.php index 1598ea4..8cdee97 100644 --- a/tests/Support/ActionSchedulerFake.php +++ b/tests/Support/ActionSchedulerFake.php @@ -46,6 +46,11 @@ public function get_schedule(): FakeActionSchedule { return $this->schedule; } + + public function get_group(): string + { + return $this->group; + } } abstract class ActionScheduler_Store @@ -57,6 +62,8 @@ abstract class ActionScheduler_Store public const STATUS_CANCELED = 'canceled'; abstract public function cancel_action($action_id); + + abstract public function fetch_action($action_id); } final class ActionScheduler_FakeStore extends ActionScheduler_Store @@ -70,6 +77,11 @@ public function cancel_action($action_id): void { self::$actions[$action_id]->status = self::STATUS_CANCELED; } + + public function fetch_action($action_id): ?FakeAction + { + return self::$actions[(int) $action_id] ?? null; + } } final class ActionScheduler diff --git a/tests/Unit/CronIndexTest.php b/tests/Unit/CronIndexTest.php new file mode 100644 index 0000000..8255268 --- /dev/null +++ b/tests/Unit/CronIndexTest.php @@ -0,0 +1,301 @@ +actions = []; + $this->cron = []; + $this->wpCronCalls = []; + + $this->scheduleFilters = []; + Functions\when('add_filter')->alias(function (string $hook, callable $callback): bool { + $this->scheduleFilters[$hook][] = $callback; + + return true; + }); + Functions\when('remove_filter')->alias(function (string $hook, callable $callback): bool { + $this->scheduleFilters[$hook] = array_values(array_filter($this->scheduleFilters[$hook] ?? [], fn ($c) => $c !== $callback)); + + return true; + }); + Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { + $this->actions[$hook][] = $callback; + + return true; + }); + $this->schedules = ['daily' => ['interval' => 86400, 'display' => 'Once Daily']]; + Functions\when('wp_get_schedules')->alias(function (): array { + $schedules = $this->schedules; + + foreach ($this->scheduleFilters['cron_schedules'] ?? [] as $filter) { + $schedules = $filter($schedules); + } + + return $schedules; + }); + Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + Functions\when('_set_cron_array')->alias(function (array $cron): bool { + $this->cron = $cron; + + return true; + }); + Functions\when('wp_clear_scheduled_hook')->justReturn(0); + Functions\when('wp_unschedule_hook')->justReturn(0); + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\when('wp_schedule_event')->justReturn(true); + Functions\when('wp_schedule_single_event')->justReturn(true); + + $this->engine = new ScheduleEngine; + $this->engine->selectDriver(new ActionSchedulerDriver); + $this->fire = function (string $hook, int $id): void { + foreach ($this->actions[$hook] ?? [] as $callback) { + $callback($id); + } + }; +}); + +it('records an event and finds its owner, and an entry without a marker is WP-Cron\'s', function (): void { + CronIndex::record(new Event('reports', 2000, 'daily', ['a' => 1]), 'action-scheduler'); + CronIndex::record(new Event('wp_version_check', 3000, 'daily'), 'wp-cron'); + + expect(CronIndex::ownerOf('reports', ['a' => 1]))->toBe('action-scheduler') + ->and(CronIndex::ownerOf('wp_version_check'))->toBe('wp-cron') + ->and(CronIndex::ownerOf('reports'))->toBeNull() + ->and($this->cron[3000]['wp_version_check'][md5(serialize([]))])->toBe(['schedule' => 'daily', 'args' => [], 'interval' => 86400]) + ->and(CronIndex::hasMarked())->toBeTrue() + ->and(array_column(CronIndex::events('action-scheduler'), 'driver'))->toBe(['action-scheduler']); +}); + +it('lists the index with the driver of each event, without asking a driver', function (): void { + $this->engine->place(new Event('digest', time() + 100)); + $this->cron[time() + 50] = ['wp_version_check' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400]]]; + ksort($this->cron); + + expect(array_map(fn (Event $e): array => [$e->hook, $e->driver], $this->engine->index()))->toBe([['wp_version_check', 'wp-cron'], ['digest', 'action-scheduler']]); +}); + +it('keeps the name of a schedule when only the driver\'s interval is known', function (): void { + $this->schedules = ['custom_a' => ['interval' => 900], 'every_900_seconds' => ['interval' => 900]]; + CronIndex::record(new Event('job', 1000, 'custom_a', [], 900), 'action-scheduler'); + CronIndex::record(new Event('job', 1900, 'every_900_seconds', [], 900), 'action-scheduler'); + + expect(array_keys($this->cron))->toBe([1900]) + ->and($this->cron[1900]['job'][md5(serialize([]))]['schedule'])->toBe('custom_a'); +}); + +it('forgets only what its owner keeps', function (): void { + CronIndex::record(new Event('job', 1000), 'action-scheduler'); + CronIndex::forget('job', [], 'wp-cron'); + + expect(CronIndex::ownerOf('job'))->toBe('action-scheduler'); + + CronIndex::forget('job', [], 'action-scheduler'); + + expect($this->cron)->toBe([]); +}); + +it('follows Action Scheduler: stored, completed, cancelled, and not other groups', function (): void { + $id = as_schedule_recurring_action(time() + 100, 86400, 'reports', [], ActionSchedulerDriver::GROUP); + ($this->fire)('action_scheduler_stored_action', $id); + + expect(CronIndex::ownerOf('reports'))->toBe('action-scheduler'); + + ActionScheduler::store()->cancel_action($id); + ($this->fire)('action_scheduler_canceled_action', $id); + + expect(CronIndex::ownerOf('reports'))->toBeNull(); + + $other = as_schedule_single_action(time() + 100, 'woo_thing', [], 'woocommerce'); + ($this->fire)('action_scheduler_stored_action', $other); + + expect($this->cron)->toBe([]); +}); + +it('ignores what Action Scheduler reports while the engine is changing it', function (): void { + $this->engine->place(new Event('digest', time() + 100)); + $entry = $this->cron; + + $this->engine->quietly(function (): void { + $id = as_schedule_single_action(time() + 5, 'sneaky', [], ActionSchedulerDriver::GROUP); + ($this->fire)('action_scheduler_stored_action', $id); + }); + + expect($this->cron)->toBe($entry); +}); + +it('schedules an event again when its entry is stale: the driver has no such event', function (): void { + $time = time() + 3600; + // A mirror of an action that was deleted from Tools › Scheduled Actions + $this->cron = [$time => ['reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400, 'driver' => 'action-scheduler']]]]; + // What the core answers: it reads the option + Functions\when('wp_next_scheduled')->alias(fn (string $hook): int|false => array_key_first(array_filter($this->cron, fn (array $hooks): bool => isset($hooks[$hook]))) ?? false); + + expect($this->engine->next('reports'))->toBeNull() + ->and(CronIndex::ownerOf('reports'))->toBeNull(); + + $this->cron = [$time => ['reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400, 'driver' => 'action-scheduler']]]]; + + expect($this->engine->schedule(new Event('reports', $time, 'daily')))->toBeTrue() + ->and(ActionScheduler_FakeStore::$actions)->toHaveCount(1) + ->and(CronIndex::ownerOf('reports'))->toBe('action-scheduler'); +}); + +it('trusts the index for an event of a driver that cannot answer yet', function (): void { + $time = time() + 3600; + ActionScheduler::$initialized = false; + $this->cron = [$time => ['reports' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => [], 'interval' => 86400, 'driver' => 'action-scheduler']]]]; + + expect($this->engine->next('reports'))->toBe($time) + ->and(CronIndex::ownerOf('reports'))->toBe('action-scheduler'); +}); + +it('reads the option again from the database before it changes it', function (): void { + $cleared = []; + Functions\when('wp_cache_delete')->alias(function (string $key, string $group) use (&$cleared): bool { + $cleared[] = $group.'/'.$key; + + return true; + }); + + CronIndex::record(new Event('job', 1000), 'action-scheduler'); + + expect($cleared)->toBe(['options/alloptions', 'options/cron']); +}); + +it('reconciles the index once, when the request ends, after an action was deleted', function (): void { + $this->shutdown = []; + Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { + $hook === 'shutdown' ? $this->shutdown[] = $callback : $this->actions[$hook][] = $callback; + + return true; + }); + $engine = new ScheduleEngine; + $engine->selectDriver(new ActionSchedulerDriver); + CronIndex::record(new Event('gone', time() + 100), 'action-scheduler'); + as_schedule_single_action(time() + 200, 'unseen', [], ActionSchedulerDriver::GROUP); + + // The last observer is this engine's: the one of the test's own engine was registered before + $observer = end($this->actions['action_scheduler_deleted_action']); + + foreach ([1, 2, 3] as $id) { + $observer($id); + } + + expect($this->shutdown)->toHaveCount(1); + + ($this->shutdown[0])(); + + expect(CronIndex::ownerOf('gone'))->toBeNull() + ->and(CronIndex::ownerOf('unseen'))->toBe('action-scheduler'); +}); + +it('rebuilds the index from the driver: adds what it keeps, drops what it does not', function (): void { + as_schedule_single_action(time() + 100, 'unseen', ['a' => 1], ActionSchedulerDriver::GROUP); + CronIndex::record(new Event('stale', time() + 50), 'action-scheduler'); + + expect($this->engine->reindex())->toBe(2) + ->and(CronIndex::ownerOf('unseen', ['a' => 1]))->toBe('action-scheduler') + ->and(CronIndex::ownerOf('stale'))->toBeNull() + ->and($this->engine->reindex())->toBe(0); +}); + +describe('migration', function (): void { + beforeEach(function (): void { + $this->time = time() + 3600; + $this->cron = [ + $this->time => [ + 'reports' => [md5(serialize([])) => ['schedule' => 'custom_x', 'args' => [], 'interval' => 7200]], + 'digest' => [md5(serialize(['u' => 1])) => ['schedule' => false, 'args' => ['u' => 1]]], + 'action_scheduler_run_queue' => [md5(serialize([])) => ['schedule' => 'every_minute', 'args' => [], 'interval' => 60]], + ], + 'version' => 2, + ]; + $this->migrator = new Migrator($this->engine); + }); + + it('lists what a driver still keeps', function (): void { + expect(array_column($this->migrator->pending('wp-cron'), 'hook'))->toEqualCanonicalizing(['reports', 'digest', 'action_scheduler_run_queue']) + ->and($this->migrator->pending('action-scheduler'))->toBe([]); + }); + + it('shows what would move and moves nothing with --dry-run', function (): void { + $report = $this->migrator->migrate('wp-cron', 'action-scheduler', dryRun: true); + + expect(array_column($report->moved, 'hook'))->toEqualCanonicalizing(['reports', 'digest']) + ->and(array_column(array_column($report->skipped, 'event'), 'hook'))->toBe(['action_scheduler_run_queue']) + ->and(ActionScheduler_FakeStore::$actions)->toBe([]) + ->and(CronIndex::ownerOf('reports'))->toBe('wp-cron'); + }); + + it('moves events to the target, keeping a recurring one\'s own interval, and leaves protected hooks', function (): void { + $report = $this->migrator->migrate('wp-cron', 'action-scheduler'); + + expect($report->succeeded())->toBeTrue() + ->and(CronIndex::ownerOf('reports'))->toBe('action-scheduler') + ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBe('action-scheduler') + ->and(CronIndex::ownerOf('action_scheduler_run_queue'))->toBe('wp-cron') + ->and(array_values(array_map(fn ($a) => $a->schedule->interval, ActionScheduler_FakeStore::$actions)))->toContain(7200) + ->and($this->cron[$this->time]['reports'][md5(serialize([]))]['schedule'])->toBe('custom_x'); + }); + + it('can run again without duplicating', function (): void { + $this->migrator->migrate('wp-cron', 'action-scheduler'); + $second = $this->migrator->migrate('wp-cron', 'action-scheduler'); + + expect($second->moved)->toBe([]) + ->and(ActionScheduler_FakeStore::$actions)->toHaveCount(2); + }); + + it('settles an event an earlier run created in the target but did not take out of the source', function (): void { + as_schedule_recurring_action($this->time, 7200, 'reports', [], ActionSchedulerDriver::GROUP); + + $report = $this->migrator->migrate('wp-cron', 'action-scheduler'); + + expect($report->succeeded())->toBeTrue() + ->and(array_filter(ActionScheduler_FakeStore::$actions, fn ($a) => $a->hook === 'reports'))->toHaveCount(1) + ->and(CronIndex::ownerOf('reports'))->toBe('action-scheduler'); + }); + + it('moves them back, cancelling in the driver', function (): void { + $this->migrator->migrate('wp-cron', 'action-scheduler'); + $back = $this->migrator->migrate('action-scheduler', 'wp-cron'); + + expect(array_column($back->moved, 'hook'))->toEqualCanonicalizing(['reports', 'digest']) + ->and(CronIndex::ownerOf('reports'))->toBe('wp-cron') + ->and(array_values(array_unique(array_map(fn ($a) => $a->status, ActionScheduler_FakeStore::$actions))))->toBe(['canceled']) + ->and(CronIndex::hasMarked())->toBeFalse(); + }); + + it('reports an event it could not move and leaves it where it was', function (): void { + $broken = new ActionSchedulerDriver; + ActionScheduler::$initialized = true; + $this->schedules = []; + // No schedule for the interval and none stored: the driver refuses + $this->cron[$this->time]['reports'][md5(serialize([]))] = ['schedule' => 'custom_x', 'args' => []]; // no stored interval either + + $report = $this->migrator->migrate('wp-cron', 'action-scheduler'); + + expect(array_column(array_column($report->failed, 'event'), 'hook'))->toBe(['reports']) + ->and($report->succeeded())->toBeFalse() + ->and(CronIndex::ownerOf('reports'))->toBe('wp-cron') + ->and($broken->available())->toBeTrue(); + }); + + it('refuses a target that is not there, or the same driver twice', function (): void { + expect(fn () => $this->migrator->migrate('wp-cron', 'wp-cron'))->toThrow(ScheduleException::class); + + ActionScheduler::$initialized = false; + + expect(fn () => $this->migrator->migrate('wp-cron', 'action-scheduler'))->toThrow(ScheduleException::class); + }); +}); diff --git a/tests/Unit/CronInterceptorTest.php b/tests/Unit/CronInterceptorTest.php index 641e288..ae704f4 100644 --- a/tests/Unit/CronInterceptorTest.php +++ b/tests/Unit/CronInterceptorTest.php @@ -6,6 +6,7 @@ use Pollora\Schedule\Drivers\ActionSchedulerDriver; use Pollora\Schedule\Event; use Pollora\Schedule\ScheduleEngine; +use Pollora\Schedule\Support\CronIndex; use Pollora\Schedule\Testing\ScheduleFake; require_once __DIR__.'/../Support/ActionSchedulerFake.php'; @@ -39,6 +40,11 @@ public function get_error_code(): string Functions\when('add_action')->justReturn(true); Functions\when('wp_get_schedules')->justReturn(['daily' => ['interval' => 86400, 'display' => 'Once Daily']]); Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + Functions\when('_set_cron_array')->alias(function (array $cron): bool { + $this->cron = $cron; + + return true; + }); Functions\when('wp_next_scheduled')->justReturn(false); $this->engine = new ScheduleEngine; @@ -47,7 +53,7 @@ public function get_error_code(): string }); it('installs the pre_* filters of the core, and removes them', function (): void { - expect(array_keys($this->filters))->toBe(['pre_schedule_event', 'pre_reschedule_event', 'pre_unschedule_event', 'pre_clear_scheduled_hook', 'pre_unschedule_hook', 'pre_get_scheduled_event']); + expect(array_keys($this->filters))->toBe(['pre_schedule_event', 'pre_clear_scheduled_hook', 'pre_unschedule_hook', 'pre_get_ready_cron_jobs', 'pre_update_option_cron', 'pre_reschedule_event', 'pre_unschedule_event']); $this->engine->useDriver(new ScheduleFake); @@ -130,25 +136,104 @@ public function get_error_code(): string }); Functions\when('wp_unschedule_hook')->alias(fn (): int => 3); - expect(($this->filter)('pre_clear_scheduled_hook', null, 'digest', ['u' => 1], false))->toBe(3) + // The event is in the option too, so the core's count has it already + expect(($this->filter)('pre_clear_scheduled_hook', null, 'digest', ['u' => 1], false))->toBe(2) ->and($inner)->toBeNull() ->and(($this->filter)('pre_unschedule_hook', null, 'digest', false))->toBe(3) ->and($this->engine->next('digest', ['u' => 1]))->toBeNull(); }); -it('describes the driver\'s event, and answers null, never false, when it has none', function (): void { +it('writes what the driver keeps to the cron option, marked with the driver, and leaves the core to read it', function (): void { $time = time() + 600; $this->engine->place(new Event('reports', $time, 'daily', ['x' => 1])); - $event = ($this->filter)('pre_get_scheduled_event', null, 'reports', ['x' => 1], null); + expect($this->cron[$time]['reports'][md5(serialize(['x' => 1]))])->toBe(['schedule' => 'daily', 'args' => ['x' => 1], 'interval' => 86400, 'driver' => 'action-scheduler']) + ->and($this->filters)->not->toHaveKey('pre_get_scheduled_event'); +}); + +it('does not let WP-Cron run what another driver keeps', function (): void { + $past = time() - 60; + $this->cron = [ + $past - 10 => ['wp_version_check' => ['k1' => ['schedule' => 'daily', 'args' => []]]], + $past => ['reports' => ['k2' => ['schedule' => 'daily', 'args' => [], 'interval' => 86400, 'driver' => 'action-scheduler']]], + time() + 600 => ['later' => ['k3' => ['schedule' => false, 'args' => []]]], + 'version' => 2, + ]; + + expect(array_keys(($this->filter)('pre_get_ready_cron_jobs', null)))->toBe([$past - 10]) + ->and(($this->filter)('pre_get_ready_cron_jobs', ['mine' => 1]))->toBe(['mine' => 1]); + + $this->cron = [$past => ['wp_version_check' => ['k1' => ['schedule' => 'daily', 'args' => []]]]]; - expect($event)->toEqual((object) ['hook' => 'reports', 'timestamp' => $time, 'schedule' => 'daily', 'args' => ['x' => 1], 'interval' => 86400]) - ->and(($this->filter)('pre_get_scheduled_event', null, 'reports', ['x' => 1], $time + 5))->toBeNull() - ->and(($this->filter)('pre_get_scheduled_event', null, 'unknown', [], null))->toBeNull(); + expect(($this->filter)('pre_get_ready_cron_jobs', null))->toBeNull(); }); -it('lets the core read an event in the cron option', function (): void { - $this->cron = [time() + 5 => ['wp_version_check' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => []]]]]; +it('unschedules an event the index shows as the driver\'s, and only at its time', function (): void { + $time = time() + 600; + $this->engine->place(new Event('reports', $time, 'daily')); + + expect(($this->filter)('pre_unschedule_event', null, $time + 1, 'reports', [], true))->toBeNull() + ->and(($this->filter)('pre_unschedule_event', null, $time, 'reports', [], true))->toBeTrue() + ->and($this->engine->next('reports'))->toBeNull() + ->and($this->cron)->toBe([]); +}); + +it('counts a cleared event once, though both the driver and the option had it', function (): void { + $time = time() + 600; + $this->engine->place(new Event('digest', $time, null, ['u' => 1])); + Functions\when('wp_clear_scheduled_hook')->alias(function (string $hook, array $args): int { + // The core counts what is still in the option, the event of the driver included + $count = 0; + foreach ($this->cron as $hooks) { + $count += count($hooks[$hook] ?? []); + } - expect(($this->filter)('pre_get_scheduled_event', null, 'wp_version_check', [], null))->toBeNull(); + return $count; + }); + + expect(($this->filter)('pre_clear_scheduled_hook', null, 'digest', ['u' => 1], false))->toBe(1); +}); + +it('gives the driver what a direct write to the option removed or moved', function (): void { + $time = time() + 600; + $this->engine->place(new Event('gone', $time, null, ['u' => 1])); + $this->engine->place(new Event('moved', $time + 1)); + $old = $this->cron; + $new = $old; + unset($new[$time]); + $new[$time + 100]['moved'] = $old[$time + 1]['moved']; + unset($new[$time + 1]); + + expect(($this->filter)('pre_update_option_cron', $new, $old, 'cron'))->toBe($new) + ->and($this->engine->next('gone', ['u' => 1]))->toBeNull() + ->and($this->engine->next('moved'))->toBe($time + 100); +}); + +it('does not take its own writes for somebody else\'s', function (): void { + Functions\when('wp_clear_scheduled_hook')->justReturn(0); + $time = time() + 600; + $this->engine->place(new Event('digest', $time)); + $before = $this->cron; + + $this->engine->cancel('digest'); + + expect($before)->not->toBe([]) + ->and($this->cron)->toBe([]) + ->and(array_values(ActionScheduler_FakeStore::$actions)[0]->status)->toBe('canceled'); +}); + +it('keeps an event another driver keeps when a runner reschedules it and unschedules its old time', function (): void { + $time = time() + 600; + $this->engine->place(new Event('reports', $time, 'daily')); + $event = (object) ['hook' => 'reports', 'timestamp' => $time, 'schedule' => 'daily', 'args' => []]; + + // What `wp cron event run` does, in this order + expect(($this->filter)('pre_reschedule_event', null, $event, true))->toBeTrue() + ->and(($this->filter)('pre_unschedule_event', null, $time, 'reports', [], true))->toBeTrue() + ->and($this->engine->next('reports'))->toBe($time) + ->and(CronIndex::ownerOf('reports'))->toBe('action-scheduler'); + + // An unschedule that no reschedule announced is a real one + expect(($this->filter)('pre_unschedule_event', null, $time, 'reports', [], true))->toBeTrue() + ->and($this->engine->next('reports'))->toBeNull(); }); diff --git a/tests/Unit/DriverRoutingTest.php b/tests/Unit/DriverRoutingTest.php index 0e47c74..87a5928 100644 --- a/tests/Unit/DriverRoutingTest.php +++ b/tests/Unit/DriverRoutingTest.php @@ -8,6 +8,7 @@ use Pollora\Schedule\Event; use Pollora\Schedule\Schedule; use Pollora\Schedule\ScheduleEngine; +use Pollora\Schedule\Support\CronIndex; use Pollora\Schedule\Testing\ScheduleFake; require_once __DIR__.'/../Support/ActionSchedulerFake.php'; @@ -35,6 +36,11 @@ }); Functions\when('wp_get_schedules')->justReturn(['daily' => ['interval' => 86400, 'display' => 'Once Daily']]); Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + Functions\when('_set_cron_array')->alias(function (array $cron): bool { + $this->cron = $cron; + + return true; + }); // WP-Cron, as the engine's fallback reaches it Functions\when('wp_schedule_single_event')->alias(function (int $ts, string $hook, array $args): bool { $this->wpCron[] = ['once', $hook, $ts, $args]; @@ -59,6 +65,7 @@ $this->engine->schedule(new Event('reports', time() + 100, 'daily')); expect($this->wpCron)->toBe([]) + ->and(array_column(CronIndex::events('action-scheduler'), 'hook'))->toEqualCanonicalizing(['digest', 'reports']) ->and(ActionScheduler_FakeStore::$actions)->toHaveCount(2) ->and($this->engine->next('digest', ['u' => 1]))->not->toBeNull(); }); @@ -186,15 +193,62 @@ public function events(): array ->and($this->engine->next('digest'))->toBeNull(); }); -it('chooses a driver by name, and wp-cron puts WordPress back on its own', function (): void { +it('chooses a driver by name, and a site that never left WP-Cron hooks into nothing', function (): void { Schedule::extend('test', fn (): ScheduleDriver => new ScheduleFake); Schedule::driver('test'); expect(Schedule::engine()->driver())->toBeInstanceOf(ScheduleFake::class) - ->and($this->filters)->toHaveKey('pre_schedule_event'); + ->and($this->filters)->toHaveKey('pre_schedule_event') + ->and(Schedule::knows('test'))->toBeTrue() + ->and(Schedule::knows('nope'))->toBeFalse(); + $this->filters = []; + $this->actions = []; Schedule::driver('wp-cron'); + // Nothing marked in the option: the check waits for init, and finds nothing to guard expect($this->filters)->toBe([]) ->and(fn () => Schedule::driver('nope'))->toThrow(InvalidArgumentException::class); + + foreach ($this->actions['init'] ?? [] as $callback) { + $callback(); + } + + expect($this->filters)->toBe([]) + ->and($this->actions)->not->toHaveKey('action_scheduler_stored_action'); +}); + +it('guards a site back on WP-Cron that another driver still keeps events for, once WordPress has initialised', function (): void { + CronIndex::record(new Event('digest', time() + 100), 'action-scheduler'); + $this->filters = []; + $this->actions = []; + + Schedule::driver('wp-cron'); + + expect($this->filters)->toBe([]); + + foreach ($this->actions['init'] as $callback) { + $callback(); + } + + expect(array_keys($this->filters))->toBe(['pre_get_ready_cron_jobs', 'pre_update_option_cron', 'pre_reschedule_event', 'pre_unschedule_event']) + ->and($this->actions)->toHaveKey('action_scheduler_stored_action'); +}); + +it('wires the driver chosen before WordPress could take hooks, once it can', function (): void { + // The application chose it at its own start: Schedule::driver() only noted the name + $this->filters = []; + (new ReflectionClass(Schedule::class))->setStaticPropertyValue('wanted', 'action-scheduler'); + + expect($this->filters)->toBe([]); + + Schedule::boot(); + + expect(Schedule::engine()->driverName())->toBe('action-scheduler') + ->and($this->filters)->toHaveKey('pre_schedule_event'); + + $filters = $this->filters; + Schedule::boot(); + + expect($this->filters)->toBe($filters); }); diff --git a/tests/Unit/FluentApiTest.php b/tests/Unit/FluentApiTest.php index b0c9ccb..018a1fa 100644 --- a/tests/Unit/FluentApiTest.php +++ b/tests/Unit/FluentApiTest.php @@ -44,6 +44,7 @@ public function __invoke(): void {} $this->filters = []; $this->fake = new ScheduleFake; + Functions\when('_get_cron_array')->justReturn([]); Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { $this->hooks[$hook][] = $callback; @@ -228,7 +229,8 @@ public function has(string $id): bool Schedule::assertNotScheduled('send_digest', fn (Event $event): bool => $event->args === ['user' => 2]); Schedule::assertNotScheduled('other'); - expect($this->hooks)->toBe([]); + // Only the engine's own watching of Action Scheduler, never a handler of an event + expect(array_keys($this->hooks))->not->toContain('reports_compile', 'send_digest'); }); it('asserts that nothing was scheduled, until something is', function (): void { diff --git a/tests/Unit/PackageBoundaryTest.php b/tests/Unit/PackageBoundaryTest.php index df4e622..1236ef1 100644 --- a/tests/Unit/PackageBoundaryTest.php +++ b/tests/Unit/PackageBoundaryTest.php @@ -44,6 +44,8 @@ 'wp_timezone', 'wp_unschedule_hook', '_get_cron_array', + '_set_cron_array', + 'wp_cache_delete', ]); arch('the contract and the vocabulary do not depend on a driver') diff --git a/tests/Unit/ScheduleDiscoveryTest.php b/tests/Unit/ScheduleDiscoveryTest.php index ad16b49..e1799b3 100644 --- a/tests/Unit/ScheduleDiscoveryTest.php +++ b/tests/Unit/ScheduleDiscoveryTest.php @@ -48,6 +48,7 @@ public function refresh(): void $this->filters = []; $this->events = []; + Functions\when('_get_cron_array')->justReturn([]); Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { $this->hooks[$hook] = $callback; diff --git a/tests/Unit/WpCronDriverTest.php b/tests/Unit/WpCronDriverTest.php index 9f25e74..2b0e97f 100644 --- a/tests/Unit/WpCronDriverTest.php +++ b/tests/Unit/WpCronDriverTest.php @@ -83,7 +83,7 @@ public function get_error_message(): string ]); expect((new WpCronDriver)->events())->toEqual([ - new Event('digest', 1000, null, ['user' => 1]), - new Event('reports', 2000, 'daily', [], 86400), + new Event('digest', 1000, null, ['user' => 1], null, 'wp-cron'), + new Event('reports', 2000, 'daily', [], 86400, 'wp-cron'), ]); });