diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bcefb6..e82554d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ All notable changes to `pollora/hook` are documented in this file. - `tries()`: a handler that throws is queued again, through the driver that queued it, until it has used its attempts; each failed attempt is reported, the last one is fired as `pollora/async/failed`. `backoff()` sets the seconds before each retry (default 10, 60, then 300; the last value repeats). A handler that no longer exists is not retried. - `asUser()`: run as the user who fired the hook, then remove that user. Without it the handler runs without a current user. - `onQueue()`: queue name, used as the Action Scheduler group and the Laravel queue; WP-Cron ignores it. -- Payloads record the driver that queued them, the queue, `asUser` and the backoff. These fields are optional, so payloads queued by 1.2.0 still run. +- `unique()`: merge identical triggers (same hook, same handler, same arguments) while the first one has not started running. The lock is an option that is not autoloaded, taken atomically with `add_option()`, released when the first attempt starts, and expiring after `unique(for: …)` seconds (one day by default) if never released; the daily maintenance task deletes expired locks. A lock that cannot be stored is a queuing failure, never a silent merge. +- Payloads record the driver that queued them, the queue, `asUser`, the backoff and the unique lock. These fields are optional, so payloads queued by 1.2.0 still run. ## [1.2.0] - 2026-10-07 diff --git a/src/Async/AsyncDispatcher.php b/src/Async/AsyncDispatcher.php index 48adf1d..989bfff 100644 --- a/src/Async/AsyncDispatcher.php +++ b/src/Async/AsyncDispatcher.php @@ -37,15 +37,29 @@ public function dispatch(QueuedHandler $handler, array $arguments): void return; } + $uniqueKey = null; + try { [$driverName, $driver] = $this->driverFor($handler); + $normalizedArguments = $this->normalizer->normalize($arguments); + + $uniqueFor = $handler->options->uniqueFor(); + if ($uniqueFor !== null) { + $key = UniqueLock::key($handler->hook, $handler->descriptor, $normalizedArguments); + + if (! UniqueLock::acquire($key, $uniqueFor)) { + return; + } + + $uniqueKey = $key; + } $payload = new AsyncPayload( id: AsyncPayload::newId(), hook: $handler->hook, handler: $handler->descriptor, priority: $handler->priority, - arguments: $this->normalizer->normalize($arguments), + arguments: $normalizedArguments, origin: self::origin(), captured: $this->normalizer->normalize($this->capture($handler, $arguments), 'captured value'), tries: $handler->options->attempts(), @@ -54,10 +68,15 @@ public function dispatch(QueuedHandler $handler, array $arguments): void queue: $handler->options->queue(), asUser: $handler->options->runsAsUser(), backoff: $handler->options->backoffDelays(), + uniqueKey: $uniqueKey, ); $driver->dispatch($payload, $handler->options->delayInSeconds()); } catch (\Throwable $throwable) { + if ($uniqueKey !== null) { + UniqueLock::release($uniqueKey); + } + if (Async::isDebug()) { throw $throwable; } diff --git a/src/Async/AsyncPayload.php b/src/Async/AsyncPayload.php index 1f3b3dc..0c6800a 100644 --- a/src/Async/AsyncPayload.php +++ b/src/Async/AsyncPayload.php @@ -33,6 +33,7 @@ * @param string|null $queue Queue name: the group with Action Scheduler, ignored by WP-Cron * @param bool $asUser Run as the user who fired the hook * @param list $backoff Seconds before each retry; the last value repeats + * @param string|null $uniqueKey Lock to release when the first attempt starts, see UniqueLock */ public function __construct( public string $id, @@ -49,6 +50,7 @@ public function __construct( public ?string $queue = null, public bool $asUser = false, public array $backoff = [], + public ?string $uniqueKey = null, ) {} /** @@ -107,6 +109,7 @@ public static function fromJson(string $json): self queue: self::optionalField($data, 'queue', 'is_string', null), asUser: self::optionalField($data, 'asUser', 'is_bool', false), backoff: self::backoffField($data), + uniqueKey: self::optionalField($data, 'uniqueKey', 'is_string', null), ); } @@ -133,6 +136,7 @@ public function toJson(): string 'queue' => $this->queue, 'asUser' => $this->asUser, 'backoff' => $this->backoff, + 'uniqueKey' => $this->uniqueKey, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); } diff --git a/src/Async/AsyncRunner.php b/src/Async/AsyncRunner.php index 72f79ba..e2ff9ce 100644 --- a/src/Async/AsyncRunner.php +++ b/src/Async/AsyncRunner.php @@ -29,6 +29,11 @@ public function __construct( public function run(AsyncPayload $payload): void { + // Identical triggers queue again from the moment the first attempt starts + if ($payload->uniqueKey !== null && $payload->attempt === 1) { + UniqueLock::release($payload->uniqueKey); + } + try { $callable = $this->descriptor->resolve($payload->handler); $arguments = $this->normalizer->denormalize($payload->arguments, $payload->keepMissing); diff --git a/src/Async/Drivers/WpCronDriver.php b/src/Async/Drivers/WpCronDriver.php index f005d6a..b56aedc 100644 --- a/src/Async/Drivers/WpCronDriver.php +++ b/src/Async/Drivers/WpCronDriver.php @@ -8,6 +8,7 @@ use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\Contracts\AsyncDriver; use Pollora\Hook\Async\Exceptions\AsyncException; +use Pollora\Hook\Async\UniqueLock; /** * Queues through WP-Cron. @@ -19,7 +20,8 @@ * an execution as a duplicate of another scheduled within ten minutes. * * A payload whose event was lost (the 'cron' option reset, an event deleted by - * a plugin) is found by a daily recovery task and scheduled again. Only the + * a plugin) is found by a daily recovery task and scheduled again; the same + * task deletes expired unique locks. Only the * process that deletes a payload runs it, so scheduling it again never runs it * twice. */ @@ -137,6 +139,8 @@ public static function recover(): int } } + UniqueLock::prune(); + if ($recovered > 0) { Async::report(sprintf('WP-Cron: %d asynchronous execution(s) whose event was lost scheduled again.', $recovered)); } diff --git a/src/Async/PendingAsync.php b/src/Async/PendingAsync.php index 46df956..e530a75 100644 --- a/src/Async/PendingAsync.php +++ b/src/Async/PendingAsync.php @@ -30,6 +30,8 @@ final class PendingAsync private ?string $queue = null; + private ?int $uniqueFor = null; + /** @var (\Closure(mixed...): array)|null */ private ?\Closure $capture = null; @@ -160,6 +162,25 @@ public function asUser(bool $asUser = true): self return $this; } + /** + * Merge identical triggers (same hook, same handler, same arguments) while + * the first one has not started running. + * + * @param int $for Seconds after which the lock expires if it was never released + * + * @throws \InvalidArgumentException When $for is lower than 1 + */ + public function unique(int $for = 86400): self + { + if ($for < 1) { + throw new \InvalidArgumentException(sprintf('unique() expects a lock of at least 1 second, %d given.', $for)); + } + + $this->uniqueFor = $for; + + return $this; + } + /** * Queue name: the group with Action Scheduler, the queue with a Laravel queue. WP-Cron ignores it. */ @@ -276,6 +297,16 @@ public function runsAsUser(): bool return $this->asUser; } + /** + * @internal + * + * @return int|null Lock duration, null when the registration is not unique + */ + public function uniqueFor(): ?int + { + return $this->uniqueFor; + } + /** * @internal */ diff --git a/src/Async/UniqueLock.php b/src/Async/UniqueLock.php new file mode 100644 index 0000000..70fbbfd --- /dev/null +++ b/src/Async/UniqueLock.php @@ -0,0 +1,102 @@ + $arguments Normalized arguments + */ + public static function key(string $hook, string $handler, array $arguments): string + { + return sha1((string) json_encode([$hook, $handler, $arguments])); + } + + /** + * Take the lock, unless an identical trigger holds it. + * + * @param int $seconds How long the lock lasts when it is never released + * @return bool False when an identical trigger already holds it + * + * @throws AsyncException When the lock cannot be stored + */ + public static function acquire(string $key, int $seconds): bool + { + $option = self::OPTION_PREFIX.$key; + $expiresAt = time() + $seconds; + + WpCronDriver::scheduleRecovery(); + + if (add_option($option, $expiresAt, '', false)) { + return true; + } + + wp_cache_delete($option, 'options'); + $current = get_option($option, null); + + if ($current === null) { + throw new AsyncException('The unique lock of an asynchronous action could not be stored.'); + } + + if ((int) $current >= time()) { + return false; + } + + // Expired: take it over. Of two processes doing so, add_option() lets one win. + delete_option($option); + + return add_option($option, $expiresAt, '', false); + } + + public static function release(string $key): void + { + delete_option(self::OPTION_PREFIX.$key); + } + + /** + * Delete expired locks. + * + * @return int Number of locks deleted + */ + public static function prune(int $limit = 500): int + { + global $wpdb; + + if (! is_object($wpdb)) { + return 0; + } + + $names = $wpdb->get_col($wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d LIMIT %d", + $wpdb->esc_like(self::OPTION_PREFIX).'%', + time(), + $limit, + )); + + $deleted = 0; + foreach ((array) $names as $name) { + $deleted += delete_option((string) $name) ? 1 : 0; + } + + return $deleted; + } +} diff --git a/tests/Stubs/wordpress.php b/tests/Stubs/wordpress.php index f2edfc5..ffa2b9b 100644 --- a/tests/Stubs/wordpress.php +++ b/tests/Stubs/wordpress.php @@ -346,5 +346,32 @@ public function get_results(array $prepared, string $output): array return array_slice($rows, 0, $prepared['args'][1]); } + + /** + * Answers the expired-lock query: prefix, time, limit. + */ + public function get_col(array $prepared): array + { + [$like, $now, $limit] = $prepared['args']; + $prefix = stripslashes(rtrim($like, '%')); + $names = []; + + foreach ($GLOBALS['wp_options'] ?? [] as $name => $option) { + if (str_starts_with($name, $prefix) && (int) $option['value'] < $now) { + $names[] = $name; + } + } + + return array_slice($names, 0, $limit); + } }; } + +if (! function_exists('wp_cache_delete')) { + function wp_cache_delete(int|string $key, string $group = ''): bool + { + $GLOBALS['wp_cache_deleted'][] = [$key, $group]; + + return true; + } +} diff --git a/tests/Unit/Async/UniqueTest.php b/tests/Unit/Async/UniqueTest.php new file mode 100644 index 0000000..6af3aa4 --- /dev/null +++ b/tests/Unit/Async/UniqueTest.php @@ -0,0 +1,175 @@ + 5, 'blog' => 1, 'locale' => 'fr_FR', 'multisite' => false]; + $GLOBALS['wpdb'] = wp_stub_wpdb(); + $GLOBALS['incidents'] = []; + RecordingHandler::$calls = []; + RecordingHandler::$failuresLeft = 0; + + Async::flush(); + Async::setDebug(true); + Async::reportUsing(function (string $message): void { + $GLOBALS['incidents'][] = $message; + }); + $this->driver = new RecordingDriver; + Async::extend('recording', fn (): AsyncDriver => $this->driver); + Async::setDefaultDriver('recording'); + + $this->action = new Action; +}); + +afterEach(function (): void { + Async::flush(); +}); + +function uniqueLocks(): array +{ + return array_filter($GLOBALS['wp_options'], fn (string $name): bool => str_starts_with($name, UniqueLock::OPTION_PREFIX), ARRAY_FILTER_USE_KEY); +} + +it('merges identical triggers while the first one has not run', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->unique(); + + wp_stub_fire('save_post', 42); + wp_stub_fire('save_post', 42); + wp_stub_fire('save_post', 43); + + expect(array_map(fn (array $entry): array => $entry['payload']->arguments, $this->driver->queued))->toBe([[42], [43]]); +}); + +it('stores the lock in an option that is not autoloaded, expiring after the given time', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->unique(for: 600); + + wp_stub_fire('save_post', 42); + + $lock = array_values(uniqueLocks())[0]; + expect(uniqueLocks())->toHaveCount(1) + ->and($lock['autoload'])->toBeFalse() + ->and($lock['value'])->toBeGreaterThanOrEqual(time() + 599) + ->and($lock['value'])->toBeLessThanOrEqual(time() + 600) + ->and(UniqueLock::OPTION_PREFIX.$this->driver->queued[0]['payload']->uniqueKey)->toBe(array_key_first(uniqueLocks())); +}); + +it('queues again once the first trigger starts running', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->unique(); + wp_stub_fire('save_post', 42); + + $this->driver->run(0); + wp_stub_fire('save_post', 42); + + expect($this->driver->queued)->toHaveCount(2) + ->and(RecordingHandler::$calls)->toHaveCount(1); +}); + +it('takes over an expired lock', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->unique(for: 60); + wp_stub_fire('save_post', 42); + $name = array_key_first(uniqueLocks()); + $GLOBALS['wp_options'][$name]['value'] = time() - 1; + + wp_stub_fire('save_post', 42); + + expect($this->driver->queued)->toHaveCount(2); +}); + +it('tells triggers apart by hook and by handler', function (): void { + $this->action->add(['save_post', 'edit_post'], [RecordingHandler::class, 'handle'])->async()->unique(); + $this->action->add('save_post', [RecordingHandler::class, 'post'])->async()->unique(); + + wp_stub_fire('save_post', 42); + wp_stub_fire('edit_post', 42); + wp_stub_fire('save_post', 42); + + expect(array_map(fn (array $entry): string => $entry['payload']->hook.' '.$entry['payload']->handler, $this->driver->queued))->toBe([ + 'save_post '.RecordingHandler::class.'@handle', + 'save_post '.RecordingHandler::class.'@post', + 'edit_post '.RecordingHandler::class.'@handle', + ]); +}); + +it('lets a retry run without releasing the lock of a newer trigger', function (): void { + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->unique()->tries(2); + wp_stub_fire('save_post', 42); + $this->driver->run(0); + wp_stub_fire('save_post', 42); + + $this->driver->run(1); + wp_stub_fire('save_post', 42); + + expect(array_map(fn (array $entry): int => $entry['payload']->attempt, $this->driver->queued))->toBe([1, 2, 1]) + ->and(uniqueLocks())->toHaveCount(1); +}); + +it('releases the lock when the trigger cannot be queued', function (): void { + Async::setDebug(false); + Async::setDefaultDriver(null); + $GLOBALS['wp_fail'] = ['wp_schedule_single_event']; + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->unique(); + + wp_stub_fire('save_post', 42); + + expect(uniqueLocks())->toBe([]) + ->and(RecordingHandler::$calls)->toHaveCount(1); +}); + +it('runs every trigger through the sync driver, the lock being released at once', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->via('sync')->unique(); + + wp_stub_fire('save_post', 42); + wp_stub_fire('save_post', 42); + + expect(RecordingHandler::$calls)->toHaveCount(2); +}); + +it('fails, rather than merging, when the lock cannot be stored', function (): void { + $GLOBALS['wp_fail'] = ['add_option']; + + expect(fn () => UniqueLock::acquire('key', 60))->toThrow(AsyncException::class, 'could not be stored'); +}); + +it('rejects a lock shorter than a second', function (): void { + expect(fn () => $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->unique(for: 0)) + ->toThrow(InvalidArgumentException::class, 'at least 1 second'); +}); + +it('deletes expired locks during the daily maintenance', function (): void { + add_option(UniqueLock::OPTION_PREFIX.'expired', time() - 10, '', false); + add_option(UniqueLock::OPTION_PREFIX.'active', time() + 600, '', false); + + WpCronDriver::recover(); + + expect(array_keys(uniqueLocks()))->toBe([UniqueLock::OPTION_PREFIX.'active']); +}); + +it('reads a payload without a unique key', function (): void { + $data = json_decode((new AsyncPayload( + id: AsyncPayload::newId(), + hook: 'save_post', + handler: 'pollora_test', + priority: 10, + arguments: [], + origin: ['userId' => 0, 'blogId' => 1, 'locale' => 'fr_FR', 'dispatchedAt' => '2026-10-07T10:00:00+00:00'], + ))->toJson(), true); + unset($data['uniqueKey']); + + expect(AsyncPayload::fromJson(json_encode($data))->uniqueKey)->toBeNull(); +});