From beeb0c2e1d759eeb27e0970a45c8a3418ae9decf Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 13:44:18 +0200 Subject: [PATCH 01/10] fix(login): language switcher layout and button colours on the login screen The card rule for the login form also reached the language switcher's form and made it a full-width block, so its label sat at the window's left edge. It is an inline block again. The button rules used .pollora-login .wp-core-ui as a descendant selector while both classes are on , so they never matched: the switcher's button kept wp-admin's blue. They are compound selectors now. --- CHANGELOG.md | 4 ++++ resources/css/login.css | 25 +++++++++++++++++-------- tests/e2e/specs/features.spec.ts | 9 +++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e14a92d3..eb3955bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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) +### 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 + ## [v13.35.1](https://github.com/Pollora/framework/compare/v13.35.0...v13.35.1) - 2026-10-07 ### Security diff --git a/resources/css/login.css b/resources/css/login.css index c3e9e775..79b52b60 100644 --- a/resources/css/login.css +++ b/resources/css/login.css @@ -74,6 +74,15 @@ body.login.pollora-login::before { display: flow-root; } +/* The language switcher is a form too, but not a card: WordPress's + #language-switcher rule already strips the surface above, and this gives + back the inline-block its own sheet sets. As a block the form spanned the + page, and its left-aligned label ("Language" and its icon) sat at the + window's edge, far from the dropdown. */ +.pollora-login .language-switcher form { + display: inline-block; +} + .pollora-login form label { color: var(--pollora-login-foreground); font-size: .875rem; @@ -132,7 +141,7 @@ body.login.pollora-login::before { of the primary's family the sweep goes muddy — measured on apiary, slate #1f2937 into amber #fbbf24. Two shades of one role read as one button on every palette, and on theme-default they are Pollora's coral and pink. */ -.pollora-login .wp-core-ui .button-primary, +.pollora-login.wp-core-ui .button-primary, .pollora-login .button-primary { background: linear-gradient(135deg, var(--pollora-login-primary), var(--pollora-login-primary-hover)); border: 0; @@ -146,32 +155,32 @@ body.login.pollora-login::before { transition: filter .15s ease, box-shadow .15s ease, transform .15s ease; } -.pollora-login .wp-core-ui .button-primary:hover, +.pollora-login.wp-core-ui .button-primary:hover, .pollora-login .button-primary:hover { filter: saturate(1.1) brightness(1.03); box-shadow: 0 4px 12px -2px color-mix(in srgb, var(--pollora-login-primary) 45%, transparent); transform: translateY(-1px); } -.pollora-login .wp-core-ui .button-primary:focus, +.pollora-login.wp-core-ui .button-primary:focus, .pollora-login .button-primary:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--pollora-login-primary) 35%, transparent); transform: none; } -.pollora-login .wp-core-ui .button-primary:active, +.pollora-login.wp-core-ui .button-primary:active, .pollora-login .button-primary:active { transform: none; filter: brightness(.97); } @media (prefers-reduced-motion: reduce) { - .pollora-login .wp-core-ui .button-primary, + .pollora-login.wp-core-ui .button-primary, .pollora-login .button-primary { transition: none; } - .pollora-login .wp-core-ui .button-primary:hover, + .pollora-login.wp-core-ui .button-primary:hover, .pollora-login .button-primary:hover { transform: none; } @@ -183,7 +192,7 @@ body.login.pollora-login::before { `.button`, and left alone it kept wp-admin's blue next to a palette that has none. The password-reveal toggle is a `.button` too, and is styled above as the icon it is, so it stays out of this. */ -.pollora-login .wp-core-ui .button:not(.button-primary):not(.wp-hide-pw) { +.pollora-login.wp-core-ui .button:not(.button-primary):not(.wp-hide-pw) { color: var(--pollora-login-primary); border-color: var(--pollora-login-outline); background: var(--pollora-login-surface); @@ -191,7 +200,7 @@ body.login.pollora-login::before { min-height: 2.25rem; } -.pollora-login .wp-core-ui .button:not(.button-primary):not(.wp-hide-pw):hover { +.pollora-login.wp-core-ui .button:not(.button-primary):not(.wp-hide-pw):hover { color: var(--pollora-login-primary-hover); border-color: var(--pollora-login-primary); background: var(--pollora-login-surface); diff --git a/tests/e2e/specs/features.spec.ts b/tests/e2e/specs/features.spec.ts index 5e9ebda5..e8701783 100644 --- a/tests/e2e/specs/features.spec.ts +++ b/tests/e2e/specs/features.spec.ts @@ -204,6 +204,15 @@ test.describe('The active theme', () => { if (dressed) { await expect(page.locator('body')).toHaveClass(/pollora-login/); expect(await page.locator('style#pollora-login').textContent()).toMatch(/--pollora-login-primary:\s*[^;]+;/); + + // WordPress shows the language switcher only when a second language is installed. + // Its label sits above the dropdown, not at the window's edge. + const switcher = page.locator('#language-switcher'); + if (await switcher.count()) { + const label = await switcher.locator('label').boundingBox(); + const select = await switcher.locator('select').boundingBox(); + expect(Math.abs(label!.x - select!.x)).toBeLessThan(2); + } } await context.close(); From 7d9ed4506ec5de29383ab4d17aa1c7f155359bfc Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 17:57:45 +0200 Subject: [PATCH 02/10] feat: queue async actions through Laravel and configure them from config/hooks.php --- composer.json | 2 +- config/hooks.php | 44 ++++++ .../Async/EloquentModelReference.php | 53 +++++++ src/Hook/Infrastructure/Async/QueueDriver.php | 50 +++++++ .../Infrastructure/Jobs/RunAsyncAction.php | 58 ++++++++ .../Providers/AsyncServiceProvider.php | 75 ++++++++++ src/Providers/PolloraServiceProvider.php | 2 + .../Hook/Async/AsyncServiceProviderTest.php | 139 ++++++++++++++++++ .../Hook/Async/EloquentModelReferenceTest.php | 64 ++++++++ tests/Feature/Hook/Async/QueueDriverTest.php | 85 +++++++++++ .../Feature/Hook/Async/RunAsyncActionTest.php | 88 +++++++++++ 11 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 config/hooks.php create mode 100644 src/Hook/Infrastructure/Async/EloquentModelReference.php create mode 100644 src/Hook/Infrastructure/Async/QueueDriver.php create mode 100644 src/Hook/Infrastructure/Jobs/RunAsyncAction.php create mode 100644 src/Hook/Infrastructure/Providers/AsyncServiceProvider.php create mode 100644 tests/Feature/Hook/Async/AsyncServiceProviderTest.php create mode 100644 tests/Feature/Hook/Async/EloquentModelReferenceTest.php create mode 100644 tests/Feature/Hook/Async/QueueDriverTest.php create mode 100644 tests/Feature/Hook/Async/RunAsyncActionTest.php 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..7ac92f33 --- /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..ac81bd12 --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php @@ -0,0 +1,139 @@ + 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 () => 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..b3ac0760 --- /dev/null +++ b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php @@ -0,0 +1,64 @@ + '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..4ac4d2f3 --- /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(app(Dispatcher::class), app('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..f63ca9c2 --- /dev/null +++ b/tests/Feature/Hook/Async/RunAsyncActionTest.php @@ -0,0 +1,88 @@ +> */ + 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 { + expect((new RunAsyncAction(runAsyncActionPayload()))->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); +}); From 8a0fee9c27c8df11bdeec7d03d88c68d795bf138 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 18:03:30 +0200 Subject: [PATCH 03/10] test: cover the async queue driver, job, model reference and provider --- CHANGELOG.md | 5 +++++ src/Hook/Infrastructure/Jobs/RunAsyncAction.php | 4 ++-- .../Hook/Async/AsyncServiceProviderTest.php | 3 +-- .../Hook/Async/EloquentModelReferenceTest.php | 15 +++++++-------- tests/Feature/Hook/Async/QueueDriverTest.php | 2 +- tests/Feature/Hook/Async/RunAsyncActionTest.php | 5 ++++- 6 files changed, 20 insertions(+), 14 deletions(-) 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/src/Hook/Infrastructure/Jobs/RunAsyncAction.php b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php index 7ac92f33..62f58fad 100644 --- a/src/Hook/Infrastructure/Jobs/RunAsyncAction.php +++ b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php @@ -6,6 +6,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\Attributes\Tries; use Illuminate\Queue\InteractsWithQueue; use Pollora\Hook\Async\Async; @@ -16,13 +17,12 @@ * the job itself is tried once; the last failure is thrown, which lands it in * the failed jobs table. */ +#[Tries(1)] final class RunAsyncAction implements ShouldQueue { use InteractsWithQueue; use Queueable; - public int $tries = 1; - /** * @param string $payload The payload, as JSON */ diff --git a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php index ac81bd12..5dae74fd 100644 --- a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php +++ b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php @@ -6,7 +6,6 @@ use Illuminate\Support\ServiceProvider; use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncFake; -use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\Exceptions\UnresolvableHandler; use Pollora\Hook\Async\PendingAsync; use Pollora\Hook\Async\QueuedHandler; @@ -85,7 +84,7 @@ function bootAsyncProvider(array $config = []): void it('does not sign closures with an empty application key', function (): void { bootAsyncProvider(['app.key' => '']); - expect(fn () => Async::closureKey())->toThrow(UnresolvableHandler::class, 'without a signing 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 { diff --git a/tests/Feature/Hook/Async/EloquentModelReferenceTest.php b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php index b3ac0760..fd97d6b3 100644 --- a/tests/Feature/Hook/Async/EloquentModelReferenceTest.php +++ b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php @@ -2,19 +2,18 @@ declare(strict_types=1); +use Illuminate\Database\Eloquent\Attributes\Table; +use Illuminate\Database\Eloquent\Attributes\Unguarded; +use Illuminate\Database\Eloquent\Attributes\WithoutTimestamps; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Pollora\Hook\Infrastructure\Async\EloquentModelReference; -final class AsyncReferenceOrder extends Model -{ - public $timestamps = false; - - protected $table = 'async_reference_orders'; - - protected $guarded = []; -} +#[Unguarded] +#[Table(name: 'async_reference_orders')] +#[WithoutTimestamps] +final class AsyncReferenceOrder extends Model {} beforeEach(function (): void { config(['database.default' => 'testing', 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); diff --git a/tests/Feature/Hook/Async/QueueDriverTest.php b/tests/Feature/Hook/Async/QueueDriverTest.php index 4ac4d2f3..14e48fba 100644 --- a/tests/Feature/Hook/Async/QueueDriverTest.php +++ b/tests/Feature/Hook/Async/QueueDriverTest.php @@ -21,7 +21,7 @@ function queueDriver(): QueueDriver { - return new QueueDriver(app(Dispatcher::class), app('config')); + return new QueueDriver(resolve(Dispatcher::class), resolve('config')); } function queuePayload(?string $queue = null): AsyncPayload diff --git a/tests/Feature/Hook/Async/RunAsyncActionTest.php b/tests/Feature/Hook/Async/RunAsyncActionTest.php index f63ca9c2..f53cafb7 100644 --- a/tests/Feature/Hook/Async/RunAsyncActionTest.php +++ b/tests/Feature/Hook/Async/RunAsyncActionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Brain\Monkey\Functions; +use Illuminate\Queue\Attributes\Tries; use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Infrastructure\Jobs\RunAsyncAction; @@ -73,7 +74,9 @@ function runAsyncActionPayload(string $handler = 'RunAsyncActionFixture@handle') }); it('is tried once: retries are new jobs, queued by the package after the backoff', function (): void { - expect((new RunAsyncAction(runAsyncActionPayload()))->tries)->toBe(1); + $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 { From 1687acd6a85605aa4d15127e6083a39787d610cd Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 18:22:35 +0200 Subject: [PATCH 04/10] feat: make #[Action] methods asynchronous with #[Async] --- CHANGELOG.md | 2 + rector.php | 2 + src/Attributes/Action.php | 27 ++- src/Attributes/Async.php | 53 +++++ .../Providers/HookServiceProvider.php | 4 +- .../Services/AsyncAttributeRegistrar.php | 186 ++++++++++++++++++ .../Infrastructure/Services/HookDiscovery.php | 28 ++- .../Feature/Hook/Async/AsyncAttributeTest.php | 185 +++++++++++++++++ .../Async/Fixtures/AsyncAttributeFixtures.php | 97 +++++++++ .../plugins/e2e-features/app/AsyncQueue.php | 42 ++++ tests/e2e/specs/async.spec.ts | 40 ++++ tests/e2e/support/site.ts | 18 ++ 12 files changed, 677 insertions(+), 7 deletions(-) create mode 100644 src/Attributes/Async.php create mode 100644 src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php create mode 100644 tests/Feature/Hook/Async/AsyncAttributeTest.php create mode 100644 tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php create mode 100644 tests/e2e/specs/async.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 015eb606..19e2a55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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]`, 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 - 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 +- Errors met while registering hooks discovered by attribute went to no logger: `HookDiscovery` now receives the application's logger - 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/rector.php b/rector.php index bfb229f9..79d51b3f 100644 --- a/rector.php +++ b/rector.php @@ -41,6 +41,8 @@ // Binding sources each broken on purpose (an untyped field, a private // one): fixing them would remove what the tests check is refused. __DIR__.'/tests/Unit/BlockBinding/Fixtures/Invalid.php', + // Hook classes with empty, unused or private methods on purpose: they carry the attributes under test. + __DIR__.'/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php', ]) ->withPreparedSets( deadCode: true, diff --git a/src/Attributes/Action.php b/src/Attributes/Action.php index 0bafe195..0e1eae4b 100755 --- a/src/Attributes/Action.php +++ b/src/Attributes/Action.php @@ -6,6 +6,8 @@ use Attribute; use Pollora\Hook\Domain\Contract\Action as ActionService; +use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar; +use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionMethod; @@ -38,11 +40,32 @@ public function handle( return; } - $actionService->add( + // #[Action] targets methods only + if (! $context instanceof ReflectionMethod) { + return; + } + + // Asynchronously when the method or its class carries #[Async] + (new AsyncAttributeRegistrar($actionService, $this->logger($serviceLocator)))->register( $attribute->hook, - [$instance, $context->getName()], + $instance, + $context, $attribute->priority, $context->getNumberOfParameters() ); } + + /** + * The application logger, when the service locator provides one. + */ + private function logger(object $serviceLocator): ?LoggerInterface + { + try { + $logger = $serviceLocator->get(LoggerInterface::class); + } catch (\Throwable) { + return null; + } + + return $logger instanceof LoggerInterface ? $logger : null; + } } diff --git a/src/Attributes/Async.php b/src/Attributes/Async.php new file mode 100644 index 00000000..fb36b91c --- /dev/null +++ b/src/Attributes/Async.php @@ -0,0 +1,53 @@ +async(): #[Action] keeps the hook and + * the priority, #[Async] carries the deferred-execution options. + * + * #[Action('save_post_event')] + * #[Async(delay: 60, unique: true, tries: 3)] + * public function syncToCrm(int $postId): void {} + * + * On a class, it applies to every #[Action] method; an #[Async] on a method + * replaces the class options for that method. With several #[Action] on a + * method, it applies to all of them, except the hooks listed in $except. + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +final readonly class Async +{ + /** + * @param int|\DateInterval|null $delay Minimum delay before execution, in seconds + * @param string|null $via Driver: queue, action-scheduler, wp-cron, sync + * @param string|null $onQueue Queue name, the group with Action Scheduler + * @param bool|int $unique Merge identical triggers until the first one runs; an integer sets how long the lock lasts, in seconds + * @param int|null $tries Attempts when the handler throws + * @param int|list|null $backoff Seconds before each retry + * @param bool|null $asUser Run as the user who fired the hook + * @param string|null $capture Public method of the class recording values at trigger time + * @param string|null $when Public method of the class deciding, at trigger time, whether to queue + * @param bool $keepMissing Run with null in place of a referenced object deleted in the meantime + * @param string|list $except Hooks of the method that stay synchronous + */ + public function __construct( + public int|\DateInterval|null $delay = null, + public ?string $via = null, + public ?string $onQueue = null, + public bool|int $unique = false, + public ?int $tries = null, + public int|array|null $backoff = null, + public ?bool $asUser = null, + public ?string $capture = null, + public ?string $when = null, + public bool $keepMissing = false, + public string|array $except = [], + ) {} +} diff --git a/src/Hook/Infrastructure/Providers/HookServiceProvider.php b/src/Hook/Infrastructure/Providers/HookServiceProvider.php index dcc08479..61a64ce6 100755 --- a/src/Hook/Infrastructure/Providers/HookServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/HookServiceProvider.php @@ -16,6 +16,7 @@ use Pollora\Hook\Infrastructure\Services\HookDiscovery; use Pollora\Hook\UI\Console\ActionMakeCommand; use Pollora\Hook\UI\Console\FilterMakeCommand; +use Psr\Log\LoggerInterface; /** * Service provider for Hook feature (Infrastructure layer). @@ -76,7 +77,8 @@ public function register(): void // Register Hook Discovery $this->app->singleton(HookDiscovery::class, fn (Application $app): HookDiscovery => new HookDiscovery( $app->make(ActionContract::class), - $app->make(FilterContract::class) + $app->make(FilterContract::class), + $app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null )); if ($this->consoleDetectionService->isConsole()) { diff --git a/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php new file mode 100644 index 00000000..4cc6e430 --- /dev/null +++ b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php @@ -0,0 +1,186 @@ +getAttributes(Async::class) ?: $method->getDeclaringClass()->getAttributes(Async::class); + + return $attributes === [] ? null : $attributes[0]->newInstance(); + } + + /** + * Whether the method itself carries #[Async]. + */ + public static function isDeclaredOn(ReflectionMethod $method): bool + { + return $method->getAttributes(Async::class) !== []; + } + + /** + * Register one hook of an #[Action] method. + * + * @param int|null $acceptedArgs Null to detect it from the method + */ + public function register(string $hook, object $instance, ReflectionMethod $method, int $priority, ?int $acceptedArgs = null): void + { + $this->actions->add($hook, [$instance, $method->getName()], $priority, $acceptedArgs); + + $attribute = self::attributeFor($method); + + if (! $attribute instanceof Async) { + return; + } + + $problem = $this->problem($method, $attribute); + + if ($problem !== null) { + $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously: %s', $method->getDeclaringClass()->getName(), $method->getName(), $problem)); + + return; + } + + if (in_array($hook, (array) $attribute->except, true)) { + return; + } + + $this->configure($this->actions->async(), $attribute, $instance); + } + + /** + * Why a declaration cannot be honoured, or null. + */ + private function problem(ReflectionMethod $method, Async $attribute): ?string + { + $declaredOnMethod = self::isDeclaredOn($method); + $hooks = $this->actionHooks($method, $declaredOnMethod); + + $unknown = array_values(array_diff((array) $attribute->except, $hooks)); + if ($unknown !== []) { + return sprintf("except names '%s', which is not a hook of its #[Action] (%s)", implode("', '", $unknown), implode(', ', $hooks)); + } + + foreach (['capture' => $attribute->capture, 'when' => $attribute->when] as $option => $name) { + if ($name !== null && ! $this->isPublicMethod($method->getDeclaringClass(), $name)) { + return sprintf("%s names '%s', which is not a public method of the class", $option, $name); + } + } + + try { + if ($attribute->tries !== null) { + PendingAsync::validTries($attribute->tries); + } + + if ($attribute->backoff !== null) { + PendingAsync::validBackoff($attribute->backoff); + } + } catch (\InvalidArgumentException $invalidArgumentException) { + return $invalidArgumentException->getMessage(); + } + + if (is_int($attribute->unique) && $attribute->unique < 1) { + return sprintf('unique expects true or a lock of at least 1 second, %d given', $attribute->unique); + } + + return null; + } + + /** + * Hooks of the #[Action] attributes of the method, or of the whole class for a class-level #[Async]. + * + * @return list + */ + private function actionHooks(ReflectionMethod $method, bool $declaredOnMethod): array + { + $methods = $declaredOnMethod ? [$method] : $method->getDeclaringClass()->getMethods(ReflectionMethod::IS_PUBLIC); + $hooks = []; + + foreach ($methods as $candidate) { + foreach ($candidate->getAttributes(Action::class) as $action) { + $hooks[] = $action->newInstance()->hook; + } + } + + return array_values(array_unique($hooks)); + } + + /** + * @param \ReflectionClass $class + */ + private function isPublicMethod(\ReflectionClass $class, string $name): bool + { + return $class->hasMethod($name) && $class->getMethod($name)->isPublic(); + } + + private function configure(PendingAsync $pending, Async $attribute, object $instance): void + { + if ($attribute->delay !== null) { + $pending->delay($attribute->delay); + } + + if ($attribute->via !== null) { + $pending->via($attribute->via); + } + + if ($attribute->onQueue !== null) { + $pending->onQueue($attribute->onQueue); + } + + if ($attribute->unique === true) { + $pending->unique(); + } elseif (is_int($attribute->unique)) { + $pending->unique($attribute->unique); + } + + if ($attribute->tries !== null) { + $pending->tries($attribute->tries); + } + + if ($attribute->backoff !== null) { + $pending->backoff($attribute->backoff); + } + + if ($attribute->asUser !== null) { + $pending->asUser($attribute->asUser); + } + + if ($attribute->capture !== null) { + $pending->capture([$instance, $attribute->capture]); + } + + if ($attribute->when !== null) { + $pending->when([$instance, $attribute->when]); + } + + if ($attribute->keepMissing) { + $pending->keepMissing(); + } + } +} diff --git a/src/Hook/Infrastructure/Services/HookDiscovery.php b/src/Hook/Infrastructure/Services/HookDiscovery.php index d03fef43..d765cd17 100644 --- a/src/Hook/Infrastructure/Services/HookDiscovery.php +++ b/src/Hook/Infrastructure/Services/HookDiscovery.php @@ -5,6 +5,7 @@ namespace Pollora\Hook\Infrastructure\Services; use Pollora\Attributes\Action; +use Pollora\Attributes\Async; use Pollora\Attributes\Filter; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; @@ -88,6 +89,18 @@ public function discover(DiscoveryLocationInterface $location, DiscoveredStructu 'reflection_method' => $method, ]); } + + // An #[Async] with neither #[Action] nor #[Filter] has nothing to apply to + $asyncAttributes = $method->getAttributes(Async::class); + if ($asyncAttributes !== [] && $actionAttributes === [] && $filterAttributes === []) { + $this->getItems()->add($location, [ + 'type' => 'async', + 'class' => $className, + 'method' => $method->getName(), + 'attribute' => $asyncAttributes[0], + 'reflection_method' => $method, + ]); + } } } catch (\Throwable) { // Skip classes that can't be reflected @@ -118,17 +131,24 @@ public function apply(): void /** @var Action $action */ $action = $hookAttribute->newInstance(); - // Create instance and call method directly + // Create instance and call method directly; asynchronously when #[Async] says so $instance = $this->getInstanceFromPool($className); - $this->actionService->add( - hooks: $action->hook, - callback: [$instance, $methodName], + (new AsyncAttributeRegistrar($this->actionService, $this->logger))->register( + hook: $action->hook, + instance: $instance, + method: $reflectionMethod, priority: $action->priority ); + } elseif ($hookType === 'async') { + $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: the method has no #[Action] to make asynchronous.', ltrim($className, '\\'), $methodName)); } elseif ($hookType === 'filter') { /** @var Filter $filter */ $filter = $hookAttribute->newInstance(); + if (AsyncAttributeRegistrar::isDeclaredOn($reflectionMethod)) { + $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: a filter returns a value to its caller and cannot be deferred.', ltrim($className, '\\'), $methodName)); + } + // Create instance and call method directly $instance = $this->getInstanceFromPool($className); $this->filterService->add( diff --git a/tests/Feature/Hook/Async/AsyncAttributeTest.php b/tests/Feature/Hook/Async/AsyncAttributeTest.php new file mode 100644 index 00000000..a1c2a448 --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncAttributeTest.php @@ -0,0 +1,185 @@ +fake = Async::fake(); + $this->actions = new Action; + $this->logger = new class extends AbstractLogger + { + /** @var list */ + public array $errors = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->errors[] = (string) $message; + } + }; + $this->registrar = new AsyncAttributeRegistrar($this->actions, $this->logger); + $this->register = function (string $class, string $method, ?string $hook = null): void { + $reflection = new ReflectionMethod($class, $method); + foreach ($reflection->getAttributes(ActionAttribute::class) as $action) { + $action = $action->newInstance(); + if ($hook === null || $hook === $action->hook) { + $this->registrar->register($action->hook, new $class, $reflection, $action->priority); + } + } + }; +}); + +afterEach(function (): void { + Async::flush(); +}); + +/** + * The callback registered on a hook, as WordPress would call it. + */ +function registeredCallback(Action $actions, string $hook): mixed +{ + return $actions->callbacks($hook)[0]['callback'] ?? null; +} + +it('makes an #[Action] method asynchronous with the options of its #[Async]', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'syncToCrm'); + + $queued = registeredCallback($this->actions, 'save_post_event'); + expect($queued)->toBeInstanceOf(QueuedHandler::class) + ->and($queued->priority)->toBe(20); + + $queued(42, null); + + Async::assertDispatched(AsyncAttributeHooks::class.'@syncToCrm', fn (AsyncPayload $payload, int $delay): bool => $delay === 60 + && $payload->driver === 'queue' + && $payload->queue === 'integrations' + && $payload->uniqueKey !== null + && $payload->tries === 3 + && $payload->backoff === [5] + && $payload->asUser + && $payload->keepMissing); +}); + +it('keeps the hooks listed in except synchronous', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'handle'); + + expect(registeredCallback($this->actions, 'save_post_event'))->toBeInstanceOf(QueuedHandler::class) + ->and(registeredCallback($this->actions, 'save_post_page'))->toBeArray() + ->and($this->logger->errors)->toBe([]); +}); + +it('records values with the capture method and queues only when the when method agrees', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'notify'); + $queued = registeredCallback($this->actions, 'transition_post_status'); + + $queued('draft', 'pending'); + $queued('publish', 'draft'); + + Async::assertDispatchedTimes(AsyncAttributeHooks::class, 1); + Async::assertDispatched(AsyncAttributeHooks::class, fn (AsyncPayload $payload): bool => $payload->captured === ['from' => 'draft']); +}); + +it('registers a method without #[Async] synchronously', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'synchronous'); + + expect(registeredCallback($this->actions, 'wp_loaded'))->toBeArray(); +}); + +it('applies a class #[Async] to every #[Action] method, a method #[Async] replacing it', function (): void { + ($this->register)(AsyncAttributeClassHooks::class, 'welcome'); + ($this->register)(AsyncAttributeClassHooks::class, 'audit'); + + registeredCallback($this->actions, 'user_register')(1); + registeredCallback($this->actions, 'profile_update')(1); + + Async::assertDispatched(AsyncAttributeClassHooks::class.'@welcome', fn (AsyncPayload $payload): bool => $payload->tries === 2); + Async::assertDispatched(AsyncAttributeClassHooks::class.'@audit', fn (AsyncPayload $payload): bool => $payload->tries === 5); +}); + +it('registers synchronously, and logs why, a declaration it cannot honour', function (string $method, string $reason): void { + ($this->register)(AsyncAttributeInvalidHooks::class, $method); + + expect(registeredCallback($this->actions, 'save_post'))->toBeArray() + ->and($this->logger->errors)->toHaveCount(1) + ->and($this->logger->errors[0])->toContain(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously', AsyncAttributeInvalidHooks::class, $method)) + ->and($this->logger->errors[0])->toContain($reason); +})->with([ + 'unknown hook in except' => ['typo', "except names 'save_psot', which is not a hook of its #[Action] (save_post)"], + 'missing capture method' => ['missingCapture', "capture names 'missing', which is not a public method of the class"], + 'private when method' => ['privateCondition', "when names 'hidden', which is not a public method of the class"], + 'no attempt' => ['noAttempt', 'at least 1 attempt'], + 'negative backoff' => ['negativeBackoff', 'one or more delays'], + 'empty lock' => ['emptyLock', 'unique expects true or a lock of at least 1 second, 0 given'], +]); + +describe('Discovery', function (): void { + beforeEach(function (): void { + $this->discovery = new HookDiscovery($this->actions, new Filter, $this->logger); + $this->discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(AsyncAttributeHooks::class)), new ReflectionCache); + $this->discovery->apply(); + }); + + it('registers #[Action] methods asynchronously or not, as declared', function (): void { + expect(registeredCallback($this->actions, 'transition_post_status'))->toBeInstanceOf(QueuedHandler::class) + ->and(registeredCallback($this->actions, 'wp_loaded'))->toBeArray(); + }); + + it('logs an #[Async] on a filter, which still registers, and an #[Async] without #[Action]', function (): void { + expect($this->logger->errors)->toContain(sprintf('#[Async] on %s::filterContent() is ignored: a filter returns a value to its caller and cannot be deferred.', AsyncAttributeHooks::class)) + ->and($this->logger->errors)->toContain(sprintf('#[Async] on %s::orphan() is ignored: the method has no #[Action] to make asynchronous.', AsyncAttributeHooks::class)); + }); +}); + +describe('Action::handle()', function (): void { + it('registers through the registrar, asynchronously when #[Async] says so', function (): void { + $locator = new readonly class($this->actions, $this->logger) + { + public function __construct(private ActionContract $actions, private AbstractLogger $logger) {} + + public function get(string $id): mixed + { + return $id === ActionContract::class ? $this->actions : $this->logger; + } + }; + $method = new ReflectionMethod(AsyncAttributeHooks::class, 'notify'); + $attribute = new ActionAttribute('transition_post_status'); + + $attribute->handle($locator, new AsyncAttributeHooks, $method, $attribute); + + expect(registeredCallback($this->actions, 'transition_post_status'))->toBeInstanceOf(QueuedHandler::class); + }); + + it('works without a logger in the service locator, and ignores a class context', function (): void { + $locator = new readonly class($this->actions) + { + public function __construct(private ActionContract $actions) {} + + public function get(string $id): mixed + { + return $id === ActionContract::class ? $this->actions : throw new RuntimeException('Not bound'); + } + }; + $attribute = new ActionAttribute('save_post'); + + $attribute->handle($locator, new AsyncAttributeInvalidHooks, new ReflectionMethod(AsyncAttributeInvalidHooks::class, 'typo'), $attribute); + $attribute->handle($locator, new AsyncAttributeHooks, new ReflectionClass(AsyncAttributeHooks::class), new ActionAttribute('init')); + + expect(registeredCallback($this->actions, 'save_post'))->toBeArray() + ->and($this->actions->callbacks('init'))->toBeNull(); + }); +}); diff --git a/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php b/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php new file mode 100644 index 00000000..43b40f79 --- /dev/null +++ b/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php @@ -0,0 +1,97 @@ + + */ + public function captureTransition(string $new, string $old): array + { + return ['from' => $old]; + } + + public function isPublished(string $new): bool + { + return $new === 'publish'; + } + + #[FilterAttribute('the_content')] + #[AsyncAttribute] + public function filterContent(string $content): string + { + return $content; + } + + #[AsyncAttribute] + public function orphan(): void {} +} + +#[AsyncAttribute(tries: 2)] +final class AsyncAttributeClassHooks +{ + #[ActionAttribute('user_register')] + public function welcome(int $userId): void {} + + #[ActionAttribute('profile_update')] + #[AsyncAttribute(tries: 5)] + public function audit(int $userId): void {} +} + +final class AsyncAttributeInvalidHooks +{ + #[ActionAttribute('save_post')] + #[AsyncAttribute(except: 'save_psot')] + public function typo(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(capture: 'missing')] + public function missingCapture(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(when: 'hidden')] + public function privateCondition(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(tries: 0)] + public function noAttempt(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(backoff: [-1])] + public function negativeBackoff(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(unique: 0)] + public function emptyLock(int $postId): void {} + + private function hidden(): bool + { + return true; + } +} diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php b/tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php new file mode 100644 index 00000000..4c1c453c --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php @@ -0,0 +1,42 @@ + true]); + } + + #[Action('e2e_async_event')] + #[Async(via: 'queue')] + public function handle(int $id, AsyncContext $context, LoggerInterface $logger): void + { + update_option('e2e_async_result', [ + 'id' => $id, + 'hook' => $context->hook, + 'console' => app()->runningInConsole(), + 'injected' => $logger instanceof LoggerInterface, + ], false); + } +} diff --git a/tests/e2e/specs/async.spec.ts b/tests/e2e/specs/async.spec.ts new file mode 100644 index 00000000..88167e7a --- /dev/null +++ b/tests/e2e/specs/async.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { request as playwrightRequest } from '@playwright/test'; +import { artisan, wp } from '../support/site'; + +/** + * Asynchronous actions: an #[Action] made asynchronous by #[Async(via: 'queue')] + * is queued as a Laravel job when its hook fires, and runs in a queue worker. + */ +test.describe('Asynchronous actions', () => { + const result = (): unknown => { + try { + return JSON.parse(wp('option', 'get', 'e2e_async_result', '--format=json')); + } catch { + return null; + } + }; + + test.beforeEach(() => { + try { + wp('option', 'delete', 'e2e_async_result'); + } catch { + // Not there yet + } + // Leave no job of an earlier run behind + artisan('queue:clear', '--force'); + }); + + test('an #[Action] with #[Async] runs in a Laravel queue worker, not in the request', async () => { + const visitor = await playwrightRequest.newContext({ ignoreHTTPSErrors: true, storageState: { cookies: [], origins: [] } }); + const response = await visitor.get(new URL('wp-admin/admin-ajax.php?action=e2e_async_dispatch&id=42', process.env.WP_BASE_URL).toString()); + + expect(await response.json()).toEqual({ success: true, data: { queued: true } }); + expect(result()).toBeNull(); + await visitor.dispose(); + + artisan('queue:work', '--once', '--stop-when-empty'); + + expect(result()).toEqual({ id: 42, hook: 'e2e_async_event', console: true, injected: true }); + }); +}); diff --git a/tests/e2e/support/site.ts b/tests/e2e/support/site.ts index 099d04d4..3ee38b19 100644 --- a/tests/e2e/support/site.ts +++ b/tests/e2e/support/site.ts @@ -25,6 +25,24 @@ export function wp(...args: string[]): string { } } +/** + * Run an Artisan command against the site under test: E2E_ARTISAN ("ddev exec php artisan" by default), + * from E2E_SITE_DIR. + */ +export function artisan(...args: string[]): string { + const [command, ...prefix] = (process.env.E2E_ARTISAN ?? 'ddev exec php artisan').split(' '); + + try { + return execFileSync(command, [...prefix, ...args], { + cwd: process.env.E2E_SITE_DIR ?? process.cwd(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + throw new Error(`Artisan failed: ${command} ${prefix.join(' ')} ${args.join(' ')}\n${(error as { stderr?: string }).stderr ?? ''}`); + } +} + /** * The template that rendered a page, from the marker the framework writes under WP_DEBUG. */ From fbc932688b0b095bb451065b0b5fa31408cd095c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 18:37:18 +0200 Subject: [PATCH 05/10] feat: leave the Laravel queue out of auto until a connection is set for async actions --- CHANGELOG.md | 2 +- config/hooks.php | 9 ++++++--- .../Providers/AsyncServiceProvider.php | 7 +++++-- .../Feature/Hook/Async/AsyncServiceProviderTest.php | 12 +++++++++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e2a55d..45522809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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 +- 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 - 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 diff --git a/config/hooks.php b/config/hooks.php index 0ebc8b3f..8b485337 100644 --- a/config/hooks.php +++ b/config/hooks.php @@ -12,8 +12,11 @@ | Action::add(...)->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. + | default: auto picks the Laravel queue once HOOKS_ASYNC_CONNECTION names + | the connection a queue worker runs (never a "sync" or "null" one), then + | Action Scheduler when a plugin bundling it is active, then WP-Cron. + | Without a worker, queued jobs would never run, so the queue is opt-in; + | via('queue') or HOOKS_ASYNC_DRIVER=queue uses the default connection. | Set HOOKS_ASYNC_DRIVER=sync in a developer's .env to run every handler | at once, without touching the code. | @@ -33,7 +36,7 @@ 'as_user' => false, 'queue' => [ - // null: the default queue connection + // The connection a worker runs. null: the default connection, and auto leaves the queue out '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/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php index 2d03fcb5..c8b9f7b9 100644 --- a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php @@ -18,7 +18,7 @@ * Connects the asynchronous actions of pollora/hook to the application. * * - config/hooks.php (publishable with the "pollora-hooks" tag): default driver, attempts, backoff, as_user, queue - * - the "queue" driver, first of the drivers "auto" tries + * - the "queue" driver, first of the drivers "auto" tries once hooks.async.queue.connection is set * - debug mode from app.debug, incidents to the Laravel log * - closures signed with the application key * - Eloquent models carried by reference @@ -45,7 +45,10 @@ 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']); + // 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']); $default = $config->get('hooks.async.default', 'auto'); Async::setDefaultDriver(is_string($default) && $default !== '' && $default !== 'auto' ? $default : null); diff --git a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php index 5dae74fd..f6aa8012 100644 --- a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php +++ b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php @@ -47,14 +47,20 @@ function bootAsyncProvider(array $config = []): void ->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(); +it('leaves the Laravel queue out of auto until a connection is set for async actions', function (): void { + bootAsyncProvider(['queue.default' => 'database']); expect(Async::defaultDriver())->toBe('wp-cron'); +}); - config(['queue.default' => 'database']); +it('makes the Laravel queue the first driver auto tries once a connection is set, skipped when it is sync', function (): void { + bootAsyncProvider(['hooks.async.queue.connection' => 'database']); expect(Async::defaultDriver())->toBe('queue'); + + config(['hooks.async.queue.connection' => 'sync']); + + expect(Async::defaultDriver())->toBe('wp-cron'); }); it('sets the default driver from the configuration', function (): void { From 553b92f45f66a08e4833b6fb21db2f71aa7d34e2 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 19:07:26 +0200 Subject: [PATCH 06/10] feat: check asynchronous actions in pollora:doctor and Site Health --- CHANGELOG.md | 1 + src/Attributes/Action.php | 19 +- .../Services/AsyncDeclarationFailures.php | 33 +++ .../Checks/AsyncActionsCheck.php | 226 ++++++++++++++++++ .../Providers/AsyncServiceProvider.php | 6 + .../Providers/HookServiceProvider.php | 7 +- .../Services/AsyncAttributeRegistrar.php | 7 +- .../Services/AsyncInspector.php | 188 +++++++++++++++ .../Infrastructure/Services/HookDiscovery.php | 8 +- .../Hook/Async/AsyncActionsCheckTest.php | 221 +++++++++++++++++ .../Feature/Hook/Async/AsyncAttributeTest.php | 15 +- .../Feature/Hook/Async/AsyncInspectorTest.php | 163 +++++++++++++ tests/e2e/specs/doctor.spec.ts | 2 +- 13 files changed, 880 insertions(+), 16 deletions(-) create mode 100644 src/Hook/Application/Services/AsyncDeclarationFailures.php create mode 100644 src/Hook/Infrastructure/Checks/AsyncActionsCheck.php create mode 100644 src/Hook/Infrastructure/Services/AsyncInspector.php create mode 100644 tests/Feature/Hook/Async/AsyncActionsCheckTest.php create mode 100644 tests/Feature/Hook/Async/AsyncInspectorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 45522809..0b4ddd1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/Attributes/Action.php b/src/Attributes/Action.php index 0e1eae4b..fad73f62 100755 --- a/src/Attributes/Action.php +++ b/src/Attributes/Action.php @@ -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; @@ -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, @@ -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 $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; } } diff --git a/src/Hook/Application/Services/AsyncDeclarationFailures.php b/src/Hook/Application/Services/AsyncDeclarationFailures.php new file mode 100644 index 00000000..c92f7ad7 --- /dev/null +++ b/src/Hook/Application/Services/AsyncDeclarationFailures.php @@ -0,0 +1,33 @@ + 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 Reason, by method ("Class::method()") + */ + public function all(): array + { + return $this->failures; + } +} diff --git a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php new file mode 100644 index 00000000..5da9aa05 --- /dev/null +++ b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php @@ -0,0 +1,226 @@ +failures->all(); + $registrations = $this->inspector->registrations(); + + if ($failures === [] && $registrations === []) { + return CheckResult::ok('No asynchronous action registered.'); + } + + /** @var list $errors Line and fix */ + $errors = []; + /** @var list $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 $registrations + * @return array> + */ + 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 + */ + 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 the drivers in use queued and nothing ran. + * + * @param list $drivers + * @return list + */ + 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->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', + ]; + } + } + + 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; + } +} diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php index c8b9f7b9..03a61093 100644 --- a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php @@ -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; /** @@ -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 { @@ -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)); } diff --git a/src/Hook/Infrastructure/Providers/HookServiceProvider.php b/src/Hook/Infrastructure/Providers/HookServiceProvider.php index 61a64ce6..cf22814d 100755 --- a/src/Hook/Infrastructure/Providers/HookServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/HookServiceProvider.php @@ -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; @@ -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()) { diff --git a/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php index 4cc6e430..21829005 100644 --- a/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php +++ b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php @@ -6,6 +6,7 @@ use Pollora\Attributes\Action; use Pollora\Attributes\Async; +use Pollora\Hook\Application\Services\AsyncDeclarationFailures; use Pollora\Hook\Async\PendingAsync; use Pollora\Hook\Domain\Contract\Action as ActionContract; use Psr\Log\LoggerInterface; @@ -16,14 +17,15 @@ * * A declaration that cannot be honoured (an unknown hook in except, a capture * or when method that is not a public method of the class, invalid attempts, - * backoff or lock duration) is logged and the action is registered - * synchronously: the work still happens. + * backoff or lock duration) is logged, kept for pollora:doctor, and the + * action is registered synchronously: the work still happens. */ final readonly class AsyncAttributeRegistrar { public function __construct( private ActionContract $actions, private ?LoggerInterface $logger = null, + private ?AsyncDeclarationFailures $failures = null, ) {} /** @@ -63,6 +65,7 @@ public function register(string $hook, object $instance, ReflectionMethod $metho if ($problem !== null) { $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously: %s', $method->getDeclaringClass()->getName(), $method->getName(), $problem)); + $this->failures?->fail($method->getDeclaringClass()->getName(), $method->getName(), $problem); return; } diff --git a/src/Hook/Infrastructure/Services/AsyncInspector.php b/src/Hook/Infrastructure/Services/AsyncInspector.php new file mode 100644 index 00000000..da55592e --- /dev/null +++ b/src/Hook/Infrastructure/Services/AsyncInspector.php @@ -0,0 +1,188 @@ +async() or #[Async]. + * + * @return list + */ + public function registrations(): array + { + global $wp_filter; + + $registrations = []; + + foreach ((array) $wp_filter as $hook) { + foreach (is_object($hook) && isset($hook->callbacks) ? (array) $hook->callbacks : [] as $callbacks) { + foreach ((array) $callbacks as $callback) { + if (($callback['function'] ?? null) instanceof QueuedHandler) { + $registrations[] = $callback['function']; + } + } + } + } + + return $registrations; + } + + /** + * Whether WordPress runs WP-Cron on page loads. Pollora turns it off: a system cron must request wp-cron.php. + */ + public function cronRunsOnPageLoad(): bool + { + return ! defined('DISABLE_WP_CRON') || ! constant('DISABLE_WP_CRON'); + } + + /** + * WP-Cron events due for longer than that: nothing runs wp-cron.php. + * + * @return array{count: int, oldest: int|null} Count, and the timestamp of the oldest one + */ + public function overdueCronEvents(int $seconds): array + { + if (! function_exists('_get_cron_array')) { + return ['count' => 0, 'oldest' => null]; + } + + $threshold = time() - $seconds; + $count = 0; + $oldest = null; + + foreach ((array) _get_cron_array() as $timestamp => $hooks) { + if (! is_int($timestamp) || $timestamp >= $threshold) { + continue; + } + + foreach ((array) $hooks as $events) { + $count += count((array) $events); + } + + $oldest = $oldest === null ? $timestamp : min($oldest, $timestamp); + } + + return ['count' => $count, 'oldest' => $oldest]; + } + + /** + * Stored payloads dispatched longer ago than that, that no event will run: + * the daily recovery task did not schedule them again. + */ + public function strandedPayloads(int $seconds): int + { + $threshold = time() - $seconds; + $stranded = 0; + + foreach ($this->options(PayloadStore::OPTION_PREFIX) as $name => $value) { + $id = substr($name, strlen(PayloadStore::OPTION_PREFIX)); + + try { + $dispatchedAt = (new \DateTimeImmutable(AsyncPayload::fromJson($value)->origin['dispatchedAt']))->getTimestamp(); + } catch (\Throwable) { + $stranded++; + + continue; + } + + if ($dispatchedAt < $threshold && ! $this->isPending($id)) { + $stranded++; + } + } + + return $stranded; + } + + /** + * Unique locks expired longer ago than that: the daily maintenance task does not run. + */ + public function expiredLocks(int $seconds): int + { + $threshold = time() - $seconds; + + return count(array_filter($this->options(UniqueLock::OPTION_PREFIX), static fn (string $expiresAt): bool => (int) $expiresAt < $threshold)); + } + + /** + * Jobs of a database queue connection available for longer than that and never picked up: no worker runs. + * + * @return array{count: int, oldest: int|null}|null Null when the connection does not keep its jobs in a table + */ + public function waitingJobs(string $connection, int $seconds): ?array + { + $settings = $this->config->get('queue.connections.'.$connection); + + if (! is_array($settings) || ($settings['driver'] ?? null) !== 'database') { + return null; + } + + $jobs = $this->databases->connection($settings['connection'] ?? null) + ->table($settings['table'] ?? 'jobs') + ->whereNull('reserved_at') + ->where('available_at', '<', time() - $seconds); + + $count = (clone $jobs)->count(); + + return ['count' => $count, 'oldest' => $count > 0 ? (int) $jobs->min('available_at') : null]; + } + + /** + * @return array Option values, by name + */ + private function options(string $prefix): array + { + global $wpdb; + + if (! is_object($wpdb)) { + return []; + } + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT %d", + $wpdb->esc_like($prefix).'%', + self::BATCH, + ), 'ARRAY_A'); + + $options = []; + foreach ((array) $rows as $row) { + $options[(string) $row['option_name']] = (string) $row['option_value']; + } + + return $options; + } + + private function isPending(string $id): bool + { + if (function_exists('wp_next_scheduled') && wp_next_scheduled(Async::HOOK, [$id]) !== false) { + return true; + } + + return function_exists('as_has_scheduled_action') && as_has_scheduled_action(Async::HOOK, [$id]); + } +} diff --git a/src/Hook/Infrastructure/Services/HookDiscovery.php b/src/Hook/Infrastructure/Services/HookDiscovery.php index d765cd17..3c4a3903 100644 --- a/src/Hook/Infrastructure/Services/HookDiscovery.php +++ b/src/Hook/Infrastructure/Services/HookDiscovery.php @@ -12,6 +12,7 @@ use Pollora\Discovery\Domain\Contracts\ReflectionCacheInterface; use Pollora\Discovery\Domain\Services\HasInstancePool; use Pollora\Discovery\Domain\Services\IsDiscovery; +use Pollora\Hook\Application\Services\AsyncDeclarationFailures; use Pollora\Hook\Domain\Contract\Action as ActionContract; use Pollora\Hook\Domain\Contract\Filter as FilterContract; use Psr\Log\LoggerInterface; @@ -40,7 +41,8 @@ final class HookDiscovery implements DiscoveryInterface public function __construct( private readonly ActionContract $actionService, private readonly FilterContract $filterService, - private readonly ?LoggerInterface $logger = null + private readonly ?LoggerInterface $logger = null, + private readonly ?AsyncDeclarationFailures $asyncFailures = null, ) {} /** @@ -133,7 +135,7 @@ public function apply(): void // Create instance and call method directly; asynchronously when #[Async] says so $instance = $this->getInstanceFromPool($className); - (new AsyncAttributeRegistrar($this->actionService, $this->logger))->register( + (new AsyncAttributeRegistrar($this->actionService, $this->logger, $this->asyncFailures))->register( hook: $action->hook, instance: $instance, method: $reflectionMethod, @@ -141,12 +143,14 @@ public function apply(): void ); } elseif ($hookType === 'async') { $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: the method has no #[Action] to make asynchronous.', ltrim($className, '\\'), $methodName)); + $this->asyncFailures?->fail($className, $methodName, 'the method has no #[Action] to make asynchronous'); } elseif ($hookType === 'filter') { /** @var Filter $filter */ $filter = $hookAttribute->newInstance(); if (AsyncAttributeRegistrar::isDeclaredOn($reflectionMethod)) { $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: a filter returns a value to its caller and cannot be deferred.', ltrim($className, '\\'), $methodName)); + $this->asyncFailures?->fail($className, $methodName, 'a filter returns a value to its caller and cannot be deferred'); } // Create instance and call method directly diff --git a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php new file mode 100644 index 00000000..5a9c9537 --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php @@ -0,0 +1,221 @@ + */ + public array $registered = []; + + public bool $cronOnPageLoad = false; + + /** @var array{count: int, oldest: int|null} */ + public array $overdue = ['count' => 0, 'oldest' => null]; + + /** @var array{count: int, oldest: int|null}|null */ + public ?array $waiting = null; + + public int $stranded = 0; + + public int $locks = 0; + + public function registrations(): array + { + return $this->registered; + } + + public function cronRunsOnPageLoad(): bool + { + return $this->cronOnPageLoad; + } + + public function overdueCronEvents(int $seconds): array + { + return $this->overdue; + } + + public function strandedPayloads(int $seconds): int + { + return $this->stranded; + } + + public function expiredLocks(int $seconds): int + { + return $this->locks; + } + + public function waitingJobs(string $connection, int $seconds): ?array + { + return $this->waiting; + } +} + +function asyncCheckDriver(bool $available): AsyncDriver +{ + return new readonly class($available) implements AsyncDriver + { + public function __construct(private bool $available) {} + + public function available(): bool + { + return $this->available; + } + + public function dispatch(AsyncPayload $payload, int $delay = 0): void {} + }; +} + +beforeEach(function (): void { + Async::flush(); + Functions\when('did_action')->justReturn(1); + Async::extend('wp-cron', fn (): AsyncDriver => asyncCheckDriver(true)); + Async::setDefaultDriver('wp-cron'); + + $this->failures = new AsyncDeclarationFailures; + $this->inspector = new AsyncCheckInspector(resolve('config'), resolve('db')); + $this->check = new AsyncActionsCheck($this->failures, $this->inspector, resolve('config')); + + $this->actions = new Action; + $this->register = function (string $hook, ?string $via = null): void { + $pending = $this->actions->add($hook, [new AsyncCheckHandler, 'handle'])->async(); + if ($via !== null) { + $pending->via($via); + } + + $this->inspector->registered[] = $this->actions->callbacks($hook)[0]['callback']; + }; +}); + +afterEach(function (): void { + Async::flush(); +}); + +it('waits for WordPress', function (): void { + Functions\when('did_action')->justReturn(0); + + expect($this->check->run(RunContext::Console)->status)->toBe(CheckStatus::Skipped); +}); + +it('passes when nothing is asynchronous', function (): void { + expect($this->check->run(RunContext::Console)->summary)->toBe('No asynchronous action registered.'); +}); + +it('passes with the registrations and the default driver', function (): void { + ($this->register)('save_post'); + + $result = $this->check->run(RunContext::Http); + + expect($result->status)->toBe(CheckStatus::Ok) + ->and($result->summary)->toBe('1 asynchronous action(s), default driver "wp-cron".'); +}); + +it('lists the #[Async] declarations discovery ignored', function (): void { + $this->failures->fail('\\App\\Hooks\\Crm', 'sync', "except names 'save_post_page', which is not a hook of its #[Action] (save_post)"); + + $result = $this->check->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Error) + ->and($result->details)->toBe(["App\\Hooks\\Crm::sync(): #[Async] is ignored, the action runs synchronously — except names 'save_post_page', which is not a hook of its #[Action] (save_post)"]) + ->and($result->fix)->toContain('discovery:clear'); +}); + +it('fails when the default driver is unavailable, since every handler then runs in the request', function (): void { + Async::extend('queue', fn (): AsyncDriver => asyncCheckDriver(false)); + Async::setDefaultDriver('queue'); + ($this->register)('save_post'); + + $result = $this->check->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Error) + ->and($result->details[0])->toStartWith('The default driver "queue" is unavailable'); +}); + +it('warns about a registration whose driver is unavailable', function (): void { + ($this->register)('save_post', 'action-scheduler'); + + $result = $this->check->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and($result->details[0])->toStartWith('save_post ask(s) for the driver "action-scheduler", unavailable, and go(es) through "wp-cron" instead'); +}); + +it('warns when HOOKS_ASYNC_CONNECTION names a connection the queue driver cannot use', function (array $connections, string $expected): void { + config(['hooks.async.queue.connection' => 'jobs', 'queue.connections' => $connections]); + ($this->register)('save_post'); + + $result = $this->check->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and($result->details[0])->toContain($expected); +})->with([ + 'undefined' => [[], 'which config/queue.php does not define'], + 'sync' => [['jobs' => ['driver' => 'sync']], 'runs jobs inside the request'], + 'null' => [['jobs' => ['driver' => 'null']], 'discards jobs'], +]); + +it('warns when WP-Cron events are overdue, with the fix for DISABLE_WP_CRON', function (bool $onPageLoad, string $fix): void { + ($this->register)('save_post'); + $this->inspector->cronOnPageLoad = $onPageLoad; + $this->inspector->overdue = ['count' => 3, 'oldest' => 1_800_000_000]; + + $result = $this->check->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and($result->details[0])->toBe('3 WP-Cron event(s) overdue, the oldest since 2027-01-15 08:00 UTC: WP-Cron and Action Scheduler run nothing until wp-cron.php is requested') + ->and($result->fix)->toContain($fix); +})->with([ + 'disabled' => [false, 'DISABLE_WP_CRON is set'], + 'on page load' => [true, 'too few visits'], +]); + +it('leaves overdue WP-Cron events alone when no driver in use relies on WP-Cron', function (): void { + Async::extend('queue', fn (): AsyncDriver => asyncCheckDriver(true)); + Async::setDefaultDriver('queue'); + ($this->register)('save_post'); + $this->inspector->overdue = ['count' => 3, 'oldest' => 1_800_000_000]; + + expect($this->check->run(RunContext::Console)->status)->toBe(CheckStatus::Ok); +}); + +it('warns when queued jobs wait for a worker', function (): void { + Async::extend('queue', fn (): AsyncDriver => asyncCheckDriver(true)); + config(['queue.default' => 'database']); + ($this->register)('save_post', 'queue'); + $this->inspector->waiting = ['count' => 2, 'oldest' => 1_800_000_000]; + + $result = $this->check->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and($result->details[0])->toStartWith('2 job(s) of the "database" queue connection waiting since') + ->and($result->fix)->toContain('php artisan queue:work database'); +}); + +it('warns about payloads and locks the daily task left behind', function (): void { + ($this->register)('save_post'); + $this->inspector->stranded = 4; + $this->inspector->locks = 2; + + $result = $this->check->run(RunContext::Console); + + expect($result->status)->toBe(CheckStatus::Warning) + ->and($result->details)->toHaveCount(2) + ->and($result->details[0])->toStartWith('4 stored payload(s) older than a day') + ->and($result->details[1])->toStartWith('2 unique lock(s) expired'); +}); diff --git a/tests/Feature/Hook/Async/AsyncAttributeTest.php b/tests/Feature/Hook/Async/AsyncAttributeTest.php index a1c2a448..8506b5bb 100644 --- a/tests/Feature/Hook/Async/AsyncAttributeTest.php +++ b/tests/Feature/Hook/Async/AsyncAttributeTest.php @@ -7,6 +7,7 @@ use Pollora\Discovery\Infrastructure\Services\ReflectionCache; use Pollora\Hook\Adapter\Out\WordPress\Action; use Pollora\Hook\Adapter\Out\WordPress\Filter; +use Pollora\Hook\Application\Services\AsyncDeclarationFailures; use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\QueuedHandler; @@ -32,7 +33,8 @@ public function log($level, string|Stringable $message, array $context = []): vo $this->errors[] = (string) $message; } }; - $this->registrar = new AsyncAttributeRegistrar($this->actions, $this->logger); + $this->failures = new AsyncDeclarationFailures; + $this->registrar = new AsyncAttributeRegistrar($this->actions, $this->logger, $this->failures); $this->register = function (string $class, string $method, ?string $hook = null): void { $reflection = new ReflectionMethod($class, $method); foreach ($reflection->getAttributes(ActionAttribute::class) as $action) { @@ -111,10 +113,12 @@ function registeredCallback(Action $actions, string $hook): mixed Async::assertDispatched(AsyncAttributeClassHooks::class.'@audit', fn (AsyncPayload $payload): bool => $payload->tries === 5); }); -it('registers synchronously, and logs why, a declaration it cannot honour', function (string $method, string $reason): void { +it('registers synchronously, and logs and records why, a declaration it cannot honour', function (string $method, string $reason): void { ($this->register)(AsyncAttributeInvalidHooks::class, $method); expect(registeredCallback($this->actions, 'save_post'))->toBeArray() + ->and($this->failures->all())->toHaveKey(AsyncAttributeInvalidHooks::class.'::'.$method.'()') + ->and($this->failures->all()[AsyncAttributeInvalidHooks::class.'::'.$method.'()'])->toContain($reason) ->and($this->logger->errors)->toHaveCount(1) ->and($this->logger->errors[0])->toContain(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously', AsyncAttributeInvalidHooks::class, $method)) ->and($this->logger->errors[0])->toContain($reason); @@ -129,7 +133,7 @@ function registeredCallback(Action $actions, string $hook): mixed describe('Discovery', function (): void { beforeEach(function (): void { - $this->discovery = new HookDiscovery($this->actions, new Filter, $this->logger); + $this->discovery = new HookDiscovery($this->actions, new Filter, $this->logger, $this->failures); $this->discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(AsyncAttributeHooks::class)), new ReflectionCache); $this->discovery->apply(); }); @@ -139,8 +143,9 @@ function registeredCallback(Action $actions, string $hook): mixed ->and(registeredCallback($this->actions, 'wp_loaded'))->toBeArray(); }); - it('logs an #[Async] on a filter, which still registers, and an #[Async] without #[Action]', function (): void { - expect($this->logger->errors)->toContain(sprintf('#[Async] on %s::filterContent() is ignored: a filter returns a value to its caller and cannot be deferred.', AsyncAttributeHooks::class)) + it('logs and records an #[Async] on a filter, which still registers, and an #[Async] without #[Action]', function (): void { + expect(array_keys($this->failures->all()))->toContain(AsyncAttributeHooks::class.'::filterContent()', AsyncAttributeHooks::class.'::orphan()') + ->and($this->logger->errors)->toContain(sprintf('#[Async] on %s::filterContent() is ignored: a filter returns a value to its caller and cannot be deferred.', AsyncAttributeHooks::class)) ->and($this->logger->errors)->toContain(sprintf('#[Async] on %s::orphan() is ignored: the method has no #[Action] to make asynchronous.', AsyncAttributeHooks::class)); }); }); diff --git a/tests/Feature/Hook/Async/AsyncInspectorTest.php b/tests/Feature/Hook/Async/AsyncInspectorTest.php new file mode 100644 index 00000000..1ed06cca --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncInspectorTest.php @@ -0,0 +1,163 @@ + 0, 'blogId' => 1, 'locale' => 'en_US', 'dispatchedAt' => gmdate(DATE_ATOM, $dispatchedAt)], + ))->toJson(); +} + +/** + * Stands in for $wpdb: the options rows whose name starts with the prefix asked for. + * + * @param array $options + */ +function asyncInspectorWpdb(array $options): object +{ + return new class($options) + { + public string $options = 'wp_options'; + + private string $prefix = ''; + + public function __construct(private readonly array $rows) {} + + public function esc_like(string $text): string + { + return $text; + } + + public function prepare(string $query, string $like, int $limit): string + { + $this->prefix = rtrim($like, '%'); + + return $query; + } + + public function get_results(string $query, string $output): array + { + $rows = []; + foreach ($this->rows as $name => $value) { + if (str_starts_with($name, $this->prefix)) { + $rows[] = ['option_name' => $name, 'option_value' => $value]; + } + } + + return $rows; + } + }; +} + +beforeEach(function (): void { + Async::flush(); + $this->inspector = new AsyncInspector(resolve('config'), resolve('db')); + $this->previousWpdb = $GLOBALS['wpdb'] ?? null; +}); + +afterEach(function (): void { + Async::flush(); + $GLOBALS['wpdb'] = $this->previousWpdb; + unset($GLOBALS['wp_filter']); +}); + +it('finds the asynchronous registrations among the WordPress hooks', function (): void { + $actions = new Action; + $actions->add('save_post', [new AsyncInspectorHandler, 'handle'])->async(); + $queued = $actions->callbacks('save_post')[0]['callback']; + + $GLOBALS['wp_filter'] = [ + 'save_post' => (object) ['callbacks' => [10 => ['a' => ['function' => $queued], 'b' => ['function' => 'strlen']]]], + 'init' => (object) ['callbacks' => [10 => ['c' => ['function' => 'strlen']]]], + ]; + + expect($this->inspector->registrations())->toBe([$queued]) + ->and($queued)->toBeInstanceOf(QueuedHandler::class); +}); + +it('counts the WP-Cron events overdue for longer than the grace period', function (): void { + $now = time(); + Functions\when('_get_cron_array')->justReturn([ + $now - 7200 => ['wp_version_check' => ['k1' => []], 'pollora/async/run' => ['k2' => [], 'k3' => []]], + $now - 4000 => ['wp_update_plugins' => ['k4' => []]], + $now - 60 => ['recent' => ['k5' => []]], + $now + 600 => ['future' => ['k6' => []]], + ]); + + expect($this->inspector->overdueCronEvents(3600))->toBe(['count' => 4, 'oldest' => $now - 7200]); +}); + +it('counts stored payloads no event will run, and unreadable ones', function (): void { + $old = '11111111-1111-4111-8111-111111111111'; + $scheduled = '22222222-2222-4222-8222-222222222222'; + $recent = '33333333-3333-4333-8333-333333333333'; + Functions\when('wp_next_scheduled')->alias(fn (string $hook, array $args): int|false => $args === [$scheduled] ? time() + 3600 : false); + + $GLOBALS['wpdb'] = asyncInspectorWpdb([ + 'pollora_async_'.$old => asyncInspectorPayload($old, time() - 200_000), + 'pollora_async_'.$scheduled => asyncInspectorPayload($scheduled, time() - 200_000), + 'pollora_async_'.$recent => asyncInspectorPayload($recent, time() - 60), + 'pollora_async_44444444-4444-4444-8444-444444444444' => 'not a payload', + ]); + + expect($this->inspector->strandedPayloads(86400))->toBe(2); +}); + +it('counts the unique locks expired for longer than the grace period', function (): void { + $GLOBALS['wpdb'] = asyncInspectorWpdb([ + 'pollora_unique_a' => (string) (time() - 200_000), + 'pollora_unique_b' => (string) (time() - 60), + 'pollora_unique_c' => (string) (time() + 600), + 'pollora_async_x' => (string) (time() - 200_000), + ]); + + expect($this->inspector->expiredLocks(86400))->toBe(1); +}); + +it('counts the jobs of a database queue that no worker picked up', function (): void { + config([ + 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'queue.connections.database' => ['driver' => 'database', 'connection' => 'testing', 'table' => 'jobs'], + ]); + Schema::connection('testing')->create('jobs', function (Blueprint $table): void { + $table->id(); + $table->string('queue'); + $table->longText('payload'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + }); + $old = time() - 3600; + DB::connection('testing')->table('jobs')->insert([ + ['queue' => 'default', 'payload' => '{}', 'reserved_at' => null, 'available_at' => $old], + ['queue' => 'default', 'payload' => '{}', 'reserved_at' => null, 'available_at' => $old + 60], + ['queue' => 'default', 'payload' => '{}', 'reserved_at' => time(), 'available_at' => $old], + ['queue' => 'default', 'payload' => '{}', 'reserved_at' => null, 'available_at' => time()], + ]); + + expect($this->inspector->waitingJobs('database', 900))->toBe(['count' => 2, 'oldest' => $old]); +}); + +it('cannot count the jobs of a queue that keeps them elsewhere', function (): void { + config(['queue.connections.redis' => ['driver' => 'redis']]); + + expect($this->inspector->waitingJobs('redis', 900))->toBeNull() + ->and($this->inspector->waitingJobs('missing', 900))->toBeNull(); +}); diff --git a/tests/e2e/specs/doctor.spec.ts b/tests/e2e/specs/doctor.spec.ts index f970acb1..3d86b2cd 100644 --- a/tests/e2e/specs/doctor.spec.ts +++ b/tests/e2e/specs/doctor.spec.ts @@ -16,7 +16,7 @@ test('Site Health lists the Pollora checks, the web-only block check and the che // Passed tests sit in a collapsed list: read the triggers, visible or not. const results = page.locator('.health-check-accordion-trigger'); - for (const label of ['WordPress core patch', 'Composer patches lock', 'Theme, plugin and module builds', 'Blocks registered', 'Typed meta', 'Roles', 'Block bindings']) { + for (const label of ['WordPress core patch', 'Composer patches lock', 'Theme, plugin and module builds', 'Blocks registered', 'Typed meta', 'Roles', 'Block bindings', 'Asynchronous actions']) { await expect(results.filter({ hasText: label }).filter({ hasText: 'Pollora' }), label).toHaveCount(1); } From a31ee6fadca1f65ffb04a96f421df27a05b2090f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 19:16:13 +0200 Subject: [PATCH 07/10] feat: point to a queue worker when the Laravel scheduler runs WP-Cron --- .../Checks/AsyncActionsCheck.php | 18 +++++++++++++++--- .../Hook/Async/AsyncActionsCheckTest.php | 8 +++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php index 5da9aa05..1ce6440f 100644 --- a/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php +++ b/src/Hook/Infrastructure/Checks/AsyncActionsCheck.php @@ -174,6 +174,20 @@ private function connectionProblems(): array 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. * @@ -190,9 +204,7 @@ private function unprocessed(array $drivers): array 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->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', + $this->cronFix(), ]; } } diff --git a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php index 5a9c9537..ed96ee04 100644 --- a/tests/Feature/Hook/Async/AsyncActionsCheckTest.php +++ b/tests/Feature/Hook/Async/AsyncActionsCheckTest.php @@ -170,7 +170,8 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {} 'null' => [['jobs' => ['driver' => 'null']], 'discards jobs'], ]); -it('warns when WP-Cron events are overdue, with the fix for DISABLE_WP_CRON', function (bool $onPageLoad, string $fix): void { +it('warns when WP-Cron events are overdue, with the fix for what runs WP-Cron', function (bool $onPageLoad, bool $laravelScheduler, string $fix): void { + config(['wordpress.use_laravel_scheduler' => $laravelScheduler]); ($this->register)('save_post'); $this->inspector->cronOnPageLoad = $onPageLoad; $this->inspector->overdue = ['count' => 3, 'oldest' => 1_800_000_000]; @@ -181,8 +182,9 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void {} ->and($result->details[0])->toBe('3 WP-Cron event(s) overdue, the oldest since 2027-01-15 08:00 UTC: WP-Cron and Action Scheduler run nothing until wp-cron.php is requested') ->and($result->fix)->toContain($fix); })->with([ - 'disabled' => [false, 'DISABLE_WP_CRON is set'], - 'on page load' => [true, 'too few visits'], + 'disabled' => [false, false, 'DISABLE_WP_CRON is set'], + 'on page load' => [true, false, 'too few visits'], + 'Laravel scheduler' => [false, true, 'run a queue worker'], ]); it('leaves overdue WP-Cron events alone when no driver in use relies on WP-Cron', function (): void { From 72012affe9e1264056a29ec029004b7118e6f975 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 19:13:27 +0200 Subject: [PATCH 08/10] feat: generate #[Async] actions with pollora:make:action --async --- CHANGELOG.md | 3 ++ src/Hook/UI/Console/ActionMakeCommand.php | 14 +++++- src/Hook/UI/Console/AttributeMakeCommand.php | 29 +++++++++++-- src/Hook/UI/Console/FilterMakeCommand.php | 2 +- .../Console/stubs/hook-attribute-update.stub | 2 +- src/Hook/UI/Console/stubs/hook-attribute.stub | 4 +- tests/Feature/Hook/ActionMakeCommandTest.php | 43 +++++++++++++++++++ 7 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 tests/Feature/Hook/ActionMakeCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4ddd1b..1cd9a16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 +- `pollora:make:action --async` generates the method with `#[Async]` and its import, in a new class or added to an existing one - 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 +- `pollora:make:action` and `pollora:make:filter` were registered as `pollora:make:action {name}` and `pollora:make:filter {name}`: the console reached them by prefix, but `Artisan::call('pollora:make:action')` did not find them +- The method generated for a hook with separators read `handleSavepost` for `save_post`: it is now `handleSavePost` - Errors met while registering hooks discovered by attribute went to no logger: `HookDiscovery` now receives the application's logger - 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/src/Hook/UI/Console/ActionMakeCommand.php b/src/Hook/UI/Console/ActionMakeCommand.php index 078731e4..aadecb88 100755 --- a/src/Hook/UI/Console/ActionMakeCommand.php +++ b/src/Hook/UI/Console/ActionMakeCommand.php @@ -6,6 +6,7 @@ use Illuminate\Console\Attributes\Aliases; use Illuminate\Console\Attributes\Description; +use Symfony\Component\Console\Input\InputOption; /** * Class ActionMakeCommand @@ -22,7 +23,7 @@ class ActionMakeCommand extends AttributeMakeCommand * * @var string */ - protected $name = 'pollora:make:action {name}'; + protected $name = 'pollora:make:action'; /** * The type of the attribute. @@ -30,4 +31,15 @@ class ActionMakeCommand extends AttributeMakeCommand * @var string */ protected $type = 'Action'; + + /** + * Get the console command options. + */ + protected function getOptions(): array + { + return [ + ...parent::getOptions(), + ['async', null, InputOption::VALUE_NONE, 'Run the method after the request, with #[Async]'], + ]; + } } diff --git a/src/Hook/UI/Console/AttributeMakeCommand.php b/src/Hook/UI/Console/AttributeMakeCommand.php index 2eeb984b..daf48130 100755 --- a/src/Hook/UI/Console/AttributeMakeCommand.php +++ b/src/Hook/UI/Console/AttributeMakeCommand.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use Pollora\Attributes\Action; +use Pollora\Attributes\Async; use Pollora\Attributes\Filter; use Pollora\Console\AbstractGeneratorCommand; use Symfony\Component\Console\Input\InputOption; @@ -144,7 +145,7 @@ protected function updateExistingFile(string $path): void $stub = File::get($stubPath); $hook = $this->getDefaultOption('hook'); - $hookMethodName = 'handle'.Str::studly(preg_replace('/[^a-zA-Z0-9]/', '', (string) $hook)); + $hookMethodName = $this->methodName((string) $hook); $existingContent = File::get($path); @@ -173,6 +174,7 @@ protected function ensureAttributeImports(string $content): string $requiredImports = [ Action::class, Filter::class, + ...($this->isAsync() ? [Async::class] : []), ]; foreach ($requiredImports as $import) { @@ -198,21 +200,40 @@ public function makeReplacements(string $stub): string { $hook = $this->getDefaultOption('hook'); $priority = $this->getDefaultOption('priority'); - $hookMethodName = 'handle'.Str::studly(preg_replace('/[^a-zA-Z0-9]/', '', (string) $hook)); + $hookMethodName = $this->methodName((string) $hook); $hookType = $this->type; $returnType = $hookType === 'Action' ? ': void' : ''; $arg = $hookType === 'Filter' ? '$arg' : ''; $return = $hookType === 'Filter' ? "\n".' return $arg;' : ''; + $async = $this->isAsync(); + return str_replace( ['{{ hookType }}', '{{ hook }}', '{{ priority }}', '{{ hookMethodName }}', - '{{ arg }}', '{{ returnType }}', '{{ return }}'], - [$hookType, $hook, ', priority:'.$priority, $hookMethodName, $arg, $returnType, $return], + '{{ arg }}', '{{ returnType }}', '{{ return }}', '{{ async }}', '{{ asyncImport }}'], + [$hookType, $hook, ', priority:'.$priority, $hookMethodName, $arg, $returnType, $return, + $async ? "\n #[Async]" : '', $async ? "\nuse ".Async::class.';' : ''], $stub ); } + /** + * The generated method's name: save_post gives handleSavePost. + */ + protected function methodName(string $hook): string + { + return 'handle'.Str::studly((string) preg_replace('/[^a-zA-Z0-9]+/', ' ', $hook)); + } + + /** + * Whether the generated method carries #[Async]: only an action can be asynchronous. + */ + protected function isAsync(): bool + { + return $this->type === 'Action' && $this->hasOption('async') && $this->option('async'); + } + /** * Get the update stub file path. */ diff --git a/src/Hook/UI/Console/FilterMakeCommand.php b/src/Hook/UI/Console/FilterMakeCommand.php index fdb7d98c..7949fed3 100755 --- a/src/Hook/UI/Console/FilterMakeCommand.php +++ b/src/Hook/UI/Console/FilterMakeCommand.php @@ -24,7 +24,7 @@ class FilterMakeCommand extends AttributeMakeCommand * * @var string */ - protected $name = 'pollora:make:filter {name}'; + protected $name = 'pollora:make:filter'; /** * The type of the attribute. diff --git a/src/Hook/UI/Console/stubs/hook-attribute-update.stub b/src/Hook/UI/Console/stubs/hook-attribute-update.stub index 574b97c5..fbefd4e6 100755 --- a/src/Hook/UI/Console/stubs/hook-attribute-update.stub +++ b/src/Hook/UI/Console/stubs/hook-attribute-update.stub @@ -1,4 +1,4 @@ - #[{{ hookType }}('{{ hook }}'{{ priority }})] + #[{{ hookType }}('{{ hook }}'{{ priority }})]{{ async }} public function {{ hookMethodName }}({{ arg }}){{ returnType }} { // Code is executed on the "{{ hook }}" WordPress {{ hookType }} only.{{ return }} diff --git a/src/Hook/UI/Console/stubs/hook-attribute.stub b/src/Hook/UI/Console/stubs/hook-attribute.stub index 7159bf7f..7ef25132 100755 --- a/src/Hook/UI/Console/stubs/hook-attribute.stub +++ b/src/Hook/UI/Console/stubs/hook-attribute.stub @@ -1,11 +1,11 @@ themesDir = sys_get_temp_dir().'/pollora-make-action-'.uniqid(); + mkdir($this->themesDir.'/test-theme', 0755, true); + config(['theme.path' => $this->themesDir]); + $this->app->make(Kernel::class)->registerCommand($this->app->make(ActionMakeCommand::class)); + $this->generated = fn (): string => (string) file_get_contents((string) collect(File::allFiles($this->themesDir))->first()?->getPathname()); +}); + +afterEach(function (): void { + File::deleteDirectory($this->themesDir); +}); + +it('generates an #[Async] method with --async', function (): void { + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post', '--async' => true])->assertSuccessful(); + + expect(($this->generated)()) + ->toContain("use Pollora\\Attributes\\Action;\nuse Pollora\\Attributes\\Async;\n") + ->toContain(" #[Action('save_post', priority:10)]\n #[Async]\n public function handleSavePost(): void"); +}); + +it('generates a synchronous method without --async', function (): void { + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post'])->assertSuccessful(); + + expect(($this->generated)())->not->toContain('Async'); +}); + +it('adds an #[Async] method, and its import, to an existing class', function (): void { + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post'])->assertSuccessful(); + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'user_register', '--async' => true])->assertSuccessful(); + + expect(($this->generated)()) + ->toContain('use Pollora\\Attributes\\Async;') + ->toContain(" #[Action('save_post', priority:10)]\n public function handleSavePost(): void") + ->toContain(" #[Action('user_register', priority:10)]\n #[Async]\n public function handleUserRegister(): void"); +}); From b3d76c1abf46f9cf2056eaa5a5d3e0efd1d6380f Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 19:10:11 +0200 Subject: [PATCH 09/10] 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(); +}); From 4bc6db221f4203521162db7e2f2baf1d975489b3 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 20:47:55 +0200 Subject: [PATCH 10/10] chore: release v13.35.2 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9c06dfc..a9c3bac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.1...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.2...develop) + +## [v13.35.2](https://github.com/Pollora/framework/compare/v13.35.1...v13.35.2) - 2026-10-07 ### 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, 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