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}> */ + public array $records = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->records[] = [$level, $message, $context]; + } + }; + $exception = new RuntimeException('CRM unreachable'); + app()->instance(LoggerInterface::class, $logger); + bootAsyncProvider(); + + Async::report('Lock released', ['hook' => 'save_post']); + Async::report($exception); + + expect($logger->records)->toBe([ + ['warning', 'Lock released', ['hook' => 'save_post']], + ['error', 'CRM unreachable', ['exception' => $exception]], + ]); +}); + +it('resolves the handler parameters that are not hook arguments from the container', function (): void { + bootAsyncProvider(); + $parameter = (new ReflectionFunction(fn (int $id, AsyncProviderService $service): null => null))->getParameters()[1]; + + expect(Async::isInjectable($parameter))->toBeTrue() + ->and(Async::inject($parameter)->name)->toBe('from the container'); +}); + +it('injects nothing for a parameter without a named type', function (): void { + $resolve = (new ReflectionClass(AsyncServiceProvider::class))->getMethod('resolveParameter'); + $parameter = (new ReflectionFunction(fn (int|string $id): null => null))->getParameters()[0]; + + expect($resolve->invoke(null, app(), $parameter))->toBeNull(); +}); + +it('carries Eloquent models by reference', function (): void { + bootAsyncProvider(); + $fake = Async::fake(); + $handler = new QueuedHandler('save_post', 10, 'App\Hooks\Sync@handle', 'App\Hooks\Sync@handle', 1, 1, new PendingAsync(static fn (): null => null)); + + Async::dispatcher()->dispatch($handler, [new AsyncProviderModel]); + + expect($fake)->toBeInstanceOf(AsyncFake::class) + ->and($fake->dispatched()[0]->arguments[0])->toBe(['@type' => 'ref', 'kind' => 'eloquent', 'ref' => ['class' => AsyncProviderModel::class, 'key' => 7]]); +}); diff --git a/tests/Feature/Hook/Async/EloquentModelReferenceTest.php b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php new file mode 100644 index 00000000..fd97d6b3 --- /dev/null +++ b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php @@ -0,0 +1,63 @@ + 'testing', 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); + Schema::create('async_reference_orders', function (Blueprint $table): void { + $table->id(); + $table->string('status'); + }); + + $this->reference = new EloquentModelReference; +}); + +it('carries saved models only', function (): void { + expect($this->reference->name())->toBe('eloquent') + ->and($this->reference->supports(AsyncReferenceOrder::create(['status' => 'paid'])))->toBeTrue() + ->and($this->reference->supports(new AsyncReferenceOrder(['status' => 'draft'])))->toBeFalse() + ->and($this->reference->supports(new stdClass))->toBeFalse(); +}); + +it('identifies a model by class and key, with its connection when it has one', function (): void { + $order = (new AsyncReferenceOrder)->forceFill(['id' => 5]); + $order->exists = true; + + expect($this->reference->reference($order))->toBe(['class' => AsyncReferenceOrder::class, 'key' => 5]); + + $order->setConnection('testing'); + + expect($this->reference->reference($order))->toBe(['class' => AsyncReferenceOrder::class, 'key' => 5, 'connection' => 'testing']); +}); + +it('reloads the model in its current state, on its connection', function (?string $connection): void { + $order = AsyncReferenceOrder::create(['status' => 'paid']); + $identifier = $this->reference->reference($connection === null ? $order : $order->setConnection($connection)); + $order->update(['status' => 'refunded']); + + $reloaded = $this->reference->resolve($identifier); + + expect($reloaded)->toBeInstanceOf(AsyncReferenceOrder::class) + ->and($reloaded->status)->toBe('refunded'); +})->with(['default connection' => [null], 'named connection' => ['testing']]); + +it('returns null for a deleted model or a class that is not a model', function (array $identifier): void { + expect($this->reference->resolve($identifier))->toBeNull(); +})->with([ + 'deleted' => [['class' => AsyncReferenceOrder::class, 'key' => 999]], + 'not a model' => [['class' => stdClass::class, 'key' => 1]], + 'missing class' => [['key' => 1]], +]); diff --git a/tests/Feature/Hook/Async/QueueDriverTest.php b/tests/Feature/Hook/Async/QueueDriverTest.php new file mode 100644 index 00000000..14e48fba --- /dev/null +++ b/tests/Feature/Hook/Async/QueueDriverTest.php @@ -0,0 +1,85 @@ + 'database', + 'queue.connections.database' => ['driver' => 'database'], + 'queue.connections.redis' => ['driver' => 'redis'], + 'queue.connections.sync' => ['driver' => 'sync'], + 'queue.connections.null' => ['driver' => 'null'], + 'hooks.async.queue' => ['connection' => null, 'queue' => 'default'], + ]); +}); + +function queueDriver(): QueueDriver +{ + return new QueueDriver(resolve(Dispatcher::class), resolve('config')); +} + +function queuePayload(?string $queue = null): AsyncPayload +{ + return new AsyncPayload( + id: AsyncPayload::newId(), + hook: 'save_post', + handler: 'App\Hooks\Sync@handle', + priority: 10, + arguments: [42], + origin: ['userId' => 1, 'blogId' => 1, 'locale' => 'fr_FR', 'dispatchedAt' => '2026-10-07T10:00:00+00:00'], + queue: $queue, + ); +} + +describe('available()', function (): void { + it('is available on a real queue connection', function (string $connection): void { + config(['queue.default' => $connection]); + + expect(queueDriver()->available())->toBeTrue(); + })->with(['database', 'redis']); + + it('is never available on a connection that would run the handler in the request, or drop it', function (string $connection): void { + config(['queue.default' => $connection]); + + expect(queueDriver()->available())->toBeFalse(); + })->with(['sync', 'null', 'missing']); + + it('follows the connection set for async actions before the default one', function (): void { + config(['queue.default' => 'sync', 'hooks.async.queue.connection' => 'redis']); + + expect(queueDriver()->available())->toBeTrue(); + }); +}); + +describe('dispatch()', function (): void { + beforeEach(function (): void { + Bus::fake(); + }); + + it('dispatches a RunAsyncAction job carrying the payload, on the configured connection and queue', function (): void { + $payload = queuePayload(); + + queueDriver()->dispatch($payload); + + Bus::assertDispatched(RunAsyncAction::class, fn (RunAsyncAction $job): bool => $job->payload === $payload->toJson() + && $job->connection === 'database' + && $job->queue === 'default' + && $job->delay === null); + }); + + it('uses the queue name of the action and its delay', function (): void { + config(['hooks.async.queue.connection' => 'redis']); + + queueDriver()->dispatch(queuePayload('integrations'), 30); + + Bus::assertDispatched(RunAsyncAction::class, fn (RunAsyncAction $job): bool => $job->connection === 'redis' + && $job->queue === 'integrations' + && $job->delay === 30); + }); +}); diff --git a/tests/Feature/Hook/Async/RunAsyncActionTest.php b/tests/Feature/Hook/Async/RunAsyncActionTest.php new file mode 100644 index 00000000..f53cafb7 --- /dev/null +++ b/tests/Feature/Hook/Async/RunAsyncActionTest.php @@ -0,0 +1,91 @@ +> */ + public static array $calls = []; + + public static bool $fails = false; + + public function handle(int $postId): void + { + if (self::$fails) { + throw new RuntimeException('CRM unreachable'); + } + + self::$calls[] = [$postId]; + } +} + +beforeEach(function (): void { + Functions\when('wp_cache_flush_runtime')->justReturn(true); + Async::flush(); + RunAsyncActionFixture::$calls = []; + RunAsyncActionFixture::$fails = false; + $this->incidents = []; + Async::reportUsing(function (string $message): void { + $this->incidents[] = $message; + }); +}); + +afterEach(function (): void { + Async::flush(); +}); + +function runAsyncActionPayload(string $handler = 'RunAsyncActionFixture@handle'): string +{ + return (new AsyncPayload( + id: AsyncPayload::newId(), + hook: 'save_post', + handler: $handler, + priority: 10, + arguments: [42], + origin: ['userId' => 1, 'blogId' => 1, 'locale' => 'fr_FR', 'dispatchedAt' => '2026-10-07T10:00:00+00:00'], + ))->toJson(); +} + +it('runs the handler, after forgetting what WordPress cached in memory', function (): void { + $flushes = 0; + Functions\when('wp_cache_flush_runtime')->alias(function () use (&$flushes): bool { + $flushes++; + + return true; + }); + + (new RunAsyncAction(runAsyncActionPayload()))->handle(); + + expect(RunAsyncActionFixture::$calls)->toBe([[42]]) + ->and($flushes)->toBe(1); +}); + +it('throws the last failure, so the job lands in the failed jobs table, without reporting it twice', function (): void { + RunAsyncActionFixture::$fails = true; + + expect(fn () => (new RunAsyncAction(runAsyncActionPayload()))->handle())->toThrow(RuntimeException::class, 'CRM unreachable'); + expect($this->incidents)->toBe([]); +}); + +it('is tried once: retries are new jobs, queued by the package after the backoff', function (): void { + $tries = (new ReflectionClass(RunAsyncAction::class))->getAttributes(Tries::class)[0]->newInstance(); + + expect($tries->tries)->toBe(1); +}); + +it('names itself after the hook and the handler', function (string $handler, string $name): void { + expect((new RunAsyncAction(runAsyncActionPayload($handler)))->displayName())->toBe($name); +})->with([ + 'class method' => ['RunAsyncActionFixture@handle', 'pollora/async save_post RunAsyncActionFixture@handle'], + 'closure' => ['closure:'.str_repeat('a', 64).':abc', 'pollora/async save_post closure'], +]); + +it('falls back to its class name for a payload it cannot read', function (): void { + expect((new RunAsyncAction('not json'))->displayName())->toBe(RunAsyncAction::class); +});