Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`, 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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions config/hooks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

return [

/*
|--------------------------------------------------------------------------
| Asynchronous Actions
|--------------------------------------------------------------------------
|
| 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.
| 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'),
],

],

];
53 changes: 53 additions & 0 deletions src/Hook/Infrastructure/Async/EloquentModelReference.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Infrastructure\Async;

use Illuminate\Database\Eloquent\Model;
use Pollora\Hook\Async\Contracts\ObjectReference;

/**
* Carries Eloquent models by class, key and connection, and reloads them at execution.
*
* The principle of SerializesModels: no stale data and a small payload.
*/
final class EloquentModelReference implements ObjectReference
{
public function name(): string
{
return 'eloquent';
}

public function supports(object $object): bool
{
return $object instanceof Model && $object->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);
}
}
50 changes: 50 additions & 0 deletions src/Hook/Infrastructure/Async/QueueDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Infrastructure\Async;

use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Contracts\Config\Repository;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Infrastructure\Jobs\RunAsyncAction;

/**
* Queues asynchronous actions as Laravel jobs.
*
* Available when the configured connection is a real queue: a "sync" or
* "null" connection would run the handler inside the request, or never.
*/
final readonly class QueueDriver implements AsyncDriver
{
public function __construct(
private Dispatcher $dispatcher,
private Repository $config,
) {}

public function available(): bool
{
$driver = $this->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'));
}
}
58 changes: 58 additions & 0 deletions src/Hook/Infrastructure/Jobs/RunAsyncAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Infrastructure\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\InteractsWithQueue;
use Pollora\Hook\Async\Async;

/**
* Runs an asynchronous action in a queue worker.
*
* Retries are made by the package, through a new job after the backoff, so
* 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;

/**
* @param string $payload The payload, as JSON
*/
public function __construct(
public readonly string $payload,
) {}

public function handle(): void
{
// A worker lives across jobs: forget what WordPress cached in memory for the previous one
if (function_exists('wp_cache_flush_runtime')) {
wp_cache_flush_runtime();
}

Async::receive($this->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);
}
}
75 changes: 75 additions & 0 deletions src/Hook/Infrastructure/Providers/AsyncServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Infrastructure\Providers;

use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\ServiceProvider;
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Infrastructure\Async\EloquentModelReference;
use Pollora\Hook\Infrastructure\Async\QueueDriver;
use Psr\Log\LoggerInterface;

/**
* 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
* - debug mode from app.debug, incidents to the Laravel log
* - closures signed with the application key
* - Eloquent models carried by reference
* - handler parameters that are not hook arguments resolved from the container
*/
class AsyncServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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;
}
}
2 changes: 2 additions & 0 deletions src/Providers/PolloraServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading