diff --git a/CHANGELOG.md b/CHANGELOG.md index 4271cff..d469bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/Contracts/Identifies.php b/src/Contracts/Identifies.php new file mode 100644 index 0000000..90e5514 --- /dev/null +++ b/src/Contracts/Identifies.php @@ -0,0 +1,28 @@ + $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, @@ -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; diff --git a/src/Migration/Migrator.php b/src/Migration/Migrator.php index 76f8663..4ba1744 100644 --- a/src/Migration/Migrator.php +++ b/src/Migration/Migrator.php @@ -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) { diff --git a/src/Schedule.php b/src/Schedule.php index 4acb12c..68a4dd5 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -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(); diff --git a/src/ScheduleEngine.php b/src/ScheduleEngine.php index 5170543..3cbc682 100644 --- a/src/ScheduleEngine.php +++ b/src/ScheduleEngine.php @@ -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; @@ -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 @@ -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; } /** diff --git a/src/Support/CronIndex.php b/src/Support/CronIndex.php index e1d59e2..ab4f724 100644 --- a/src/Support/CronIndex.php +++ b/src/Support/CronIndex.php @@ -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 $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. * @@ -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, ); } } @@ -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); @@ -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]; @@ -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); @@ -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, ); } } diff --git a/tests/Unit/IdentifiesTest.php b/tests/Unit/IdentifiesTest.php new file mode 100644 index 0000000..f45db28 --- /dev/null +++ b/tests/Unit/IdentifiesTest.php @@ -0,0 +1,161 @@ + */ + public array $placed = []; + + public function available(): bool + { + return true; + } + + public function placeIdentified(Event $event): bool + { + $this->placed[] = $event; + + return true; + } + + public function once(string $hook, int $timestamp, array $args = []): bool + { + throw new LogicException('Identified placement only'); + } + + public function recurring(string $hook, int $timestamp, string $recurrence, array $args = []): bool + { + throw new LogicException('Identified placement only'); + } + + public function cancel(string $hook, array $args = []): int + { + return 0; + } + + public function clear(string $hook): int + { + return 0; + } + + public function next(string $hook, array $args = []): ?int + { + return null; + } + + public function events(): array + { + return []; + } +} + +beforeEach(function (): void { + $this->cron = []; + + Functions\when('add_filter')->justReturn(true); + Functions\when('add_action')->justReturn(true); + Functions\when('remove_filter')->justReturn(true); + Functions\when('wp_get_schedules')->justReturn(['daily' => ['interval' => 86400, 'display' => 'Once Daily']]); + Functions\when('_get_cron_array')->alias(fn (): array => $this->cron); + Functions\when('_set_cron_array')->alias(function (array $cron): bool { + $this->cron = $cron; + + return true; + }); + + $this->driver = new IdentifyingDriver; + $this->engine = new ScheduleEngine; + $this->engine->selectDriver($this->driver, 'queue'); +}); + +it('gives each placement an identifier and writes it in the entry', function (): void { + $this->engine->place(new Event('digest', time() + 100, null, ['u' => 1])); + $this->engine->place(new Event('digest', time() + 200, null, ['u' => 2])); + + $first = $this->driver->placed[0]; + + expect($first->id)->toMatch('/^[0-9a-f]{16}$/') + ->and(CronIndex::idOf('digest', ['u' => 1]))->toBe($first->id) + ->and($this->driver->placed[1]->id)->not->toBe($first->id) + ->and(CronIndex::events('queue')[0]->id)->toBe($first->id) + ->and(CronIndex::ownerOf('digest', ['u' => 1]))->toBe('queue'); +}); + +it('keeps the identifier when the index records the event again without one', function (): void { + $this->engine->place(new Event('digest', time() + 100)); + $id = CronIndex::idOf('digest'); + + CronIndex::record(new Event('digest', time() + 100), 'queue'); + + expect(CronIndex::idOf('digest'))->toBe($id); +}); + +it('replaces the identifier when a placement carries one', function (): void { + $this->engine->place(new Event('digest', time() + 100)); + CronIndex::record((new Event('digest', time() + 500))->withId('next'), 'queue'); + + expect(CronIndex::idOf('digest'))->toBe('next') + ->and(CronIndex::events('queue'))->toHaveCount(1); +}); + +it('has no identifier for an entry that never had one', function (): void { + $this->cron = [time() + 100 => ['legacy' => [md5(serialize([])) => ['schedule' => false, 'args' => []]]]]; + + expect(CronIndex::idOf('legacy'))->toBeNull(); +}); + +it('places the events a direct write moved with a new identifier', function (): void { + $moved = new Event('digest', time() + 900, null, [], null, 'queue', 'stale'); + + $this->engine->applyChanges(['removed' => [], 'moved' => [$moved]]); + + expect($this->driver->placed)->toHaveCount(1) + ->and($this->driver->placed[0]->id)->not->toBe('stale') + ->and(CronIndex::idOf('digest'))->toBe($this->driver->placed[0]->id); +}); + +it('identifies the placement a migration makes', function (): void { + $this->cron = [time() + 100 => ['digest' => [md5(serialize([])) => ['schedule' => false, 'args' => []]]]]; + Functions\when('wp_unschedule_event')->justReturn(true); + Functions\when('wp_next_scheduled')->justReturn(false); + + $this->engine->driverNamed('queue'); + $report = (new Migrator($this->engine))->migrate('wp-cron', 'queue'); + + expect($report->moved)->toHaveCount(1) + ->and($this->driver->placed[0]->id)->not->toBeNull() + ->and(CronIndex::idOf('digest'))->toBe($this->driver->placed[0]->id); +}); + +it('does not fail when the environment names a driver the application adds afterwards', function (): void { + $schedule = new ReflectionClass(Schedule::class); + $schedule->setStaticPropertyValue('engine', null); + $schedule->setStaticPropertyValue('wanted', null); + putenv('POLLORA_SCHEDULE_DRIVER=queue'); + + try { + expect(Schedule::engine()->driverName())->toBe('wp-cron'); + + Schedule::extend('queue', new IdentifyingDriver); + Schedule::driver('queue'); + + expect(Schedule::engine()->driverName())->toBe('queue'); + } finally { + putenv('POLLORA_SCHEDULE_DRIVER'); + $schedule->setStaticPropertyValue('engine', null); + $schedule->setStaticPropertyValue('drivers', []); + } +});