From 6bd70222f581b9257874b27808878c4b8d9f5223 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 13 Oct 2026 15:28:19 +0200 Subject: [PATCH] feat: keep Action Scheduler actions in a group of the caller's choice An ActionSchedulerDriver takes its group, and the cron index follows the default one only, so a package can queue work in its own group without the index ever seeing it. enqueue() stores an event as it is, with no duplicate check, and queues an async action when it is due now. --- CHANGELOG.md | 5 ++ README.md | 4 ++ src/Contracts/Enqueues.php | 27 ++++++++++ src/Drivers/ActionSchedulerDriver.php | 37 +++++++++++--- stubs/action-scheduler.php | 8 +++ tests/Support/ActionSchedulerFake.php | 6 +++ tests/Unit/ActionSchedulerDriverTest.php | 63 ++++++++++++++++++++++++ tests/Unit/PackageBoundaryTest.php | 1 + 8 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 src/Contracts/Enqueues.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e565feb..9cb6486 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] +### Added + +- `ActionSchedulerDriver` takes the group of its actions (`new ActionSchedulerDriver('crm')`), `pollora/schedule` by default. Every query, cancellation and observation uses the instance's group, and the `cron` index follows the default group only: another package can queue work in a group of its own without it ever reaching the index +- `Contracts\Enqueues` and `ActionSchedulerDriver::enqueue()`: store an event as it is, with no duplicate check, for a caller that gives each event an identifier of its own. A time that is now or past queues an async action (`as_enqueue_async_action()`) + ## [0.1.1] - 2026-10-13 The Action Scheduler driver and the `cron` option as an index of every driver. `ScheduleDriver` is unchanged, so the framework's `^0.1` still holds; 1.0.0 comes with the framework's release. diff --git a/README.md b/README.md index 90e5a77..4658f7e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ A driver is where events are kept and who runs them. `WpCronDriver` is the defau Another driver implements `Pollora\Schedule\Contracts\ScheduleDriver` and is given to the engine with `Schedule::useDriver()`. +## Work another package queues + +`new ActionSchedulerDriver('my-group')` keeps its actions in a group of its own, which the `cron` index below never follows. `enqueue($hook, $timestamp, $args)` stores an action as it is, with no duplicate check, and queues an async action when the time is now: for a caller that gives each event an identifier and queues many. + ## The `cron` option as an index Whatever the driver, the `cron` option lists every scheduled event. An event another driver keeps carries a `driver` key next to `schedule` and `args`; an entry without it is WP-Cron's. WordPress reads the option without a query, so `wp_next_scheduled()`, Site Health, WP Crontrol, Query Monitor and `wp cron event list` all see the event, and WP-Cron never runs one it does not own. diff --git a/src/Contracts/Enqueues.php b/src/Contracts/Enqueues.php new file mode 100644 index 0000000..f4b2226 --- /dev/null +++ b/src/Contracts/Enqueues.php @@ -0,0 +1,27 @@ + $args + * @param int $timestamp When it runs; now or earlier means as soon as possible + * + * @throws ScheduleException When the driver does not store it + */ + public function enqueue(string $hook, int $timestamp, array $args = []): void; +} diff --git a/src/Drivers/ActionSchedulerDriver.php b/src/Drivers/ActionSchedulerDriver.php index 74f64c7..5409959 100644 --- a/src/Drivers/ActionSchedulerDriver.php +++ b/src/Drivers/ActionSchedulerDriver.php @@ -6,6 +6,7 @@ use Closure; use Pollora\Schedule\Contracts\DefersUntilReady; +use Pollora\Schedule\Contracts\Enqueues; use Pollora\Schedule\Contracts\FindsEvent; use Pollora\Schedule\Contracts\ReportsChanges; use Pollora\Schedule\Contracts\ScheduleDriver; @@ -21,8 +22,11 @@ * own and is only looked up in that group, so cancelling a hook never reaches * the actions WooCommerce or another plugin keeps on the same hook. */ -final class ActionSchedulerDriver implements DefersUntilReady, FindsEvent, ReportsChanges, ScheduleDriver +final class ActionSchedulerDriver implements DefersUntilReady, Enqueues, FindsEvent, ReportsChanges, ScheduleDriver { + /** + * The group of the engine's events: the only one the 'cron' index follows. + */ public const string GROUP = 'pollora/schedule'; /** @@ -30,6 +34,11 @@ final class ActionSchedulerDriver implements DefersUntilReady, FindsEvent, Repor */ public const int DUPLICATE_WINDOW = 600; + /** + * @param string $group Where the actions live. Another group keeps them out of the 'cron' index, which follows the default one only: for work another package queues and tracks itself + */ + public function __construct(private readonly string $group = self::GROUP) {} + public function available(): bool { return function_exists('as_schedule_single_action') && function_exists('as_schedule_recurring_action'); @@ -84,13 +93,29 @@ public function observe(Closure $changed): void } } + /** + * Store an action as it is, with no look at what is already there. + * + * An action due now or earlier is an async action: Action Scheduler runs it as soon as a queue runner is free. + */ + public function enqueue(string $hook, int $timestamp, array $args = []): void + { + $id = $timestamp <= time() + ? (function_exists('as_enqueue_async_action') ? as_enqueue_async_action($hook, $args, $this->group) : as_schedule_single_action(time(), $hook, $args, $this->group)) + : as_schedule_single_action($timestamp, $hook, $args, $this->group); + + if ((int) $id === 0) { + throw new ScheduleException(sprintf("Action Scheduler: the event '%s' could not be scheduled.", $hook)); + } + } + public function once(string $hook, int $timestamp, array $args = []): bool { if ($this->hasDuplicate($hook, $timestamp, $args)) { return false; } - if ((int) as_schedule_single_action($timestamp, $hook, $args, self::GROUP) === 0) { + if ((int) as_schedule_single_action($timestamp, $hook, $args, $this->group) === 0) { throw new ScheduleException(sprintf("Action Scheduler: the event '%s' could not be scheduled.", $hook)); } @@ -105,7 +130,7 @@ public function recurring(string $hook, int $timestamp, string $recurrence, arra throw new ScheduleException(sprintf("Action Scheduler: the event '%s' has no known recurrence '%s'.", $hook, $recurrence)); } - if ((int) as_schedule_recurring_action($timestamp, $interval, $hook, $args, self::GROUP) === 0) { + if ((int) as_schedule_recurring_action($timestamp, $interval, $hook, $args, $this->group) === 0) { throw new ScheduleException(sprintf("Action Scheduler: the event '%s' could not be scheduled.", $hook)); } @@ -124,7 +149,7 @@ public function clear(string $hook): int public function next(string $hook, array $args = []): ?int { - $next = as_next_scheduled_action($hook, $args, self::GROUP); + $next = as_next_scheduled_action($hook, $args, $this->group); if ($next === true) { // Running, or waiting with no date: due now @@ -160,7 +185,7 @@ private function fetch(mixed $id): ?object $action = \ActionScheduler::store()->fetch_action((string) $id); - return is_object($action) && method_exists($action, 'get_group') && $action->get_group() === self::GROUP ? $action : null; + return is_object($action) && method_exists($action, 'get_group') && $action->get_group() === $this->group ? $action : null; } /** @@ -171,7 +196,7 @@ private function fetch(mixed $id): ?object */ private function pending(?string $hook = null, ?array $args = null): array { - $query = ['group' => self::GROUP, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => -1, 'orderby' => 'date', 'order' => 'ASC']; + $query = ['group' => $this->group, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => -1, 'orderby' => 'date', 'order' => 'ASC']; if ($hook !== null) { $query['hook'] = $hook; diff --git a/stubs/action-scheduler.php b/stubs/action-scheduler.php index 76ca997..4c5dc03 100644 --- a/stubs/action-scheduler.php +++ b/stubs/action-scheduler.php @@ -24,6 +24,14 @@ abstract public function fetch_action($action_id); /** * @param array $args */ +/** + * @param array $args + */ +function as_enqueue_async_action(string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10): int +{ + return 0; +} + function as_schedule_single_action(int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10): int {} /** diff --git a/tests/Support/ActionSchedulerFake.php b/tests/Support/ActionSchedulerFake.php index 8cdee97..76c373d 100644 --- a/tests/Support/ActionSchedulerFake.php +++ b/tests/Support/ActionSchedulerFake.php @@ -114,6 +114,12 @@ function fake_as_create(string $hook, array $args, string $group, int $timestamp return $id; } +function as_enqueue_async_action($hook, $args = [], $group = '', $unique = false, $priority = 10): int +{ + // An async action has no date: it is due now + return ActionScheduler::$initialized ? fake_as_create($hook, $args, $group, time(), null) : 0; +} + function as_schedule_single_action($timestamp, $hook, $args = [], $group = '', $unique = false, $priority = 10): int { return ActionScheduler::$initialized ? fake_as_create($hook, $args, $group, (int) $timestamp, null) : 0; diff --git a/tests/Unit/ActionSchedulerDriverTest.php b/tests/Unit/ActionSchedulerDriverTest.php index a2d96ec..d389cda 100644 --- a/tests/Unit/ActionSchedulerDriverTest.php +++ b/tests/Unit/ActionSchedulerDriverTest.php @@ -91,3 +91,66 @@ expect($this->driver->ready())->toBeFalse(); }); + +describe('another group', function (): void { + it('keeps its actions apart: the default group neither sees nor cancels them', function (): void { + $crm = new ActionSchedulerDriver('crm'); + $time = time() + 3600; + + $crm->once('digest', $time, ['user' => 1]); + $this->driver->once('digest', $time, ['user' => 1]); + + expect($crm->next('digest', ['user' => 1]))->toBe($time) + ->and(array_column(array_map(fn ($a) => (array) $a, array_values(ActionScheduler_FakeStore::$actions)), 'group'))->toBe(['crm', 'pollora/schedule']) + ->and($crm->cancel('digest', ['user' => 1]))->toBe(1) + ->and($this->driver->next('digest', ['user' => 1]))->toBe($time) + ->and($crm->events())->toBe([]); + }); + + it('is not watched as the engine\'s: an action of another group is never reported', function (): void { + $reported = []; + $added = []; + Functions\when('add_action')->alias(function (string $hook, callable $callback) use (&$added): bool { + $added[$hook][] = $callback; + + return true; + }); + (new ActionSchedulerDriver('crm'))->observe(function (string $hook) use (&$reported): void { + $reported[] = $hook; + }); + $id = as_schedule_single_action(time() + 60, 'mine', [], 'pollora/schedule'); + + foreach ($added['action_scheduler_stored_action'] as $callback) { + $callback($id); + } + + expect($reported)->toBe([]); + }); +}); + +describe('enqueue', function (): void { + it('stores an event as it is, without looking for a duplicate', function (): void { + $time = time() + 3600; + $this->driver->enqueue('digest', $time, ['id' => 'a']); + $this->driver->enqueue('digest', $time + 1, ['id' => 'a']); + + expect(ActionScheduler_FakeStore::$actions)->toHaveCount(2); + }); + + it('queues an async action when the time is now or past, in its own group', function (): void { + $crm = new ActionSchedulerDriver('crm'); + $crm->enqueue('digest', time() - 5, ['id' => 'a']); + + $action = array_values(ActionScheduler_FakeStore::$actions)[0]; + + expect($action->group)->toBe('crm') + ->and($action->schedule->timestamp)->toBeLessThanOrEqual(time()); + }); + + it('says when Action Scheduler does not store it', function (): void { + ActionScheduler::$initialized = false; + + expect(fn () => $this->driver->enqueue('digest', time(), []))->toThrow(ScheduleException::class) + ->and(fn () => $this->driver->enqueue('digest', time() + 600, []))->toThrow(ScheduleException::class); + }); +}); diff --git a/tests/Unit/PackageBoundaryTest.php b/tests/Unit/PackageBoundaryTest.php index 1236ef1..a5f0eab 100644 --- a/tests/Unit/PackageBoundaryTest.php +++ b/tests/Unit/PackageBoundaryTest.php @@ -25,6 +25,7 @@ 'ActionScheduler_Store', 'add_action', 'apply_filters', + 'as_enqueue_async_action', 'as_get_scheduled_actions', 'as_next_scheduled_action', 'as_schedule_recurring_action',