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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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, tries the Laravel queue first once `HOOKS_ASYNC_CONNECTION` names the connection a worker runs (without a worker, jobs would never run, so the queue is opt-in), then Action Scheduler, then WP-Cron; a `sync` or `null` connection is always skipped. `via('queue')` and `HOOKS_ASYNC_DRIVER=queue` use the default connection. 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
- `pollora:doctor` and Site Health check asynchronous actions (`async-actions`): an ignored `#[Async]` (now kept, not only logged), an unavailable default driver (every handler then runs in the request) or `via()` driver, a `HOOKS_ASYNC_CONNECTION` that is undefined or `sync`/`null`, WP-Cron events overdue for an hour while WP-Cron or Action Scheduler is in use (Pollora sets `DISABLE_WP_CRON`: a system cron must request `wp-cron.php`), jobs of a database queue waiting 15 minutes for a worker, and payloads or unique locks the daily recovery task left behind
- 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
Expand Down
19 changes: 14 additions & 5 deletions src/Attributes/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Pollora\Attributes;

use Attribute;
use Pollora\Hook\Application\Services\AsyncDeclarationFailures;
use Pollora\Hook\Domain\Contract\Action as ActionService;
use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -46,7 +47,10 @@ public function handle(
}

// Asynchronously when the method or its class carries #[Async]
(new AsyncAttributeRegistrar($actionService, $this->logger($serviceLocator)))->register(
$logger = $this->optional($serviceLocator, LoggerInterface::class);
$failures = $this->optional($serviceLocator, AsyncDeclarationFailures::class);

(new AsyncAttributeRegistrar($actionService, $logger, $failures))->register(
$attribute->hook,
$instance,
$context,
Expand All @@ -56,16 +60,21 @@ public function handle(
}

/**
* The application logger, when the service locator provides one.
* A service the service locator may not provide: the logger, the record of ignored #[Async].
*
* @template T of object
*
* @param class-string<T> $class
* @return T|null
*/
private function logger(object $serviceLocator): ?LoggerInterface
private function optional(object $serviceLocator, string $class): ?object
{
try {
$logger = $serviceLocator->get(LoggerInterface::class);
$service = $serviceLocator->get($class);
} catch (\Throwable) {
return null;
}

return $logger instanceof LoggerInterface ? $logger : null;
return $service instanceof $class ? $service : null;
}
}
33 changes: 33 additions & 0 deletions src/Hook/Application/Services/AsyncDeclarationFailures.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Application\Services;

/**
* The #[Async] declarations discovery could not honour, kept so that
* pollora:doctor can list them instead of leaving them in the log.
*
* Each such method still runs, synchronously: nothing else shows the
* declaration was ignored.
*/
final class AsyncDeclarationFailures
{
/**
* @var array<string, string> Reason, by method ("Class::method()")
*/
private array $failures = [];

public function fail(string $class, string $method, string $reason): void
{
$this->failures[sprintf('%s::%s()', ltrim($class, '\\'), $method)] = $reason;
}

/**
* @return array<string, string> Reason, by method ("Class::method()")
*/
public function all(): array
{
return $this->failures;
}
}
238 changes: 238 additions & 0 deletions src/Hook/Infrastructure/Checks/AsyncActionsCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Infrastructure\Checks;

use Illuminate\Contracts\Config\Repository;
use Pollora\Doctor\Domain\Contracts\CheckInterface;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Domain\Models\CheckResult;
use Pollora\Hook\Application\Services\AsyncDeclarationFailures;
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\Exceptions\DriverUnavailable;
use Pollora\Hook\Async\QueuedHandler;
use Pollora\Hook\Infrastructure\Services\AsyncInspector;

/**
* Asynchronous actions reach a driver, and something runs what they queue.
*
* None of these failures shows: an ignored #[Async] or an unavailable driver
* runs the handler inside the request; a queued handler nobody runs simply
* never happens. WP-Cron and Action Scheduler wait for wp-cron.php, which
* Pollora does not run on page loads (DISABLE_WP_CRON); the queue waits for a
* worker.
*/
final readonly class AsyncActionsCheck implements CheckInterface
{
/** WP-Cron depends on traffic or a system cron: an hour late means neither is there. */
private const int CRON_GRACE = 3600;

/** A worker picks a job up within seconds. */
private const int QUEUE_GRACE = 900;

/** The recovery and maintenance task runs daily. */
private const int MAINTENANCE_GRACE = 86400;

public function __construct(
private AsyncDeclarationFailures $failures,
private AsyncInspector $inspector,
private Repository $config,
) {}

public function id(): string
{
return 'async-actions';
}

public function label(): string
{
return 'Asynchronous actions';
}

public function runsIn(): array
{
return [RunContext::Console, RunContext::Http];
}

public function run(RunContext $context): CheckResult
{
if (! function_exists('did_action') || \did_action('init') === 0) {
return CheckResult::skipped('WordPress is not loaded.');
}

$failures = $this->failures->all();
$registrations = $this->inspector->registrations();

if ($failures === [] && $registrations === []) {
return CheckResult::ok('No asynchronous action registered.');
}

/** @var list<array{0: string, 1: string}> $errors Line and fix */
$errors = [];
/** @var list<array{0: string, 1: string}> $warnings Line and fix */
$warnings = [];

foreach ($failures as $method => $reason) {
$errors[] = [sprintf('%s: #[Async] is ignored, the action runs synchronously — %s', $method, $reason), 'Fix the #[Async] declaration named in each line, then run php artisan discovery:clear'];
}

$default = Async::defaultDriver();
$defaultProblem = $this->unavailable($default);

if ($defaultProblem !== null) {
$errors[] = [sprintf('The default driver "%s" is unavailable, so every asynchronous action runs inside the request that fires it: %s', $default, $defaultProblem), 'Set HOOKS_ASYNC_DRIVER (config/hooks.php async.default) to a driver this site has, or to auto'];
}

$used = $defaultProblem === null ? [$default] : [];

foreach ($this->requestedDrivers($registrations) as $driver => $hooks) {
$problem = $this->unavailable($driver);

if ($problem === null) {
$used[] = $driver;

continue;
}

$warnings[] = [sprintf('%s ask(s) for the driver "%s", unavailable, and go(es) through "%s" instead: %s', implode(', ', $hooks), $driver, $default, $problem), sprintf('Make the driver "%s" available, or remove via()', $driver)];
}

$warnings = [...$warnings, ...$this->connectionProblems(), ...$this->unprocessed(array_values(array_unique($used)))];

$lines = static fn (array $problems): array => array_map(static fn (array $problem): string => $problem[0], $problems);

if ($errors !== []) {
return CheckResult::error(sprintf('%d asynchronous action problem(s) that change how handlers run.', count($errors)), [...$lines($errors), ...$lines($warnings)], $errors[0][1]);
}

if ($warnings !== []) {
return CheckResult::warning(sprintf('%d problem(s) with asynchronous actions.', count($warnings)), $lines($warnings), $warnings[0][1]);
}

return CheckResult::ok(sprintf('%d asynchronous action(s), default driver "%s".', count($registrations), $default));
}

/**
* Why a driver cannot queue in this request, or null.
*/
private function unavailable(string $driver): ?string
{
try {
Async::driver($driver);
} catch (DriverUnavailable $driverUnavailable) {
return $driverUnavailable->getMessage();
}

return null;
}

/**
* Hooks of the registrations that name their driver, by driver.
*
* @param list<QueuedHandler> $registrations
* @return array<string, list<string>>
*/
private function requestedDrivers(array $registrations): array
{
$drivers = [];

foreach ($registrations as $registration) {
$driver = $registration->options->driver();

if ($driver !== null) {
$drivers[$driver][] = $registration->hook;
}
}

return array_map(static fn (array $hooks): array => array_values(array_unique($hooks)), $drivers);
}

/**
* HOOKS_ASYNC_CONNECTION names a connection the queue driver cannot use.
*
* @return list<array{0: string, 1: string}>
*/
private function connectionProblems(): array
{
$connection = $this->config->get('hooks.async.queue.connection');

if ($connection === null) {
return [];
}

$driver = $this->config->get(sprintf('queue.connections.%s.driver', $connection));

if (! is_string($driver)) {
return [[sprintf('HOOKS_ASYNC_CONNECTION names the connection "%s", which config/queue.php does not define: auto leaves the queue out', $connection), 'Set HOOKS_ASYNC_CONNECTION to a connection of config/queue.php that a worker runs']];
}

if (in_array($driver, ['sync', 'null'], true)) {
return [[sprintf('HOOKS_ASYNC_CONNECTION names the "%s" connection, whose %s driver %s: auto leaves the queue out', $connection, $driver, $driver === 'sync' ? 'runs jobs inside the request' : 'discards jobs'), 'Set HOOKS_ASYNC_CONNECTION to the connection a worker runs (database, redis…)']];
}

return [];
}

/**
* What runs WP-Cron on this site, and so what to add when nothing does.
*/
private function cronFix(): string
{
if ($this->config->get('wordpress.use_laravel_scheduler')) {
return 'WP-Cron events are Laravel jobs here (wordpress.use_laravel_scheduler): run a queue worker, php artisan queue:work';
}

return $this->inspector->cronRunsOnPageLoad()
? 'The site gets too few visits to run WP-Cron: add a system cron that requests wp-cron.php every minute'
: 'DISABLE_WP_CRON is set (Pollora sets it): add a system cron, e.g. * * * * * curl -s https://example.com/cms/wp-cron.php, or wp cron event run --due-now';
}

/**
* What the drivers in use queued and nothing ran.
*
* @param list<string> $drivers
* @return list<array{0: string, 1: string}>
*/
private function unprocessed(array $drivers): array
{
$problems = [];

if (array_intersect($drivers, ['wp-cron', 'action-scheduler']) !== []) {
$overdue = $this->inspector->overdueCronEvents(self::CRON_GRACE);

if ($overdue['count'] > 0) {
$problems[] = [
sprintf('%d WP-Cron event(s) overdue, the oldest since %s: WP-Cron and Action Scheduler run nothing until wp-cron.php is requested', $overdue['count'], gmdate('Y-m-d H:i', (int) $overdue['oldest']).' UTC'),
$this->cronFix(),
];
}
}

if (in_array('queue', $drivers, true)) {
$connection = (string) ($this->config->get('hooks.async.queue.connection') ?? $this->config->get('queue.default'));
$waiting = $this->inspector->waitingJobs($connection, self::QUEUE_GRACE);

if ($waiting !== null && $waiting['count'] > 0) {
$problems[] = [
sprintf('%d job(s) of the "%s" queue connection waiting since %s: no worker picks them up', $waiting['count'], $connection, gmdate('Y-m-d H:i', (int) $waiting['oldest']).' UTC'),
sprintf('Run a worker, kept alive by Supervisor or systemd: php artisan queue:work %s', $connection),
];
}
}

$stranded = $this->inspector->strandedPayloads(self::MAINTENANCE_GRACE);

if ($stranded > 0) {
$problems[] = [sprintf('%d stored payload(s) older than a day with no event to run them: the daily recovery task (%s) does not run', $stranded, 'pollora/async/recover'), 'Make sure WP-Cron runs: wp cron event run pollora/async/recover schedules them again'];
}

$locks = $this->inspector->expiredLocks(self::MAINTENANCE_GRACE);

if ($locks > 0) {
$problems[] = [sprintf('%d unique lock(s) expired for more than a day: the daily maintenance task (%s) does not run', $locks, 'pollora/async/recover'), 'Make sure WP-Cron runs: wp cron event run pollora/async/recover deletes them'];
}

return $problems;
}
}
6 changes: 6 additions & 0 deletions src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\ServiceProvider;
use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider;
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Infrastructure\Async\EloquentModelReference;
use Pollora\Hook\Infrastructure\Async\QueueDriver;
use Pollora\Hook\Infrastructure\Checks\AsyncActionsCheck;
use Psr\Log\LoggerInterface;

/**
Expand All @@ -23,6 +25,7 @@
* - closures signed with the application key
* - Eloquent models carried by reference
* - handler parameters that are not hook arguments resolved from the container
* - the async-actions check of pollora:doctor and Site Health
*/
class AsyncServiceProvider extends ServiceProvider
{
Expand All @@ -37,6 +40,9 @@ public function boot(): void
__DIR__.'/../../../../config/hooks.php' => config_path('hooks.php'),
], 'pollora-hooks');

// A check of pollora:doctor and Site Health; tagged on boot, so it comes after the framework's own
$this->app->tag([AsyncActionsCheck::class], DoctorServiceProvider::CHECKS_TAG);

$this->configureAsync($this->app->make(Repository::class));
}

Expand Down
7 changes: 6 additions & 1 deletion src/Hook/Infrastructure/Providers/HookServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Pollora\Application\Application\Services\ConsoleDetectionService;
use Pollora\Hook\Adapter\Out\WordPress\Action;
use Pollora\Hook\Adapter\Out\WordPress\Filter;
use Pollora\Hook\Application\Services\AsyncDeclarationFailures;
use Pollora\Hook\Domain\Contract\Action as ActionContract;
use Pollora\Hook\Domain\Contract\CallbackResolverInterface;
use Pollora\Hook\Domain\Contract\Filter as FilterContract;
Expand Down Expand Up @@ -74,11 +75,15 @@ public function register(): void
$this->app->alias(Action::class, ActionContract::class);
$this->app->alias(Filter::class, FilterContract::class);

// #[Async] declarations discovery refuses, listed by pollora:doctor
$this->app->singleton(AsyncDeclarationFailures::class);

// Register Hook Discovery
$this->app->singleton(HookDiscovery::class, fn (Application $app): HookDiscovery => new HookDiscovery(
$app->make(ActionContract::class),
$app->make(FilterContract::class),
$app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null
$app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null,
$app->make(AsyncDeclarationFailures::class),
));

if ($this->consoleDetectionService->isConsole()) {
Expand Down
Loading
Loading