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
2 changes: 2 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
<file>./tests/HeaderMatchOriginTest.php</file>
<file>./tests/AppOriginAllowedTest.php</file>
<file>./tests/AppCorsMiddlewareTest.php</file>
<file>./tests/CronExpressionTest.php</file>
<file>./tests/ScheduleTest.php</file>

</testsuite>
</testsuites>
Expand Down
150 changes: 150 additions & 0 deletions src/CronExpression.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

declare(strict_types=1);

namespace Webrium;

/**
* Minimal standard 5-field cron expression parser and matcher
* (minute hour day-of-month month day-of-week).
*
* Supports the common syntax: "*", "* /n" steps, "a-b" ranges, "a-b/n"
* stepped ranges, and "a,b,c" lists, in any combination per field.
*
* Day-of-week accepts both 0 and 7 for Sunday, matching cron convention.
*/
class CronExpression
{
private const FIELD_RANGES = [
['min' => 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<int, int[]> 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);
}

/**
* 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[]
*/
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;
}
}
2 changes: 2 additions & 0 deletions src/Directory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
167 changes: 167 additions & 0 deletions src/Schedule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

namespace Webrium;

/**
* Task scheduling registry.
*
* Tasks are registered with Schedule::call() — typically from files under
* the 'schedules' directory (app/Schedules/ by default), each loaded in
* isolation by loadFromDirectory() so one broken file cannot stop the rest
* from registering. runDue() then executes whichever registered tasks are
* due, isolating each task's own failure the same way.
*
* Intended to be driven by a single system cron entry running the
* `schedule:run` console command once a minute (see webrium/console),
* mirroring Laravel's scheduler model.
*/
class Schedule
{
/** @var ScheduleEvent[] */
private static array $events = [];

/**
* @param callable|string|array $callback
*/
public static function call($callback): ScheduleEvent
{
$event = new ScheduleEvent($callback);
self::$events[] = $event;
return $event;
}

/** @return ScheduleEvent[] */
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.
*/
public static function reset(): void
{
self::$events = [];
}

/**
* Require every *.php file under $directory (recursively — so plugins
* can namespace their own task file under a subdirectory without
* colliding with anyone else's), isolating each file's own load error
* so a broken file never prevents the rest of the schedule from
* registering.
*
* @return array<int, array{file: string, error: string}> 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<int, array{file: string, error: string}>
*/
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<int, array{name: string, status: 'ran'|'skipped'|'failed', error: string|null}>
*/
public static function runDue(?\DateTimeInterface $now = null): array
{
$now = $now ?? new \DateTimeImmutable();
$report = [];

foreach (self::$events as $event) {
if ($event->isDue($now)) {
$report[] = self::run($event);
}
}

return $report;
}

/**
* 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}
*/
public static function run(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();
}
}
}
Loading
Loading