diff --git a/CHANGELOG.md b/CHANGELOG.md index c43f6bc..18d7bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to `pollora/hook` are documented in this file. ## [Unreleased] +## [1.3.0] - 2026-10-07 + ### Changed - **The default driver is now `auto`**: Action Scheduler when a plugin bundling it is active and initialised, WP-Cron otherwise, resolved on each dispatch. A site with WooCommerce therefore queues through Action Scheduler after upgrading; payloads already queued through WP-Cron still run. Set `POLLORA_ASYNC_DRIVER` to `wp-cron` to keep the previous behaviour. @@ -50,5 +52,6 @@ All notable changes to `pollora/hook` are documented in this file. - A `[ClassName::class, 'method']` callback naming an instance method is now instantiated at registration, through the callback resolver when one is set, as a class name is. WordPress used to receive it as a static call and threw a `TypeError` when the hook fired. Static methods and classes not loaded yet are unchanged. - `remove()` and `exists()` accept that same `[ClassName::class, 'method']` form and find the instance it was registered as. `exists()` now accepts a non-callable array or string as its callback. -[Unreleased]: https://github.com/Pollora/hook/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/Pollora/hook/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/Pollora/hook/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/Pollora/hook/compare/v1.1.1...v1.2.0 diff --git a/README.md b/README.md index cfd4a5d..2961205 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ $action->add('wp_loaded', MyInitializer::class); ## Asynchronous actions -Add `async()` after `add()` and the handler no longer runs inside the request that fires the hook: it is queued, then run later by WP-Cron. +Add `async()` after `add()` and the handler no longer runs inside the request that fires the hook: it is queued, then run later by Action Scheduler or WP-Cron. ```php use Pollora\Hook\Action; @@ -100,10 +100,10 @@ Action::add('save_post_event', [CrmSync::class, 'push'])->async(); An asynchronous handler changes guarantees. Four rules to keep in mind: -- **At least once, not exactly once.** A double trigger can run the handler twice: it must be safe to replay. +- **At least once, not exactly once.** A retry or a double trigger can run the handler twice: it must be safe to replay. - **No guaranteed order.** Two asynchronous actions on the same hook may run in any order. - **A variable delay.** With WP-Cron and no system cron, the handler waits for the next visit to the site. -- **A class or a named function, not a closure.** The handler travels by name and runs in its current version; closures are rejected. +- **Prefer a class to a closure.** A class or a named function travels by name and runs in its current version; a closure runs as it was when queued, and needs `laravel/serializable-closure`. The handler is written as a synchronous one. It receives the hook arguments and, if it declares a parameter of that type, an `AsyncContext`: @@ -114,26 +114,67 @@ final class CrmSync { public function push(int $postId, WP_Post $post, AsyncContext $context): void { - $context->userId; // user who fired the hook - $context->dispatchedAt; // trigger date + $context->userId; // user who fired the hook + $context->dispatchedAt; // trigger date + $context->attempt; // attempt number + $context->get('status'); // value recorded by capture() } } ``` **Arguments.** Scalars and arrays keep their value. `WP_Post`, `WP_Term`, `WP_User` and `WP_Comment` travel as a reference and are reloaded, in their state at execution time; if one was deleted in the meantime, the handler is dropped (`keepMissing()` runs it with `null` instead). Enums and dates are rebuilt, a `JsonSerializable` object becomes its array. Any other object is refused. +**Capturing values at trigger time.** At execution there is no request any more, and the content may have changed. `capture()` runs in the original request, with the hook arguments, and its values are read back through `$context->get()`. A handler class's public `capture()` method is used when `capture()` is not called. Capture an ID rather than personal data or a secret: values wait in the database. + +```php +Action::add('save_post_event', [CrmSync::class, 'push']) + ->async() + ->when(fn (int $postId) => ! wp_is_post_revision($postId) && ! wp_is_post_autosave($postId)) + ->capture(fn (int $postId, WP_Post $post) => ['status' => $post->post_status]) + ->unique() + ->tries(3); +``` + **Options**, chained after `async()`: | Method | Effect | |---|---| | `delay(60)` | Minimum delay, in seconds or as a `DateInterval` | +| `when(fn (...) => bool)` | Queue only when the condition, given the hook arguments, returns `true` | +| `capture(fn (...) => [...])` | Record values at trigger time | +| `unique()` | Merge identical triggers (same hook, handler and arguments) until the first one runs; `unique(for: 3600)` sets how long the lock lasts if never released (a day by default) | +| `tries(3)` | Attempts when the handler throws; `backoff([10, 60, 300])` sets the seconds before each retry (the default) | +| `asUser()` | Run as the user who fired the hook, with that user's capabilities. Without it, there is no current user | | `via('sync')` | Driver for this registration | +| `onQueue('integrations')` | Queue name: the Action Scheduler group | | `keepMissing()` | Run with `null` in place of a deleted object | | `except('save_post_page')` | Keep some hooks of the same `add()` synchronous; also `async(except: …)` | -**Drivers.** `wp-cron` is the default: the payload is stored in an option that is not autoloaded and the event carries only its identifier. A daily task schedules again the payloads whose event was lost. `sync` runs the handler right away, which suits development and end-to-end tests. Choose the default with the `POLLORA_ASYNC_DRIVER` constant in `wp-config.php`, or the `pollora/hook/async_driver` filter. More drivers register through `Async::extend()`. +**Drivers.** The default, `auto`, picks Action Scheduler when a plugin bundling it (WooCommerce, for example) is active and initialised, WP-Cron otherwise. + +- `action-scheduler`: the payload travels in the action, the queue name becomes its group (`pollora` by default), and history shows in Tools › Scheduled Actions. +- `wp-cron`: the payload is stored in an option that is not autoloaded and the event carries only its identifier. A daily task schedules again the payloads whose event was lost. +- `sync`: runs the handler right away, which suits development and end-to-end tests. + +Choose the default with the `POLLORA_ASYNC_DRIVER` constant in `wp-config.php`, or the `pollora/hook/async_driver` filter. More drivers register through `Async::extend()`. + +**Closures.** With `laravel/serializable-closure` installed, a closure can be queued. It is serialized and signed with a key derived from the WordPress salts, and the signature is checked before anything is unserialized. Declare it `static` and let it use IDs rather than objects. -**Failures.** At execution, the original site and locale are restored, and a handler that fires its own hook does not queue itself again. A failing handler is reported and announced through the `pollora/async/failed` action. When an action cannot be queued, `WP_DEBUG` throws; otherwise the incident goes to the PHP error log (or `Async::reportUsing()`) and the handler runs in place, so the work always happens. +**Failures.** At execution, the original site and locale are restored, and a handler that fires its own hook does not queue itself again. A handler that throws is retried while it has attempts left; the last failure is reported and announced through the `pollora/async/failed` action. When an action cannot be queued, `WP_DEBUG` throws; otherwise the incident goes to the PHP error log (or `Async::reportUsing()`) and the handler runs in place, so the work always happens. + +**Testing.** `Async::fake()` records queued handlers instead of queuing them: + +```php +use Pollora\Hook\Async\Async; +use Pollora\Hook\Async\AsyncPayload; + +Async::fake(); + +wp_update_post(['ID' => $postId, 'post_title' => 'New title']); + +Async::assertDispatched(CrmSync::class, fn (AsyncPayload $payload) => $payload->captured['status'] === 'publish'); +Async::assertDispatchedTimes(CrmSync::class, 1); +``` ## Documentation diff --git a/tests/WordPress/AsyncWpCronTest.php b/tests/WordPress/AsyncWpCronTest.php index e3134af..01cbc29 100644 --- a/tests/WordPress/AsyncWpCronTest.php +++ b/tests/WordPress/AsyncWpCronTest.php @@ -5,6 +5,7 @@ use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\Drivers\WpCronDriver; +use Pollora\Hook\Async\UniqueLock; /* * Asynchronous actions in a real WordPress, without the framework. Queued @@ -20,7 +21,13 @@ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like(WpCronDriver::OPTION_PREFIX).'%', )); + $wpdb->query($wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like(UniqueLock::OPTION_PREFIX).'%', + )); delete_option(PolloraHookFixture::RUNS); + delete_option(PolloraHookFixture::FAILURES); + unset($_POST['acme_source']); wp_unschedule_hook(Async::HOOK); wp_unschedule_hook(WpCronDriver::RECOVERY_HOOK); wp_set_current_user(0); @@ -119,6 +126,7 @@ function fixtureRuns(): array 'userId' => 1, 'attempt' => 1, 'cron' => true, + 'currentUser' => 0, ]]) ->and(scheduledExecutions())->toBe([]) ->and(get_option(WpCronDriver::OPTION_PREFIX.$id))->toBeFalse(); @@ -238,3 +246,67 @@ function fixtureRuns(): array Async::flush(); } }); + +it('captures values at trigger time, request data included, beside the reloaded post', function (): void { + $_POST['acme_source'] = 'API'; + + do_action('pollora_fixture_captured', $this->postId, get_post($this->postId)); + unset($_POST['acme_source']); + wp_update_post(['ID' => $this->postId, 'post_status' => 'draft']); + + runWpCron(); + + expect(fixtureRuns())->toBe([['key' => 'captured', 'statusThen' => 'publish', 'statusNow' => 'draft', 'source' => 'api']]); +}); + +it('queues only when the condition holds', function (): void { + $revisionId = wp_save_post_revision($this->postId) ?: _wp_put_post_revision(get_post($this->postId)); + + do_action('pollora_fixture_when', $revisionId); + expect(scheduledExecutions())->toBe([]); + + do_action('pollora_fixture_when', $this->postId); + expect(scheduledExecutions())->toHaveCount(1); +}); + +it('merges identical triggers until the first one runs', function (): void { + global $wpdb; + + do_action('pollora_fixture_unique', 7); + do_action('pollora_fixture_unique', 7); + + $locks = $wpdb->get_results($wpdb->prepare( + "SELECT autoload FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like(UniqueLock::OPTION_PREFIX).'%', + )); + expect(scheduledExecutions())->toHaveCount(1) + ->and($locks)->toHaveCount(1) + ->and($locks[0]->autoload)->toBeIn(['off', 'no']); + + runWpCron(); + do_action('pollora_fixture_unique', 7); + + expect(fixtureRuns())->toBe([['key' => 'counted', 'id' => 7]]) + ->and(scheduledExecutions())->toHaveCount(1); +}); + +it('runs as the user who fired the hook with asUser(), with that user\'s capabilities', function (): void { + wp_set_current_user(1); + do_action('pollora_fixture_user', 1); + wp_set_current_user(0); + + runWpCron(); + + expect(fixtureRuns())->toBe([['key' => 'user', 'currentUser' => 1, 'canEditPosts' => true, 'triggeredBy' => 1]]); +}); + +it('retries a failing handler through WP-Cron', function (): void { + update_option(PolloraHookFixture::FAILURES, 1, false); + + do_action('pollora_fixture_retried', 1); + runWpCron(); + runWpCron(); + + expect(array_column(fixtureRuns(), 'attempt'))->toBe([1, 2]) + ->and(scheduledExecutions())->toBe([]); +}); diff --git a/tests/WordPress/Fixtures/async-actions.php b/tests/WordPress/Fixtures/async-actions.php index 96b9407..6ac2fc3 100644 --- a/tests/WordPress/Fixtures/async-actions.php +++ b/tests/WordPress/Fixtures/async-actions.php @@ -41,9 +41,33 @@ public function event(int $postId, WP_Post $post, AsyncContext $context): void 'userId' => $context->userId, 'attempt' => $context->attempt, 'cron' => defined('DOING_CRON') && DOING_CRON, + 'currentUser' => get_current_user_id(), ]); } + public function captured(int $postId, WP_Post $post, AsyncContext $context): void + { + self::record('captured', [ + 'statusThen' => $context->get('statusThen'), + 'statusNow' => $post->post_status, + 'source' => $context->get('source'), + ]); + } + + public function user(int $id, AsyncContext $context): void + { + self::record('user', [ + 'currentUser' => get_current_user_id(), + 'canEditPosts' => current_user_can('edit_posts'), + 'triggeredBy' => $context->userId, + ]); + } + + public function counted(int $id): void + { + self::record('counted', ['id' => $id]); + } + public function large(int $id, AsyncContext $context): void { self::record('large', ['id' => $id, 'length' => strlen((string) $context->get('report'))]); @@ -84,6 +108,18 @@ public function loop(int $id): void ->capture(fn (int $id): array => ['report' => str_repeat('x', 9000)]); Action::add('pollora_fixture_flaky', [PolloraHookFixture::class, 'flaky'])->async()->tries(2)->backoff(0); +// Phase 2 options +Action::add('pollora_fixture_captured', [PolloraHookFixture::class, 'captured'])->async() + ->capture(fn (int $postId, WP_Post $post): array => [ + 'statusThen' => $post->post_status, + 'source' => isset($_POST['acme_source']) ? sanitize_key(wp_unslash($_POST['acme_source'])) : 'admin', + ]); +Action::add('pollora_fixture_when', [PolloraHookFixture::class, 'counted'])->async() + ->when(fn (int $postId): bool => ! wp_is_post_revision($postId)); +Action::add('pollora_fixture_unique', [PolloraHookFixture::class, 'counted'])->async()->unique(); +Action::add('pollora_fixture_user', [PolloraHookFixture::class, 'user'])->async()->asUser(); +Action::add('pollora_fixture_retried', [PolloraHookFixture::class, 'flaky'])->async()->tries(2)->backoff(0); + // A closure, signed with a key derived from the WordPress salts $closureLabel = 'closure'; Action::add('pollora_fixture_closure', static function (int $id, AsyncContext $context) use ($closureLabel): void {