From fa55f5d3b8d1728d2bc83765dbd8c8590919de4c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 13:47:38 +0200 Subject: [PATCH 1/5] feat: index every driver's events in the cron option The 'cron' option holds each event with the driver that keeps it, so wp_next_scheduled(), Site Health, WP Crontrol and Query Monitor see events Action Scheduler runs. WP-Cron no longer runs what another driver keeps, a direct write to the option reaches the driver, and Action Scheduler's own reports keep the index current. A migrator moves events between drivers, in both directions, with a dry run. --- src/Contracts/ReportsChanges.php | 22 ++ src/Drivers/ActionSchedulerDriver.php | 39 ++- src/Drivers/WpCronDriver.php | 29 +- src/Event.php | 2 + src/Migration/MigrationReport.php | 32 +++ src/Migration/Migrator.php | 164 +++++++++++ src/Schedule.php | 34 ++- src/ScheduleEngine.php | 340 ++++++++++++++++++++-- src/Support/CronIndex.php | 388 ++++++++++++++++++++++++++ src/Support/CronInterceptor.php | 126 ++++++--- stubs/action-scheduler.php | 2 + tests/Support/ActionSchedulerFake.php | 12 + tests/Unit/CronIndexTest.php | 227 +++++++++++++++ tests/Unit/CronInterceptorTest.php | 88 +++++- tests/Unit/DriverRoutingTest.php | 9 +- tests/Unit/PackageBoundaryTest.php | 1 + tests/Unit/WpCronDriverTest.php | 4 +- 17 files changed, 1406 insertions(+), 113 deletions(-) create mode 100644 src/Contracts/ReportsChanges.php create mode 100644 src/Migration/MigrationReport.php create mode 100644 src/Migration/Migrator.php create mode 100644 src/Support/CronIndex.php create mode 100644 tests/Unit/CronIndexTest.php diff --git a/src/Contracts/ReportsChanges.php b/src/Contracts/ReportsChanges.php new file mode 100644 index 0000000..8955c3d --- /dev/null +++ b/src/Contracts/ReportsChanges.php @@ -0,0 +1,22 @@ +): void $changed + */ + public function observe(Closure $changed): void; +} diff --git a/src/Drivers/ActionSchedulerDriver.php b/src/Drivers/ActionSchedulerDriver.php index 8fb2211..08f3403 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,27 @@ 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()); + } + }; + + 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 +140,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..8029484 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use InvalidArgumentException; use LogicException; +use Pollora\Schedule\Contracts\ReportsChanges; use Pollora\Schedule\Contracts\ScheduleDriver; use Pollora\Schedule\Drivers\ActionSchedulerDriver; use Pollora\Schedule\Drivers\WpCronDriver; @@ -124,17 +125,44 @@ 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; + $engine->resolveDriversWith(static fn (string $driverName): ?ScheduleDriver => self::tryBuild($driverName)); if ($driver instanceof WpCronDriver) { $engine->useDriver($driver); + $engine->guard(); + + // Events a driver kept before it was set aside still report to the index + foreach (self::DRIVERS as $driverName => $class) { + if ($driverName !== 'wp-cron' && is_subclass_of($class, ReportsChanges::class)) { + $engine->watch($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; + } } /** diff --git a/src/ScheduleEngine.php b/src/ScheduleEngine.php index bbc0f0c..acf265f 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,151 @@ 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); + } + + /** + * @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; + } + + 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 +302,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 +315,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 +333,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 { - $key = md5(serialize($args)); + $this->busy = true; + + try { + $count = $this->driverNamed($owner)->cancel($hook, $args); + } finally { + $this->busy = false; + } + + CronIndex::forget($hook, $args, $owner); - foreach ((array) _get_cron_array() as $hooks) { - if (is_array($hooks) && isset($hooks[$hook][$key])) { - return true; + 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 + { + $this->busy = 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 +444,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 +460,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 { @@ -264,9 +502,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 +530,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 +555,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); @@ -339,11 +607,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..988ff7a --- /dev/null +++ b/src/Support/CronIndex.php @@ -0,0 +1,388 @@ + $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(); + $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(); + + 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(); + $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)); + } + + /** + * @return array + */ + private static function read(): array + { + $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..1bf018a 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,20 +13,29 @@ * '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], ]; private bool $installed = false; @@ -40,7 +49,7 @@ 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; @@ -48,7 +57,7 @@ public function install(): void $this->installed = true; - 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 +73,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 @@ -124,13 +163,36 @@ 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) { return null; } - $driver->cancel($hook, $args); + 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 +206,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 +221,14 @@ private function unscheduleHook(mixed $pre, string $hook, bool $wpError = false) return $pre; } - $cancelled = $this->engine->stores()[0]->clear($hook); - - return $this->withCore(static fn () => wp_unschedule_hook($hook, $wpError), $cancelled); - } - - /** - * 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 - */ - private function getScheduledEvent(mixed $pre, string $hook, array $args, mixed $timestamp = null): mixed - { - $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; - } + $indexed = CronIndex::countMarked($hook); + $cancelled = $this->engine->quietly(fn (): int => $this->engine->stores()[0]->clear($hook)); - return $event; + return $this->withCore(static fn () => wp_unschedule_hook($hook, $wpError), max(0, $cancelled - $indexed)); } /** - * 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/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..145507c --- /dev/null +++ b/tests/Unit/CronIndexTest.php @@ -0,0 +1,227 @@ +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('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('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..bac1e20 100644 --- a/tests/Unit/CronInterceptorTest.php +++ b/tests/Unit/CronInterceptorTest.php @@ -39,6 +39,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 +52,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_reschedule_event', 'pre_unschedule_event', 'pre_clear_scheduled_hook', 'pre_unschedule_hook', 'pre_get_ready_cron_jobs', 'pre_update_option_cron']); $this->engine->useDriver(new ScheduleFake); @@ -130,25 +135,88 @@ 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] ?? []); + } + + 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(($this->filter)('pre_get_scheduled_event', null, 'wp_version_check', [], null))->toBeNull(); + expect($before)->not->toBe([]) + ->and($this->cron)->toBe([]) + ->and(array_values(ActionScheduler_FakeStore::$actions)[0]->status)->toBe('canceled'); }); diff --git a/tests/Unit/DriverRoutingTest.php b/tests/Unit/DriverRoutingTest.php index 0e47c74..c1dd30a 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(); }); @@ -195,6 +202,6 @@ public function events(): array Schedule::driver('wp-cron'); - expect($this->filters)->toBe([]) + expect(array_keys($this->filters))->toBe(['pre_get_ready_cron_jobs', 'pre_update_option_cron']) ->and(fn () => Schedule::driver('nope'))->toThrow(InvalidArgumentException::class); }); diff --git a/tests/Unit/PackageBoundaryTest.php b/tests/Unit/PackageBoundaryTest.php index df4e622..0b6ff41 100644 --- a/tests/Unit/PackageBoundaryTest.php +++ b/tests/Unit/PackageBoundaryTest.php @@ -44,6 +44,7 @@ 'wp_timezone', 'wp_unschedule_hook', '_get_cron_array', + '_set_cron_array', ]); arch('the contract and the vocabulary do not depend on a driver') 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'), ]); }); From 47e588b416627eda7d21cf436ae321942ecb3e82 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 13:53:46 +0200 Subject: [PATCH 2/5] feat: enrich wp cron event list, migrate from WP-CLI, wire after WordPress loads wp cron event list gains type and driver and their filters, and running an event another driver keeps leaves its schedule alone. wp pollora schedule migrate moves events between drivers, with --dry-run. An application that starts before WordPress chooses the driver at once and the engine hooks into WordPress as soon as it can. --- phpstan.neon | 1 + src/Cli/CronEventCommand.php | 151 +++++++++++++++++++++++++++++++ src/Cli/MigrateCommand.php | 98 ++++++++++++++++++++ src/Cli/Registrar.php | 30 ++++++ src/Schedule.php | 39 +++++++- stubs/wp-cli.php | 77 ++++++++++++++++ tests/Pest.php | 2 +- tests/Unit/DriverRoutingTest.php | 18 ++++ tests/Unit/FluentApiTest.php | 3 +- 9 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 src/Cli/CronEventCommand.php create mode 100644 src/Cli/MigrateCommand.php create mode 100644 src/Cli/Registrar.php create mode 100644 stubs/wp-cli.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..838ef7a --- /dev/null +++ b/src/Cli/CronEventCommand.php @@ -0,0 +1,151 @@ +] + * : 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 + * - 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); + } + + /** + * An event another driver keeps runs by itself: run its hook, and leave its schedule alone. + */ + protected static function run_event(stdClass $event) + { + $crons = (array) _get_cron_array(); + + if (self::driverOf($crons, $event) === CronIndex::WP_CRON) { + return parent::run_event($event); + } + + if (! defined('DOING_CRON')) { + define('DOING_CRON', true); + } + + if ($event->schedule === false) { + wp_unschedule_event($event->time, $event->hook, $event->args); + } + + do_action_ref_array($event->hook, $event->args); + + return true; + } + + /** + * @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 @@ + */ @@ -105,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(). * @@ -127,7 +152,15 @@ public static function driver(string $name): void { $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); diff --git a/stubs/wp-cli.php b/stubs/wp-cli.php new file mode 100644 index 0000000..2405cc4 --- /dev/null +++ b/stubs/wp-cli.php @@ -0,0 +1,77 @@ +|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 ''; + } + + protected static function run_event(stdClass $event) + { + return true; + } + } +} + +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..4a65c4c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -24,7 +24,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/Unit/DriverRoutingTest.php b/tests/Unit/DriverRoutingTest.php index c1dd30a..f973666 100644 --- a/tests/Unit/DriverRoutingTest.php +++ b/tests/Unit/DriverRoutingTest.php @@ -205,3 +205,21 @@ public function events(): array expect(array_keys($this->filters))->toBe(['pre_get_ready_cron_jobs', 'pre_update_option_cron']) ->and(fn () => Schedule::driver('nope'))->toThrow(InvalidArgumentException::class); }); + +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..1163b4d 100644 --- a/tests/Unit/FluentApiTest.php +++ b/tests/Unit/FluentApiTest.php @@ -228,7 +228,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 { From dbe14aa5423af6aa391e36f9a5d35bc7f52725de Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:02:50 +0200 Subject: [PATCH 3/5] feat: keep a runner's reschedule and unschedule from cancelling an indexed event wp cron event run reschedules the event, then unschedules its old time. For an event another driver keeps, that pair now leaves it scheduled. Documents the index and the migration. --- CHANGELOG.md | 10 +++++++ README.md | 20 +++++++++++++ src/Cli/CronEventCommand.php | 30 ++----------------- src/Support/CronInterceptor.php | 46 ++++++++++++++++++++++++++++-- stubs/wp-cli.php | 5 ---- tests/Unit/CronInterceptorTest.php | 19 +++++++++++- tests/Unit/DriverRoutingTest.php | 2 +- 7 files changed, 95 insertions(+), 37 deletions(-) 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/src/Cli/CronEventCommand.php b/src/Cli/CronEventCommand.php index 838ef7a..06b298d 100644 --- a/src/Cli/CronEventCommand.php +++ b/src/Cli/CronEventCommand.php @@ -15,9 +15,8 @@ * 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. What changes besides the list is `run`: running an event - * another driver keeps must not reschedule it nor unschedule it, since the - * driver keeps its own schedule. + * 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 { @@ -42,6 +41,7 @@ class CronEventCommand extends \Cron_Event_Command * - table * - csv * - ids + * - count * - json * - yaml * --- @@ -117,30 +117,6 @@ public function list_($args, $assoc_args): void $formatter->display_items($events); } - /** - * An event another driver keeps runs by itself: run its hook, and leave its schedule alone. - */ - protected static function run_event(stdClass $event) - { - $crons = (array) _get_cron_array(); - - if (self::driverOf($crons, $event) === CronIndex::WP_CRON) { - return parent::run_event($event); - } - - if (! defined('DOING_CRON')) { - define('DOING_CRON', true); - } - - if ($event->schedule === false) { - wp_unschedule_event($event->time, $event->hook, $event->args); - } - - do_action_ref_array($event->hook, $event->args); - - return true; - } - /** * @param array $crons */ diff --git a/src/Support/CronInterceptor.php b/src/Support/CronInterceptor.php index 1bf018a..33d32d1 100644 --- a/src/Support/CronInterceptor.php +++ b/src/Support/CronInterceptor.php @@ -27,8 +27,6 @@ final class CronInterceptor { 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], ]; @@ -36,12 +34,23 @@ final class CronInterceptor 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 */ @@ -56,6 +65,7 @@ public function install(bool $routing = true): void } $this->installed = true; + $this->routing = $routing; foreach ([...($routing ? self::ROUTING : []), ...self::GUARD] as $filter => [$method, $arguments]) { $this->callbacks[$filter] = $this->{$method}(...); @@ -141,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 @@ -150,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; } @@ -165,10 +191,16 @@ private function unscheduleEvent(mixed $pre, int $timestamp, string $hook, array $owner = CronIndex::ownerOf($hook, $args); - if ($owner === CronIndex::WP_CRON) { + if ($owner === CronIndex::WP_CRON || ($owner === null && ! $this->routing)) { return null; } + 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]; @@ -227,6 +259,14 @@ private function unscheduleHook(mixed $pre, string $hook, bool $wpError = false) return $this->withCore(static fn () => wp_unschedule_hook($hook, $wpError), max(0, $cancelled - $indexed)); } + /** + * @param array $args + */ + private static function identity(string $hook, array $args): string + { + return $hook.'|'.md5(serialize($args)); + } + /** * Run the core function for what is in the 'cron' option and add what the driver cancelled and the option does not show. */ diff --git a/stubs/wp-cli.php b/stubs/wp-cli.php index 2405cc4..b72d63b 100644 --- a/stubs/wp-cli.php +++ b/stubs/wp-cli.php @@ -45,11 +45,6 @@ protected static function get_hook_actions(string $hook_name): string { return ''; } - - protected static function run_event(stdClass $event) - { - return true; - } } } diff --git a/tests/Unit/CronInterceptorTest.php b/tests/Unit/CronInterceptorTest.php index bac1e20..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'; @@ -52,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_ready_cron_jobs', 'pre_update_option_cron']); + 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); @@ -220,3 +221,19 @@ public function get_error_code(): string ->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 f973666..248a6ac 100644 --- a/tests/Unit/DriverRoutingTest.php +++ b/tests/Unit/DriverRoutingTest.php @@ -202,7 +202,7 @@ public function events(): array Schedule::driver('wp-cron'); - expect(array_keys($this->filters))->toBe(['pre_get_ready_cron_jobs', 'pre_update_option_cron']) + expect(array_keys($this->filters))->toBe(['pre_get_ready_cron_jobs', 'pre_update_option_cron', 'pre_reschedule_event', 'pre_unschedule_event']) ->and(fn () => Schedule::driver('nope'))->toThrow(InvalidArgumentException::class); }); From 32683d09185aaf09abc004f3ff21ceec2d33f16a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:04:49 +0200 Subject: [PATCH 4/5] feat: expose the index on the engine --- src/ScheduleEngine.php | 10 ++++++++++ tests/Unit/CronIndexTest.php | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/src/ScheduleEngine.php b/src/ScheduleEngine.php index acf265f..f56b217 100644 --- a/src/ScheduleEngine.php +++ b/src/ScheduleEngine.php @@ -584,6 +584,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. * diff --git a/tests/Unit/CronIndexTest.php b/tests/Unit/CronIndexTest.php index 145507c..10ba230 100644 --- a/tests/Unit/CronIndexTest.php +++ b/tests/Unit/CronIndexTest.php @@ -76,6 +76,14 @@ ->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'); From 4f86e0f9b1f62b37de4f5764467a013753c8f5be Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 14:33:32 +0200 Subject: [PATCH 5/5] fix: drop an index entry the driver no longer has, and write from a fresh read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index mirrors a driver's events, so an action deleted from Tools › Scheduled Actions left an entry that wp_next_scheduled() believed and that kept a recurring event from ever being scheduled again: the engine now asks the driver that keeps an event, and drops the entry when the driver has none. A deleted action also reconciles the index when the request ends. An index write reads the option from the database first, so a long request does not undo what others scheduled since. With WP-Cron as the driver, the guard and the watchers are installed at init, and only when another driver still keeps events. --- src/Drivers/ActionSchedulerDriver.php | 9 +++ src/Schedule.php | 21 +++++-- src/ScheduleEngine.php | 82 +++++++++++++++++++++++++++ src/Support/CronIndex.php | 22 +++++-- tests/Pest.php | 3 + tests/Unit/CronIndexTest.php | 66 +++++++++++++++++++++ tests/Unit/DriverRoutingTest.php | 35 +++++++++++- tests/Unit/FluentApiTest.php | 1 + tests/Unit/PackageBoundaryTest.php | 1 + tests/Unit/ScheduleDiscoveryTest.php | 1 + 10 files changed, 228 insertions(+), 13 deletions(-) diff --git a/src/Drivers/ActionSchedulerDriver.php b/src/Drivers/ActionSchedulerDriver.php index 08f3403..74f64c7 100644 --- a/src/Drivers/ActionSchedulerDriver.php +++ b/src/Drivers/ActionSchedulerDriver.php @@ -70,6 +70,15 @@ public function observe(Closure $changed): void } }; + // 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); } diff --git a/src/Schedule.php b/src/Schedule.php index dc3c931..4acb12c 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -164,14 +164,15 @@ public static function driver(string $name): void if ($driver instanceof WpCronDriver) { $engine->useDriver($driver); - $engine->guard(); - // Events a driver kept before it was set aside still report to the index - foreach (self::DRIVERS as $driverName => $class) { - if ($driverName !== 'wp-cron' && is_subclass_of($class, ReportsChanges::class)) { - $engine->watch($driverName, self::build($driverName)); + // 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; } @@ -198,6 +199,14 @@ private static function tryBuild(string $name): ?ScheduleDriver } } + /** + * 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]); + } + /** * Add a driver, to choose by name with driver(). * diff --git a/src/ScheduleEngine.php b/src/ScheduleEngine.php index f56b217..5170543 100644 --- a/src/ScheduleEngine.php +++ b/src/ScheduleEngine.php @@ -156,6 +156,37 @@ public function guard(): void $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 */ @@ -219,6 +250,13 @@ public function refresh(string $name, string $hook, array $args = []): void 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) { @@ -480,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) { @@ -493,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. * diff --git a/src/Support/CronIndex.php b/src/Support/CronIndex.php index 988ff7a..e1d59e2 100644 --- a/src/Support/CronIndex.php +++ b/src/Support/CronIndex.php @@ -145,7 +145,7 @@ public static function events(?string $owner = null): array */ public static function record(Event $event, string $owner): void { - $crons = self::read(); + $crons = self::read(fresh: true); $key = self::key($event->args); $schedule = $event->recurrence; $interval = $event->interval; @@ -183,7 +183,7 @@ public static function record(Event $event, string $owner): void */ public static function forget(string $hook, array $args, string $owner): void { - $crons = self::read(); + $crons = self::read(fresh: true); if (self::strip($crons, $hook, self::key($args), $owner) !== []) { self::write($crons); @@ -195,7 +195,7 @@ public static function forget(string $hook, array $args, string $owner): void */ public static function forgetHook(string $hook, string $owner): void { - $crons = self::read(); + $crons = self::read(fresh: true); $found = false; foreach ($crons as $timestamp => $hooks) { @@ -363,10 +363,24 @@ private static function key(array $args): string } /** + * 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(): 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 : []; diff --git a/tests/Pest.php b/tests/Pest.php index 4a65c4c..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()); diff --git a/tests/Unit/CronIndexTest.php b/tests/Unit/CronIndexTest.php index 10ba230..8255268 100644 --- a/tests/Unit/CronIndexTest.php +++ b/tests/Unit/CronIndexTest.php @@ -133,6 +133,72 @@ 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'); diff --git a/tests/Unit/DriverRoutingTest.php b/tests/Unit/DriverRoutingTest.php index 248a6ac..87a5928 100644 --- a/tests/Unit/DriverRoutingTest.php +++ b/tests/Unit/DriverRoutingTest.php @@ -193,17 +193,46 @@ 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'); - expect(array_keys($this->filters))->toBe(['pre_get_ready_cron_jobs', 'pre_update_option_cron', 'pre_reschedule_event', 'pre_unschedule_event']) + // 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 { diff --git a/tests/Unit/FluentApiTest.php b/tests/Unit/FluentApiTest.php index 1163b4d..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; diff --git a/tests/Unit/PackageBoundaryTest.php b/tests/Unit/PackageBoundaryTest.php index 0b6ff41..1236ef1 100644 --- a/tests/Unit/PackageBoundaryTest.php +++ b/tests/Unit/PackageBoundaryTest.php @@ -45,6 +45,7 @@ '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;