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]

### 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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions src/Contracts/Enqueues.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Contracts;

use Pollora\Schedule\Exceptions\ScheduleException;

/**
* A driver that can store an event as it is: no look at what is already there,
* no duplicate window.
*
* For a caller that gives each event an identifier of its own, which makes a
* duplicate impossible, and queues many of them: the check is a query for
* nothing. The event does not go through the engine, so the 'cron' index never
* sees it.
*/
interface Enqueues
{
/**
* @param array<int|string, mixed> $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;
}
37 changes: 31 additions & 6 deletions src/Drivers/ActionSchedulerDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,15 +22,23 @@
* 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';

/**
* Window, in seconds, in which an identical single event is a duplicate: WordPress's own.
*/
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');
Expand Down Expand Up @@ -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));
}

Expand All @@ -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));
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions stubs/action-scheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ abstract public function fetch_action($action_id);
/**
* @param array<int|string, mixed> $args
*/
/**
* @param array<int|string, mixed> $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 {}

/**
Expand Down
6 changes: 6 additions & 0 deletions tests/Support/ActionSchedulerFake.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions tests/Unit/ActionSchedulerDriverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions tests/Unit/PackageBoundaryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading