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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to `pollora/hook` are documented in this file.

## [Unreleased]

### Changed

- WP-Cron and Action Scheduler are the drivers of [`pollora/schedule`](https://github.com/Pollora/schedule), which the package now requires: `Async\Drivers\ScheduledDriver` queues through them and keeps what is the hook's own, the message an event carries and where the payload lives. Nothing changes for what is already queued: the hook, the Action Scheduler groups (`pollora` or the queue name), the identifier-only WP-Cron events and the `pollora_async_` options are the same, so work queued by 1.x runs once 2.0 is installed. `->async()`, `#[Async]`, `Async::fake()`, `via()` and their options do not change
- `Async::listen()` keeps `pollora/async/run` and `pollora/async/recover` on WP-Cron (the `pollora/schedule/protected_hooks` filter), whatever driver the site gave pollora/schedule: `via('wp-cron')` still means WP-Cron
- An Action Scheduler action of the hook lives in its own group and is never in the `cron` index of pollora/schedule

### Removed

- `Async\Drivers\WpCronDriver` and `Async\Drivers\ActionSchedulerDriver`, with no alias. Code that used them directly (the framework does not) uses `ScheduledDriver::wpCron()` and `ScheduledDriver::actionScheduler()`, or the drivers of pollora/schedule
- `WpCronDriver::handles()` and `claim()`: use `PayloadStore`. The recovery task (`recover()`, `scheduleRecovery()`, `RECOVERY_HOOK`, `RECOVERY_GRACE`) moved to `Async\Recovery`

## [1.5.0] - 2026-10-09

### Added
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
}
],
"require": {
"php": "^8.3"
"php": "^8.3",
"pollora/schedule": "^0.1.2"
},
"require-dev": {
"laravel/pint": "^v1.22.1",
Expand Down
33 changes: 27 additions & 6 deletions src/Async/Async.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@

use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Async\Contracts\ObjectReference;
use Pollora\Hook\Async\Drivers\ActionSchedulerDriver;
use Pollora\Hook\Async\Drivers\ScheduledDriver;
use Pollora\Hook\Async\Drivers\SyncDriver;
use Pollora\Hook\Async\Drivers\WpCronDriver;
use Pollora\Hook\Async\Exceptions\DriverUnavailable;
use Pollora\Hook\Async\Exceptions\UnresolvableHandler;
use Pollora\Hook\Domain\Contract\CallbackResolverInterface;
Expand Down Expand Up @@ -51,6 +50,8 @@ final class Async

private static ?string $defaultDriver = null;

private static bool $protecting = false;

/** @var list<string> Drivers 'auto' tries, in order */
private static array $autoDrivers = ['action-scheduler', 'wp-cron'];

Expand Down Expand Up @@ -345,11 +346,30 @@ public static function listen(): void
add_action(self::HOOK, [self::class, 'receive'], 10, 1);
}

if (has_action(WpCronDriver::RECOVERY_HOOK, [WpCronDriver::class, 'recover']) === false) {
add_action(WpCronDriver::RECOVERY_HOOK, [WpCronDriver::class, 'recover'], 10, 0);
if (has_action(Recovery::HOOK, [Recovery::class, 'recover']) === false) {
add_action(Recovery::HOOK, [Recovery::class, 'recover'], 10, 0);
}

// Whatever driver pollora/schedule gives the site, these events run through WP-Cron: that is what via('wp-cron') promises
if (function_exists('add_filter') && ! self::$protecting) {
self::$protecting = true;
add_filter('pollora/schedule/protected_hooks', [self::class, 'protectHooks']);
}
}

/**
* The hooks pollora/schedule keeps on WP-Cron, whatever driver the site chose.
*
* @param array<int, string> $hooks
* @return array<int, string>
*
* @internal
*/
public static function protectHooks(array $hooks): array
{
return [...$hooks, self::HOOK, Recovery::HOOK];
}

/**
* Run a queued handler, from the message a driver hands back.
*
Expand Down Expand Up @@ -457,6 +477,7 @@ public static function flush(): void
self::$factories = [];
self::$drivers = [];
self::$defaultDriver = null;
self::$protecting = false;
self::$autoDrivers = ['action-scheduler', 'wp-cron'];
self::$resolver = null;
self::$closureKey = null;
Expand Down Expand Up @@ -503,8 +524,8 @@ private static function normalizer(): ArgumentNormalizer
private static function factories(): array
{
return self::$factories + [
'action-scheduler' => static fn (): AsyncDriver => new ActionSchedulerDriver,
'wp-cron' => static fn (): AsyncDriver => new WpCronDriver,
'action-scheduler' => static fn (): AsyncDriver => ScheduledDriver::actionScheduler(),
'wp-cron' => static fn (): AsyncDriver => ScheduledDriver::wpCron(),
'sync' => static fn (): AsyncDriver => new SyncDriver,
];
}
Expand Down
80 changes: 0 additions & 80 deletions src/Async/Drivers/ActionSchedulerDriver.php

This file was deleted.

125 changes: 125 additions & 0 deletions src/Async/Drivers/ScheduledDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Async\Drivers;

use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Contracts\AsyncDriver;
use Pollora\Hook\Async\Exceptions\AsyncException;
use Pollora\Hook\Async\PayloadStore;
use Pollora\Hook\Async\Recovery;
use Pollora\Schedule\Contracts\DefersUntilReady;
use Pollora\Schedule\Contracts\Enqueues;
use Pollora\Schedule\Contracts\ScheduleDriver;
use Pollora\Schedule\Drivers\ActionSchedulerDriver;
use Pollora\Schedule\Drivers\WpCronDriver;

/**
* Queues through a driver of pollora/schedule: WP-Cron or Action Scheduler.
*
* pollora/schedule keeps and runs the event; this class keeps what is the
* hook's own: the message the event carries, and where the payload lives.
*
* WP-Cron keeps the arguments of every event in the autoloaded 'cron' option,
* so its payload is stored apart, in an option that is not autoloaded, and the
* event carries only its identifier; every payload has one of its own, so
* WP-Cron never discards an execution as a duplicate of another within ten
* minutes. Action Scheduler carries the payload whole as the action argument,
* and the queue name is the group; a payload too long for its arguments (8,000
* characters as JSON) is kept apart like WP-Cron's. Action Scheduler's actions
* live in their own groups, which the 'cron' index of pollora/schedule never follows.
*/
final class ScheduledDriver implements AsyncDriver
{
public const string DEFAULT_GROUP = 'pollora';

/**
* Longest JSON-encoded argument list carried whole, under Action Scheduler's 8,000.
*/
public const int MAX_ARGUMENTS_LENGTH = 7900;

/**
* @param \Closure(?string): ScheduleDriver $driverFor The driver of pollora/schedule for a queue name
*/
private function __construct(
private readonly string $label,
private readonly \Closure $driverFor,
private readonly bool $alwaysStoredApart,
) {}

public static function wpCron(): self
{
$driver = new WpCronDriver;

return new self('WP-Cron', static fn (?string $queue): ScheduleDriver => $driver, true);
}

public static function actionScheduler(): self
{
$drivers = [];

return new self('Action Scheduler', static function (?string $queue) use (&$drivers): ScheduleDriver {
$group = $queue ?? self::DEFAULT_GROUP;

return $drivers[$group] ??= new ActionSchedulerDriver($group);
}, false);
}

/**
* Whether the driver can store now: Action Scheduler, only once it is initialised.
*/
public function available(): bool
{
$driver = ($this->driverFor)(null);

return $driver->available() && (! $driver instanceof DefersUntilReady || $driver->ready());
}

/**
* @throws AsyncException When the payload cannot be stored or the event queued
*/
public function dispatch(AsyncPayload $payload, int $delay = 0): void
{
$arguments = [$payload->toJson()];
$storedApart = $this->alwaysStoredApart || strlen((string) json_encode($arguments)) > self::MAX_ARGUMENTS_LENGTH;

if ($storedApart) {
try {
PayloadStore::store($payload);
} catch (AsyncException $asyncException) {
throw new AsyncException($this->label.': '.lcfirst($asyncException->getMessage()), 0, $asyncException);
}

$arguments = [$payload->id];
}

$driver = ($this->driverFor)($payload->queue);
$timestamp = time() + max(0, $delay);

try {
if ($driver instanceof Enqueues) {
$driver->enqueue(Async::HOOK, $timestamp, $arguments);
$queued = true;
} else {
$queued = $driver->once(Async::HOOK, $timestamp, $arguments);
}
} catch (\Throwable $throwable) {
$queued = false;
$reason = $throwable->getMessage();
}

if (! $queued) {
if ($storedApart) {
PayloadStore::forget($payload->id);
}

throw new AsyncException(sprintf("%s: the execution of '%s' could not be queued%s", $this->label, $payload->hook, isset($reason) ? ': '.$reason : '.'));
}

if ($storedApart) {
Recovery::schedule();
}
}
}
Loading
Loading