From b3d76c1abf46f9cf2056eaa5a5d3e0efd1d6380f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 19:10:11 +0200 Subject: [PATCH] feat: list asynchronous actions with pollora:async:list --- CHANGELOG.md | 1 + .../Providers/AsyncServiceProvider.php | 22 ++- src/Hook/UI/Console/AsyncListCommand.php | 137 ++++++++++++++++++ .../Hook/Async/AsyncListCommandTest.php | 91 ++++++++++++ 4 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 src/Hook/UI/Console/AsyncListCommand.php create mode 100644 tests/Feature/Hook/Async/AsyncListCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd9a16e..e9c06dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `#[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 - `pollora:make:action --async` generates the method with `#[Async]` and its import, in a new class or added to an existing one +- `pollora:async:list` lists the asynchronous actions (hook, priority, handler, driver, delay, attempts and backoff, unique lock, queue, as user), which drivers are available in this request, and the default driver with where it comes from (`HOOKS_ASYNC_DRIVER`, the `POLLORA_ASYNC_DRIVER` constant, the `pollora/hook/async_driver` filter, or `auto` and the drivers it tries); `--json` for scripts - 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 diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php index 03a61093..705cbe79 100644 --- a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php @@ -14,6 +14,7 @@ use Pollora\Hook\Infrastructure\Async\EloquentModelReference; use Pollora\Hook\Infrastructure\Async\QueueDriver; use Pollora\Hook\Infrastructure\Checks\AsyncActionsCheck; +use Pollora\Hook\UI\Console\AsyncListCommand; use Psr\Log\LoggerInterface; /** @@ -32,6 +33,10 @@ class AsyncServiceProvider extends ServiceProvider public function register(): void { $this->mergeConfigFrom(__DIR__.'/../../../../config/hooks.php', 'hooks'); + + if ($this->app->runningInConsole()) { + $this->commands([AsyncListCommand::class]); + } } public function boot(): void @@ -51,10 +56,7 @@ private function configureAsync(Repository $config): void $container = $this->app; Async::extend('queue', static fn (): AsyncDriver => new QueueDriver($container->make(Dispatcher::class), $config)); - // The Laravel queue needs a worker: auto only picks it once a connection is set for async actions - Async::setAutoDrivers($config->get('hooks.async.queue.connection') !== null - ? ['queue', 'action-scheduler', 'wp-cron'] - : ['action-scheduler', 'wp-cron']); + Async::setAutoDrivers(self::autoDrivers($config)); $default = $config->get('hooks.async.default', 'auto'); Async::setDefaultDriver(is_string($default) && $default !== '' && $default !== 'auto' ? $default : null); @@ -75,6 +77,18 @@ private function configureAsync(Repository $config): void }); } + /** + * The drivers auto tries, in order. The Laravel queue needs a worker: it comes first only once a connection is set for async actions. + * + * @return list + */ + public static function autoDrivers(Repository $config): array + { + return $config->get('hooks.async.queue.connection') !== null + ? ['queue', 'action-scheduler', 'wp-cron'] + : ['action-scheduler', 'wp-cron']; + } + private static function resolveParameter(Container $container, \ReflectionParameter $parameter): mixed { $type = $parameter->getType(); diff --git a/src/Hook/UI/Console/AsyncListCommand.php b/src/Hook/UI/Console/AsyncListCommand.php new file mode 100644 index 00000000..b9bd15ee --- /dev/null +++ b/src/Hook/UI/Console/AsyncListCommand.php @@ -0,0 +1,137 @@ +describe(...), $inspector->registrations()); + usort($registrations, static fn (array $a, array $b): int => [$a['hook'], $a['priority']] <=> [$b['hook'], $b['priority']]); + + $drivers = []; + foreach (self::DRIVERS as $name) { + $drivers[$name] = $this->unavailable($name); + } + + $default = ['driver' => Async::defaultDriver(), 'source' => $this->defaultSource($config)]; + + if ($this->option('json')) { + $this->line((string) json_encode([ + 'default' => $default, + 'drivers' => array_map(static fn (?string $problem): array => ['available' => $problem === null, 'reason' => $problem], $drivers), + 'actions' => $registrations, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return self::SUCCESS; + } + + $this->components->twoColumnDetail('Default driver', sprintf('%s (%s)', $default['driver'], $default['source'])); + + foreach ($drivers as $name => $problem) { + $this->components->twoColumnDetail(' '.$name, $problem === null ? 'available' : 'unavailable '.$problem.''); + } + + $this->newLine(); + + if ($registrations === []) { + $this->components->info('No asynchronous action registered.'); + + return self::SUCCESS; + } + + $this->components->info(sprintf('%d asynchronous action(s)', count($registrations))); + + $this->table( + ['Hook', 'Priority', 'Handler', 'Driver', 'Delay', 'Tries', 'Unique', 'Queue', 'As user'], + array_map(static fn (array $registration): array => [ + $registration['hook'], + $registration['priority'], + $registration['handler'], + $registration['driver'] ?? 'default', + $registration['delay'] === 0 ? '—' : $registration['delay'].' s', + $registration['tries'] === 1 ? '1' : sprintf('%d (backoff %s s)', $registration['tries'], implode(', ', $registration['backoff'])), + $registration['unique'] === null ? '—' : $registration['unique'].' s', + $registration['queue'] ?? '—', + $registration['as_user'] ? 'yes' : 'no', + ], $registrations), + ); + + return self::SUCCESS; + } + + /** + * @return array{hook: string, priority: int, handler: string, driver: string|null, delay: int, tries: int, backoff: list, unique: int|null, queue: string|null, as_user: bool} + */ + private function describe(QueuedHandler $registration): array + { + $options = $registration->options; + + return [ + 'hook' => $registration->hook, + 'priority' => $registration->priority, + 'handler' => $registration->label(), + 'driver' => $options->driver(), + 'delay' => $options->delayInSeconds(), + 'tries' => $options->attempts(), + 'backoff' => $options->backoffDelays(), + 'unique' => $options->uniqueFor(), + 'queue' => $options->queue(), + 'as_user' => $options->runsAsUser(), + ]; + } + + /** + * Where the default driver comes from, in the order Async::defaultDriver() reads them. + */ + private function defaultSource(Repository $config): string + { + $configured = $config->get('hooks.async.default', 'auto'); + + if (is_string($configured) && $configured !== '' && $configured !== 'auto') { + return 'HOOKS_ASYNC_DRIVER, config/hooks.php'; + } + + if (defined(Async::DRIVER_CONSTANT) && is_string(constant(Async::DRIVER_CONSTANT)) && constant(Async::DRIVER_CONSTANT) !== '') { + return 'the '.Async::DRIVER_CONSTANT.' constant'; + } + + if (function_exists('apply_filters') && ! in_array(apply_filters(Async::DRIVER_FILTER, Async::DEFAULT_DRIVER), [Async::DEFAULT_DRIVER, ''], true)) { + return 'the '.Async::DRIVER_FILTER.' filter'; + } + + return 'auto: the first available of '.implode(', ', AsyncServiceProvider::autoDrivers($config)); + } + + private function unavailable(string $driver): ?string + { + try { + Async::driver($driver); + } catch (DriverUnavailable $driverUnavailable) { + return $driverUnavailable->getMessage(); + } + + return null; + } +} diff --git a/tests/Feature/Hook/Async/AsyncListCommandTest.php b/tests/Feature/Hook/Async/AsyncListCommandTest.php new file mode 100644 index 00000000..0e297031 --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncListCommandTest.php @@ -0,0 +1,91 @@ + 'sync', 'queue.connections.sync' => ['driver' => 'sync']]); + app()->register(AsyncServiceProvider::class, force: true); + Async::extend('wp-cron', fn (): AsyncDriver => new class implements AsyncDriver + { + public function available(): bool + { + return true; + } + + public function dispatch(AsyncPayload $payload, int $delay = 0): void {} + }); + + $actions = new Action; + $actions->add('user_register', [new AsyncListHandler, 'handle'])->async(); + $actions->add('save_post', [new AsyncListHandler, 'handle'], 20)->async()->via('queue')->delay(60)->tries(3)->backoff([5, 30])->unique(600)->onQueue('crm')->asUser(); + + $registered = array_map(fn (string $hook): QueuedHandler => $actions->callbacks($hook)[0]['callback'], ['user_register', 'save_post']); + app()->instance(AsyncInspector::class, new class($registered) extends AsyncInspector + { + public function __construct(private readonly array $registered) {} + + public function registrations(): array + { + return $this->registered; + } + }); +}); + +/** + * @return array + */ +function asyncListJson(): array +{ + expect(Artisan::call('pollora:async:list', ['--json' => true]))->toBe(0); + + return json_decode(Artisan::output(), true); +} + +afterEach(function (): void { + Async::flush(); +}); + +it('lists the asynchronous actions with their options, the default driver and why', function (): void { + $output = asyncListJson(); + + expect($output['default'])->toBe(['driver' => 'wp-cron', 'source' => 'auto: the first available of action-scheduler, wp-cron']) + ->and($output['drivers']['wp-cron'])->toBe(['available' => true, 'reason' => null]) + ->and($output['drivers']['queue']['available'])->toBeFalse() + ->and($output['actions'])->toBe([ + ['hook' => 'save_post', 'priority' => 20, 'handler' => AsyncListHandler::class.'@handle', 'driver' => 'queue', 'delay' => 60, 'tries' => 3, 'backoff' => [5, 30], 'unique' => 600, 'queue' => 'crm', 'as_user' => true], + ['hook' => 'user_register', 'priority' => 10, 'handler' => AsyncListHandler::class.'@handle', 'driver' => null, 'delay' => 0, 'tries' => 1, 'backoff' => [10, 60, 300], 'unique' => null, 'queue' => null, 'as_user' => false], + ]); +}); + +it('names the configuration when it sets the default driver', function (): void { + config(['hooks.async.default' => 'sync']); + Async::setDefaultDriver('sync'); + + expect(asyncListJson()['default'])->toBe(['driver' => 'sync', 'source' => 'HOOKS_ASYNC_DRIVER, config/hooks.php']); +}); + +it('prints a table', function (): void { + $this->artisan('pollora:async:list') + ->expectsOutputToContain('Default driver') + ->expectsOutputToContain('2 asynchronous action(s)') + ->expectsTable( + ['Hook', 'Priority', 'Handler', 'Driver', 'Delay', 'Tries', 'Unique', 'Queue', 'As user'], + [ + ['save_post', 20, AsyncListHandler::class.'@handle', 'queue', '60 s', '3 (backoff 5, 30 s)', '600 s', 'crm', 'yes'], + ['user_register', 10, AsyncListHandler::class.'@handle', 'default', '—', '1', '—', '—', 'no'], + ], + ) + ->assertSuccessful(); +});