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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/phpstan.neon export-ignore
/stubs export-ignore
/phpunit.xml.dist export-ignore
/pint.json export-ignore
/renovate.json export-ignore
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `ActionSchedulerDriver`: events are Action Scheduler actions, in a group of their own (`pollora/schedule`), so cancelling a hook never reaches the actions another plugin keeps on it. The core's ten-minute window ignores a duplicate single event
- `Schedule::driver('action-scheduler')`, or `POLLORA_SCHEDULE_DRIVER`, chooses the driver of the site; `Schedule::extend()` adds one. WordPress's own scheduling functions follow it, for the core, plugins and themes, through the `pre_schedule_event`, `pre_reschedule_event`, `pre_unschedule_event`, `pre_clear_scheduled_hook`, `pre_unschedule_hook` and `pre_get_scheduled_event` filters
- What stays on WP-Cron: events already in the `cron` option (nothing moves until a migration says so, so a recurring event WP-Cron runs keeps rescheduling itself there), Action Scheduler's own queue runner (`action_scheduler_run_queue`) and the hooks given to `ScheduleEngine::protect()` or the `pollora/schedule/protected_hooks` filter, and every event when the driver is not there
- Events scheduled before Action Scheduler is ready are kept and given to it on `action_scheduler_init`, once each

## [0.1.0] - 2026-10-13

A first version for the framework's next release to build on; 1.0.0 comes with that release.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ final class Reports

## Drivers

A driver is where events are kept and who runs them. `WpCronDriver` is the default: a thin layer over `wp_schedule_event()`, `wp_schedule_single_event()` and the other functions of WordPress, so every filter the site hangs on them keeps working. Another driver implements `Pollora\Schedule\Contracts\ScheduleDriver` and is given to the engine with `Schedule::useDriver()`.
A driver is where events are kept and who runs them. `WpCronDriver` is the default: a thin layer over `wp_schedule_event()`, `wp_schedule_single_event()` and the other functions of WordPress, so every filter the site hangs on them keeps working. Choose the driver of the site with `Schedule::driver('action-scheduler')` or `POLLORA_SCHEDULE_DRIVER=action-scheduler`. Events then live in Action Scheduler (its queue, its retries, its Tools › Scheduled Actions screen), in a group of their own, and WordPress's own functions follow: `wp_schedule_event()` called by the core, a plugin or a theme reaches it too. Events already in the `cron` option stay on WP-Cron, as do Action Scheduler's own queue runner, the hooks you protect (`ScheduleEngine::protect()`, `pollora/schedule/protected_hooks`) and every event when Action Scheduler is not there.

Another driver implements `Pollora\Schedule\Contracts\ScheduleDriver` and is given to the engine with `Schedule::useDriver()`.

## Testing

Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ includes:
parameters:
level: 5
treatPhpDocTypesAsCertain: false
bootstrapFiles:
- stubs/action-scheduler.php
paths:
- src
23 changes: 23 additions & 0 deletions src/Contracts/DefersUntilReady.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Contracts;

/**
* A driver that exists before it can store anything: Action Scheduler's
* functions are there from the start, its data store only after
* 'action_scheduler_init'. The engine keeps what is scheduled meanwhile.
*/
interface DefersUntilReady
{
/**
* Whether the driver can store events now.
*/
public function ready(): bool;

/**
* The WordPress action that fires when the driver becomes ready.
*/
public function readyAction(): string;
}
21 changes: 21 additions & 0 deletions src/Contracts/FindsEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Contracts;

use Pollora\Schedule\Event;

/**
* A driver that can describe one event without listing them all, which is what
* wp_get_scheduled_event() asks for.
*/
interface FindsEvent
{
/**
* The next event of the hook with exactly these arguments.
*
* @param array<int|string, mixed> $args
*/
public function find(string $hook, array $args = []): ?Event;
}
201 changes: 201 additions & 0 deletions src/Drivers/ActionSchedulerDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Drivers;

use Pollora\Schedule\Contracts\DefersUntilReady;
use Pollora\Schedule\Contracts\FindsEvent;
use Pollora\Schedule\Contracts\ScheduleDriver;
use Pollora\Schedule\Event;
use Pollora\Schedule\Exceptions\ScheduleException;

/**
* Schedules through Action Scheduler: its queue, its retries, its Tools ›
* Scheduled Actions screen.
*
* Action Scheduler is never required: the driver is available once a plugin
* that bundles it is active. Every action it creates belongs to a group of its
* 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, ScheduleDriver
{
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;

public function available(): bool
{
return function_exists('as_schedule_single_action') && function_exists('as_schedule_recurring_action');
}

public function ready(): bool
{
if (! $this->available()) {
return false;
}

// Before 'action_scheduler_init', its functions exist but cannot store anything
if (class_exists('ActionScheduler', false)) {
return \ActionScheduler::is_initialized();
}

return function_exists('did_action') && did_action($this->readyAction()) > 0;
}

public function readyAction(): string
{
return 'action_scheduler_init';
}

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) {
throw new ScheduleException(sprintf("Action Scheduler: the event '%s' could not be scheduled.", $hook));
}

return true;
}

public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool
{
$interval = (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$recurrence]['interval'] ?? 0);

if ($interval <= 0) {
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) {
throw new ScheduleException(sprintf("Action Scheduler: the event '%s' could not be scheduled.", $hook));
}

return true;
}

public function cancel(string $hook, array $args = []): int
{
return $this->cancelActions($this->pending($hook, $args));
}

public function clear(string $hook): int
{
return $this->cancelActions($this->pending($hook));
}

public function next(string $hook, array $args = []): ?int
{
$next = as_next_scheduled_action($hook, $args, self::GROUP);

if ($next === true) {
// Running, or waiting with no date: due now
return time();
}

return $next === false ? null : (int) $next;
}

public function find(string $hook, array $args = []): ?Event
{
$actions = $this->pending($hook, $args);

return $actions === [] ? null : $this->toEvent(reset($actions));
}

public function events(): array
{
$events = array_values(array_map($this->toEvent(...), $this->pending()));
usort($events, static fn (Event $a, Event $b): int => $a->timestamp <=> $b->timestamp);

return $events;
}

/**
* Pending actions of the group, soonest first.
*
* @param array<int|string, mixed>|null $args Exact arguments, null for any
* @return array<int, 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'];

if ($hook !== null) {
$query['hook'] = $hook;
}

if ($args !== null) {
$query['args'] = $args;
}

return as_get_scheduled_actions($query);
}

/**
* @param array<int, object> $actions
*/
private function cancelActions(array $actions): int
{
foreach (array_keys($actions) as $id) {
\ActionScheduler::store()->cancel_action($id);
}

return count($actions);
}

/**
* @param array<int|string, mixed> $args
*/
private function hasDuplicate(string $hook, int $timestamp, array $args): bool
{
$now = time();
$min = $timestamp < $now + self::DUPLICATE_WINDOW ? 0 : $timestamp - self::DUPLICATE_WINDOW;
$max = ($timestamp < $now ? $now : $timestamp) + self::DUPLICATE_WINDOW;

foreach ($this->pending($hook, $args) as $action) {
$date = $action->get_schedule()->get_date();

if ($date !== null && $date->getTimestamp() >= $min && $date->getTimestamp() <= $max) {
return true;
}
}

return false;
}

private function toEvent(object $action): Event
{
$schedule = $action->get_schedule();
$date = $schedule->get_date();
$interval = method_exists($schedule, 'get_recurrence') && is_numeric($schedule->get_recurrence()) ? (int) $schedule->get_recurrence() : null;

return new Event(
hook: (string) $action->get_hook(),
timestamp: $date === null ? time() : $date->getTimestamp(),
recurrence: $interval === null ? null : self::recurrenceFor($interval),
args: (array) $action->get_args(),
interval: $interval,
);
}

/**
* Key of a registered schedule with this interval, the first when several share it.
*/
private static function recurrenceFor(int $interval): string
{
foreach (function_exists('wp_get_schedules') ? wp_get_schedules() : [] as $key => $schedule) {
if ((int) ($schedule['interval'] ?? 0) === $interval) {
return (string) $key;
}
}

return 'every_'.$interval.'_seconds';
}
}
Loading
Loading