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',