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));