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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to `pollora/hook` are documented in this file.

## [Unreleased]

## [1.4.0] - 2026-10-07

### Added

- `Async::setDefaults()`: the attempts, backoff and `asUser` every asynchronous registration starts from. The framework sets them from `config/hooks.php`.
- `Async::injectParametersUsing()`: handler parameters that are not hook arguments are injected at execution. By default, a parameter is injected when its type is a class or interface that does not travel as a hook argument (WordPress objects, enums, dates, `JsonSerializable` and `AsyncContext` do). These parameters are not requested from WordPress, so they can sit anywhere in the signature. The framework resolves them from its container.
- `Async::receive(..., throwOnFinalFailure: true)`: the last failure is announced through `pollora/async/failed`, then thrown instead of reported, for a driver whose queue records failures itself (a Laravel job then lands in `failed_jobs`). Retries still happen while attempts are left.

### Changed

- A handler parameter with no hook argument left takes its default value, so a later `AsyncContext` or injected parameter still gets its own.

## [1.3.0] - 2026-10-07

### Changed
Expand Down Expand Up @@ -52,6 +64,7 @@ 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.3.0...HEAD
[Unreleased]: https://github.com/Pollora/hook/compare/v1.4.0...HEAD
[1.4.0]: https://github.com/Pollora/hook/compare/v1.3.0...v1.4.0
[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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ Action::add('save_post_event', [CrmSync::class, 'push'])

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()`.

**Dependencies.** `Async::injectParametersUsing($resolver)` injects, at execution, the handler parameters typed with a class that does not travel as a hook argument, such as a mailer or an API client. Pollora resolves them from its container.

**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 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.
Expand Down
112 changes: 110 additions & 2 deletions src/Async/Async.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ final class Async
*/
public const string DRIVER_FILTER = 'pollora/hook/async_driver';

private const array DEFAULTS = ['tries' => 1, 'backoff' => [10, 60, 300], 'asUser' => false];

/** @var array<string, \Closure(): AsyncDriver> */
private static array $factories = [];

Expand All @@ -58,6 +60,15 @@ final class Async

private static ?AsyncFake $fake = null;

/** @var array{tries: int, backoff: list<int>, asUser: bool} */
private static array $defaults = self::DEFAULTS;

/** @var (\Closure(\ReflectionParameter): mixed)|null */
private static ?\Closure $injector = null;

/** @var (\Closure(\ReflectionParameter): bool)|null */
private static ?\Closure $injectable = null;

private static ?ArgumentNormalizer $normalizer = null;

private static ?AsyncDispatcher $dispatcher = null;
Expand Down Expand Up @@ -109,6 +120,94 @@ public static function driver(?string $name = null): AsyncDriver
return $driver;
}

/**
* Options every asynchronous registration starts from. The framework sets them from config/hooks.php.
*
* @param int|null $tries Attempts, 1 by default
* @param int|list<int>|null $backoff Seconds before each retry, [10, 60, 300] by default
* @param bool|null $asUser Run as the user who fired the hook, false by default
*
* @throws \InvalidArgumentException When a value is invalid
*/
public static function setDefaults(?int $tries = null, int|array|null $backoff = null, ?bool $asUser = null): void
{
self::$defaults = [
'tries' => $tries === null ? self::$defaults['tries'] : PendingAsync::validTries($tries),
'backoff' => $backoff === null ? self::$defaults['backoff'] : PendingAsync::validBackoff($backoff),
'asUser' => $asUser ?? self::$defaults['asUser'],
];
}

/**
* @return array{tries: int, backoff: list<int>, asUser: bool}
*/
public static function defaults(): array
{
return self::$defaults;
}

/**
* Inject the handler parameters that are not hook arguments, at execution.
*
* A parameter is injected when $isInjectable says so; by default, when it is
* typed with a class or interface that does not travel as a hook argument
* (WordPress objects, enums, dates, JsonSerializable and AsyncContext do).
* Injected parameters are not taken from the hook. The framework resolves
* them from its container.
*
* Async::injectParametersUsing(fn (ReflectionParameter $parameter) => $container->make($parameter->getType()->getName()));
*
* @param (callable(\ReflectionParameter): mixed)|null $resolver Null to stop injecting
* @param (callable(\ReflectionParameter): bool)|null $isInjectable
*/
public static function injectParametersUsing(?callable $resolver, ?callable $isInjectable = null): void
{
self::$injector = $resolver === null ? null : $resolver(...);
self::$injectable = $isInjectable === null ? null : $isInjectable(...);
}

/**
* @internal
*/
public static function isInjectable(\ReflectionParameter $parameter): bool
{
if (! self::$injector instanceof \Closure) {
return false;
}

if (self::$injectable instanceof \Closure) {
return (self::$injectable)($parameter) === true;
}

$type = $parameter->getType();

if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) {
return false;
}

$name = $type->getName();

if (! class_exists($name) && ! interface_exists($name)) {
return false;
}

foreach ([AsyncContext::class, \UnitEnum::class, \DateTimeInterface::class, \JsonSerializable::class, 'WP_Post', 'WP_Term', 'WP_User', 'WP_Comment'] as $travelling) {
if (is_a($name, $travelling, true)) {
return false;
}
}

return true;
}

/**
* @internal
*/
public static function inject(\ReflectionParameter $parameter): mixed
{
return self::$injector instanceof \Closure ? (self::$injector)($parameter) : null;
}

/**
* Record queued handlers instead of queuing them, whatever the driver. For tests.
*/
Expand Down Expand Up @@ -255,8 +354,10 @@ 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 kept by PayloadStore
* @param bool $throwOnFinalFailure Throw the last failure instead of reporting it, for a driver whose
* queue records failures itself (a Laravel job lands in failed_jobs)
*/
public static function receive(string $message): void
public static function receive(string $message, bool $throwOnFinalFailure = false): void
{
if (PayloadStore::handles($message)) {
$message = PayloadStore::claim($message);
Expand All @@ -269,12 +370,16 @@ public static function receive(string $message): void
try {
$payload = AsyncPayload::fromJson($message);
} catch (\Throwable $throwable) {
if ($throwOnFinalFailure) {
throw $throwable;
}

self::report($throwable);

return;
}

self::runner()->run($payload);
self::runner()->run($payload, $throwOnFinalFailure);
}

/**
Expand Down Expand Up @@ -356,6 +461,9 @@ public static function flush(): void
self::$resolver = null;
self::$closureKey = null;
self::$fake = null;
self::$defaults = self::DEFAULTS;
self::$injector = null;
self::$injectable = null;
self::$normalizer = null;
self::$dispatcher = null;
self::$runner = null;
Expand Down
23 changes: 16 additions & 7 deletions src/Async/AsyncRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ public function __construct(
private readonly ArgumentNormalizer $normalizer,
) {}

public function run(AsyncPayload $payload): void
/**
* @param bool $throwOnFinalFailure Announce the last failure, then throw it instead of reporting it
*/
public function run(AsyncPayload $payload, bool $throwOnFinalFailure = false): void
{
// Identical triggers queue again from the moment the first attempt starts
if ($payload->uniqueKey !== null && $payload->attempt === 1) {
Expand All @@ -41,7 +44,7 @@ public function run(AsyncPayload $payload): void
} catch (MissingReferencedObject) {
return;
} catch (\Throwable $throwable) {
$this->fail($payload, $throwable);
$this->fail($payload, $throwable, $throwOnFinalFailure);

return;
}
Expand All @@ -53,7 +56,7 @@ public function run(AsyncPayload $payload): void
try {
$this->call($callable, $arguments, $payload->context($captured));
} catch (\Throwable $throwable) {
$this->retryOrFail($payload, $throwable);
$this->retryOrFail($payload, $throwable, $throwOnFinalFailure);
} finally {
$leaveContext();

Expand Down Expand Up @@ -126,7 +129,7 @@ private function enterContext(AsyncPayload $payload): \Closure
/**
* Queue the handler again when it has attempts left, report the failure otherwise.
*/
private function retryOrFail(AsyncPayload $payload, \Throwable $throwable): void
private function retryOrFail(AsyncPayload $payload, \Throwable $throwable, bool $throwOnFinalFailure): void
{
if ($payload->attempt < $payload->tries) {
$delay = $payload->retryDelay($payload->attempt);
Expand All @@ -144,15 +147,21 @@ private function retryOrFail(AsyncPayload $payload, \Throwable $throwable): void
}
}

$this->fail($payload, $throwable);
$this->fail($payload, $throwable, $throwOnFinalFailure);
}

private function fail(AsyncPayload $payload, \Throwable $throwable): void
private function fail(AsyncPayload $payload, \Throwable $throwable, bool $throw = false): void
{
Async::report($throwable, ['hook' => $payload->hook, 'handler' => $payload->handler, 'payload' => $payload->id]);
if (! $throw) {
Async::report($throwable, ['hook' => $payload->hook, 'handler' => $payload->handler, 'payload' => $payload->id]);
}

if (function_exists('do_action')) {
do_action('pollora/async/failed', $payload, $throwable);
}

if ($throw) {
throw $throwable;
}
}
}
29 changes: 23 additions & 6 deletions src/Async/HandlerArguments.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
/**
* Maps hook arguments onto a handler's signature.
*
* A parameter typed AsyncContext receives the context, wherever it sits; the
* other parameters receive the hook arguments, in order.
* A parameter typed AsyncContext receives the context, and an injectable
* parameter (see Async::injectParametersUsing()) its resolved value, wherever
* they sit; the other parameters receive the hook arguments, in order.
*
* @internal
*/
final class HandlerArguments
{
/**
* How many hook arguments a handler takes, its AsyncContext parameters left out.
* How many hook arguments a handler takes, its AsyncContext and injected parameters left out.
*
* @param callable|string|array $callback The registered callback
* @param int $registeredArgs The argument count it was registered with
Expand All @@ -28,9 +29,12 @@ public static function hookArgumentCount(callable|string|array $callback, int $r
return $registeredArgs;
}

$contextParameters = count(array_filter($reflection->getParameters(), self::isContextParameter(...)));
$hookParameters = count(array_filter(
$reflection->getParameters(),
fn (\ReflectionParameter $parameter): bool => ! self::isContextParameter($parameter) && ! Async::isInjectable($parameter),
));

return max(0, $registeredArgs - $contextParameters);
return max(0, min($registeredArgs, $hookParameters));
}

/**
Expand All @@ -57,12 +61,25 @@ public static function for(callable $callback, array $hookArguments, AsyncContex
continue;
}

if (Async::isInjectable($parameter)) {
$arguments[] = Async::inject($parameter);

continue;
}

if ($parameter->isVariadic()) {
return [...$arguments, ...$hookArguments];
}

if ($hookArguments === []) {
break;
// Use the default value, so a later context or injected parameter still gets its own
if (! $parameter->isDefaultValueAvailable()) {
break;
}

$arguments[] = $parameter->getDefaultValue();

continue;
}

$arguments[] = array_shift($hookArguments);
Expand Down
Loading
Loading