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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

What a driver with no store of its own (a queue of deferred jobs) needs. `ScheduleDriver` is unchanged.

### Fixed

- `POLLORA_SCHEDULE_DRIVER` naming a driver the application adds afterwards with `Schedule::extend()` no longer throws when the engine is first used: it starts on WP-Cron, and the application chooses its driver

### Added

- `Contracts\Identifies`: a driver that can only be told what to run, not asked what it holds. The engine gives each placement an identifier (`Event::$id`), hands it over with `placeIdentified()` and writes it in the `cron` entry (`id` key); the driver's job runs only if the entry still exists and carries its identifier, so cancelling is removing the entry
- `CronIndex::idOf()`, and `id` on the events `CronIndex::events()` lists. Recording an event again without an identifier keeps the one the entry had
- `ScheduleEngine::hand()`: give an event to a driver the way the engine does. Migrations and the replay of a direct write to the `cron` option go through it, so a placement made there is identified too

## [0.1.2] - 2026-10-13

What pollora/hook 2.0 queues through: Action Scheduler groups of the caller's choice, and `enqueue()`. `ScheduleDriver` is unchanged.
Expand Down
28 changes: 28 additions & 0 deletions src/Contracts/Identifies.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Pollora\Schedule\Contracts;

use Pollora\Schedule\Event;
use Pollora\Schedule\Exceptions\ScheduleException;

/**
* A driver that keeps nothing it can be asked about: a queue of deferred jobs.
*
* The 'cron' index is its only record of what is scheduled. The engine gives
* each placement an identifier, writes it in the entry (the 'id' key) and hands
* it to the driver with the event; when the job runs, it runs only if the entry
* still exists and carries its identifier. Cancelling is then removing the entry.
*/
interface Identifies
{
/**
* Place the event, whose identifier the engine has set; recurring or single.
*
* @return bool False when the driver ignored it
*
* @throws ScheduleException When the event cannot be scheduled
*/
public function placeIdentified(Event $event): bool;
}
7 changes: 7 additions & 0 deletions src/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* @param array<int|string, mixed> $args Arguments the hook receives
* @param int|null $interval Seconds between two runs, null for a single event
* @param string|null $driver Name of the driver that keeps the event, known when it comes from the index
* @param string|null $id Identifier of the placement, for a driver that has no store to ask (see Contracts\Identifies)
*/
public function __construct(
public string $hook,
Expand All @@ -25,8 +26,14 @@ public function __construct(
public array $args = [],
public ?int $interval = null,
public ?string $driver = null,
public ?string $id = null,
) {}

public function withId(?string $id): self
{
return new self($this->hook, $this->timestamp, $this->recurrence, $this->args, $this->interval, $this->driver, $id);
}

public function isRecurring(): bool
{
return $this->recurrence !== null;
Expand Down
10 changes: 4 additions & 6 deletions src/Migration/Migrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,11 @@ private function move(Event $event, string $from, string $to, ScheduleDriver $ta
}

// Already there, from an earlier run: only the index and the source are left to settle
if ($target->next($event->hook, $event->args) === null) {
$this->engine->quietly(fn (): bool => $event->recurrence !== null
? $target->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args)
: $target->once($event->hook, $event->timestamp, $event->args));
}
$placed = $target->next($event->hook, $event->args) === null
? $this->engine->quietly(fn (): ?Event => $this->engine->hand($target, $event))
: null;

CronIndex::record($event, $to);
CronIndex::record($placed ?? $event, $to);
});

if ($from === CronIndex::WP_CRON) {
Expand Down
7 changes: 6 additions & 1 deletion src/Schedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ public static function engine(): ScheduleEngine
$name = getenv('POLLORA_SCHEDULE_DRIVER');

// WP-Cron by default still sets the guard: it must not run what another driver kept
self::driver(is_string($name) && $name !== '' ? $name : 'wp-cron');
try {
self::driver(is_string($name) && $name !== '' ? $name : 'wp-cron');
} catch (InvalidArgumentException) {
// A driver added with extend() later: the application chooses it then
self::driver('wp-cron');
}
}

self::boot();
Expand Down
40 changes: 31 additions & 9 deletions src/ScheduleEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Closure;
use Pollora\Schedule\Contracts\DefersUntilReady;
use Pollora\Schedule\Contracts\FindsEvent;
use Pollora\Schedule\Contracts\Identifies;
use Pollora\Schedule\Contracts\ReportsChanges;
use Pollora\Schedule\Contracts\ScheduleDriver;
use Pollora\Schedule\Drivers\ActionSchedulerDriver;
Expand Down Expand Up @@ -406,10 +407,11 @@ public function applyChanges(array $changes): void

foreach ($changes['moved'] as $event) {
$driver = $this->driverNamed((string) $event->driver);
$placed = $this->hand($driver, $event);

$event->recurrence !== null
? $driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args)
: $driver->once($event->hook, $event->timestamp, $event->args);
if ($placed?->id !== null) {
CronIndex::record($placed, (string) $event->driver);
}
}
} catch (\Throwable) {
// A driver that is gone or cannot take the event: the option keeps what was written, and the doctor reports the drift
Expand Down Expand Up @@ -702,18 +704,38 @@ private function give(ScheduleDriver $driver, Event $event): bool
$this->busy = true;

try {
$placed = $event->recurrence !== null
? $driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args)
: $driver->once($event->hook, $event->timestamp, $event->args);
$placed = $this->hand($driver, $event);
} finally {
$this->busy = false;
}

if ($placed && $this->indexes() && $driver === $this->driver) {
CronIndex::record($event, $this->driverName);
if ($placed instanceof Event && $this->indexes() && $driver === $this->driver) {
CronIndex::record($placed, $this->driverName);
}

return $placed;
return $placed instanceof Event;
}

/**
* Give an event to a driver, as it takes it: with the identifier of the placement when the driver keeps no store of its own.
*
* The caller writes the index; the event comes back as the driver took it, null when it ignored it.
*
* @throws ScheduleException When the driver cannot schedule it
*/
public function hand(ScheduleDriver $driver, Event $event): ?Event
{
if ($driver instanceof Identifies) {
$event = $event->withId(bin2hex(random_bytes(8)));

return $driver->placeIdentified($event) ? $event : null;
}

$placed = $event->recurrence !== null
? $driver->recurring($event->hook, $event->timestamp, $event->recurrence, $event->args)
: $driver->once($event->hook, $event->timestamp, $event->args);

return $placed ? $event : null;
}

/**
Expand Down
27 changes: 27 additions & 0 deletions src/Support/CronIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ public static function ownerOf(string $hook, array $args = []): ?string
return null;
}

/**
* The identifier of the placement of the event with these arguments, null when it has none.
*
* @param array<int|string, mixed> $args
*/
public static function idOf(string $hook, array $args = []): ?string
{
$key = self::key($args);

foreach (self::read() as $hooks) {
if (is_array($hooks) && isset($hooks[$hook][$key]) && is_array($hooks[$hook][$key]) && isset($hooks[$hook][$key]['id'])) {
return (string) $hooks[$hook][$key]['id'];
}
}

return null;
}

/**
* Drivers that keep events of a hook, each once.
*
Expand Down Expand Up @@ -128,6 +146,7 @@ public static function events(?string $owner = null): array
args: (array) ($entry['args'] ?? []),
interval: isset($entry['interval']) ? (int) $entry['interval'] : null,
driver: self::owner($entry),
id: isset($entry['id']) ? (string) $entry['id'] : null,
);
}
}
Expand All @@ -149,6 +168,7 @@ public static function record(Event $event, string $owner): void
$key = self::key($event->args);
$schedule = $event->recurrence;
$interval = $event->interval;
$id = $event->id;

if ($event->recurrence !== null) {
$interval ??= (int) ((function_exists('wp_get_schedules') ? wp_get_schedules() : [])[$event->recurrence]['interval'] ?? 0);
Expand All @@ -158,6 +178,8 @@ public static function record(Event $event, string $owner): void
if ($schedule !== null && ($previous['interval'] ?? null) === $interval && ! empty($previous['schedule'])) {
$schedule = (string) $previous['schedule'];
}

$id ??= isset($previous['id']) ? (string) $previous['id'] : null;
}

$entry = ['schedule' => $schedule ?? false, 'args' => $event->args];
Expand All @@ -170,6 +192,10 @@ public static function record(Event $event, string $owner): void
$entry[self::MARKER] = $owner;
}

if ($id !== null) {
$entry['id'] = $id;
}

$crons[$event->timestamp][$event->hook][$key] = $entry;
ksort($crons);

Expand Down Expand Up @@ -303,6 +329,7 @@ private static function marked(array $crons): array
args: (array) ($entry['args'] ?? []),
interval: isset($entry['interval']) ? (int) $entry['interval'] : null,
driver: self::owner($entry),
id: isset($entry['id']) ? (string) $entry['id'] : null,
);
}
}
Expand Down
Loading
Loading