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 @@ -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
Expand Down
22 changes: 18 additions & 4 deletions src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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<string>
*/
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();
Expand Down
137 changes: 137 additions & 0 deletions src/Hook/UI/Console/AsyncListCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\UI\Console;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Contracts\Config\Repository;
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\Exceptions\DriverUnavailable;
use Pollora\Hook\Async\QueuedHandler;
use Pollora\Hook\Infrastructure\Providers\AsyncServiceProvider;
use Pollora\Hook\Infrastructure\Services\AsyncInspector;

/**
* Lists the asynchronous actions registered by the end of the boot, with their
* options, and the driver they go through by default and why.
*/
#[Description('List the asynchronous actions, their options and the default driver')]
#[Signature('pollora:async:list {--json : Output as JSON}')]
class AsyncListCommand extends Command
{
/** The drivers Pollora provides. */
private const array DRIVERS = ['queue', 'action-scheduler', 'wp-cron', 'sync'];

public function handle(AsyncInspector $inspector, Repository $config): int
{
$registrations = array_map($this->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('<options=bold>Default driver</>', sprintf('<fg=green;options=bold>%s</> <fg=gray>(%s)</>', $default['driver'], $default['source']));

foreach ($drivers as $name => $problem) {
$this->components->twoColumnDetail(' '.$name, $problem === null ? '<fg=green>available</>' : '<fg=yellow>unavailable</> <fg=gray>'.$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<int>, 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;
}
}
91 changes: 91 additions & 0 deletions tests/Feature/Hook/Async/AsyncListCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Artisan;
use Pollora\Hook\Adapter\Out\WordPress\Action;
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Async\QueuedHandler;
use Pollora\Hook\Infrastructure\Providers\AsyncServiceProvider;
use Pollora\Hook\Infrastructure\Services\AsyncInspector;

final class AsyncListHandler {}

beforeEach(function (): void {
Async::flush();
config(['queue.default' => '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<string, mixed>
*/
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();
});
Loading