diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3955bd..015eb606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.1...develop) +### 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 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 - 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 `
`, 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 diff --git a/composer.json b/composer.json index 039de3f5..a215a651 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", "pollora/entity": "^1.2", - "pollora/hook": "^1.1", + "pollora/hook": "^1.4", "pollora/option": "^1.1", "watson/rememberable": "^7.1", "cweagans/composer-patches": "^2.0", diff --git a/config/hooks.php b/config/hooks.php new file mode 100644 index 00000000..0ebc8b3f --- /dev/null +++ b/config/hooks.php @@ -0,0 +1,44 @@ +async() and #[Async] queue a handler instead of running + | it inside the request that fires the hook. + | + | default: auto picks the Laravel queue when its connection is not "sync", + | then Action Scheduler when a plugin bundling it is active, then WP-Cron. + | Set HOOKS_ASYNC_DRIVER=sync in a developer's .env to run every handler + | at once, without touching the code. + | + | Supported: "auto", "queue", "action-scheduler", "wp-cron", "sync" + | + */ + + 'async' => [ + + 'default' => env('HOOKS_ASYNC_DRIVER', 'auto'), + + // Attempts when a handler throws, and seconds before each retry (the last value repeats) + 'tries' => 1, + 'backoff' => [10, 60, 300], + + // Run handlers as the user who fired the hook. Off by default: without a user, a capability check fails, which is the safe behaviour + 'as_user' => false, + + 'queue' => [ + // null: the default queue connection + 'connection' => env('HOOKS_ASYNC_CONNECTION'), + // Overridden per action by onQueue() or #[Async(onQueue: ...)] + 'queue' => env('HOOKS_ASYNC_QUEUE', 'default'), + ], + + ], + +]; diff --git a/src/Hook/Infrastructure/Async/EloquentModelReference.php b/src/Hook/Infrastructure/Async/EloquentModelReference.php new file mode 100644 index 00000000..86afd59f --- /dev/null +++ b/src/Hook/Infrastructure/Async/EloquentModelReference.php @@ -0,0 +1,53 @@ +exists; + } + + public function reference(object $object): array + { + /** @var Model $object */ + $reference = ['class' => $object::class, 'key' => $object->getKey()]; + + if ($object->getConnectionName() !== null) { + $reference['connection'] = $object->getConnectionName(); + } + + return $reference; + } + + public function resolve(array $reference): ?object + { + $class = $reference['class'] ?? null; + + if (! is_string($class) || ! is_a($class, Model::class, true)) { + return null; + } + + $query = isset($reference['connection']) && is_string($reference['connection']) + ? $class::on($reference['connection']) + : $class::query(); + + return $query->find($reference['key'] ?? null); + } +} diff --git a/src/Hook/Infrastructure/Async/QueueDriver.php b/src/Hook/Infrastructure/Async/QueueDriver.php new file mode 100644 index 00000000..7ae7dab4 --- /dev/null +++ b/src/Hook/Infrastructure/Async/QueueDriver.php @@ -0,0 +1,50 @@ +config->get(sprintf('queue.connections.%s.driver', $this->connection())); + + return is_string($driver) && ! in_array($driver, ['sync', 'null'], true); + } + + public function dispatch(AsyncPayload $payload, int $delay = 0): void + { + $job = (new RunAsyncAction($payload->toJson())) + ->onConnection($this->connection()) + ->onQueue($payload->queue ?? $this->config->get('hooks.async.queue.queue', 'default')); + + if ($delay > 0) { + $job->delay($delay); + } + + $this->dispatcher->dispatch($job); + } + + private function connection(): string + { + return (string) ($this->config->get('hooks.async.queue.connection') ?? $this->config->get('queue.default', 'sync')); + } +} diff --git a/src/Hook/Infrastructure/Jobs/RunAsyncAction.php b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php new file mode 100644 index 00000000..62f58fad --- /dev/null +++ b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php @@ -0,0 +1,58 @@ +payload, throwOnFinalFailure: true); + } + + /** + * Name shown by Horizon and in the failed jobs table: the hook and the handler. + */ + public function displayName(): string + { + $payload = json_decode($this->payload, true); + + if (! is_array($payload) || ! is_string($payload['hook'] ?? null) || ! is_string($payload['handler'] ?? null)) { + return self::class; + } + + $handler = str_starts_with($payload['handler'], 'closure:') ? 'closure' : $payload['handler']; + + return sprintf('pollora/async %s %s', $payload['hook'], $handler); + } +} diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php new file mode 100644 index 00000000..2d03fcb5 --- /dev/null +++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php @@ -0,0 +1,75 @@ +mergeConfigFrom(__DIR__.'/../../../../config/hooks.php', 'hooks'); + } + + public function boot(): void + { + $this->publishes([ + __DIR__.'/../../../../config/hooks.php' => config_path('hooks.php'), + ], 'pollora-hooks'); + + $this->configureAsync($this->app->make(Repository::class)); + } + + private function configureAsync(Repository $config): void + { + $container = $this->app; + + Async::extend('queue', static fn (): AsyncDriver => new QueueDriver($container->make(Dispatcher::class), $config)); + Async::setAutoDrivers(['queue', 'action-scheduler', 'wp-cron']); + + $default = $config->get('hooks.async.default', 'auto'); + Async::setDefaultDriver(is_string($default) && $default !== '' && $default !== 'auto' ? $default : null); + + Async::setDefaults( + tries: (int) $config->get('hooks.async.tries', 1), + backoff: (array) $config->get('hooks.async.backoff', [10, 60, 300]), + asUser: (bool) $config->get('hooks.async.as_user', false), + ); + + Async::setDebug((bool) $config->get('app.debug', false)); + Async::useClosureKey(is_string($key = $config->get('app.key')) ? $key : null); + Async::reference(new EloquentModelReference); + Async::injectParametersUsing(static fn (\ReflectionParameter $parameter): mixed => self::resolveParameter($container, $parameter)); + + Async::reportUsing(static function (string $message, array $context) use ($container): void { + $container->make(LoggerInterface::class)->log(isset($context['exception']) ? 'error' : 'warning', $message, $context); + }); + } + + private static function resolveParameter(Container $container, \ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + return $type instanceof \ReflectionNamedType ? $container->make($type->getName()) : null; + } +} diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index 48ce82aa..b83b4b7a 100755 --- a/src/Providers/PolloraServiceProvider.php +++ b/src/Providers/PolloraServiceProvider.php @@ -23,6 +23,7 @@ use Pollora\Exceptions\Infrastructure\Providers\ExceptionServiceProvider; use Pollora\Foundation\Providers\ArtisanServiceProvider; use Pollora\Hashing\HashServiceProvider; +use Pollora\Hook\Infrastructure\Providers\AsyncServiceProvider; use Pollora\Hook\Infrastructure\Providers\HookServiceProvider; use Pollora\Logging\Infrastructure\Providers\LoggingServiceProvider; use Pollora\Login\Infrastructure\Providers\LoginServiceProvider; @@ -106,6 +107,7 @@ public function register(): void $this->app->register(WordPressMailServiceProvider::class); $this->app->register(HookServiceProvider::class); + $this->app->register(AsyncServiceProvider::class); $this->app->register(PermalinkServiceProvider::class); $this->app->register(ThemeServiceProvider::class); diff --git a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php new file mode 100644 index 00000000..5dae74fd --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php @@ -0,0 +1,138 @@ + 7]; +} + +beforeEach(function (): void { + Async::flush(); +}); + +afterEach(function (): void { + Async::flush(); +}); + +function bootAsyncProvider(array $config = []): void +{ + config(['queue.default' => 'sync', 'queue.connections.sync' => ['driver' => 'sync'], 'queue.connections.database' => ['driver' => 'database'], ...$config]); + app()->register(AsyncServiceProvider::class, force: true); +} + +it('merges config/hooks.php and publishes it under the pollora-hooks tag', function (): void { + bootAsyncProvider(); + + expect(config('hooks.async.default'))->toBe('auto') + ->and(config('hooks.async.tries'))->toBe(1) + ->and(array_values(ServiceProvider::pathsToPublish(AsyncServiceProvider::class, 'pollora-hooks')))->toBe([config_path('hooks.php')]); +}); + +it('makes the Laravel queue the first driver auto tries, skipped on a sync connection', function (): void { + bootAsyncProvider(); + + expect(Async::defaultDriver())->toBe('wp-cron'); + + config(['queue.default' => 'database']); + + expect(Async::defaultDriver())->toBe('queue'); +}); + +it('sets the default driver from the configuration', function (): void { + bootAsyncProvider(['hooks.async.default' => 'sync']); + + expect(Async::defaultDriver())->toBe('sync'); +}); + +it('starts registrations from the configured attempts, backoff and user', function (): void { + bootAsyncProvider(['hooks.async.tries' => 3, 'hooks.async.backoff' => [5, 50], 'hooks.async.as_user' => true]); + + expect(Async::defaults())->toBe(['tries' => 3, 'backoff' => [5, 50], 'asUser' => true]); +}); + +it('follows app.debug', function (bool $debug): void { + bootAsyncProvider(['app.debug' => $debug]); + + expect(Async::isDebug())->toBe($debug); +})->with([true, false]); + +it('signs closures with the application key', function (): void { + bootAsyncProvider(['app.key' => 'base64:application-key']); + + expect(Async::closureKey())->toBe('base64:application-key'); +}); + +it('does not sign closures with an empty application key', function (): void { + bootAsyncProvider(['app.key' => '']); + + expect(fn (): string => Async::closureKey())->toThrow(UnresolvableHandler::class, 'without a signing key'); +}); + +it('sends incidents to the Laravel log, as errors when they carry an exception', function (): void { + $logger = new class extends AbstractLogger + { + /** @var list