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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Asynchronous actions in the framework (pollora/hook 1.4): `Action::add(...)->async()` can queue handlers as Laravel jobs. The `queue` driver dispatches a `RunAsyncAction` job on `hooks.async.queue.connection` (the default connection when null) and on the action's `onQueue()` or `hooks.async.queue.queue`. `auto`, the default, now tries the Laravel queue first, then Action Scheduler, then WP-Cron; a `sync` or `null` connection is always skipped. The job is tried once: retries are new jobs queued after the backoff, and the last failure lands in the failed jobs table. It also clears WordPress's in-memory cache before each handler, since a worker lives across jobs
- `config/hooks.php`, published with `php artisan vendor:publish --tag=pollora-hooks`: default driver (`HOOKS_ASYNC_DRIVER`, `sync` in a developer's `.env` runs every handler at once), attempts, backoff, `as_user`, queue connection and name
- `#[Async]`, next to `#[Action]`, makes a method asynchronous with the options of `->async()` as named parameters: `delay`, `via`, `onQueue`, `unique` (`true` or the lock duration), `tries`, `backoff`, `asUser`, `capture` and `when` (public methods of the class), `keepMissing`, `except`. On a class it applies to every `#[Action]` method, and a method's own `#[Async]` replaces it. A declaration that cannot be honoured (a hook in `except` the method does not declare, a missing or non-public `capture`/`when` method, invalid attempts, backoff or lock) is logged and the action runs synchronously; `#[Async]` on a `#[Filter]` or without `#[Action]` is logged
- Async handlers in the framework: debug mode follows `app.debug`, incidents go to the Laravel log, closures are signed with the application key, Eloquent models travel by class and key and are reloaded at execution, and parameters typed with a service are resolved from the container

### Fixed
- Errors met while registering hooks discovered by attribute went to no logger: `HookDiscovery` now receives the application's logger
- Login screen: the language switcher's label ("Language" and its icon, shown when a second language is installed) sat at the left edge of the window, far from its dropdown. The card style given to the login form also reached the switcher's form and made it a full-width block; it is an inline block again, as in WordPress's own sheet
- Login screen: the rules for the buttons were written `.pollora-login .wp-core-ui …`, but both classes are on `<body>`, so they never matched. The language switcher's button kept wp-admin's blue instead of the theme's primary colour, and the submit button kept WordPress's `button-large` padding

Expand Down
2 changes: 2 additions & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
// Binding sources each broken on purpose (an untyped field, a private
// one): fixing them would remove what the tests check is refused.
__DIR__.'/tests/Unit/BlockBinding/Fixtures/Invalid.php',
// Hook classes with empty, unused or private methods on purpose: they carry the attributes under test.
__DIR__.'/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php',
])
->withPreparedSets(
deadCode: true,
Expand Down
27 changes: 25 additions & 2 deletions src/Attributes/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Attribute;
use Pollora\Hook\Domain\Contract\Action as ActionService;
use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionMethod;

Expand Down Expand Up @@ -38,11 +40,32 @@ public function handle(
return;
}

$actionService->add(
// #[Action] targets methods only
if (! $context instanceof ReflectionMethod) {
return;
}

// Asynchronously when the method or its class carries #[Async]
(new AsyncAttributeRegistrar($actionService, $this->logger($serviceLocator)))->register(
$attribute->hook,
[$instance, $context->getName()],
$instance,
$context,
$attribute->priority,
$context->getNumberOfParameters()
);
}

/**
* The application logger, when the service locator provides one.
*/
private function logger(object $serviceLocator): ?LoggerInterface
{
try {
$logger = $serviceLocator->get(LoggerInterface::class);
} catch (\Throwable) {
return null;
}

return $logger instanceof LoggerInterface ? $logger : null;
}
}
53 changes: 53 additions & 0 deletions src/Attributes/Async.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Pollora\Attributes;

use Attribute;

/**
* Makes #[Action] methods asynchronous: the handler is queued when its hook
* fires, then run later (Laravel queue, Action Scheduler or WP-Cron).
*
* The counterpart of Action::add(...)->async(): #[Action] keeps the hook and
* the priority, #[Async] carries the deferred-execution options.
*
* #[Action('save_post_event')]
* #[Async(delay: 60, unique: true, tries: 3)]
* public function syncToCrm(int $postId): void {}
*
* On a class, it applies to every #[Action] method; an #[Async] on a method
* replaces the class options for that method. With several #[Action] on a
* method, it applies to all of them, except the hooks listed in $except.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
final readonly class Async
{
/**
* @param int|\DateInterval|null $delay Minimum delay before execution, in seconds
* @param string|null $via Driver: queue, action-scheduler, wp-cron, sync
* @param string|null $onQueue Queue name, the group with Action Scheduler
* @param bool|int $unique Merge identical triggers until the first one runs; an integer sets how long the lock lasts, in seconds
* @param int|null $tries Attempts when the handler throws
* @param int|list<int>|null $backoff Seconds before each retry
* @param bool|null $asUser Run as the user who fired the hook
* @param string|null $capture Public method of the class recording values at trigger time
* @param string|null $when Public method of the class deciding, at trigger time, whether to queue
* @param bool $keepMissing Run with null in place of a referenced object deleted in the meantime
* @param string|list<string> $except Hooks of the method that stay synchronous
*/
public function __construct(
public int|\DateInterval|null $delay = null,
public ?string $via = null,
public ?string $onQueue = null,
public bool|int $unique = false,
public ?int $tries = null,
public int|array|null $backoff = null,
public ?bool $asUser = null,
public ?string $capture = null,
public ?string $when = null,
public bool $keepMissing = false,
public string|array $except = [],
) {}
}
4 changes: 3 additions & 1 deletion src/Hook/Infrastructure/Providers/HookServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Pollora\Hook\Infrastructure\Services\HookDiscovery;
use Pollora\Hook\UI\Console\ActionMakeCommand;
use Pollora\Hook\UI\Console\FilterMakeCommand;
use Psr\Log\LoggerInterface;

/**
* Service provider for Hook feature (Infrastructure layer).
Expand Down Expand Up @@ -76,7 +77,8 @@ public function register(): void
// Register Hook Discovery
$this->app->singleton(HookDiscovery::class, fn (Application $app): HookDiscovery => new HookDiscovery(
$app->make(ActionContract::class),
$app->make(FilterContract::class)
$app->make(FilterContract::class),
$app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null
));

if ($this->consoleDetectionService->isConsole()) {
Expand Down
186 changes: 186 additions & 0 deletions src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Infrastructure\Services;

use Pollora\Attributes\Action;
use Pollora\Attributes\Async;
use Pollora\Hook\Async\PendingAsync;
use Pollora\Hook\Domain\Contract\Action as ActionContract;
use Psr\Log\LoggerInterface;
use ReflectionMethod;

/**
* Registers an #[Action] method, asynchronously when it or its class carries #[Async].
*
* A declaration that cannot be honoured (an unknown hook in except, a capture
* or when method that is not a public method of the class, invalid attempts,
* backoff or lock duration) is logged and the action is registered
* synchronously: the work still happens.
*/
final readonly class AsyncAttributeRegistrar
{
public function __construct(
private ActionContract $actions,
private ?LoggerInterface $logger = null,
) {}

/**
* The #[Async] of a method, or else of its class.
*/
public static function attributeFor(ReflectionMethod $method): ?Async
{
$attributes = $method->getAttributes(Async::class) ?: $method->getDeclaringClass()->getAttributes(Async::class);

return $attributes === [] ? null : $attributes[0]->newInstance();
}

/**
* Whether the method itself carries #[Async].
*/
public static function isDeclaredOn(ReflectionMethod $method): bool
{
return $method->getAttributes(Async::class) !== [];
}

/**
* Register one hook of an #[Action] method.
*
* @param int|null $acceptedArgs Null to detect it from the method
*/
public function register(string $hook, object $instance, ReflectionMethod $method, int $priority, ?int $acceptedArgs = null): void
{
$this->actions->add($hook, [$instance, $method->getName()], $priority, $acceptedArgs);

$attribute = self::attributeFor($method);

if (! $attribute instanceof Async) {
return;
}

$problem = $this->problem($method, $attribute);

if ($problem !== null) {
$this->logger?->error(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously: %s', $method->getDeclaringClass()->getName(), $method->getName(), $problem));

return;
}

if (in_array($hook, (array) $attribute->except, true)) {
return;
}

$this->configure($this->actions->async(), $attribute, $instance);
}

/**
* Why a declaration cannot be honoured, or null.
*/
private function problem(ReflectionMethod $method, Async $attribute): ?string
{
$declaredOnMethod = self::isDeclaredOn($method);
$hooks = $this->actionHooks($method, $declaredOnMethod);

$unknown = array_values(array_diff((array) $attribute->except, $hooks));
if ($unknown !== []) {
return sprintf("except names '%s', which is not a hook of its #[Action] (%s)", implode("', '", $unknown), implode(', ', $hooks));
}

foreach (['capture' => $attribute->capture, 'when' => $attribute->when] as $option => $name) {
if ($name !== null && ! $this->isPublicMethod($method->getDeclaringClass(), $name)) {
return sprintf("%s names '%s', which is not a public method of the class", $option, $name);
}
}

try {
if ($attribute->tries !== null) {
PendingAsync::validTries($attribute->tries);
}

if ($attribute->backoff !== null) {
PendingAsync::validBackoff($attribute->backoff);
}
} catch (\InvalidArgumentException $invalidArgumentException) {
return $invalidArgumentException->getMessage();
}

if (is_int($attribute->unique) && $attribute->unique < 1) {
return sprintf('unique expects true or a lock of at least 1 second, %d given', $attribute->unique);
}

return null;
}

/**
* Hooks of the #[Action] attributes of the method, or of the whole class for a class-level #[Async].
*
* @return list<string>
*/
private function actionHooks(ReflectionMethod $method, bool $declaredOnMethod): array
{
$methods = $declaredOnMethod ? [$method] : $method->getDeclaringClass()->getMethods(ReflectionMethod::IS_PUBLIC);
$hooks = [];

foreach ($methods as $candidate) {
foreach ($candidate->getAttributes(Action::class) as $action) {
$hooks[] = $action->newInstance()->hook;
}
}

return array_values(array_unique($hooks));
}

/**
* @param \ReflectionClass<object> $class
*/
private function isPublicMethod(\ReflectionClass $class, string $name): bool
{
return $class->hasMethod($name) && $class->getMethod($name)->isPublic();
}

private function configure(PendingAsync $pending, Async $attribute, object $instance): void
{
if ($attribute->delay !== null) {
$pending->delay($attribute->delay);
}

if ($attribute->via !== null) {
$pending->via($attribute->via);
}

if ($attribute->onQueue !== null) {
$pending->onQueue($attribute->onQueue);
}

if ($attribute->unique === true) {
$pending->unique();
} elseif (is_int($attribute->unique)) {
$pending->unique($attribute->unique);
}

if ($attribute->tries !== null) {
$pending->tries($attribute->tries);
}

if ($attribute->backoff !== null) {
$pending->backoff($attribute->backoff);
}

if ($attribute->asUser !== null) {
$pending->asUser($attribute->asUser);
}

if ($attribute->capture !== null) {
$pending->capture([$instance, $attribute->capture]);
}

if ($attribute->when !== null) {
$pending->when([$instance, $attribute->when]);
}

if ($attribute->keepMissing) {
$pending->keepMissing();
}
}
}
28 changes: 24 additions & 4 deletions src/Hook/Infrastructure/Services/HookDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Pollora\Hook\Infrastructure\Services;

use Pollora\Attributes\Action;
use Pollora\Attributes\Async;
use Pollora\Attributes\Filter;
use Pollora\Discovery\Domain\Contracts\DiscoveryInterface;
use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface;
Expand Down Expand Up @@ -88,6 +89,18 @@ public function discover(DiscoveryLocationInterface $location, DiscoveredStructu
'reflection_method' => $method,
]);
}

// An #[Async] with neither #[Action] nor #[Filter] has nothing to apply to
$asyncAttributes = $method->getAttributes(Async::class);
if ($asyncAttributes !== [] && $actionAttributes === [] && $filterAttributes === []) {
$this->getItems()->add($location, [
'type' => 'async',
'class' => $className,
'method' => $method->getName(),
'attribute' => $asyncAttributes[0],
'reflection_method' => $method,
]);
}
}
} catch (\Throwable) {
// Skip classes that can't be reflected
Expand Down Expand Up @@ -118,17 +131,24 @@ public function apply(): void
/** @var Action $action */
$action = $hookAttribute->newInstance();

// Create instance and call method directly
// Create instance and call method directly; asynchronously when #[Async] says so
$instance = $this->getInstanceFromPool($className);
$this->actionService->add(
hooks: $action->hook,
callback: [$instance, $methodName],
(new AsyncAttributeRegistrar($this->actionService, $this->logger))->register(
hook: $action->hook,
instance: $instance,
method: $reflectionMethod,
priority: $action->priority
);
} elseif ($hookType === 'async') {
$this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: the method has no #[Action] to make asynchronous.', ltrim($className, '\\'), $methodName));
} elseif ($hookType === 'filter') {
/** @var Filter $filter */
$filter = $hookAttribute->newInstance();

if (AsyncAttributeRegistrar::isDeclaredOn($reflectionMethod)) {
$this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: a filter returns a value to its caller and cannot be deferred.', ltrim($className, '\\'), $methodName));
}

// Create instance and call method directly
$instance = $this->getInstanceFromPool($className);
$this->filterService->add(
Expand Down
Loading
Loading