From 49618d262af644b96603b032f99ab59e0292546d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 15:32:18 +0200 Subject: [PATCH] feat!: queue through the drivers of pollora/schedule WP-Cron and Action Scheduler are the drivers of pollora/schedule, which the package now requires. ScheduledDriver keeps what is the hook's own, and Recovery the daily task. Work queued by 1.x runs unchanged. Async\Drivers\WpCronDriver and ActionSchedulerDriver are removed, with no alias. --- CHANGELOG.md | 13 ++ composer.json | 3 +- src/Async/Async.php | 33 +++- src/Async/Drivers/ActionSchedulerDriver.php | 80 -------- src/Async/Drivers/ScheduledDriver.php | 125 ++++++++++++ src/Async/Drivers/WpCronDriver.php | 183 ------------------ src/Async/Recovery.php | 118 +++++++++++ src/Async/UniqueLock.php | 3 +- tests/Stubs/wordpress.php | 12 ++ .../Unit/Async/ActionSchedulerDriverTest.php | 77 +++++++- tests/Unit/Async/AsyncActionTest.php | 4 +- tests/Unit/Async/UniqueTest.php | 4 +- tests/Unit/Async/WpCronDriverTest.php | 44 +++-- tests/WordPress/AsyncWpCronTest.php | 25 +-- 14 files changed, 408 insertions(+), 316 deletions(-) delete mode 100644 src/Async/Drivers/ActionSchedulerDriver.php create mode 100644 src/Async/Drivers/ScheduledDriver.php delete mode 100644 src/Async/Drivers/WpCronDriver.php create mode 100644 src/Async/Recovery.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c26e3..d946e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `pollora/hook` are documented in this file. +## [Unreleased] + +### Changed + +- WP-Cron and Action Scheduler are the drivers of [`pollora/schedule`](https://github.com/Pollora/schedule), which the package now requires: `Async\Drivers\ScheduledDriver` queues through them and keeps what is the hook's own, the message an event carries and where the payload lives. Nothing changes for what is already queued: the hook, the Action Scheduler groups (`pollora` or the queue name), the identifier-only WP-Cron events and the `pollora_async_` options are the same, so work queued by 1.x runs once 2.0 is installed. `->async()`, `#[Async]`, `Async::fake()`, `via()` and their options do not change +- `Async::listen()` keeps `pollora/async/run` and `pollora/async/recover` on WP-Cron (the `pollora/schedule/protected_hooks` filter), whatever driver the site gave pollora/schedule: `via('wp-cron')` still means WP-Cron +- An Action Scheduler action of the hook lives in its own group and is never in the `cron` index of pollora/schedule + +### Removed + +- `Async\Drivers\WpCronDriver` and `Async\Drivers\ActionSchedulerDriver`, with no alias. Code that used them directly (the framework does not) uses `ScheduledDriver::wpCron()` and `ScheduledDriver::actionScheduler()`, or the drivers of pollora/schedule +- `WpCronDriver::handles()` and `claim()`: use `PayloadStore`. The recovery task (`recover()`, `scheduleRecovery()`, `RECOVERY_HOOK`, `RECOVERY_GRACE`) moved to `Async\Recovery` + ## [1.5.0] - 2026-10-09 ### Added diff --git a/composer.json b/composer.json index 09d5a55..7c30aa2 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ } ], "require": { - "php": "^8.3" + "php": "^8.3", + "pollora/schedule": "^0.1.2" }, "require-dev": { "laravel/pint": "^v1.22.1", diff --git a/src/Async/Async.php b/src/Async/Async.php index b06b134..eef96b5 100644 --- a/src/Async/Async.php +++ b/src/Async/Async.php @@ -6,9 +6,8 @@ use Pollora\Hook\Async\Contracts\AsyncDriver; use Pollora\Hook\Async\Contracts\ObjectReference; -use Pollora\Hook\Async\Drivers\ActionSchedulerDriver; +use Pollora\Hook\Async\Drivers\ScheduledDriver; use Pollora\Hook\Async\Drivers\SyncDriver; -use Pollora\Hook\Async\Drivers\WpCronDriver; use Pollora\Hook\Async\Exceptions\DriverUnavailable; use Pollora\Hook\Async\Exceptions\UnresolvableHandler; use Pollora\Hook\Domain\Contract\CallbackResolverInterface; @@ -51,6 +50,8 @@ final class Async private static ?string $defaultDriver = null; + private static bool $protecting = false; + /** @var list Drivers 'auto' tries, in order */ private static array $autoDrivers = ['action-scheduler', 'wp-cron']; @@ -345,11 +346,30 @@ public static function listen(): void add_action(self::HOOK, [self::class, 'receive'], 10, 1); } - if (has_action(WpCronDriver::RECOVERY_HOOK, [WpCronDriver::class, 'recover']) === false) { - add_action(WpCronDriver::RECOVERY_HOOK, [WpCronDriver::class, 'recover'], 10, 0); + if (has_action(Recovery::HOOK, [Recovery::class, 'recover']) === false) { + add_action(Recovery::HOOK, [Recovery::class, 'recover'], 10, 0); + } + + // Whatever driver pollora/schedule gives the site, these events run through WP-Cron: that is what via('wp-cron') promises + if (function_exists('add_filter') && ! self::$protecting) { + self::$protecting = true; + add_filter('pollora/schedule/protected_hooks', [self::class, 'protectHooks']); } } + /** + * The hooks pollora/schedule keeps on WP-Cron, whatever driver the site chose. + * + * @param array $hooks + * @return array + * + * @internal + */ + public static function protectHooks(array $hooks): array + { + return [...$hooks, self::HOOK, Recovery::HOOK]; + } + /** * Run a queued handler, from the message a driver hands back. * @@ -457,6 +477,7 @@ public static function flush(): void self::$factories = []; self::$drivers = []; self::$defaultDriver = null; + self::$protecting = false; self::$autoDrivers = ['action-scheduler', 'wp-cron']; self::$resolver = null; self::$closureKey = null; @@ -503,8 +524,8 @@ private static function normalizer(): ArgumentNormalizer private static function factories(): array { return self::$factories + [ - 'action-scheduler' => static fn (): AsyncDriver => new ActionSchedulerDriver, - 'wp-cron' => static fn (): AsyncDriver => new WpCronDriver, + 'action-scheduler' => static fn (): AsyncDriver => ScheduledDriver::actionScheduler(), + 'wp-cron' => static fn (): AsyncDriver => ScheduledDriver::wpCron(), 'sync' => static fn (): AsyncDriver => new SyncDriver, ]; } diff --git a/src/Async/Drivers/ActionSchedulerDriver.php b/src/Async/Drivers/ActionSchedulerDriver.php deleted file mode 100644 index 0d50753..0000000 --- a/src/Async/Drivers/ActionSchedulerDriver.php +++ /dev/null @@ -1,80 +0,0 @@ - 0; - } - - /** - * @throws AsyncException When Action Scheduler does not create the action - */ - public function dispatch(AsyncPayload $payload, int $delay = 0): void - { - $group = $payload->queue ?? self::DEFAULT_GROUP; - $arguments = [$payload->toJson()]; - $storedApart = strlen((string) json_encode($arguments)) > self::MAX_ARGUMENTS_LENGTH; - - if ($storedApart) { - PayloadStore::store($payload); - $arguments = [$payload->id]; - } - - try { - $actionId = $delay > 0 - ? as_schedule_single_action(time() + $delay, Async::HOOK, $arguments, $group) - : as_enqueue_async_action(Async::HOOK, $arguments, $group); - } catch (\Throwable $throwable) { - $actionId = 0; - $reason = $throwable->getMessage(); - } - - if ($actionId === 0) { - if ($storedApart) { - PayloadStore::forget($payload->id); - } - - throw new AsyncException(sprintf("Action Scheduler: the execution of '%s' could not be queued%s", $payload->hook, isset($reason) ? ': '.$reason : '.')); - } - - if ($storedApart) { - WpCronDriver::scheduleRecovery(); - } - } -} diff --git a/src/Async/Drivers/ScheduledDriver.php b/src/Async/Drivers/ScheduledDriver.php new file mode 100644 index 0000000..4027a08 --- /dev/null +++ b/src/Async/Drivers/ScheduledDriver.php @@ -0,0 +1,125 @@ + $driver, true); + } + + public static function actionScheduler(): self + { + $drivers = []; + + return new self('Action Scheduler', static function (?string $queue) use (&$drivers): ScheduleDriver { + $group = $queue ?? self::DEFAULT_GROUP; + + return $drivers[$group] ??= new ActionSchedulerDriver($group); + }, false); + } + + /** + * Whether the driver can store now: Action Scheduler, only once it is initialised. + */ + public function available(): bool + { + $driver = ($this->driverFor)(null); + + return $driver->available() && (! $driver instanceof DefersUntilReady || $driver->ready()); + } + + /** + * @throws AsyncException When the payload cannot be stored or the event queued + */ + public function dispatch(AsyncPayload $payload, int $delay = 0): void + { + $arguments = [$payload->toJson()]; + $storedApart = $this->alwaysStoredApart || strlen((string) json_encode($arguments)) > self::MAX_ARGUMENTS_LENGTH; + + if ($storedApart) { + try { + PayloadStore::store($payload); + } catch (AsyncException $asyncException) { + throw new AsyncException($this->label.': '.lcfirst($asyncException->getMessage()), 0, $asyncException); + } + + $arguments = [$payload->id]; + } + + $driver = ($this->driverFor)($payload->queue); + $timestamp = time() + max(0, $delay); + + try { + if ($driver instanceof Enqueues) { + $driver->enqueue(Async::HOOK, $timestamp, $arguments); + $queued = true; + } else { + $queued = $driver->once(Async::HOOK, $timestamp, $arguments); + } + } catch (\Throwable $throwable) { + $queued = false; + $reason = $throwable->getMessage(); + } + + if (! $queued) { + if ($storedApart) { + PayloadStore::forget($payload->id); + } + + throw new AsyncException(sprintf("%s: the execution of '%s' could not be queued%s", $this->label, $payload->hook, isset($reason) ? ': '.$reason : '.')); + } + + if ($storedApart) { + Recovery::schedule(); + } + } +} diff --git a/src/Async/Drivers/WpCronDriver.php b/src/Async/Drivers/WpCronDriver.php deleted file mode 100644 index b943cf4..0000000 --- a/src/Async/Drivers/WpCronDriver.php +++ /dev/null @@ -1,183 +0,0 @@ -getMessage()), 0, $asyncException); - } - - $scheduled = wp_schedule_single_event(time() + max(0, $delay), Async::HOOK, [$payload->id], true); - - if ($scheduled !== true) { - PayloadStore::forget($payload->id); - - $reason = is_object($scheduled) && method_exists($scheduled, 'get_error_message') ? ': '.$scheduled->get_error_message() : '.'; - - throw new AsyncException(sprintf("WP-Cron: the execution of '%s' could not be scheduled%s", $payload->hook, $reason)); - } - - self::scheduleRecovery(); - } - - /** - * Schedule the daily recovery task, unless it already is. - */ - public static function scheduleRecovery(): void - { - if (! function_exists('wp_next_scheduled') || wp_next_scheduled(self::RECOVERY_HOOK) !== false) { - return; - } - - wp_schedule_event(time() + self::RECOVERY_GRACE, 'daily', self::RECOVERY_HOOK); - } - - /** - * Schedule again the stored payloads whose event was lost. - * - * A payload counts as lost when no event carries its identifier and it was - * dispatched more than RECOVERY_GRACE seconds ago. A stored value that is - * not a readable payload is deleted. - * - * @return int Number of executions scheduled again - */ - public static function recover(): int - { - global $wpdb; - - if (! is_object($wpdb)) { - return 0; - } - - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d", - $wpdb->esc_like(self::OPTION_PREFIX).'%', - self::RECOVERY_BATCH, - ), 'ARRAY_A'); - - $threshold = time() - self::RECOVERY_GRACE; - $recovered = 0; - - foreach ((array) $rows as $row) { - $id = substr((string) $row['option_name'], strlen(self::OPTION_PREFIX)); - - if (! PayloadStore::handles($id) || self::isPending($id)) { - continue; - } - - try { - $payload = AsyncPayload::fromJson((string) $row['option_value']); - $dispatchedAt = (new \DateTimeImmutable($payload->origin['dispatchedAt']))->getTimestamp(); - } catch (\Throwable $throwable) { - delete_option((string) $row['option_name']); - Async::report(sprintf('WP-Cron: unreadable payload %s deleted: %s', $id, $throwable->getMessage())); - - continue; - } - - if ($dispatchedAt > $threshold) { - continue; - } - - if (wp_schedule_single_event(time(), Async::HOOK, [$id], true) === true) { - $recovered++; - } - } - - UniqueLock::prune(); - - if ($recovered > 0) { - Async::report(sprintf('WP-Cron: %d asynchronous execution(s) whose event was lost scheduled again.', $recovered)); - } - - return $recovered; - } - - /** - * Whether a driver message is the identifier of a payload stored by this driver. - */ - public static function handles(string $message): bool - { - return PayloadStore::handles($message); - } - - /** - * Take a stored payload out of the database, to run it. - * - * Only the process that deletes the option gets the payload, so two cron - * runs that pick the same event do not both execute it. - * - * @return string|null The payload as JSON, or null when it was already taken or never existed - */ - public static function claim(string $id): ?string - { - return PayloadStore::claim($id); - } - - /** - * Whether an event still carries the identifier: a WP-Cron event, or an - * Action Scheduler action for a payload too long for its arguments. - */ - private static function isPending(string $id): bool - { - if (wp_next_scheduled(Async::HOOK, [$id]) !== false) { - return true; - } - - return function_exists('as_has_scheduled_action') && as_has_scheduled_action(Async::HOOK, [$id]); - } -} diff --git a/src/Async/Recovery.php b/src/Async/Recovery.php new file mode 100644 index 0000000..289459b --- /dev/null +++ b/src/Async/Recovery.php @@ -0,0 +1,118 @@ +get_results($wpdb->prepare( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d", + $wpdb->esc_like(PayloadStore::OPTION_PREFIX).'%', + self::BATCH, + ), 'ARRAY_A'); + + $threshold = time() - self::GRACE; + $recovered = 0; + + foreach ((array) $rows as $row) { + $id = substr((string) $row['option_name'], strlen(PayloadStore::OPTION_PREFIX)); + + if (! PayloadStore::handles($id) || self::isPending($id)) { + continue; + } + + try { + $payload = AsyncPayload::fromJson((string) $row['option_value']); + $dispatchedAt = (new \DateTimeImmutable($payload->origin['dispatchedAt']))->getTimestamp(); + } catch (\Throwable $throwable) { + delete_option((string) $row['option_name']); + Async::report(sprintf('WP-Cron: unreadable payload %s deleted: %s', $id, $throwable->getMessage())); + + continue; + } + + if ($dispatchedAt > $threshold) { + continue; + } + + if (wp_schedule_single_event(time(), Async::HOOK, [$id], true) === true) { + $recovered++; + } + } + + UniqueLock::prune(); + + if ($recovered > 0) { + Async::report(sprintf('WP-Cron: %d asynchronous execution(s) whose event was lost scheduled again.', $recovered)); + } + + return $recovered; + } + + /** + * Whether an event still carries the identifier: a WP-Cron event, or an + * Action Scheduler action, in any group, for a payload too long for its arguments. + */ + private static function isPending(string $id): bool + { + if (wp_next_scheduled(Async::HOOK, [$id]) !== false) { + return true; + } + + return function_exists('as_has_scheduled_action') && as_has_scheduled_action(Async::HOOK, [$id]); + } +} diff --git a/src/Async/UniqueLock.php b/src/Async/UniqueLock.php index 70fbbfd..f6f083e 100644 --- a/src/Async/UniqueLock.php +++ b/src/Async/UniqueLock.php @@ -4,7 +4,6 @@ namespace Pollora\Hook\Async; -use Pollora\Hook\Async\Drivers\WpCronDriver; use Pollora\Hook\Async\Exceptions\AsyncException; /** @@ -44,7 +43,7 @@ public static function acquire(string $key, int $seconds): bool $option = self::OPTION_PREFIX.$key; $expiresAt = time() + $seconds; - WpCronDriver::scheduleRecovery(); + Recovery::schedule(); if (add_option($option, $expiresAt, '', false)) { return true; diff --git a/tests/Stubs/wordpress.php b/tests/Stubs/wordpress.php index 6acaeea..fd402c9 100644 --- a/tests/Stubs/wordpress.php +++ b/tests/Stubs/wordpress.php @@ -279,6 +279,11 @@ function wp_schedule_single_event(int $timestamp, string $hook, array $args = [] if (in_array('wp_schedule_single_event', $GLOBALS['wp_fail'] ?? [], true)) { return $wpError ? new class { + public function get_error_code(): string + { + return 'schedule_error'; + } + public function get_error_message(): string { return 'A plugin prevented the event from being scheduled.'; @@ -411,6 +416,13 @@ function as_schedule_single_action(int $timestamp, string $hook, array $args = [ } } +if (! function_exists('as_schedule_recurring_action')) { + function as_schedule_recurring_action(int $timestamp, int $interval, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10): int + { + return as_schedule_single_action($timestamp, $hook, $args, $group); + } +} + if (! function_exists('as_has_scheduled_action')) { function as_has_scheduled_action(string $hook, ?array $args = null, string $group = ''): bool { diff --git a/tests/Unit/Async/ActionSchedulerDriverTest.php b/tests/Unit/Async/ActionSchedulerDriverTest.php index 57c4ed5..af15370 100644 --- a/tests/Unit/Async/ActionSchedulerDriverTest.php +++ b/tests/Unit/Async/ActionSchedulerDriverTest.php @@ -5,10 +5,10 @@ use Pollora\Hook\Adapter\Out\WordPress\Action; use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; -use Pollora\Hook\Async\Drivers\ActionSchedulerDriver; -use Pollora\Hook\Async\Drivers\WpCronDriver; +use Pollora\Hook\Async\Drivers\ScheduledDriver; use Pollora\Hook\Async\Exceptions\AsyncException; use Pollora\Hook\Async\PayloadStore; +use Pollora\Hook\Async\Recovery; use Pollora\Hook\Tests\Fixtures\Async\RecordingHandler; beforeEach(function (): void { @@ -80,7 +80,7 @@ function runScheduledActions(): void $action = $GLOBALS['as_actions'][0]; expect($action['hook'])->toBe(Async::HOOK) - ->and($action['group'])->toBe(ActionSchedulerDriver::DEFAULT_GROUP) + ->and($action['group'])->toBe(ScheduledDriver::DEFAULT_GROUP) ->and(AsyncPayload::fromJson($action['args'][0])->arguments)->toBe([42]) ->and($GLOBALS['wp_options'])->toBe([]); }); @@ -120,7 +120,7 @@ function runScheduledActions(): void expect(PayloadStore::handles($id))->toBeTrue() ->and($GLOBALS['wp_options'][PayloadStore::OPTION_PREFIX.$id]['autoload'])->toBeFalse() - ->and(wp_next_scheduled(WpCronDriver::RECOVERY_HOOK))->not->toBeFalse(); + ->and(wp_next_scheduled(Recovery::HOOK))->not->toBeFalse(); runScheduledActions(); runScheduledActions(); @@ -137,10 +137,10 @@ function runScheduledActions(): void [$id] = $GLOBALS['as_actions'][0]['args']; $option = PayloadStore::OPTION_PREFIX.$id; $stored = json_decode($GLOBALS['wp_options'][$option]['value'], true); - $stored['origin']['dispatchedAt'] = gmdate(DATE_ATOM, time() - 2 * WpCronDriver::RECOVERY_GRACE); + $stored['origin']['dispatchedAt'] = gmdate(DATE_ATOM, time() - 2 * Recovery::GRACE); $GLOBALS['wp_options'][$option]['value'] = json_encode($stored); - expect(WpCronDriver::recover())->toBe(0); + expect(Recovery::recover())->toBe(0); }); it('retries through Action Scheduler', function (): void { @@ -167,5 +167,68 @@ function runScheduledActions(): void it('is unavailable without Action Scheduler', function (): void { $GLOBALS['as_initialized'] = false; - expect((new ActionSchedulerDriver)->available())->toBeFalse(); + expect((ScheduledDriver::actionScheduler())->available())->toBeFalse(); +}); + +it('keeps the shapes sites already have queued: the hook, the group, the arguments and the options', function (): void { + // What hook 1.x queued: work in flight must run unchanged once 2.0 is installed + expect(Async::HOOK)->toBe('pollora/async/run') + ->and(Recovery::HOOK)->toBe('pollora/async/recover') + ->and(PayloadStore::OPTION_PREFIX)->toBe('pollora_async_') + ->and(ScheduledDriver::DEFAULT_GROUP)->toBe('pollora'); + + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->via('action-scheduler'); + wp_stub_fire('save_post', 42); + + $queued = $GLOBALS['as_actions'][0]; + + expect($queued['hook'])->toBe('pollora/async/run') + ->and($queued['group'])->toBe('pollora') + ->and($queued['args'])->toHaveCount(1) + ->and(AsyncPayload::fromJson($queued['args'][0])->hook)->toBe('save_post'); +}); + +it('runs work queued by 1.x in all three forms: an identifier kept in an option, a payload as the action argument, an identifier for a payload too long', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->via('wp-cron'); + wp_stub_fire('save_post', 1); + $this->action->add('edit_post', [RecordingHandler::class, 'handle'])->async()->via('action-scheduler'); + wp_stub_fire('edit_post', 2); + + $cronMessage = $GLOBALS['wp_cron_events'][0]['args'][0]; + $actionMessage = $GLOBALS['as_actions'][0]['args'][0]; + $long = new AsyncPayload(...[...array_diff_key((array) AsyncPayload::fromJson($actionMessage), ['id' => 1]), 'id' => '11111111-2222-4333-8444-555555555555']); + PayloadStore::store($long); + $GLOBALS['wp_cron_events'] = []; + RecordingHandler::$calls = []; + + foreach ([$cronMessage, $actionMessage, $long->id] as $message) { + Async::receive($message); + } + + expect(RecordingHandler::$calls)->toHaveCount(3) + ->and(PayloadStore::handles($cronMessage))->toBeTrue() + ->and(PayloadStore::handles($actionMessage))->toBeFalse(); +}); + +it('queues each queue name in a group of its own, and now as an async action', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->onQueue('crm'); + $this->action->add('edit_post', [RecordingHandler::class, 'handle'])->async()->delay(600); + + wp_stub_fire('save_post', 1); + wp_stub_fire('edit_post', 2); + + expect(array_column($GLOBALS['as_actions'], 'group'))->toBe(['crm', 'pollora']) + ->and($GLOBALS['as_actions'][0]['timestamp'])->toBeLessThanOrEqual(time()) + ->and($GLOBALS['as_actions'][1]['timestamp'])->toBeGreaterThan(time() + 590); +}); + +it('is not available before Action Scheduler is initialised, so auto falls back to WP-Cron', function (): void { + $GLOBALS['as_initialized'] = false; + + expect(ScheduledDriver::actionScheduler()->available())->toBeFalse() + ->and(ScheduledDriver::wpCron()->available())->toBeTrue(); +}); + +it('keeps its own events on WP-Cron, whatever driver pollora/schedule gives the site', function (): void { + expect(Async::protectHooks(['mine']))->toBe(['mine', 'pollora/async/run', 'pollora/async/recover']); }); diff --git a/tests/Unit/Async/AsyncActionTest.php b/tests/Unit/Async/AsyncActionTest.php index 35c95a8..8eb2c49 100644 --- a/tests/Unit/Async/AsyncActionTest.php +++ b/tests/Unit/Async/AsyncActionTest.php @@ -8,12 +8,12 @@ use Pollora\Hook\Async\AsyncContext; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\Contracts\AsyncDriver; -use Pollora\Hook\Async\Drivers\WpCronDriver; use Pollora\Hook\Async\Exceptions\DriverUnavailable; use Pollora\Hook\Async\Exceptions\UnresolvableHandler; use Pollora\Hook\Async\Exceptions\UnsupportedArgument; use Pollora\Hook\Async\PendingAsync; use Pollora\Hook\Async\QueuedHandler; +use Pollora\Hook\Async\Recovery; use Pollora\Hook\Domain\Contract\CallbackResolverInterface; use Pollora\Hook\Tests\Fixtures\Async\RecordingDriver; use Pollora\Hook\Tests\Fixtures\Async\RecordingHandler; @@ -141,7 +141,7 @@ expect(array_map(fn (array $listener): array => [$listener['hook'], $listener['callback']], $GLOBALS['wp_async_listeners']))->toBe([ [Async::HOOK, [Async::class, 'receive']], - [WpCronDriver::RECOVERY_HOOK, [WpCronDriver::class, 'recover']], + [Recovery::HOOK, [Recovery::class, 'recover']], ]); }); }); diff --git a/tests/Unit/Async/UniqueTest.php b/tests/Unit/Async/UniqueTest.php index 6af3aa4..2498d2d 100644 --- a/tests/Unit/Async/UniqueTest.php +++ b/tests/Unit/Async/UniqueTest.php @@ -6,8 +6,8 @@ use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\Contracts\AsyncDriver; -use Pollora\Hook\Async\Drivers\WpCronDriver; use Pollora\Hook\Async\Exceptions\AsyncException; +use Pollora\Hook\Async\Recovery; use Pollora\Hook\Async\UniqueLock; use Pollora\Hook\Tests\Fixtures\Async\RecordingDriver; use Pollora\Hook\Tests\Fixtures\Async\RecordingHandler; @@ -155,7 +155,7 @@ function uniqueLocks(): array add_option(UniqueLock::OPTION_PREFIX.'expired', time() - 10, '', false); add_option(UniqueLock::OPTION_PREFIX.'active', time() + 600, '', false); - WpCronDriver::recover(); + Recovery::recover(); expect(array_keys(uniqueLocks()))->toBe([UniqueLock::OPTION_PREFIX.'active']); }); diff --git a/tests/Unit/Async/WpCronDriverTest.php b/tests/Unit/Async/WpCronDriverTest.php index 8b8eb53..7018eed 100644 --- a/tests/Unit/Async/WpCronDriverTest.php +++ b/tests/Unit/Async/WpCronDriverTest.php @@ -5,8 +5,10 @@ use Pollora\Hook\Adapter\Out\WordPress\Action; use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; -use Pollora\Hook\Async\Drivers\WpCronDriver; +use Pollora\Hook\Async\Drivers\ScheduledDriver; use Pollora\Hook\Async\Exceptions\AsyncException; +use Pollora\Hook\Async\PayloadStore; +use Pollora\Hook\Async\Recovery; use Pollora\Hook\Tests\Fixtures\Async\RecordingHandler; beforeEach(function (): void { @@ -49,7 +51,7 @@ function executionEvents(): array function storeLostPayload(int $age): string { $id = AsyncPayload::newId(); - add_option(WpCronDriver::OPTION_PREFIX.$id, (new AsyncPayload( + add_option(PayloadStore::OPTION_PREFIX.$id, (new AsyncPayload( id: $id, hook: 'save_post', handler: RecordingHandler::class.'@handle', @@ -76,7 +78,7 @@ function runCronEvents(): void } it('is the default driver', function (): void { - expect(Async::driver())->toBeInstanceOf(WpCronDriver::class); + expect(Async::driver())->toBeInstanceOf(ScheduledDriver::class); }); it('stores the payload in an option that is not autoloaded and schedules an event carrying only its identifier', function (): void { @@ -86,11 +88,11 @@ function runCronEvents(): void expect(executionEvents())->toHaveCount(1); $event = executionEvents()[0]; [$id] = $event['args']; - $stored = $GLOBALS['wp_options'][WpCronDriver::OPTION_PREFIX.$id]; + $stored = $GLOBALS['wp_options'][PayloadStore::OPTION_PREFIX.$id]; expect($event['hook'])->toBe(Async::HOOK) ->and($event['args'])->toHaveCount(1) - ->and(WpCronDriver::handles($id))->toBeTrue() + ->and(PayloadStore::handles($id))->toBeTrue() ->and($event['timestamp'])->toBeGreaterThanOrEqual($before) ->and($stored['autoload'])->toBeFalse() ->and(json_decode($stored['value'], true)['arguments'])->toBe([42]); @@ -152,7 +154,7 @@ function runCronEvents(): void $GLOBALS['wp_fail'] = ['wp_schedule_single_event']; expect(fn () => wp_stub_fire('save_post', 42)) - ->toThrow(AsyncException::class, "the execution of 'save_post' could not be scheduled: A plugin prevented the event from being scheduled."); + ->toThrow(AsyncException::class, "WP-Cron: the execution of 'save_post' could not be queued: WP-Cron: the event 'pollora/async/run' could not be scheduled: A plugin prevented the event from being scheduled."); expect($GLOBALS['wp_options'])->toBe([]); }); @@ -161,15 +163,15 @@ function runCronEvents(): void wp_stub_fire('save_post', 1); wp_stub_fire('save_post', 2); - $recovery = array_values(array_filter($GLOBALS['wp_cron_events'], fn (array $event): bool => $event['hook'] === WpCronDriver::RECOVERY_HOOK)); + $recovery = array_values(array_filter($GLOBALS['wp_cron_events'], fn (array $event): bool => $event['hook'] === Recovery::HOOK)); expect($recovery)->toHaveCount(1) ->and($recovery[0]['recurrence'])->toBe('daily'); }); it('schedules again a payload whose event was lost, and it then runs once', function (): void { - $id = storeLostPayload(WpCronDriver::RECOVERY_GRACE + 60); + $id = storeLostPayload(Recovery::GRACE + 60); - expect(WpCronDriver::recover())->toBe(1) + expect(Recovery::recover())->toBe(1) ->and(executionEvents())->toHaveCount(1) ->and(executionEvents()[0]['args'])->toBe([$id]) ->and($GLOBALS['incidents'][0])->toContain('1 asynchronous execution(s) whose event was lost scheduled again'); @@ -182,8 +184,8 @@ function runCronEvents(): void }); it('runs from its WP-Cron event', function (): void { - storeLostPayload(WpCronDriver::RECOVERY_GRACE + 60); - $GLOBALS['wp_cron_events'][] = ['timestamp' => time(), 'hook' => WpCronDriver::RECOVERY_HOOK, 'args' => []]; + storeLostPayload(Recovery::GRACE + 60); + $GLOBALS['wp_cron_events'][] = ['timestamp' => time(), 'hook' => Recovery::HOOK, 'args' => []]; runCronEvents(); @@ -193,7 +195,7 @@ function runCronEvents(): void it('leaves a recent payload alone, its event may be about to be scheduled', function (): void { storeLostPayload(60); - expect(WpCronDriver::recover())->toBe(0) + expect(Recovery::recover())->toBe(0) ->and(executionEvents())->toBe([]); }); @@ -201,28 +203,28 @@ function runCronEvents(): void (new Action)->add('save_post_event', [RecordingHandler::class, 'handle'])->async()->delay(7200); wp_stub_fire('save_post_event', 1); [$id] = executionEvents()[0]['args']; - $stored = json_decode($GLOBALS['wp_options'][WpCronDriver::OPTION_PREFIX.$id]['value'], true); - $stored['origin']['dispatchedAt'] = gmdate(DATE_ATOM, time() - 2 * WpCronDriver::RECOVERY_GRACE); - $GLOBALS['wp_options'][WpCronDriver::OPTION_PREFIX.$id]['value'] = json_encode($stored); + $stored = json_decode($GLOBALS['wp_options'][PayloadStore::OPTION_PREFIX.$id]['value'], true); + $stored['origin']['dispatchedAt'] = gmdate(DATE_ATOM, time() - 2 * Recovery::GRACE); + $GLOBALS['wp_options'][PayloadStore::OPTION_PREFIX.$id]['value'] = json_encode($stored); - expect(WpCronDriver::recover())->toBe(0) + expect(Recovery::recover())->toBe(0) ->and(executionEvents())->toHaveCount(1); }); it('deletes a stored value that is not a readable payload', function (): void { $id = AsyncPayload::newId(); - add_option(WpCronDriver::OPTION_PREFIX.$id, '{"v":1}', '', false); + add_option(PayloadStore::OPTION_PREFIX.$id, '{"v":1}', '', false); - WpCronDriver::recover(); + Recovery::recover(); expect($GLOBALS['wp_options'])->toBe([]) ->and($GLOBALS['incidents'][0])->toContain("unreadable payload {$id} deleted"); }); it('ignores options that only share the prefix', function (): void { - add_option(WpCronDriver::OPTION_PREFIX.'settings', 'kept', '', false); + add_option(PayloadStore::OPTION_PREFIX.'settings', 'kept', '', false); - expect(WpCronDriver::recover())->toBe(0) - ->and($GLOBALS['wp_options'])->toHaveKey(WpCronDriver::OPTION_PREFIX.'settings'); + expect(Recovery::recover())->toBe(0) + ->and($GLOBALS['wp_options'])->toHaveKey(PayloadStore::OPTION_PREFIX.'settings'); }); }); diff --git a/tests/WordPress/AsyncWpCronTest.php b/tests/WordPress/AsyncWpCronTest.php index 01cbc29..aac12ed 100644 --- a/tests/WordPress/AsyncWpCronTest.php +++ b/tests/WordPress/AsyncWpCronTest.php @@ -4,7 +4,8 @@ use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; -use Pollora\Hook\Async\Drivers\WpCronDriver; +use Pollora\Hook\Async\PayloadStore; +use Pollora\Hook\Async\Recovery; use Pollora\Hook\Async\UniqueLock; /* @@ -19,7 +20,7 @@ $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like(WpCronDriver::OPTION_PREFIX).'%', + $wpdb->esc_like(PayloadStore::OPTION_PREFIX).'%', )); $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", @@ -29,7 +30,7 @@ delete_option(PolloraHookFixture::FAILURES); unset($_POST['acme_source']); wp_unschedule_hook(Async::HOOK); - wp_unschedule_hook(WpCronDriver::RECOVERY_HOOK); + wp_unschedule_hook(Recovery::HOOK); wp_set_current_user(0); wp_cache_flush(); @@ -109,7 +110,7 @@ function fixtureRuns(): array [$id] = $events[0]['args']; $stored = $wpdb->get_row($wpdb->prepare( "SELECT option_value, autoload FROM {$wpdb->options} WHERE option_name = %s", - WpCronDriver::OPTION_PREFIX.$id, + PayloadStore::OPTION_PREFIX.$id, )); expect($events[0]['args'])->toBe([$id]) @@ -129,7 +130,7 @@ function fixtureRuns(): array 'currentUser' => 0, ]]) ->and(scheduledExecutions())->toBe([]) - ->and(get_option(WpCronDriver::OPTION_PREFIX.$id))->toBeFalse(); + ->and(get_option(PayloadStore::OPTION_PREFIX.$id))->toBeFalse(); }); it('waits for the delay before running', function (): void { @@ -170,18 +171,18 @@ function fixtureRuns(): array do_action('pollora_fixture_event', $this->postId, get_post($this->postId)); [$id] = scheduledExecutions()[0]['args']; - expect(wp_next_scheduled(WpCronDriver::RECOVERY_HOOK))->not->toBeFalse(); + expect(wp_next_scheduled(Recovery::HOOK))->not->toBeFalse(); // The event is lost, an hour after the trigger wp_unschedule_hook(Async::HOOK); - $option = WpCronDriver::OPTION_PREFIX.$id; + $option = PayloadStore::OPTION_PREFIX.$id; $payload = json_decode(get_option($option), true); - $payload['origin']['dispatchedAt'] = gmdate(DATE_ATOM, time() - 2 * WpCronDriver::RECOVERY_GRACE); + $payload['origin']['dispatchedAt'] = gmdate(DATE_ATOM, time() - 2 * Recovery::GRACE); update_option($option, json_encode($payload)); // The daily recovery task comes due - wp_unschedule_hook(WpCronDriver::RECOVERY_HOOK); - wp_schedule_single_event(time() - 1, WpCronDriver::RECOVERY_HOOK); + wp_unschedule_hook(Recovery::HOOK); + wp_schedule_single_event(time() - 1, Recovery::HOOK); runWpCron(); @@ -212,7 +213,7 @@ function fixtureRuns(): array do_action('pollora_fixture_closure', 3); [$id] = scheduledExecutions()[0]['args']; - expect(json_decode(get_option(WpCronDriver::OPTION_PREFIX.$id), true)['handler'])->toStartWith('closure:'); + expect(json_decode(get_option(PayloadStore::OPTION_PREFIX.$id), true)['handler'])->toStartWith('closure:'); runWpCron(); @@ -222,7 +223,7 @@ function fixtureRuns(): array it('refuses to run a closure whose signature does not match', function (): void { do_action('pollora_fixture_closure', 3); [$id] = scheduledExecutions()[0]['args']; - $option = WpCronDriver::OPTION_PREFIX.$id; + $option = PayloadStore::OPTION_PREFIX.$id; $payload = json_decode(get_option($option), true); $payload['handler'] = preg_replace('/^closure:[0-9a-f]{64}:/', 'closure:'.str_repeat('0', 64).':', $payload['handler']); update_option($option, json_encode($payload));