diff --git a/.gitattributes b/.gitattributes index f76a15b..24d0a02 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ /.gitattributes export-ignore /.gitignore export-ignore /phpstan.neon export-ignore +/stubs export-ignore /phpunit.xml.dist export-ignore /pint.json export-ignore /renovate.json export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ac418..08af852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ActionSchedulerDriver`: events are Action Scheduler actions, in a group of their own (`pollora/schedule`), so cancelling a hook never reaches the actions another plugin keeps on it. The core's ten-minute window ignores a duplicate single event +- `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 + ## [0.1.0] - 2026-10-13 A first version for the framework's next release to build on; 1.0.0 comes with that release. diff --git a/README.md b/README.md index bf93caa..45ac2e0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ final class Reports ## Drivers -A driver is where events are kept and who runs them. `WpCronDriver` is the default: a thin layer over `wp_schedule_event()`, `wp_schedule_single_event()` and the other functions of WordPress, so every filter the site hangs on them keeps working. Another driver implements `Pollora\Schedule\Contracts\ScheduleDriver` and is given to the engine with `Schedule::useDriver()`. +A driver is where events are kept and who runs them. `WpCronDriver` is the default: a thin layer over `wp_schedule_event()`, `wp_schedule_single_event()` and the other functions of WordPress, so every filter the site hangs on them keeps working. Choose the driver of the site with `Schedule::driver('action-scheduler')` or `POLLORA_SCHEDULE_DRIVER=action-scheduler`. Events then live in Action Scheduler (its queue, its retries, its Tools › Scheduled Actions screen), in a group of their own, and WordPress's own functions follow: `wp_schedule_event()` called by the core, a plugin or a theme reaches it too. Events already in the `cron` option stay on WP-Cron, as do Action Scheduler's own queue runner, the hooks you protect (`ScheduleEngine::protect()`, `pollora/schedule/protected_hooks`) and every event when Action Scheduler is not there. + +Another driver implements `Pollora\Schedule\Contracts\ScheduleDriver` and is given to the engine with `Schedule::useDriver()`. ## Testing diff --git a/phpstan.neon b/phpstan.neon index bdad291..000e9e1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,5 +4,7 @@ includes: parameters: level: 5 treatPhpDocTypesAsCertain: false + bootstrapFiles: + - stubs/action-scheduler.php paths: - src diff --git a/src/Contracts/DefersUntilReady.php b/src/Contracts/DefersUntilReady.php new file mode 100644 index 0000000..44cc2ee --- /dev/null +++ b/src/Contracts/DefersUntilReady.php @@ -0,0 +1,23 @@ + $args + */ + public function find(string $hook, array $args = []): ?Event; +} diff --git a/src/Drivers/ActionSchedulerDriver.php b/src/Drivers/ActionSchedulerDriver.php new file mode 100644 index 0000000..8fb2211 --- /dev/null +++ b/src/Drivers/ActionSchedulerDriver.php @@ -0,0 +1,201 @@ +available()) { + return false; + } + + // Before 'action_scheduler_init', its functions exist but cannot store anything + if (class_exists('ActionScheduler', false)) { + return \ActionScheduler::is_initialized(); + } + + return function_exists('did_action') && did_action($this->readyAction()) > 0; + } + + public function readyAction(): string + { + return 'action_scheduler_init'; + } + + public function once(string $hook, int $timestamp, array $args = []): bool + { + if ($this->hasDuplicate($hook, $timestamp, $args)) { + return false; + } + + if ((int) as_schedule_single_action($timestamp, $hook, $args, self::GROUP) === 0) { + throw new ScheduleException(sprintf("Action Scheduler: the event '%s' could not be scheduled.", $hook)); + } + + return true; + } + + public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool + { + $interval = (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$recurrence]['interval'] ?? 0); + + if ($interval <= 0) { + throw new ScheduleException(sprintf("Action Scheduler: the event '%s' has no known recurrence '%s'.", $hook, $recurrence)); + } + + if ((int) as_schedule_recurring_action($timestamp, $interval, $hook, $args, self::GROUP) === 0) { + throw new ScheduleException(sprintf("Action Scheduler: the event '%s' could not be scheduled.", $hook)); + } + + return true; + } + + public function cancel(string $hook, array $args = []): int + { + return $this->cancelActions($this->pending($hook, $args)); + } + + public function clear(string $hook): int + { + return $this->cancelActions($this->pending($hook)); + } + + public function next(string $hook, array $args = []): ?int + { + $next = as_next_scheduled_action($hook, $args, self::GROUP); + + if ($next === true) { + // Running, or waiting with no date: due now + return time(); + } + + return $next === false ? null : (int) $next; + } + + public function find(string $hook, array $args = []): ?Event + { + $actions = $this->pending($hook, $args); + + return $actions === [] ? null : $this->toEvent(reset($actions)); + } + + public function events(): array + { + $events = array_values(array_map($this->toEvent(...), $this->pending())); + usort($events, static fn (Event $a, Event $b): int => $a->timestamp <=> $b->timestamp); + + return $events; + } + + /** + * Pending actions of the group, soonest first. + * + * @param array|null $args Exact arguments, null for any + * @return array + */ + private function pending(?string $hook = null, ?array $args = null): array + { + $query = ['group' => self::GROUP, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => -1, 'orderby' => 'date', 'order' => 'ASC']; + + if ($hook !== null) { + $query['hook'] = $hook; + } + + if ($args !== null) { + $query['args'] = $args; + } + + return as_get_scheduled_actions($query); + } + + /** + * @param array $actions + */ + private function cancelActions(array $actions): int + { + foreach (array_keys($actions) as $id) { + \ActionScheduler::store()->cancel_action($id); + } + + return count($actions); + } + + /** + * @param array $args + */ + private function hasDuplicate(string $hook, int $timestamp, array $args): bool + { + $now = time(); + $min = $timestamp < $now + self::DUPLICATE_WINDOW ? 0 : $timestamp - self::DUPLICATE_WINDOW; + $max = ($timestamp < $now ? $now : $timestamp) + self::DUPLICATE_WINDOW; + + foreach ($this->pending($hook, $args) as $action) { + $date = $action->get_schedule()->get_date(); + + if ($date !== null && $date->getTimestamp() >= $min && $date->getTimestamp() <= $max) { + return true; + } + } + + return false; + } + + private function toEvent(object $action): Event + { + $schedule = $action->get_schedule(); + $date = $schedule->get_date(); + $interval = method_exists($schedule, 'get_recurrence') && is_numeric($schedule->get_recurrence()) ? (int) $schedule->get_recurrence() : null; + + return new Event( + hook: (string) $action->get_hook(), + timestamp: $date === null ? time() : $date->getTimestamp(), + recurrence: $interval === null ? null : self::recurrenceFor($interval), + args: (array) $action->get_args(), + interval: $interval, + ); + } + + /** + * Key of a registered schedule with this interval, the first when several share it. + */ + private static function recurrenceFor(int $interval): string + { + foreach (function_exists('wp_get_schedules') ? wp_get_schedules() : [] as $key => $schedule) { + if ((int) ($schedule['interval'] ?? 0) === $interval) { + return (string) $key; + } + } + + return 'every_'.$interval.'_seconds'; + } +} diff --git a/src/Schedule.php b/src/Schedule.php index da7006f..68fa487 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -5,8 +5,11 @@ namespace Pollora\Schedule; use DateTimeImmutable; +use InvalidArgumentException; use LogicException; use Pollora\Schedule\Contracts\ScheduleDriver; +use Pollora\Schedule\Drivers\ActionSchedulerDriver; +use Pollora\Schedule\Drivers\WpCronDriver; use Pollora\Schedule\Testing\ScheduleFake; use Psr\Container\ContainerInterface; @@ -21,8 +24,18 @@ */ final class Schedule { + private const array DRIVERS = [ + 'wp-cron' => WpCronDriver::class, + 'action-scheduler' => ActionSchedulerDriver::class, + ]; + private static ?ScheduleEngine $engine = null; + /** + * @var array + */ + private static array $drivers = []; + private static ?ScheduleFake $fake = null; private static ?ScheduleDriver $driverBeforeFake = null; @@ -51,7 +64,7 @@ public static function once(string $hook): PendingEvent */ public static function cancel(string $hook, array $args = []): int { - return self::engine()->driver()->cancel($hook, $args); + return self::engine()->cancel($hook, $args); } /** @@ -59,7 +72,7 @@ public static function cancel(string $hook, array $args = []): int */ public static function clear(string $hook): int { - return self::engine()->driver()->clear($hook); + return self::engine()->clear($hook); } /** @@ -69,7 +82,7 @@ public static function clear(string $hook): int */ public static function next(string $hook, array $args = []): ?DateTimeImmutable { - $timestamp = self::engine()->driver()->next($hook, $args); + $timestamp = self::engine()->next($hook, $args); return $timestamp === null ? null : (new DateTimeImmutable('@'.$timestamp))->setTimezone(When::timezone()); } @@ -81,12 +94,57 @@ public static function next(string $hook, array $args = []): ?DateTimeImmutable */ public static function events(): array { - return self::engine()->driver()->events(); + return self::engine()->events(); } public static function engine(): ScheduleEngine { - return self::$engine ??= new ScheduleEngine; + if (! self::$engine instanceof ScheduleEngine) { + self::$engine = new ScheduleEngine; + + $name = getenv('POLLORA_SCHEDULE_DRIVER'); + + if (is_string($name) && $name !== '') { + self::driver($name); + } + } + + return self::$engine; + } + + /** + * Choose the driver of the site by name: 'wp-cron' (the default) or 'action-scheduler', or one added with extend(). + * + * WordPress's own scheduling functions follow it, for the core, plugins and + * themes as well. A driver that is not there falls back to WP-Cron, and + * events already in the 'cron' option stay on WP-Cron until they are migrated. + * POLLORA_SCHEDULE_DRIVER sets it without code. + * + * @throws InvalidArgumentException When no driver has this name + */ + 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(); + $engine = self::$engine ?? self::$engine = new ScheduleEngine; + + if ($driver instanceof WpCronDriver) { + $engine->useDriver($driver); + + return; + } + + $engine->selectDriver($driver); + } + + /** + * Add a driver, to choose by name with driver(). + * + * @param callable(): ScheduleDriver|ScheduleDriver $driver + */ + public static function extend(string $name, callable|ScheduleDriver $driver): void + { + self::$drivers[$name] = $driver; } /** diff --git a/src/ScheduleEngine.php b/src/ScheduleEngine.php index 5844b0b..bbc0f0c 100644 --- a/src/ScheduleEngine.php +++ b/src/ScheduleEngine.php @@ -5,8 +5,10 @@ namespace Pollora\Schedule; use Closure; +use Pollora\Schedule\Contracts\DefersUntilReady; use Pollora\Schedule\Contracts\ScheduleDriver; use Pollora\Schedule\Drivers\WpCronDriver; +use Pollora\Schedule\Support\CronInterceptor; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; @@ -20,8 +22,36 @@ */ final class ScheduleEngine { + /** + * Hooks that stay on WP-Cron whatever the driver: Action Scheduler's own queue runner would never run if Action Scheduler held it. + * + * @var list + */ + public const array PROTECTED_HOOKS = ['action_scheduler_run_queue']; + private ScheduleDriver $driver; + /** + * WP-Cron, set aside when a driver other than WP-Cron is chosen: events already in the 'cron' option, protected hooks and an unavailable driver stay on it. + */ + private ?ScheduleDriver $fallback = null; + + private ?CronInterceptor $interceptor = null; + + /** + * @var list + */ + private array $protectedHooks = []; + + /** + * Events scheduled before the driver could store them, by identity. + * + * @var array + */ + private array $buffer = []; + + private bool $flushAttached = false; + private ?ContainerInterface $container = null; private bool $attachesHandlers = true; @@ -43,11 +73,105 @@ public function driver(): ScheduleDriver return $this->driver; } + /** + * Use a driver as the only place events are kept. + */ public function useDriver(ScheduleDriver $driver): void { + $this->interceptor?->uninstall(); + $this->interceptor = null; + $this->fallback = null; $this->driver = $driver; } + /** + * Use a driver for new events, WP-Cron for the rest, and have WordPress's own scheduling functions follow. + * + * 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. + */ + public function selectDriver(ScheduleDriver $driver): void + { + $this->useDriver($driver); + $this->fallback = new WpCronDriver; + $this->interceptor = new CronInterceptor($this); + $this->interceptor->install(); + } + + /** + * Hooks that stay on WP-Cron whatever the driver, with the built-in ones. + * + * @return list + */ + public function protectedHooks(): array + { + $hooks = [...self::PROTECTED_HOOKS, ...$this->protectedHooks]; + + if (function_exists('apply_filters')) { + $hooks = (array) apply_filters('pollora/schedule/protected_hooks', $hooks); + } + + return array_values(array_unique(array_map('strval', $hooks))); + } + + public function protect(string ...$hooks): void + { + $this->protectedHooks = [...$this->protectedHooks, ...$hooks]; + } + + /** + * 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. + * + * @param array $args + */ + public function driverFor(string $hook, array $args = []): ScheduleDriver + { + if (! $this->fallback instanceof ScheduleDriver) { + return $this->driver; + } + + if (! $this->driver->available() || in_array($hook, $this->protectedHooks(), true) || $this->inCronOption($hook, $args)) { + return $this->fallback; + } + + return $this->driver; + } + + /** + * Whether the 'cron' option holds an event of the hook with exactly these arguments. + * + * @param array $args + */ + public function inCronOption(string $hook, array $args = []): bool + { + $key = md5(serialize($args)); + + foreach ((array) _get_cron_array() as $hooks) { + if (is_array($hooks) && isset($hooks[$hook][$key])) { + return true; + } + } + + return false; + } + + /** + * The stores that may hold an event: the chosen driver and, next to it, WP-Cron. + * + * @return list + */ + public function stores(): array + { + return $this->fallback instanceof ScheduleDriver ? [$this->driver, $this->fallback] : [$this->driver]; + } + /** * Build the classes handlers name with this container, so they get their dependencies. */ @@ -86,15 +210,160 @@ public function now(): int */ public function schedule(Event $event): bool { - if ($this->driver->next($event->hook, $event->args) !== null) { + if ($this->next($event->hook, $event->args) !== null) { return false; } + return $this->place($event); + } + + /** + * Give an event to the driver it belongs to, as the core's wp_schedule_event() does: no check that one exists already beyond a single event's duplicate window. + * + * @return bool False when an identical single event is already due + * + * @throws Exceptions\ScheduleException When the driver cannot schedule it + */ + public function place(Event $event): bool + { + $driver = $this->driverFor($event->hook, $event->args); + + if ($driver instanceof DefersUntilReady && ! $driver->ready()) { + return $this->buffer($driver, $event); + } + + return $this->give($driver, $event); + } + + /** + * When the event with these arguments next runs, in any store, null when it is not scheduled. + * + * @param array $args + */ + public function next(string $hook, array $args = []): ?int + { + $times = []; + + foreach ($this->stores() as $store) { + $times[] = $store->next($hook, $args); + } + + $buffered = $this->buffer[self::identity($hook, $args)] ?? null; + $times[] = $buffered?->timestamp; + $times = array_filter($times, static fn (?int $time): bool => $time !== null); + + return $times === [] ? null : min($times); + } + + /** + * Cancel the events of a hook with these arguments, in every store. + * + * @param array $args + * @return int Number of events cancelled + */ + public function cancel(string $hook, array $args = []): int + { + $count = 0; + + foreach ($this->stores() as $store) { + $count += $store->cancel($hook, $args); + } + + if (isset($this->buffer[self::identity($hook, $args)])) { + unset($this->buffer[self::identity($hook, $args)]); + $count++; + } + + return $count; + } + + /** + * Cancel every event of a hook, in every store. + */ + public function clear(string $hook): int + { + $count = 0; + + foreach ($this->stores() as $store) { + $count += $store->clear($hook); + } + + foreach ($this->buffer as $identity => $event) { + if ($event->hook === $hook) { + unset($this->buffer[$identity]); + $count++; + } + } + + return $count; + } + + /** + * Everything that is scheduled, in every store, soonest first. + * + * @return list + */ + public function events(): array + { + $events = array_values($this->buffer); + + foreach ($this->stores() as $store) { + array_push($events, ...$store->events()); + } + + usort($events, static fn (Event $a, Event $b): int => $a->timestamp <=> $b->timestamp); + + return $events; + } + + /** + * Give the driver what was scheduled while it could not store anything. + * + * An event already there is not scheduled again: before the driver is ready, + * a plugin that checks wp_next_scheduled() before it schedules sees nothing + * and schedules on every request. + */ + public function flush(): void + { + $events = $this->buffer; + $this->buffer = []; + + foreach ($events as $event) { + $driver = $this->driverFor($event->hook, $event->args); + + if ($driver->next($event->hook, $event->args) === null) { + $this->give($driver, $event); + } + } + } + + private function give(ScheduleDriver $driver, Event $event): bool + { if ($event->recurrence !== null) { - return $this->driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args); + return $driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args); } - return $this->driver->once($event->hook, $event->timestamp, $event->args); + return $driver->once($event->hook, $event->timestamp, $event->args); + } + + private function buffer(DefersUntilReady $driver, Event $event): bool + { + $this->buffer[self::identity($event->hook, $event->args)] ??= $event; + + if (! $this->flushAttached) { + $this->flushAttached = true; + add_action($driver->readyAction(), $this->flush(...), 20); + } + + return true; + } + + /** + * @param array $args + */ + private static function identity(string $hook, array $args): string + { + return $hook.'|'.md5(serialize($args)); } /** diff --git a/src/Support/CronInterceptor.php b/src/Support/CronInterceptor.php new file mode 100644 index 0000000..610dbaa --- /dev/null +++ b/src/Support/CronInterceptor.php @@ -0,0 +1,213 @@ + ['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 bool $installed = false; + + private bool $stepsAside = false; + + /** + * @var array + */ + private array $callbacks = []; + + public function __construct(private readonly ScheduleEngine $engine) {} + + public function install(): void + { + if ($this->installed) { + return; + } + + $this->installed = true; + + foreach (self::FILTERS as $filter => [$method, $arguments]) { + $this->callbacks[$filter] = $this->{$method}(...); + add_filter($filter, $this->callbacks[$filter], 10, $arguments); + } + } + + public function uninstall(): void + { + foreach ($this->callbacks as $filter => $callback) { + remove_filter($filter, $callback, 10); + } + + $this->callbacks = []; + $this->installed = false; + } + + /** + * @param mixed $pre What an earlier filter returned + * @return bool|\WP_Error|null + */ + private function scheduleEvent(mixed $pre, object $event, bool $wpError = false): mixed + { + if ($pre !== null || $this->stepsAside) { + return $pre; + } + + $args = (array) $event->args; + + if ($this->engine->driverFor($event->hook, $args) === $this->engine->stores()[1]) { + return null; + } + + try { + $placed = $this->engine->place(new Event( + hook: (string) $event->hook, + timestamp: (int) $event->timestamp, + recurrence: empty($event->schedule) ? null : (string) $event->schedule, + args: $args, + interval: isset($event->interval) ? (int) $event->interval : null, + )); + } catch (\Throwable $throwable) { + return $wpError ? new \WP_Error('schedule_error', $throwable->getMessage()) : false; + } + + if (! $placed) { + return $wpError ? new \WP_Error('duplicate_event', 'A duplicate event already exists.') : false; + } + + return true; + } + + /** + * Rescheduling is the runner's job: WP-Cron reschedules what it runs, Action Scheduler its recurring actions. + * + * @return bool|\WP_Error|null + */ + private function rescheduleEvent(mixed $pre, object $event, bool $wpError = false): mixed + { + if ($pre !== null || $this->stepsAside) { + return $pre; + } + + $args = (array) $event->args; + + return $this->engine->driverFor($event->hook, $args) === $this->engine->stores()[1] ? null : true; + } + + /** + * @return bool|\WP_Error|null + */ + private function unscheduleEvent(mixed $pre, int $timestamp, string $hook, array $args, bool $wpError = false): mixed + { + if ($pre !== null || $this->stepsAside) { + return $pre; + } + + $driver = $this->engine->stores()[0]; + + if ($this->engine->inCronOption($hook, $args) || $driver->next($hook, $args) !== $timestamp) { + return null; + } + + $driver->cancel($hook, $args); + + return true; + } + + /** + * @return int|false|\WP_Error|null + */ + private function clearScheduledHook(mixed $pre, string $hook, array $args, bool $wpError = false): mixed + { + if ($pre !== null || $this->stepsAside) { + return $pre; + } + + $cancelled = $this->engine->stores()[0]->cancel($hook, $args); + + return $this->withCore(static fn () => wp_clear_scheduled_hook($hook, $args, $wpError), $cancelled); + } + + /** + * @return int|false|\WP_Error|null + */ + private function unscheduleHook(mixed $pre, string $hook, bool $wpError = false): mixed + { + if ($pre !== null || $this->stepsAside) { + 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; + } + + return $event; + } + + /** + * Run the core function for what is in the 'cron' option and add what the driver cancelled. + */ + private function withCore(\Closure $core, int $cancelled): mixed + { + $this->stepsAside = true; + + try { + $result = $core(); + } finally { + $this->stepsAside = false; + } + + if (is_int($result)) { + return $result + $cancelled; + } + + return $result === false && $cancelled > 0 ? $cancelled : $result; + } +} diff --git a/stubs/action-scheduler.php b/stubs/action-scheduler.php new file mode 100644 index 0000000..d514548 --- /dev/null +++ b/stubs/action-scheduler.php @@ -0,0 +1,42 @@ + $args + */ +function as_schedule_single_action(int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10): int {} + +/** + * @param array $args + */ +function as_schedule_recurring_action(int $timestamp, int $interval_in_seconds, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10): int {} + +/** + * @param array|null $args + * @return int|bool + */ +function as_next_scheduled_action(string $hook, ?array $args = null, string $group = '') {} + +/** + * @param array $args + * @return array + */ +function as_get_scheduled_actions(array $args = [], string $return_format = 'OBJECT'): array {} diff --git a/tests/Pest.php b/tests/Pest.php index e180c6f..3775f59 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -28,5 +28,9 @@ $schedule->setStaticPropertyValue($property, null); } + if (class_exists('ActionScheduler', false) && method_exists('ActionScheduler', 'reset')) { + ActionScheduler::reset(); + } + Monkey\tearDown(); })->in('Unit'); diff --git a/tests/Support/ActionSchedulerFake.php b/tests/Support/ActionSchedulerFake.php new file mode 100644 index 0000000..1598ea4 --- /dev/null +++ b/tests/Support/ActionSchedulerFake.php @@ -0,0 +1,145 @@ +timestamp === null ? null : new DateTimeImmutable('@'.$this->timestamp); + } + + public function get_recurrence(): ?int + { + return $this->interval; + } +} + +final class FakeAction +{ + public function __construct( + public int $id, + public string $hook, + public array $args, + public string $group, + public string $status, + public FakeActionSchedule $schedule, + ) {} + + public function get_hook(): string + { + return $this->hook; + } + + public function get_args(): array + { + return $this->args; + } + + public function get_schedule(): FakeActionSchedule + { + return $this->schedule; + } +} + +abstract class ActionScheduler_Store +{ + public const STATUS_PENDING = 'pending'; + + public const STATUS_RUNNING = 'in-progress'; + + public const STATUS_CANCELED = 'canceled'; + + abstract public function cancel_action($action_id); +} + +final class ActionScheduler_FakeStore extends ActionScheduler_Store +{ + /** @var array */ + public static array $actions = []; + + public static int $lastId = 0; + + public function cancel_action($action_id): void + { + self::$actions[$action_id]->status = self::STATUS_CANCELED; + } +} + +final class ActionScheduler +{ + public static bool $initialized = true; + + public static function is_initialized(?string $function_name = null): bool + { + return self::$initialized; + } + + public static function store(): ActionScheduler_Store + { + return new ActionScheduler_FakeStore; + } + + public static function reset(): void + { + ActionScheduler_FakeStore::$actions = []; + ActionScheduler_FakeStore::$lastId = 0; + self::$initialized = true; + } +} + +function fake_as_create(string $hook, array $args, string $group, int $timestamp, ?int $interval): int +{ + $id = ++ActionScheduler_FakeStore::$lastId; + ActionScheduler_FakeStore::$actions[$id] = new FakeAction($id, $hook, $args, $group, 'pending', new FakeActionSchedule($timestamp, $interval)); + + return $id; +} + +function as_schedule_single_action($timestamp, $hook, $args = [], $group = '', $unique = false, $priority = 10): int +{ + return ActionScheduler::$initialized ? fake_as_create($hook, $args, $group, (int) $timestamp, null) : 0; +} + +function as_schedule_recurring_action($timestamp, $interval, $hook, $args = [], $group = '', $unique = false, $priority = 10): int +{ + return ActionScheduler::$initialized ? fake_as_create($hook, $args, $group, (int) $timestamp, (int) $interval) : 0; +} + +function as_next_scheduled_action($hook, $args = null, $group = '') +{ + $matching = as_get_scheduled_actions(['hook' => $hook, 'args' => $args, 'group' => $group, 'status' => ['pending', 'in-progress']]); + + foreach ($matching as $action) { + if ($action->status === 'in-progress') { + return true; + } + } + + foreach ($matching as $action) { + if ($action->status === 'pending') { + return $action->schedule->timestamp; + } + } + + return false; +} + +function as_get_scheduled_actions($args = [], $return_format = 'OBJECT'): array +{ + $statuses = (array) ($args['status'] ?? ['pending']); + $found = array_filter(ActionScheduler_FakeStore::$actions, static fn (FakeAction $action): bool => in_array($action->status, $statuses, true) + && (! isset($args['hook']) || $action->hook === $args['hook']) + && (($args['group'] ?? '') === '' || $action->group === $args['group']) + && (! array_key_exists('args', $args) || $args['args'] === null || $action->args === $args['args'])); + + uasort($found, static fn (FakeAction $a, FakeAction $b): int => $a->schedule->timestamp <=> $b->schedule->timestamp); + + return $found; +} diff --git a/tests/Unit/ActionSchedulerDriverTest.php b/tests/Unit/ActionSchedulerDriverTest.php new file mode 100644 index 0000000..a2d96ec --- /dev/null +++ b/tests/Unit/ActionSchedulerDriverTest.php @@ -0,0 +1,93 @@ +driver = new ActionSchedulerDriver; + Functions\when('wp_get_schedules')->justReturn([ + 'hourly' => ['interval' => 3600, 'display' => 'Once Hourly'], + 'daily' => ['interval' => 86400, 'display' => 'Once Daily'], + ]); +}); + +it('creates actions in its own group', function (): void { + $this->driver->once('digest', AS_NOW + 100, ['user' => 1]); + $this->driver->recurring('reports', AS_NOW + 200, 'daily'); + + $actions = array_values(ActionScheduler_FakeStore::$actions); + + expect($actions)->toHaveCount(2) + ->and($actions[0]->group)->toBe('pollora/schedule') + ->and($actions[1]->schedule->interval)->toBe(86400); +}); + +it('ignores a single event identical to one due within ten minutes, as WordPress does', function (): void { + $time = time() + 3600; + + expect($this->driver->once('digest', $time, ['user' => 1]))->toBeTrue() + ->and($this->driver->once('digest', $time + 500, ['user' => 1]))->toBeFalse() + ->and($this->driver->once('digest', $time + 700, ['user' => 1]))->toBeTrue() + ->and($this->driver->once('digest', $time, ['user' => 2]))->toBeTrue(); +}); + +it('throws when Action Scheduler is not initialised or the recurrence is unknown', function (): void { + expect(fn () => $this->driver->recurring('reports', AS_NOW, 'fortnightly'))->toThrow(ScheduleException::class, 'recurrence'); + + ActionScheduler::$initialized = false; + + expect(fn () => $this->driver->once('digest', AS_NOW))->toThrow(ScheduleException::class); +}); + +it('cancels only the actions of its group, and counts them', function (): void { + $this->driver->once('shared', AS_NOW + 100, ['a' => 1]); + $this->driver->once('shared', AS_NOW + 5000, ['a' => 2]); + as_schedule_single_action(AS_NOW + 100, 'shared', ['a' => 1], 'woocommerce'); + + expect($this->driver->cancel('shared', ['a' => 1]))->toBe(1) + ->and($this->driver->next('shared', ['a' => 1]))->toBeNull() + ->and($this->driver->clear('shared'))->toBe(1) + ->and(as_next_scheduled_action('shared', ['a' => 1], 'woocommerce'))->toBe(AS_NOW + 100); +}); + +it('reads the next run, now for an action in progress', function (): void { + $this->driver->once('digest', AS_NOW + 100); + $id = (int) array_key_first(ActionScheduler_FakeStore::$actions); + + expect($this->driver->next('digest'))->toBe(AS_NOW + 100) + ->and($this->driver->next('other'))->toBeNull(); + + ActionScheduler_FakeStore::$actions[$id]->status = 'in-progress'; + + expect($this->driver->next('digest'))->toBeGreaterThanOrEqual(time() - 1); +}); + +it('lists its events, soonest first, with the recurrence key of their interval', function (): void { + $this->driver->recurring('reports', AS_NOW + 500, 'daily', ['x' => 1]); + $this->driver->once('digest', AS_NOW + 100); + as_schedule_single_action(AS_NOW, 'foreign', [], 'woocommerce'); + + expect($this->driver->events())->toEqual([ + new Event('digest', AS_NOW + 100), + new Event('reports', AS_NOW + 500, 'daily', ['x' => 1], 86400), + ])->and($this->driver->find('reports', ['x' => 1]))->toEqual(new Event('reports', AS_NOW + 500, 'daily', ['x' => 1], 86400)) + ->and($this->driver->find('reports'))->toBeNull(); +}); + +it('is available once its functions exist, ready once initialised', function (): void { + expect($this->driver->available())->toBeTrue() + ->and($this->driver->ready())->toBeTrue() + ->and($this->driver->readyAction())->toBe('action_scheduler_init'); + + ActionScheduler::$initialized = false; + + expect($this->driver->ready())->toBeFalse(); +}); diff --git a/tests/Unit/CronInterceptorTest.php b/tests/Unit/CronInterceptorTest.php new file mode 100644 index 0000000..641e288 --- /dev/null +++ b/tests/Unit/CronInterceptorTest.php @@ -0,0 +1,154 @@ +code; + } + } +} + +beforeEach(function (): void { + $this->filters = []; + $this->cron = []; + + Functions\when('add_filter')->alias(function (string $hook, callable $callback): bool { + $this->filters[$hook] = $callback; + + return true; + }); + Functions\when('remove_filter')->alias(function (string $hook): bool { + unset($this->filters[$hook]); + + return true; + }); + 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('wp_next_scheduled')->justReturn(false); + + $this->engine = new ScheduleEngine; + $this->engine->selectDriver(new ActionSchedulerDriver); + $this->filter = fn (string $name, mixed ...$arguments): mixed => ($this->filters[$name])(...$arguments); +}); + +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']); + + $this->engine->useDriver(new ScheduleFake); + + expect($this->filters)->toBe([]); +}); + +it('takes a new event from wp_schedule_event() and schedules it in the driver', function (): void { + $event = (object) ['hook' => 'reports', 'timestamp' => time() + 60, 'schedule' => 'daily', 'args' => [], 'interval' => 86400]; + + expect(($this->filter)('pre_schedule_event', null, $event, false))->toBeTrue() + ->and(array_values(ActionScheduler_FakeStore::$actions)[0]->schedule->interval)->toBe(86400); +}); + +it('answers a duplicate single event as the core does', function (): void { + $event = (object) ['hook' => 'digest', 'timestamp' => time() + 3600, 'schedule' => false, 'args' => ['u' => 1]]; + + expect(($this->filter)('pre_schedule_event', null, $event, true))->toBeTrue(); + + $error = ($this->filter)('pre_schedule_event', null, $event, true); + + expect($error)->toBeInstanceOf(WP_Error::class) + ->and($error->get_error_code())->toBe('duplicate_event') + ->and(($this->filter)('pre_schedule_event', null, $event, false))->toBeFalse(); +}); + +it('reports a failure as a WP_Error, or false', function (): void { + ActionScheduler::$initialized = true; + Functions\when('wp_get_schedules')->justReturn([]); + $event = (object) ['hook' => 'reports', 'timestamp' => time(), 'schedule' => 'daily', 'args' => [], 'interval' => 86400]; + + expect(($this->filter)('pre_schedule_event', null, $event, true))->toBeInstanceOf(WP_Error::class) + ->and(($this->filter)('pre_schedule_event', null, $event, false))->toBeFalse(); +}); + +it('steps aside for what WP-Cron owns: protected hooks, the cron option, an earlier filter', function (): void { + $protected = (object) ['hook' => 'action_scheduler_run_queue', 'timestamp' => time(), 'schedule' => 'daily', 'args' => []]; + $this->cron = [time() + 5 => ['wp_version_check' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => []]]]]; + $inOption = (object) ['hook' => 'wp_version_check', 'timestamp' => time() + 86400, 'schedule' => 'daily', 'args' => []]; + + expect(($this->filter)('pre_schedule_event', null, $protected, false))->toBeNull() + ->and(($this->filter)('pre_schedule_event', null, $inOption, false))->toBeNull() + ->and(($this->filter)('pre_schedule_event', true, $inOption, false))->toBeTrue() + ->and(($this->filter)('pre_reschedule_event', null, $inOption, false))->toBeNull() + ->and(ActionScheduler_FakeStore::$actions)->toBe([]); +}); + +it('does not move a recurring event WP-Cron runs: rescheduling and unscheduling it are the core\'s', function (): void { + $time = time() + 5; + $this->cron = [$time => ['wp_version_check' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => []]]]]; + $event = (object) ['hook' => 'wp_version_check', 'timestamp' => $time, 'schedule' => 'daily', 'args' => []]; + + expect(($this->filter)('pre_reschedule_event', null, $event, true))->toBeNull() + ->and(($this->filter)('pre_schedule_event', null, $event, true))->toBeNull() + ->and(($this->filter)('pre_unschedule_event', null, $time, 'wp_version_check', [], true))->toBeNull() + ->and(ActionScheduler_FakeStore::$actions)->toBe([]); +}); + +it('leaves rescheduling of the driver\'s own events to its runner', function (): void { + $event = (object) ['hook' => 'reports', 'timestamp' => time(), 'schedule' => 'daily', 'args' => []]; + + expect(($this->filter)('pre_reschedule_event', null, $event, true))->toBeTrue(); +}); + +it('unschedules an event of the driver at its timestamp, and leaves other timestamps to the core', function (): void { + $time = time() + 600; + $this->engine->place(new Event('digest', $time)); + + expect(($this->filter)('pre_unschedule_event', null, $time + 1, 'digest', [], true))->toBeNull() + ->and(($this->filter)('pre_unschedule_event', null, $time, 'digest', [], true))->toBeTrue() + ->and($this->engine->next('digest'))->toBeNull(); +}); + +it('clears both stores and adds the counts, the core function running without the interceptor', function (): void { + $this->engine->place(new Event('digest', time() + 600, null, ['u' => 1])); + $inner = null; + Functions\when('wp_clear_scheduled_hook')->alias(function (string $hook, array $args, bool $wpError) use (&$inner): int { + $inner = ($this->filter)('pre_clear_scheduled_hook', null, $hook, $args, $wpError); + + return 2; + }); + Functions\when('wp_unschedule_hook')->alias(fn (): int => 3); + + expect(($this->filter)('pre_clear_scheduled_hook', null, 'digest', ['u' => 1], false))->toBe(3) + ->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 { + $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($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(); +}); + +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' => []]]]]; + + expect(($this->filter)('pre_get_scheduled_event', null, 'wp_version_check', [], null))->toBeNull(); +}); diff --git a/tests/Unit/DriverRoutingTest.php b/tests/Unit/DriverRoutingTest.php new file mode 100644 index 0000000..0e47c74 --- /dev/null +++ b/tests/Unit/DriverRoutingTest.php @@ -0,0 +1,200 @@ +filters = []; + $this->actions = []; + $this->cron = []; + $this->wpCron = []; + + Functions\when('add_filter')->alias(function (string $hook, callable $callback): bool { + $this->filters[$hook] = $callback; + + return true; + }); + Functions\when('add_action')->alias(function (string $hook, callable $callback): bool { + $this->actions[$hook][] = $callback; + + return true; + }); + Functions\when('remove_filter')->alias(function (string $hook): bool { + unset($this->filters[$hook]); + + return true; + }); + Functions\when('wp_get_schedules')->justReturn(['daily' => ['interval' => 86400, 'display' => 'Once Daily']]); + Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + // 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]; + + return true; + }); + Functions\when('wp_schedule_event')->alias(function (int $ts, string $recurrence, string $hook, array $args): bool { + $this->wpCron[] = ['recurring', $hook, $ts, $args]; + + return true; + }); + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\when('wp_clear_scheduled_hook')->justReturn(0); + Functions\when('wp_unschedule_hook')->justReturn(0); + + $this->engine = new ScheduleEngine; + $this->engine->selectDriver(new ActionSchedulerDriver); +}); + +it('sends a new event to the chosen driver', function (): void { + $this->engine->schedule(new Event('digest', time() + 100, null, ['u' => 1])); + $this->engine->schedule(new Event('reports', time() + 100, 'daily')); + + expect($this->wpCron)->toBe([]) + ->and(ActionScheduler_FakeStore::$actions)->toHaveCount(2) + ->and($this->engine->next('digest', ['u' => 1]))->not->toBeNull(); +}); + +it('keeps Action Scheduler\'s own queue runner on WP-Cron', function (): void { + $this->engine->place(new Event('action_scheduler_run_queue', time(), 'daily')); + + expect($this->wpCron)->toHaveCount(1) + ->and(ActionScheduler_FakeStore::$actions)->toBe([]); +}); + +it('keeps hooks protected by the site on WP-Cron', function (): void { + $this->engine->protect('my_heartbeat'); + Functions\when('apply_filters')->alias(fn (string $filter, mixed $value): mixed => $filter === 'pollora/schedule/protected_hooks' ? [...$value, 'from_filter'] : $value); + + $this->engine->place(new Event('my_heartbeat', time())); + $this->engine->place(new Event('from_filter', time())); + + expect(array_column($this->wpCron, 1))->toBe(['my_heartbeat', 'from_filter']); +}); + +it('leaves an event already in the cron option on WP-Cron, so it keeps rescheduling itself there', function (): void { + $this->cron = [time() + 50 => ['wp_version_check' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => []]]]]; + + $this->engine->place(new Event('wp_version_check', time() + 86400, 'daily')); + + expect($this->wpCron)->toHaveCount(1) + ->and(ActionScheduler_FakeStore::$actions)->toBe([]) + ->and($this->engine->driverFor('wp_version_check', [])->available())->toBeTrue(); +}); + +it('falls back to WP-Cron when the driver is not there', function (): void { + $absent = new class implements ScheduleDriver + { + public function available(): bool + { + return false; + } + + public function once(string $hook, int $timestamp, array $args = []): bool + { + throw new LogicException('never'); + } + + public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool + { + throw new LogicException('never'); + } + + public function cancel(string $hook, array $args = []): int + { + return 0; + } + + public function clear(string $hook): int + { + return 0; + } + + public function next(string $hook, array $args = []): ?int + { + return null; + } + + public function events(): array + { + return []; + } + }; + $engine = new ScheduleEngine; + $engine->selectDriver($absent); + + $engine->place(new Event('digest', time() + 10)); + + expect($this->wpCron)->toHaveCount(1); +}); + +it('keeps what is scheduled before Action Scheduler is ready, then gives it once, without duplicates', function (): void { + ActionScheduler::$initialized = false; + $time = time() + 3600; + + foreach (range(1, 3) as $_) { + $this->engine->schedule(new Event('reports', $time, 'daily')); + } + + expect(ActionScheduler_FakeStore::$actions)->toBe([]) + ->and($this->engine->next('reports'))->toBe($time) + ->and($this->engine->events())->toHaveCount(1) + ->and($this->actions['action_scheduler_init'])->toHaveCount(1); + + // Another plugin got there first once it was ready + ActionScheduler::$initialized = true; + as_schedule_recurring_action($time, 86400, 'other', [], 'pollora/schedule'); + $this->engine->schedule(new Event('other', $time, 'daily')); + ($this->actions['action_scheduler_init'][0])(); + + expect(ActionScheduler_FakeStore::$actions)->toHaveCount(2) + ->and($this->engine->events())->toHaveCount(2); +}); + +it('cancels what is still buffered', function (): void { + ActionScheduler::$initialized = false; + $this->engine->schedule(new Event('digest', time() + 10, null, ['u' => 1])); + + expect($this->engine->cancel('digest', ['u' => 1]))->toBe(1) + ->and($this->engine->next('digest', ['u' => 1]))->toBeNull(); + + ($this->actions['action_scheduler_init'][0])(); + + expect(ActionScheduler_FakeStore::$actions)->toBe([]); +}); + +it('reads, cancels and lists in both stores', function (): void { + $this->cron = [time() + 50 => ['wp_version_check' => [md5(serialize([])) => ['schedule' => 'daily', 'args' => []]]]]; + Functions\when('wp_next_scheduled')->alias(fn (string $hook): int|false => $hook === 'wp_version_check' ? time() + 50 : false); + $this->engine->schedule(new Event('digest', time() + 100)); + + expect(array_column($this->engine->events(), 'hook'))->toBe(['wp_version_check', 'digest']) + ->and($this->engine->next('wp_version_check'))->not->toBeNull(); + + Functions\when('wp_clear_scheduled_hook')->alias(fn (string $hook): int => $hook === 'wp_version_check' ? 1 : 0); + + expect($this->engine->cancel('digest'))->toBe(1) + ->and($this->engine->cancel('wp_version_check'))->toBe(1) + ->and($this->engine->next('digest'))->toBeNull(); +}); + +it('chooses a driver by name, and wp-cron puts WordPress back on its own', 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'); + + Schedule::driver('wp-cron'); + + expect($this->filters)->toBe([]) + ->and(fn () => Schedule::driver('nope'))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/PackageBoundaryTest.php b/tests/Unit/PackageBoundaryTest.php index cf9ad4b..df4e622 100644 --- a/tests/Unit/PackageBoundaryTest.php +++ b/tests/Unit/PackageBoundaryTest.php @@ -21,7 +21,19 @@ // The test double asserts with PHPUnit when it is there 'PHPUnit\Framework\Assert', // WordPress + 'ActionScheduler', + 'ActionScheduler_Store', 'add_action', + 'apply_filters', + 'as_get_scheduled_actions', + 'as_next_scheduled_action', + 'as_schedule_recurring_action', + 'as_schedule_single_action', + 'did_action', + 'remove_filter', + 'wp_get_schedules', + 'wp_unschedule_hook', + 'WP_Error', 'add_filter', 'do_action_ref_array', 'function_exists',