Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ jobs:
wp config create --path=.wordpress --dbname=wordpress --dbuser=root --dbpass=root --dbhost=127.0.0.1:3306
wp core install --path=.wordpress --url=http://localhost --title=Hook \
--admin_user=admin --admin_password=admin --admin_email=admin@example.com --skip-email
wp plugin install action-scheduler --activate --path=.wordpress

- name: Run tests against WordPress
run: composer test:wordpress
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `pollora/hook` are documented in this file.

## [Unreleased]

### 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.
- The WP-Cron recovery task also leaves alone a stored payload whose Action Scheduler action is still pending.
- The type of `capture()` and `when()` callbacks is documented as `callable`, so closures with typed parameters pass static analysis.

### Added

- `capture()` on an asynchronous registration: a callback run at trigger time, in the original request, with the hook arguments; the array it returns is read back at execution through `AsyncContext::get()`. Captured values travel like arguments. A handler class's public `capture()` method is used when `capture()` is not called.
Expand All @@ -12,6 +18,8 @@ All notable changes to `pollora/hook` are documented in this file.
- `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.
- `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.
- `action-scheduler` driver, offered when a plugin bundling Action Scheduler is active and its data store is initialised (never a dependency). The payload travels whole as the action argument, the queue name becomes the group (`pollora` by default), and history is visible in Tools › Scheduled Actions. A payload too long for Action Scheduler's arguments (8,000 characters as JSON) is kept in an option that is not autoloaded and the action carries only its identifier.
- `Async::setAutoDrivers()`: the drivers `auto` tries, in order.
- 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
Expand Down
53 changes: 45 additions & 8 deletions src/Async/Async.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Async\Contracts\ObjectReference;
use Pollora\Hook\Async\Drivers\ActionSchedulerDriver;
use Pollora\Hook\Async\Drivers\SyncDriver;
use Pollora\Hook\Async\Drivers\WpCronDriver;
use Pollora\Hook\Async\Exceptions\DriverUnavailable;
Expand All @@ -24,7 +25,10 @@ final class Async
*/
public const string HOOK = 'pollora/async/run';

public const string DEFAULT_DRIVER = 'wp-cron';
/**
* Default driver: the first available among the auto drivers.
*/
public const string DEFAULT_DRIVER = 'auto';

/**
* Constant that sets the default driver, in wp-config.php.
Expand All @@ -44,6 +48,9 @@ final class Async

private static ?string $defaultDriver = null;

/** @var list<string> Drivers 'auto' tries, in order */
private static array $autoDrivers = ['action-scheduler', 'wp-cron'];

private static ?CallbackResolverInterface $resolver = null;

private static ?ArgumentNormalizer $normalizer = null;
Expand Down Expand Up @@ -96,10 +103,12 @@ public static function driver(?string $name = null): AsyncDriver
/**
* Name of the default driver, from the first of: setDefaultDriver(), the
* POLLORA_ASYNC_DRIVER constant, the 'pollora/hook/async_driver' filter,
* 'wp-cron'.
* 'auto'.
*
* 'auto' picks the best mechanism available; until the Action Scheduler
* driver exists, that is WP-Cron.
* 'auto' resolves to the first available of the auto drivers: Action
* Scheduler when a plugin bundling it is active and initialised, WP-Cron
* otherwise. It is resolved on each dispatch, so a hook fired before Action
* Scheduler is initialised still queues, through WP-Cron.
*/
public static function defaultDriver(): string
{
Expand All @@ -116,7 +125,17 @@ public static function defaultDriver(): string

$driver ??= self::DEFAULT_DRIVER;

return $driver === 'auto' ? self::DEFAULT_DRIVER : $driver;
return $driver === 'auto' ? self::resolveAuto() : $driver;
}

/**
* Set the drivers 'auto' tries, in order. The framework puts its Laravel queue first.
*
* @param array<int, string> $drivers The last one should always be available: 'wp-cron'
*/
public static function setAutoDrivers(array $drivers): void
{
self::$autoDrivers = array_values($drivers);
}

/**
Expand Down Expand Up @@ -165,12 +184,12 @@ public static function listen(): void
/**
* Run a queued handler, from the message a driver hands back.
*
* @param string $message The payload as JSON, or the identifier of a payload the wp-cron driver stored
* @param string $message The payload as JSON, or the identifier of a payload kept by PayloadStore
*/
public static function receive(string $message): void
{
if (WpCronDriver::handles($message)) {
$message = WpCronDriver::claim($message);
if (PayloadStore::handles($message)) {
$message = PayloadStore::claim($message);

if ($message === null) {
return;
Expand Down Expand Up @@ -263,6 +282,7 @@ public static function flush(): void
self::$factories = [];
self::$drivers = [];
self::$defaultDriver = null;
self::$autoDrivers = ['action-scheduler', 'wp-cron'];
self::$resolver = null;
self::$normalizer = null;
self::$dispatcher = null;
Expand All @@ -271,6 +291,22 @@ public static function flush(): void
self::$reporter = null;
}

/**
* The first available auto driver, WP-Cron when none is.
*/
private static function resolveAuto(): string
{
$factories = self::factories();

foreach (self::$autoDrivers as $name) {
if (isset($factories[$name]) && (self::$drivers[$name] ??= $factories[$name]())->available()) {
return $name;
}
}

return 'wp-cron';
}

private static function normalizer(): ArgumentNormalizer
{
return self::$normalizer ??= new ArgumentNormalizer;
Expand All @@ -282,6 +318,7 @@ 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,
'sync' => static fn (): AsyncDriver => new SyncDriver,
];
Expand Down
80 changes: 80 additions & 0 deletions src/Async/Drivers/ActionSchedulerDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Async\Drivers;

use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Async\Exceptions\AsyncException;
use Pollora\Hook\Async\PayloadStore;

/**
* Queues through Action Scheduler, when a plugin bundling it is active.
*
* Action Scheduler is never required: the driver is available once its API
* exists and its data store is initialised. The payload travels whole as the
* action argument, and the queue name becomes the group. A payload too long
* for Action Scheduler's arguments (8,000 characters as JSON) is kept by
* PayloadStore and the action carries only its identifier.
*/
final class ActionSchedulerDriver implements AsyncDriver
{
public const string DEFAULT_GROUP = 'pollora';

/**
* Longest JSON-encoded argument list carried whole, under Action Scheduler's 8,000.
*/
public const int MAX_ARGUMENTS_LENGTH = 7900;

public function available(): bool
{
if (! function_exists('as_enqueue_async_action') || ! function_exists('as_schedule_single_action')) {
return false;
}

// Before 'action_scheduler_init', its functions exist but cannot store anything
if (class_exists('ActionScheduler', false)) {
return \ActionScheduler::is_initialized();
}

return function_exists('did_action') && did_action('action_scheduler_init') > 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();
}
}
}
35 changes: 20 additions & 15 deletions src/Async/Drivers/WpCronDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\PayloadStore;
use Pollora\Hook\Async\UniqueLock;

/**
Expand All @@ -27,7 +28,7 @@
*/
final class WpCronDriver implements AsyncDriver
{
public const string OPTION_PREFIX = 'pollora_async_';
public const string OPTION_PREFIX = PayloadStore::OPTION_PREFIX;

/**
* Daily task that schedules again the payloads whose event was lost.
Expand All @@ -44,8 +45,6 @@ final class WpCronDriver implements AsyncDriver
*/
public const int RECOVERY_BATCH = 500;

private const string ID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/';

public function available(): bool
{
return function_exists('wp_schedule_single_event') && function_exists('add_option');
Expand All @@ -56,16 +55,16 @@ public function available(): bool
*/
public function dispatch(AsyncPayload $payload, int $delay = 0): void
{
$option = self::OPTION_PREFIX.$payload->id;

if (! add_option($option, $payload->toJson(), '', false)) {
throw new AsyncException(sprintf("WP-Cron: the payload of '%s' could not be stored.", $payload->hook));
try {
PayloadStore::store($payload);
} catch (AsyncException $asyncException) {
throw new AsyncException('WP-Cron: '.lcfirst($asyncException->getMessage()), 0, $asyncException);
}

$scheduled = wp_schedule_single_event(time() + max(0, $delay), Async::HOOK, [$payload->id], true);

if ($scheduled !== true) {
delete_option($option);
PayloadStore::forget($payload->id);

$reason = is_object($scheduled) && method_exists($scheduled, 'get_error_message') ? ': '.$scheduled->get_error_message() : '.';

Expand Down Expand Up @@ -116,7 +115,7 @@ public static function recover(): int
foreach ((array) $rows as $row) {
$id = substr((string) $row['option_name'], strlen(self::OPTION_PREFIX));

if (! self::handles($id) || wp_next_scheduled(Async::HOOK, [$id]) !== false) {
if (! PayloadStore::handles($id) || self::isPending($id)) {
continue;
}

Expand Down Expand Up @@ -153,7 +152,7 @@ public static function recover(): int
*/
public static function handles(string $message): bool
{
return preg_match(self::ID_PATTERN, $message) === 1;
return PayloadStore::handles($message);
}

/**
Expand All @@ -166,13 +165,19 @@ public static function handles(string $message): bool
*/
public static function claim(string $id): ?string
{
$option = self::OPTION_PREFIX.$id;
$payload = get_option($option, null);
return PayloadStore::claim($id);
}

if (! is_string($payload) || ! delete_option($option)) {
return null;
/**
* 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 $payload;
return function_exists('as_has_scheduled_action') && as_has_scheduled_action(Async::HOOK, [$id]);
}
}
65 changes: 65 additions & 0 deletions src/Async/PayloadStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Async;

use Pollora\Hook\Async\Exceptions\AsyncException;

/**
* Keeps payloads in options that are not autoloaded, for drivers that should
* only carry an identifier: WP-Cron always (its events live in the autoloaded
* 'cron' option), Action Scheduler for payloads too long for its arguments.
*
* Async::receive() takes an identifier back out with claim(): only the process
* that deletes the option gets the payload, so it runs once.
*/
final class PayloadStore
{
public const string OPTION_PREFIX = 'pollora_async_';

private const string ID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/';

/**
* @throws AsyncException When the payload cannot be stored
*/
public static function store(AsyncPayload $payload): void
{
if (! add_option(self::OPTION_PREFIX.$payload->id, $payload->toJson(), '', false)) {
throw new AsyncException(sprintf("The payload of '%s' could not be stored.", $payload->hook));
}
}

/**
* Delete a stored payload that will not be queued after all.
*/
public static function forget(string $id): void
{
delete_option(self::OPTION_PREFIX.$id);
}

/**
* Whether a driver message is the identifier of a stored payload.
*/
public static function handles(string $message): bool
{
return preg_match(self::ID_PATTERN, $message) === 1;
}

/**
* Take a stored payload out of the database, to run 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
{
$option = self::OPTION_PREFIX.$id;
$payload = get_option($option, null);

if (! is_string($payload) || ! delete_option($option)) {
return null;
}

return $payload;
}
}
Loading
Loading