From b15a8e6fe42e0582ce5f1c22e05291eb96a20173 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Sat, 19 Sep 2026 13:38:05 +0200 Subject: [PATCH 1/2] feat: add task scheduler (Schedule, ScheduleEvent, CronExpression, ScheduleLock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Laravel-scheduler-style API for running tasks on a fixed interval, designed to work with webrium's plugin system rather than a single hand-edited schedule file: - ScheduleEvent: fluent builder (everyFiveMinutes(), dailyAt(), cron(), ...) wrapping a callback (closure, [class, method], or 'Class@method'). - CronExpression: minimal 5-field cron matcher (*, steps, ranges, lists) backing the fluent helpers. - Schedule: static registry (call()/all()/reset()) plus loadFromDirectory(), which requires every *.php file under a directory recursively (so a plugin can drop its own task file into a subdirectory without touching anyone else's file), isolating each file's own load error so one broken file can't stop the rest from registering. runDue() then runs whichever registered tasks are due, isolating each task's execution the same way — a failing task is reported via Debug::triggerError and never stops the others. - ScheduleLock: flock-based per-task lock preventing a still-running task from being started again by the next scheduler tick (single server; a shared, multi-server lock is intentionally left for later). Two new Directory aliases: 'schedules' (app/Schedules) and 'schedule_locks' (storage/framework/schedule-locks). The webrium/console counterpart (make:schedule, schedule:run) drives this from a single system cron entry, mirroring Laravel's scheduler. --- phpunit.xml | 2 + src/CronExpression.php | 115 ++++++++++++++ src/Directory.php | 2 + src/Schedule.php | 147 +++++++++++++++++ src/ScheduleEvent.php | 198 +++++++++++++++++++++++ src/ScheduleLock.php | 69 ++++++++ tests/CronExpressionTest.php | 125 +++++++++++++++ tests/ScheduleTest.php | 295 +++++++++++++++++++++++++++++++++++ 8 files changed, 953 insertions(+) create mode 100644 src/CronExpression.php create mode 100644 src/Schedule.php create mode 100644 src/ScheduleEvent.php create mode 100644 src/ScheduleLock.php create mode 100644 tests/CronExpressionTest.php create mode 100644 tests/ScheduleTest.php diff --git a/phpunit.xml b/phpunit.xml index 3a2fc63..fefddce 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,6 +33,8 @@ ./tests/HeaderMatchOriginTest.php ./tests/AppOriginAllowedTest.php ./tests/AppCorsMiddlewareTest.php + ./tests/CronExpressionTest.php + ./tests/ScheduleTest.php diff --git a/src/CronExpression.php b/src/CronExpression.php new file mode 100644 index 0000000..e32405a --- /dev/null +++ b/src/CronExpression.php @@ -0,0 +1,115 @@ + 0, 'max' => 59], // minute + ['min' => 0, 'max' => 23], // hour + ['min' => 1, 'max' => 31], // day of month + ['min' => 1, 'max' => 12], // month + ['min' => 0, 'max' => 7], // day of week (0 and 7 both = Sunday) + ]; + + /** @var array Expanded valid values per field, in field order. */ + private array $fields; + + public function __construct(string $expression) + { + $parts = preg_split('/\s+/', trim($expression)); + + if ($parts === false || count($parts) !== 5) { + throw new \InvalidArgumentException( + "Invalid cron expression '$expression': expected 5 space-separated fields (minute hour day month weekday)." + ); + } + + $this->fields = []; + foreach ($parts as $index => $part) { + $range = self::FIELD_RANGES[$index]; + $this->fields[$index] = self::expandField($part, $range['min'], $range['max']); + } + + // Day-of-week: normalize 7 to 0 so both mean Sunday. + $this->fields[4] = array_values(array_unique(array_map( + fn (int $day) => $day === 7 ? 0 : $day, + $this->fields[4] + ))); + } + + public function isDue(\DateTimeInterface $at): bool + { + return in_array((int) $at->format('i'), $this->fields[0], true) + && in_array((int) $at->format('G'), $this->fields[1], true) + && in_array((int) $at->format('j'), $this->fields[2], true) + && in_array((int) $at->format('n'), $this->fields[3], true) + && in_array((int) $at->format('w'), $this->fields[4], true); + } + + /** + * @return int[] + */ + private static function expandField(string $field, int $min, int $max): array + { + $values = []; + + foreach (explode(',', $field) as $part) { + $values = array_merge($values, self::expandPart($part, $min, $max)); + } + + return array_values(array_unique($values)); + } + + /** + * @return int[] + */ + private static function expandPart(string $part, int $min, int $max): array + { + $step = 1; + + if (str_contains($part, '/')) { + [$part, $stepPart] = explode('/', $part, 2); + if (!ctype_digit($stepPart) || (int) $stepPart < 1) { + throw new \InvalidArgumentException("Invalid cron step '$stepPart' in field part '$part/$stepPart'."); + } + $step = (int) $stepPart; + } + + if ($part === '*') { + [$rangeMin, $rangeMax] = [$min, $max]; + } elseif (str_contains($part, '-')) { + $bounds = explode('-', $part, 2); + if (!ctype_digit($bounds[0]) || !ctype_digit($bounds[1])) { + throw new \InvalidArgumentException("Invalid cron range '$part'."); + } + [$rangeMin, $rangeMax] = array_map('intval', $bounds); + } elseif (ctype_digit($part)) { + $rangeMin = $rangeMax = (int) $part; + } else { + throw new \InvalidArgumentException("Invalid cron field value '$part'."); + } + + if ($rangeMin < $min || $rangeMax > $max || $rangeMin > $rangeMax) { + throw new \InvalidArgumentException("Cron field value '$part' is out of range ($min-$max)."); + } + + $values = []; + for ($i = $rangeMin; $i <= $rangeMax; $i += $step) { + $values[] = $i; + } + + return $values; + } +} diff --git a/src/Directory.php b/src/Directory.php index b3c3a10..d6a5ab7 100644 --- a/src/Directory.php +++ b/src/Directory.php @@ -629,6 +629,7 @@ public static function initDefaultStructure(): void 'middleware' => 'app/Middleware', 'helpers' => 'app/Helpers', 'services' => 'app/Services', + 'schedules' => 'app/Schedules', // Database directories 'database' => 'database', @@ -643,6 +644,7 @@ public static function initDefaultStructure(): void 'cache' => 'storage/framework/cache', 'render_views' => 'storage/framework/cache/compiled-views', 'static_views' => 'storage/framework/cache/static-views', + 'schedule_locks' => 'storage/framework/schedule-locks', // Logs and languages 'logs' => 'storage/logs', diff --git a/src/Schedule.php b/src/Schedule.php new file mode 100644 index 0000000..d896b41 --- /dev/null +++ b/src/Schedule.php @@ -0,0 +1,147 @@ + Load failures, if any. + */ + public static function loadFromDirectory(string $directory): array + { + $errors = []; + + if (!is_dir($directory)) { + return $errors; + } + + $files = array_values(array_filter( + File::getFilesRecursive($directory), + static fn (string $file): bool => str_ends_with(strtolower($file), '.php') + )); + sort($files); + + foreach ($files as $file) { + try { + require $file; + } catch (\Throwable $e) { + $errors[] = ['file' => $file, 'error' => $e->getMessage()]; + } + } + + return $errors; + } + + /** + * Convenience wrapper loading from the registered 'schedules' directory + * (app/Schedules/ by default). + * + * @return array + */ + public static function loadDefault(): array + { + $dir = Directory::path('schedules'); + return $dir === null ? [] : self::loadFromDirectory($dir); + } + + /** + * Run every registered task that is due at $now (defaults to the + * current time). Each task is isolated: a failing or already-running + * task is reported but never stops the rest from being attempted. + * + * @return array + */ + public static function runDue(?\DateTimeInterface $now = null): array + { + $now = $now ?? new \DateTimeImmutable(); + $report = []; + + foreach (self::$events as $event) { + if ($event->isDue($now)) { + $report[] = self::runOne($event); + } + } + + return $report; + } + + /** + * @return array{name: string, status: 'ran'|'skipped'|'failed', error: string|null} + */ + private static function runOne(ScheduleEvent $event): array + { + $name = $event->getName(); + $lock = new ScheduleLock($event->lockKey()); + + if (!$lock->acquire()) { + return ['name' => $name, 'status' => 'skipped', 'error' => 'already running']; + } + + try { + $event->run(); + return ['name' => $name, 'status' => 'ran', 'error' => null]; + } catch (\Throwable $e) { + Debug::triggerError( + "Scheduled task '$name' failed: " . $e->getMessage(), + $e->getFile(), + $e->getLine(), + 500, + false, + 'ScheduleTaskError' + ); + + return ['name' => $name, 'status' => 'failed', 'error' => $e->getMessage()]; + } finally { + $lock->release(); + } + } +} diff --git a/src/ScheduleEvent.php b/src/ScheduleEvent.php new file mode 100644 index 0000000..e36242a --- /dev/null +++ b/src/ScheduleEvent.php @@ -0,0 +1,198 @@ +callback = $callback; + } + + /** + * Assign a stable, human-readable name used for lock files and reports. + * Recommended for any task, required for closures if a specific report + * label is wanted (a closure otherwise falls back to its file:line). + */ + public function name(string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): string + { + return $this->name ?? $this->inferName(); + } + + /** + * Filesystem-safe identifier derived from the task name, used for the + * per-task lock file so concurrent/overlapping runs are detected. + */ + public function lockKey(): string + { + $safe = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $this->getName()) ?? ''; + return $safe === '' || strlen($safe) > 150 ? sha1($this->getName()) : $safe; + } + + public function cron(string $expression): self + { + $this->expression = $expression; + $this->cron = null; + return $this; + } + + public function getExpression(): string + { + return $this->expression; + } + + public function everyMinute(): self + { + return $this->cron('* * * * *'); + } + + public function everyFiveMinutes(): self + { + return $this->cron('*/5 * * * *'); + } + + public function everyTenMinutes(): self + { + return $this->cron('*/10 * * * *'); + } + + public function everyFifteenMinutes(): self + { + return $this->cron('*/15 * * * *'); + } + + public function everyThirtyMinutes(): self + { + return $this->cron('0,30 * * * *'); + } + + public function hourly(): self + { + return $this->cron('0 * * * *'); + } + + public function hourlyAt(int $minute): self + { + return $this->cron("$minute * * * *"); + } + + public function daily(): self + { + return $this->cron('0 0 * * *'); + } + + public function dailyAt(string $time): self + { + [$hour, $minute] = self::parseTime($time); + return $this->cron("$minute $hour * * *"); + } + + public function weekly(): self + { + return $this->cron('0 0 * * 0'); + } + + public function weeklyOn(int $dayOfWeek, string $time = '00:00'): self + { + [$hour, $minute] = self::parseTime($time); + return $this->cron("$minute $hour * * $dayOfWeek"); + } + + public function monthly(): self + { + return $this->cron('0 0 1 * *'); + } + + public function isDue(\DateTimeInterface $at): bool + { + return $this->resolvedCron()->isDue($at); + } + + /** + * Invoke the task's callback. Throws on failure — callers decide how to + * isolate/report that (see Schedule::runDue()). + */ + public function run(): mixed + { + return self::invoke($this->callback); + } + + private function resolvedCron(): CronExpression + { + return $this->cron ??= new CronExpression($this->expression); + } + + /** + * @return array{0: int, 1: int} [hour, minute] + */ + private static function parseTime(string $time): array + { + $parts = array_pad(explode(':', $time), 2, '0'); + return [(int) $parts[0], (int) $parts[1]]; + } + + /** + * @param callable|string|array $callback + */ + private static function invoke($callback): mixed + { + if (is_string($callback) && str_contains($callback, '@')) { + [$class, $method] = explode('@', $callback, 2); + + if (!class_exists($class)) { + throw new \RuntimeException("Scheduled task class '$class' not found."); + } + + $callback = [new $class(), $method]; + } + + if (!is_callable($callback)) { + throw new \RuntimeException('Scheduled task callback is not callable.'); + } + + return call_user_func($callback); + } + + private function inferName(): string + { + if (is_string($this->callback)) { + return $this->callback; + } + + if (is_array($this->callback)) { + [$target, $method] = $this->callback; + $class = is_object($target) ? get_class($target) : $target; + return "$class::$method"; + } + + if ($this->callback instanceof \Closure) { + $ref = new \ReflectionFunction($this->callback); + return 'closure@' . $ref->getFileName() . ':' . $ref->getStartLine(); + } + + return 'task@' . spl_object_id($this); + } +} diff --git a/src/ScheduleLock.php b/src/ScheduleLock.php new file mode 100644 index 0000000..3e8d4c2 --- /dev/null +++ b/src/ScheduleLock.php @@ -0,0 +1,69 @@ +path = rtrim($dir, '/\\') . '/' . $key . '.lock'; + } + + /** + * Try to acquire the lock without blocking. + * + * @return bool True if acquired, false if another process already holds it. + */ + public function acquire(): bool + { + $dir = dirname($this->path); + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + $handle = @fopen($this->path, 'c'); + if ($handle === false) { + return false; + } + + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return false; + } + + $this->handle = $handle; + return true; + } + + public function release(): void + { + if ($this->handle !== null) { + flock($this->handle, LOCK_UN); + fclose($this->handle); + $this->handle = null; + } + } +} diff --git a/tests/CronExpressionTest.php b/tests/CronExpressionTest.php new file mode 100644 index 0000000..97b6424 --- /dev/null +++ b/tests/CronExpressionTest.php @@ -0,0 +1,125 @@ +assertTrue($cron->isDue($this->dt('2024-01-01 00:00:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-06-15 23:59:00'))); + } + + public function testFixedMinuteOnlyMatchesThatMinute(): void + { + $cron = new CronExpression('30 * * * *'); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 10:30:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-01-01 10:31:00'))); + } + + public function testStepMatchesEveryNMinutes(): void + { + $cron = new CronExpression('*/5 * * * *'); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:00:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:05:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:10:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-01-01 00:07:00'))); + } + + public function testRangeMatchesWithinBounds(): void + { + $cron = new CronExpression('0 9-17 * * *'); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 09:00:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 17:00:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-01-01 08:00:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-01-01 18:00:00'))); + } + + public function testSteppedRangeMatchesEveryNWithinBounds(): void + { + $cron = new CronExpression('0-30/10 * * * *'); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:00:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:10:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:20:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:30:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-01-01 00:40:00'))); + } + + public function testListMatchesAnyListedValue(): void + { + $cron = new CronExpression('0,15,30,45 * * * *'); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:15:00'))); + $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:45:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-01-01 00:20:00'))); + } + + public function testAllFieldsMustMatchSimultaneously(): void + { + $cron = new CronExpression('30 14 1 6 *'); + $this->assertTrue($cron->isDue($this->dt('2024-06-01 14:30:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-06-01 14:31:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-06-02 14:30:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-07-01 14:30:00'))); + } + + public function testDayOfWeekZeroMeansSunday(): void + { + $cron = new CronExpression('0 0 * * 0'); + // 2024-01-07 is a Sunday. + $this->assertTrue($cron->isDue($this->dt('2024-01-07 00:00:00'))); + $this->assertFalse($cron->isDue($this->dt('2024-01-08 00:00:00'))); + } + + public function testDayOfWeekSevenAlsoMeansSunday(): void + { + $cron = new CronExpression('0 0 * * 7'); + $this->assertTrue($cron->isDue($this->dt('2024-01-07 00:00:00'))); + } + + public function testThrowsOnWrongFieldCount(): void + { + $this->expectException(\InvalidArgumentException::class); + new CronExpression('* * *'); + } + + public function testThrowsOnOutOfRangeValue(): void + { + $this->expectException(\InvalidArgumentException::class); + new CronExpression('60 * * * *'); + } + + public function testThrowsOnInvertedRange(): void + { + $this->expectException(\InvalidArgumentException::class); + new CronExpression('30-10 * * * *'); + } + + public function testThrowsOnNonNumericValue(): void + { + $this->expectException(\InvalidArgumentException::class); + new CronExpression('abc * * * *'); + } +} diff --git a/tests/ScheduleTest.php b/tests/ScheduleTest.php new file mode 100644 index 0000000..efed2e2 --- /dev/null +++ b/tests/ScheduleTest.php @@ -0,0 +1,295 @@ +root = sys_get_temp_dir() . '/webrium_schedule_test_' . uniqid(); + mkdir($this->root, 0755, true); + App::setRootPath($this->root); + Directory::initDefaultStructure(); + Schedule::reset(); + } + + protected function tearDown(): void + { + Schedule::reset(); + $this->removeDir($this->root); + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = "$dir/$item"; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } + + // ========================================================================= + // 1. ScheduleEvent fluent interval helpers + // ========================================================================= + + public function testEveryMinute(): void + { + $this->assertSame('* * * * *', (new ScheduleEvent(fn () => null))->everyMinute()->getExpression()); + } + + public function testEveryFiveMinutes(): void + { + $this->assertSame('*/5 * * * *', (new ScheduleEvent(fn () => null))->everyFiveMinutes()->getExpression()); + } + + public function testHourlyAt(): void + { + $this->assertSame('15 * * * *', (new ScheduleEvent(fn () => null))->hourlyAt(15)->getExpression()); + } + + public function testDailyAtParsesHourAndMinute(): void + { + $this->assertSame('30 13 * * *', (new ScheduleEvent(fn () => null))->dailyAt('13:30')->getExpression()); + } + + public function testDailyAtDefaultsMinuteWhenOmitted(): void + { + $this->assertSame('0 9 * * *', (new ScheduleEvent(fn () => null))->dailyAt('9')->getExpression()); + } + + public function testWeeklyOnSpecificDay(): void + { + $this->assertSame('0 8 * * 3', (new ScheduleEvent(fn () => null))->weeklyOn(3, '08:00')->getExpression()); + } + + public function testMonthly(): void + { + $this->assertSame('0 0 1 * *', (new ScheduleEvent(fn () => null))->monthly()->getExpression()); + } + + public function testExplicitCronExpressionOverridesFluentHelpers(): void + { + $event = (new ScheduleEvent(fn () => null))->everyMinute()->cron('0 3 * * *'); + $this->assertSame('0 3 * * *', $event->getExpression()); + } + + // ========================================================================= + // 2. name() / lockKey() + // ========================================================================= + + public function testExplicitNameIsReturnedVerbatim(): void + { + $event = (new ScheduleEvent(fn () => null))->name('email.send-queued'); + $this->assertSame('email.send-queued', $event->getName()); + } + + public function testStringCallbackNameIsInferredFromCallbackItself(): void + { + $event = new ScheduleEvent('App\\Services\\Reports@daily'); + $this->assertSame('App\\Services\\Reports@daily', $event->getName()); + } + + public function testArrayCallbackNameIsInferredFromClassAndMethod(): void + { + $event = new ScheduleEvent([\Tests\ScheduleTestTarget::class, 'ok']); + $this->assertSame(\Tests\ScheduleTestTarget::class . '::ok', $event->getName()); + } + + public function testClosureNameIsStableAcrossCallsForTheSameInstance(): void + { + $event = new ScheduleEvent(fn () => null); + $this->assertSame($event->getName(), $event->getName()); + } + + public function testLockKeyIsFilesystemSafe(): void + { + $event = (new ScheduleEvent(fn () => null))->name('weird name / with * chars'); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9_.-]+$/', $event->lockKey()); + } + + // ========================================================================= + // 3. Schedule registry + // ========================================================================= + + public function testCallRegistersEventAndReturnsIt(): void + { + $event = Schedule::call(fn () => null); + $this->assertInstanceOf(ScheduleEvent::class, $event); + $this->assertSame([$event], Schedule::all()); + } + + public function testResetClearsRegistry(): void + { + Schedule::call(fn () => null); + Schedule::reset(); + $this->assertSame([], Schedule::all()); + } + + // ========================================================================= + // 4. loadFromDirectory(): discovery + per-file error isolation + // ========================================================================= + + public function testLoadFromDirectoryRegistersTasksFromEveryFile(): void + { + $dir = $this->root . '/Schedules'; + mkdir($dir, 0755, true); + file_put_contents($dir . '/One.php', ' null)->name("one");'); + file_put_contents($dir . '/Two.php', ' null)->name("two");'); + + $errors = Schedule::loadFromDirectory($dir); + + $this->assertSame([], $errors); + $this->assertCount(2, Schedule::all()); + } + + public function testLoadFromDirectoryIsRecursiveForPluginSubfolders(): void + { + $dir = $this->root . '/Schedules'; + mkdir($dir . '/some-plugin', 0755, true); + file_put_contents($dir . '/some-plugin/Task.php', ' null);'); + + Schedule::loadFromDirectory($dir); + + $this->assertCount(1, Schedule::all()); + } + + public function testLoadFromDirectoryReturnsEmptyArrayForMissingDirectory(): void + { + $this->assertSame([], Schedule::loadFromDirectory($this->root . '/does-not-exist')); + } + + /** + * A broken task file (throws while being registered) must not prevent a + * different, valid file from registering its own task. + */ + public function testABrokenFileDoesNotPreventOtherFilesFromLoading(): void + { + $dir = $this->root . '/Schedules'; + mkdir($dir, 0755, true); + file_put_contents($dir . '/Broken.php', ' null)->name("good");'); + + $errors = Schedule::loadFromDirectory($dir); + + $this->assertCount(1, $errors); + $this->assertStringContainsString('Broken.php', $errors[0]['file']); + $this->assertStringContainsString('boom while loading', $errors[0]['error']); + + $this->assertCount(1, Schedule::all()); + $this->assertSame('good', Schedule::all()[0]->getName()); + } + + // ========================================================================= + // 5. runDue(): due-filtering + per-task failure isolation + overlap lock + // ========================================================================= + + public function testRunDueOnlyRunsTasksThatAreDueAtGivenTime(): void + { + $ranFive = false; + $ranHourly = false; + + Schedule::call(function () use (&$ranFive) { $ranFive = true; })->name('five')->everyFiveMinutes(); + Schedule::call(function () use (&$ranHourly) { $ranHourly = true; })->name('hourly')->hourlyAt(0); + + Schedule::runDue(new \DateTimeImmutable('2024-01-01 10:05:00')); + + $this->assertTrue($ranFive); + $this->assertFalse($ranHourly); + } + + public function testRunDueReportsRanStatusForSuccessfulTask(): void + { + Schedule::call(fn () => null)->name('ok-task')->everyMinute(); + + $report = Schedule::runDue(new \DateTimeImmutable('2024-01-01 00:00:00')); + + $this->assertSame([['name' => 'ok-task', 'status' => 'ran', 'error' => null]], $report); + } + + /** + * SECURITY/RELIABILITY: one task throwing must not stop other due tasks + * from running, and must be reported as 'failed', not silently swallowed. + */ + public function testAFailingTaskDoesNotPreventOtherDueTasksFromRunning(): void + { + $secondRan = false; + + Schedule::call(function () { throw new \RuntimeException('task blew up'); }) + ->name('failing')->everyMinute(); + Schedule::call(function () use (&$secondRan) { $secondRan = true; }) + ->name('second')->everyMinute(); + + $report = Schedule::runDue(new \DateTimeImmutable('2024-01-01 00:00:00')); + + $this->assertTrue($secondRan, 'a later due task must still run after an earlier one fails'); + + $byName = array_column($report, null, 'name'); + $this->assertSame('failed', $byName['failing']['status']); + $this->assertStringContainsString('task blew up', $byName['failing']['error']); + $this->assertSame('ran', $byName['second']['status']); + } + + /** + * Simulates "another process is still running this task" by holding the + * task's lock open from the test itself before calling runDue() — a real + * overlap can't be reproduced deterministically in a single-threaded + * test process, but the lock primitive is exactly what a slow-running + * task would leave held in production. + */ + public function testOverlappingRunIsSkippedWhileLockIsHeld(): void + { + $ran = false; + $event = Schedule::call(function () use (&$ran) { $ran = true; }) + ->name('held-task')->everyMinute(); + + $externalLock = new \Webrium\ScheduleLock($event->lockKey()); + $this->assertTrue($externalLock->acquire()); + + $now = new \DateTimeImmutable('2024-01-01 00:00:00'); + $report = Schedule::runDue($now); + + $this->assertFalse($ran, 'task must not run while its lock is already held elsewhere'); + $this->assertSame('skipped', $report[0]['status']); + + $externalLock->release(); + + $report = Schedule::runDue($now); + + $this->assertTrue($ran, 'task must run once the lock is released'); + $this->assertSame('ran', $report[0]['status']); + } +} + +class ScheduleTestTarget +{ + public function ok(): bool + { + return true; + } +} From c1b054094f127a8ad9447a98638791266ff4e736 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Sat, 19 Sep 2026 14:06:02 +0200 Subject: [PATCH 2/2] feat: add nextRunDate(), Schedule::find() and Schedule::run() Support for on-demand tooling (webrium/console's schedule:list and schedule:test) on top of the scheduler added in the previous commit: - CronExpression::nextRunDate() / ScheduleEvent::nextRunDate(): the next minute (strictly after a given reference instant, default now) the expression is due, minute-resolution, capped at ~2 years lookahead so a self-contradictory expression can't loop unbounded. - Schedule::find(): look up a registered task by its name. - Schedule::run(): run a single task immediately, bypassing its own due-check, while still going through the same overlap lock and error isolation as runDue() (runOne() is folded into this, now public, as runDue() itself uses it per due task). --- src/CronExpression.php | 35 +++++++++++++++++++++++++ src/Schedule.php | 24 +++++++++++++++-- src/ScheduleEvent.php | 8 ++++++ tests/CronExpressionTest.php | 50 ++++++++++++++++++++++++++++++++++++ tests/ScheduleTest.php | 37 ++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/CronExpression.php b/src/CronExpression.php index e32405a..f710ba8 100644 --- a/src/CronExpression.php +++ b/src/CronExpression.php @@ -58,6 +58,41 @@ public function isDue(\DateTimeInterface $at): bool && in_array((int) $at->format('w'), $this->fields[4], true); } + /** + * Safety cap on how far ahead nextRunDate() will search (in minutes) + * before giving up. Covers every realistic schedule (including yearly + * ones) without risking an unbounded loop on a self-contradictory + * expression (e.g. day-of-month 31 combined with a month that never + * has one). + */ + private const MAX_LOOKAHEAD_MINUTES = 1_053_792; // ~2 years + + /** + * Find the next minute at or after $after (default: now) that this + * expression is due, minute-resolution, seconds ignored/floored. + * + * @return \DateTimeImmutable|null Null if nothing matches within the lookahead window. + */ + public function nextRunDate(?\DateTimeInterface $after = null): ?\DateTimeImmutable + { + $reference = $after !== null + ? \DateTimeImmutable::createFromInterface($after) + : new \DateTimeImmutable(); + + $candidate = $reference + ->setTime((int) $reference->format('H'), (int) $reference->format('i'), 0) + ->modify('+1 minute'); + + for ($i = 0; $i < self::MAX_LOOKAHEAD_MINUTES; $i++) { + if ($this->isDue($candidate)) { + return $candidate; + } + $candidate = $candidate->modify('+1 minute'); + } + + return null; + } + /** * @return int[] */ diff --git a/src/Schedule.php b/src/Schedule.php index d896b41..de80215 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -38,6 +38,20 @@ public static function all(): array return self::$events; } + /** + * Find a registered task by its name (see ScheduleEvent::getName()). + */ + public static function find(string $name): ?ScheduleEvent + { + foreach (self::$events as $event) { + if ($event->getName() === $name) { + return $event; + } + } + + return null; + } + /** * Clear the registry. Mainly useful for tests and for reloading the * schedule directory from a clean state. @@ -107,7 +121,7 @@ public static function runDue(?\DateTimeInterface $now = null): array foreach (self::$events as $event) { if ($event->isDue($now)) { - $report[] = self::runOne($event); + $report[] = self::run($event); } } @@ -115,9 +129,15 @@ public static function runDue(?\DateTimeInterface $now = null): array } /** + * Run a single task immediately, bypassing its own due-check — used by + * runDue() for each due task, and directly by tooling like + * webrium/console's `schedule:test` to trigger one task on demand. + * Isolated the same way as runDue(): a failure is reported, never + * thrown, and the task's own overlap lock still applies. + * * @return array{name: string, status: 'ran'|'skipped'|'failed', error: string|null} */ - private static function runOne(ScheduleEvent $event): array + public static function run(ScheduleEvent $event): array { $name = $event->getName(); $lock = new ScheduleLock($event->lockKey()); diff --git a/src/ScheduleEvent.php b/src/ScheduleEvent.php index e36242a..dfe7dc4 100644 --- a/src/ScheduleEvent.php +++ b/src/ScheduleEvent.php @@ -131,6 +131,14 @@ public function isDue(\DateTimeInterface $at): bool return $this->resolvedCron()->isDue($at); } + /** + * @return \DateTimeImmutable|null Null if nothing matches within the lookahead window (see CronExpression). + */ + public function nextRunDate(?\DateTimeInterface $after = null): ?\DateTimeImmutable + { + return $this->resolvedCron()->nextRunDate($after); + } + /** * Invoke the task's callback. Throws on failure — callers decide how to * isolate/report that (see Schedule::runDue()). diff --git a/tests/CronExpressionTest.php b/tests/CronExpressionTest.php index 97b6424..0dfd41c 100644 --- a/tests/CronExpressionTest.php +++ b/tests/CronExpressionTest.php @@ -122,4 +122,54 @@ public function testThrowsOnNonNumericValue(): void $this->expectException(\InvalidArgumentException::class); new CronExpression('abc * * * *'); } + + // ========================================================================= + // nextRunDate() + // ========================================================================= + + public function testNextRunDateForEveryMinuteIsOneMinuteAhead(): void + { + $cron = new CronExpression('* * * * *'); + $next = $cron->nextRunDate($this->dt('2024-01-01 10:00:30')); + $this->assertSame('2024-01-01 10:01:00', $next->format('Y-m-d H:i:s')); + } + + public function testNextRunDateSkipsToNextMatchingStep(): void + { + $cron = new CronExpression('*/15 * * * *'); + $next = $cron->nextRunDate($this->dt('2024-01-01 10:01:00')); + $this->assertSame('2024-01-01 10:15:00', $next->format('Y-m-d H:i:s')); + } + + public function testNextRunDateCrossesDayBoundary(): void + { + $cron = new CronExpression('0 0 * * *'); + $next = $cron->nextRunDate($this->dt('2024-01-01 23:59:00')); + $this->assertSame('2024-01-02 00:00:00', $next->format('Y-m-d H:i:s')); + } + + public function testNextRunDateCrossesMonthBoundaryForMonthlySchedule(): void + { + $cron = new CronExpression('0 0 1 * *'); + $next = $cron->nextRunDate($this->dt('2024-01-15 12:00:00')); + $this->assertSame('2024-02-01 00:00:00', $next->format('Y-m-d H:i:s')); + } + + /** + * A candidate is never due at the reference instant itself, even when + * it exactly matches — nextRunDate() always looks strictly forward. + */ + public function testNextRunDateNeverReturnsTheReferenceInstantItself(): void + { + $cron = new CronExpression('0 0 * * *'); + $next = $cron->nextRunDate($this->dt('2024-01-02 00:00:00')); + $this->assertSame('2024-01-03 00:00:00', $next->format('Y-m-d H:i:s')); + } + + public function testNextRunDateDefaultsToNowWhenNoReferenceGiven(): void + { + $cron = new CronExpression('* * * * *'); + $next = $cron->nextRunDate(); + $this->assertGreaterThan(new \DateTimeImmutable(), $next); + } } diff --git a/tests/ScheduleTest.php b/tests/ScheduleTest.php index efed2e2..2c17724 100644 --- a/tests/ScheduleTest.php +++ b/tests/ScheduleTest.php @@ -99,6 +99,13 @@ public function testExplicitCronExpressionOverridesFluentHelpers(): void $this->assertSame('0 3 * * *', $event->getExpression()); } + public function testNextRunDateDelegatesToTheUnderlyingCronExpression(): void + { + $event = (new ScheduleEvent(fn () => null))->dailyAt('03:00'); + $next = $event->nextRunDate(new \DateTimeImmutable('2024-01-01 10:00:00')); + $this->assertSame('2024-01-02 03:00:00', $next->format('Y-m-d H:i:s')); + } + // ========================================================================= // 2. name() / lockKey() // ========================================================================= @@ -151,6 +158,36 @@ public function testResetClearsRegistry(): void $this->assertSame([], Schedule::all()); } + public function testFindReturnsRegisteredEventByName(): void + { + $event = Schedule::call(fn () => null)->name('reports.daily'); + $this->assertSame($event, Schedule::find('reports.daily')); + } + + public function testFindReturnsNullForUnknownName(): void + { + $this->assertNull(Schedule::find('does.not.exist')); + } + + /** + * Schedule::run() executes a task immediately regardless of whether it + * is actually due — the mechanism behind an on-demand `schedule:test` + * command — while still going through the same lock/error isolation + * as runDue(). + */ + public function testRunExecutesATaskImmediatelyIgnoringItsDueCheck(): void + { + $ran = false; + // Scheduled for 03:00 daily; "now" is irrelevant to Schedule::run(). + $event = Schedule::call(function () use (&$ran) { $ran = true; }) + ->name('manual-trigger')->dailyAt('03:00'); + + $result = Schedule::run($event); + + $this->assertTrue($ran); + $this->assertSame(['name' => 'manual-trigger', 'status' => 'ran', 'error' => null], $result); + } + // ========================================================================= // 4. loadFromDirectory(): discovery + per-file error isolation // =========================================================================