asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-30 02:39:50 +02:00
parent 9c9ec477d0
commit 0c5c89acfa
11 changed files with 961 additions and 65 deletions

View File

@@ -76,6 +76,22 @@ final class BaseSchema
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
module_name TEXT NOT NULL,
job_name TEXT NOT NULL,
last_scheduled_for TIMESTAMPTZ NULL,
last_started_at TIMESTAMPTZ NULL,
last_finished_at TIMESTAMPTZ NULL,
last_success_at TIMESTAMPTZ NULL,
last_status TEXT NULL,
last_message TEXT NULL,
lock_until TIMESTAMPTZ NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (module_name, job_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
@@ -179,6 +195,22 @@ final class BaseSchema
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
module_name TEXT NOT NULL,
job_name TEXT NOT NULL,
last_scheduled_for TEXT NULL,
last_started_at TEXT NULL,
last_finished_at TEXT NULL,
last_success_at TEXT NULL,
last_status TEXT NULL,
last_message TEXT NULL,
lock_until TEXT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (module_name, job_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
@@ -282,6 +314,22 @@ final class BaseSchema
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
module_name VARCHAR(190) NOT NULL,
job_name VARCHAR(190) NOT NULL,
last_scheduled_for DATETIME NULL,
last_started_at DATETIME NULL,
last_finished_at DATETIME NULL,
last_success_at DATETIME NULL,
last_status VARCHAR(32) NULL,
last_message TEXT NULL,
lock_until DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (module_name, job_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub VARCHAR(190) PRIMARY KEY,

214
src/App/CronExpression.php Normal file
View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class CronExpression
{
/** @var array<int, true> */
private array $minutes;
/** @var array<int, true> */
private array $hours;
/** @var array<int, true> */
private array $daysOfMonth;
/** @var array<int, true> */
private array $months;
/** @var array<int, true> */
private array $daysOfWeek;
private function __construct(
private string $expression,
array $minutes,
array $hours,
array $daysOfMonth,
array $months,
array $daysOfWeek,
private bool $daysOfMonthWildcard,
private bool $daysOfWeekWildcard
) {
$this->minutes = $minutes;
$this->hours = $hours;
$this->daysOfMonth = $daysOfMonth;
$this->months = $months;
$this->daysOfWeek = $daysOfWeek;
}
public static function parse(string $expression): self
{
$normalized = preg_replace('/\s+/', ' ', trim($expression)) ?? '';
if ($normalized === '') {
throw new \InvalidArgumentException('Cron-Ausdruck fehlt.');
}
$parts = explode(' ', $normalized);
if (count($parts) !== 5) {
throw new \InvalidArgumentException('Cron-Ausdruck muss aus 5 Feldern bestehen.');
}
return new self(
$normalized,
self::parseField($parts[0], 0, 59),
self::parseField($parts[1], 0, 23),
self::parseField($parts[2], 1, 31),
self::parseField($parts[3], 1, 12, [
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12,
]),
self::parseField($parts[4], 0, 6, [
'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
'THU' => 4, 'FRI' => 5, 'SAT' => 6,
'7' => 0,
]),
trim($parts[2]) === '*',
trim($parts[4]) === '*'
);
}
public function expression(): string
{
return $this->expression;
}
public function matches(DateTimeImmutable $utcDateTime, DateTimeZone $timezone): bool
{
$local = $utcDateTime->setTimezone($timezone);
$minute = (int) $local->format('i');
$hour = (int) $local->format('G');
$dayOfMonth = (int) $local->format('j');
$month = (int) $local->format('n');
$dayOfWeek = (int) $local->format('w');
if (!isset($this->minutes[$minute]) || !isset($this->hours[$hour]) || !isset($this->months[$month])) {
return false;
}
$dayOfMonthMatch = isset($this->daysOfMonth[$dayOfMonth]);
$dayOfWeekMatch = isset($this->daysOfWeek[$dayOfWeek]);
if ($this->daysOfMonthWildcard && $this->daysOfWeekWildcard) {
$dayMatches = true;
} elseif ($this->daysOfMonthWildcard) {
$dayMatches = $dayOfWeekMatch;
} elseif ($this->daysOfWeekWildcard) {
$dayMatches = $dayOfMonthMatch;
} else {
$dayMatches = $dayOfMonthMatch || $dayOfWeekMatch;
}
return $dayMatches;
}
public function previousRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookbackMinutes = 527040): ?DateTimeImmutable
{
$cursor = $this->floorToMinute($utcDateTime);
for ($i = 0; $i <= $lookbackMinutes; $i++) {
if ($this->matches($cursor, $timezone)) {
return $cursor;
}
$cursor = $cursor->sub(new DateInterval('PT1M'));
}
return null;
}
public function nextRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookaheadMinutes = 527040): ?DateTimeImmutable
{
$cursor = $this->floorToMinute($utcDateTime)->add(new DateInterval('PT1M'));
for ($i = 0; $i <= $lookaheadMinutes; $i++) {
if ($this->matches($cursor, $timezone)) {
return $cursor;
}
$cursor = $cursor->add(new DateInterval('PT1M'));
}
return null;
}
/** @return array<int, true> */
private static function parseField(string $field, int $min, int $max, array $aliases = []): array
{
$field = strtoupper(trim($field));
if ($field === '*') {
$all = [];
for ($value = $min; $value <= $max; $value++) {
$all[$value] = true;
}
return $all;
}
$values = [];
foreach (explode(',', $field) as $segment) {
$segment = strtoupper(trim($segment));
if ($segment === '') {
continue;
}
$step = 1;
if (str_contains($segment, '/')) {
[$segment, $stepPart] = explode('/', $segment, 2);
if (!is_numeric($stepPart) || (int) $stepPart <= 0) {
throw new \InvalidArgumentException('Ungueltiger Cron-Step in Feld "' . $field . '".');
}
$step = (int) $stepPart;
}
if ($segment === '*') {
$start = $min;
$end = $max;
} elseif (str_contains($segment, '-')) {
[$startPart, $endPart] = explode('-', $segment, 2);
$start = self::normalizePart($startPart, $aliases);
$end = self::normalizePart($endPart, $aliases);
} else {
$start = self::normalizePart($segment, $aliases);
$end = $start;
}
if ($start < $min || $start > $max || $end < $min || $end > $max || $end < $start) {
throw new \InvalidArgumentException('Cron-Feld "' . $field . '" liegt ausserhalb des erlaubten Bereichs.');
}
for ($value = $start; $value <= $end; $value += $step) {
$values[$value] = true;
}
}
if ($values === []) {
throw new \InvalidArgumentException('Cron-Feld "' . $field . '" ist leer.');
}
ksort($values);
return $values;
}
private static function normalizePart(string $part, array $aliases): int
{
$part = strtoupper(trim($part));
if ($part === '') {
throw new \InvalidArgumentException('Leerer Cron-Wert.');
}
if (array_key_exists($part, $aliases)) {
return (int) $aliases[$part];
}
if (!is_numeric($part)) {
throw new \InvalidArgumentException('Ungueltiger Cron-Wert "' . $part . '".');
}
return (int) $part;
}
private function floorToMinute(DateTimeImmutable $utcDateTime): DateTimeImmutable
{
return $utcDateTime->setTime(
(int) $utcDateTime->format('H'),
(int) $utcDateTime->format('i'),
0
);
}
}

View File

@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace App;
use DateTimeImmutable;
use DateTimeZone;
final class ModuleCronScheduler
{
public function __construct(
private ModuleManager $modules,
private ?\PDO $pdo
) {
}
public function definitions(string $moduleName): array
{
$module = $this->modules->get($moduleName);
$tasks = is_array($module['cron_tasks'] ?? null) ? $module['cron_tasks'] : [];
$definitions = [];
foreach ($tasks as $task) {
if (!is_array($task)) {
continue;
}
$name = trim((string) ($task['name'] ?? ''));
$callback = trim((string) ($task['callback'] ?? ''));
if ($name === '' || $callback === '') {
continue;
}
$definitions[$name] = [
'name' => $name,
'label' => trim((string) ($task['label'] ?? $name)),
'callback' => $callback,
'default_enabled' => array_key_exists('default_enabled', $task) ? (bool) $task['default_enabled'] : false,
'default_cron' => trim((string) ($task['default_cron'] ?? '0 * * * *')),
'default_timezone' => trim((string) ($task['default_timezone'] ?? 'UTC')) ?: 'UTC',
'timezone_setting' => trim((string) ($task['timezone_setting'] ?? '')),
'lock_minutes' => max(1, (int) ($task['lock_minutes'] ?? 10)),
'builder' => is_array($task['builder'] ?? null) ? $task['builder'] : [],
'help' => trim((string) ($task['help'] ?? '')),
];
}
return $definitions;
}
public function statuses(string $moduleName): array
{
$definitions = $this->definitions($moduleName);
if ($definitions === []) {
return [];
}
$settings = $this->modules->settings($moduleName);
$states = $this->fetchStates($moduleName);
$nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$statuses = [];
foreach ($definitions as $name => $definition) {
$state = $states[$name] ?? [];
$config = $this->jobConfig($definition, $settings);
$timezone = $this->safeTimezone((string) ($config['timezone'] ?? 'UTC'));
$expression = trim((string) ($config['cron_expression'] ?? ''));
$isLocked = $this->isLockActive($state, $nowUtc->getTimestamp());
$parseError = null;
$previousDueUtc = null;
$nextDueUtc = null;
$isDue = false;
try {
$cron = CronExpression::parse($expression);
$previousDueUtc = $cron->previousRun($nowUtc, $timezone);
$lastScheduledFor = $this->parseUtc((string) ($state['last_scheduled_for'] ?? ''));
$isDue = !empty($config['enabled'])
&& !$isLocked
&& $previousDueUtc instanceof DateTimeImmutable
&& ($lastScheduledFor === null || $previousDueUtc > $lastScheduledFor);
$nextDueUtc = $isDue
? $previousDueUtc
: $cron->nextRun($nowUtc, $timezone);
} catch (\Throwable $exception) {
$parseError = $exception->getMessage();
}
$statuses[] = $definition + [
'config' => $config,
'state' => $state,
'enabled' => !empty($config['enabled']),
'cron_expression' => $expression,
'timezone' => $timezone->getName(),
'is_due' => $isDue,
'is_locked' => $isLocked,
'parse_error' => $parseError,
'previous_due_at' => $previousDueUtc?->format('Y-m-d H:i:s'),
'next_due_at' => $nextDueUtc?->format('Y-m-d H:i:s'),
'previous_due_at_local' => $previousDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
'next_due_at_local' => $nextDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
];
}
return $statuses;
}
public function runDue(string $moduleName): array
{
$results = [];
foreach ($this->statuses($moduleName) as $task) {
if (empty($task['enabled']) || empty($task['is_due']) || !empty($task['parse_error'])) {
continue;
}
if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
$results[] = [
'task' => $task['name'],
'ok' => false,
'message' => 'Callback nicht registriert.',
];
continue;
}
if (!$this->acquireLock($moduleName, (string) $task['name'], (int) $task['lock_minutes'])) {
continue;
}
$startedAt = gmdate('Y-m-d H:i:s');
$scheduledForUtc = trim((string) ($task['previous_due_at'] ?? ''));
$timezone = $this->safeTimezone((string) ($task['timezone'] ?? 'UTC'));
$this->persistState($moduleName, (string) $task['name'], [
'last_started_at' => $startedAt,
'last_status' => 'running',
'last_message' => 'Cron-Lauf gestartet.',
]);
try {
$result = $this->modules->call($moduleName, (string) $task['callback'], [
'task' => $task,
'trigger' => 'cron_runner',
'started_at' => $startedAt,
'scheduled_for_utc' => $scheduledForUtc,
'scheduled_for_local' => $scheduledForUtc !== ''
? $this->parseUtc($scheduledForUtc)?->setTimezone($timezone)?->format('Y-m-d H:i:s')
: null,
'timezone' => $timezone->getName(),
]);
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
$skipped = is_array($result) && !empty($result['skipped']);
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
$finishedAt = gmdate('Y-m-d H:i:s');
$payload = [
'last_finished_at' => $finishedAt,
'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
'last_message' => $message,
'lock_until' => null,
'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
];
if ($ok && !$skipped) {
$payload['last_success_at'] = $finishedAt;
}
$this->persistState($moduleName, (string) $task['name'], $payload);
$results[] = [
'task' => $task['name'],
'ok' => $ok,
'message' => $message,
];
} catch (\Throwable $e) {
$finishedAt = gmdate('Y-m-d H:i:s');
$this->persistState($moduleName, (string) $task['name'], [
'last_finished_at' => $finishedAt,
'last_status' => 'error',
'last_message' => $e->getMessage(),
'lock_until' => null,
'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
]);
$results[] = [
'task' => $task['name'],
'ok' => false,
'message' => $e->getMessage(),
];
}
}
return $results;
}
private function jobConfig(array $definition, array $settings): array
{
$jobs = is_array($settings['cron_jobs'] ?? null) ? $settings['cron_jobs'] : [];
$job = is_array($jobs[$definition['name']] ?? null) ? $jobs[$definition['name']] : [];
$timezoneSetting = trim((string) ($definition['timezone_setting'] ?? ''));
$fallbackTimezone = $timezoneSetting !== '' ? trim((string) ($settings[$timezoneSetting] ?? '')) : '';
return [
'enabled' => array_key_exists('enabled', $job) ? $this->settingBool($job['enabled'], (bool) $definition['default_enabled']) : (bool) $definition['default_enabled'],
'cron_expression' => trim((string) ($job['cron_expression'] ?? $definition['default_cron'] ?? '')),
'timezone' => trim((string) ($job['timezone'] ?? ($fallbackTimezone !== '' ? $fallbackTimezone : $definition['default_timezone']))),
'builder' => is_array($job['builder'] ?? null) ? $job['builder'] : [],
];
}
private function fetchStates(string $moduleName): array
{
if (!$this->pdo) {
return [];
}
$stmt = $this->pdo->prepare(
'SELECT *
FROM nexus_module_cron_runs
WHERE module_name = :module_name'
);
$stmt->execute(['module_name' => $moduleName]);
$states = [];
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$name = trim((string) ($row['job_name'] ?? ''));
if ($name !== '') {
$states[$name] = $row;
}
}
return $states;
}
private function acquireLock(string $moduleName, string $jobName, int $lockMinutes): bool
{
if (!$this->pdo) {
return true;
}
$states = $this->fetchStates($moduleName);
$state = $states[$jobName] ?? [];
if ($this->isLockActive($state, time())) {
return false;
}
$lockUntil = gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60));
$this->persistState($moduleName, $jobName, [
'lock_until' => $lockUntil,
]);
return true;
}
private function persistState(string $moduleName, string $jobName, array $values): void
{
if (!$this->pdo) {
return;
}
$current = $this->fetchStates($moduleName)[$jobName] ?? [];
$payload = array_merge($current, $values, [
'module_name' => $moduleName,
'job_name' => $jobName,
'updated_at' => gmdate('Y-m-d H:i:s'),
]);
$params = [
'module_name' => $moduleName,
'job_name' => $jobName,
'last_scheduled_for' => $this->nullOrString($payload['last_scheduled_for'] ?? null),
'last_started_at' => $this->nullOrString($payload['last_started_at'] ?? null),
'last_finished_at' => $this->nullOrString($payload['last_finished_at'] ?? null),
'last_success_at' => $this->nullOrString($payload['last_success_at'] ?? null),
'last_status' => $this->nullOrString($payload['last_status'] ?? null),
'last_message' => $this->nullOrString($payload['last_message'] ?? null),
'lock_until' => $this->nullOrString($payload['lock_until'] ?? null),
'updated_at' => $payload['updated_at'],
];
$updateStmt = $this->pdo->prepare(
'UPDATE nexus_module_cron_runs
SET last_scheduled_for = :last_scheduled_for,
last_started_at = :last_started_at,
last_finished_at = :last_finished_at,
last_success_at = :last_success_at,
last_status = :last_status,
last_message = :last_message,
lock_until = :lock_until,
updated_at = :updated_at
WHERE module_name = :module_name
AND job_name = :job_name'
);
$updateStmt->execute($params);
if ($updateStmt->rowCount() > 0) {
return;
}
$insertStmt = $this->pdo->prepare(
'INSERT INTO nexus_module_cron_runs (
module_name,
job_name,
last_scheduled_for,
last_started_at,
last_finished_at,
last_success_at,
last_status,
last_message,
lock_until,
updated_at
) VALUES (
:module_name,
:job_name,
:last_scheduled_for,
:last_started_at,
:last_finished_at,
:last_success_at,
:last_status,
:last_message,
:lock_until,
:updated_at
)'
);
$insertStmt->execute($params);
}
private function nullOrString(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function settingBool(mixed $value, bool $default): bool
{
if ($value === null || $value === '') {
return $default;
}
return in_array((string) $value, ['1', 'true', 'yes', 'on'], true);
}
private function isLockActive(array $state, int $nowTs): bool
{
$lockUntil = trim((string) ($state['lock_until'] ?? ''));
if ($lockUntil === '') {
return false;
}
$lockTs = strtotime($lockUntil);
return $lockTs !== false && $lockTs > $nowTs;
}
private function parseUtc(string $value): ?DateTimeImmutable
{
$value = trim($value);
if ($value === '') {
return null;
}
try {
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
} catch (\Throwable) {
return null;
}
}
private function safeTimezone(string $timezone): DateTimeZone
{
try {
return new DateTimeZone(trim($timezone) !== '' ? trim($timezone) : 'UTC');
} catch (\Throwable) {
return new DateTimeZone('UTC');
}
}
}

View File

@@ -8,6 +8,7 @@ final class ModuleManager
private array $modules = [];
private array $callbacks = [];
private ?ModuleTaskScheduler $taskScheduler = null;
private ?ModuleCronScheduler $cronScheduler = null;
public function __construct(
private ?\PDO $basePdo,
@@ -259,6 +260,21 @@ final class ModuleManager
return $this->taskScheduler()->runDue($name);
}
public function cronTasks(string $name): array
{
return $this->cronScheduler()->definitions($name);
}
public function cronTaskStatuses(string $name): array
{
return $this->cronScheduler()->statuses($name);
}
public function runDueCronTasks(string $name): array
{
return $this->cronScheduler()->runDue($name);
}
private function scanModules(): void
{
$this->modules = [];
@@ -291,6 +307,7 @@ final class ModuleManager
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
'interval_tasks' => $data['interval_tasks'] ?? [],
'cron_tasks' => $data['cron_tasks'] ?? [],
'menu' => $data['menu'] ?? [],
'sidebar' => $data['sidebar'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
@@ -496,4 +513,13 @@ final class ModuleManager
return $this->taskScheduler;
}
private function cronScheduler(): ModuleCronScheduler
{
if (!$this->cronScheduler instanceof ModuleCronScheduler) {
$this->cronScheduler = new ModuleCronScheduler($this, $this->basePdo);
}
return $this->cronScheduler;
}
}

View File

@@ -71,7 +71,7 @@ final class ModuleTaskScheduler
}
$intervalHours = max(1.0, $intervalHours);
$referenceAt = trim((string) ($state['last_success_at'] ?? $state['last_finished_at'] ?? ''));
$referenceAt = trim((string) ($state['last_success_at'] ?? ''));
$referenceTs = $referenceAt !== '' ? strtotime($referenceAt) : false;
$nextDueTs = $referenceTs !== false ? ((int) $referenceTs + (int) round($intervalHours * 3600)) : null;
$isLocked = $this->isLockActive($state, $nowTs);
@@ -125,16 +125,17 @@ final class ModuleTaskScheduler
'started_at' => $startedAt,
]);
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
$skipped = is_array($result) && !empty($result['skipped']);
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
$finishedAt = gmdate('Y-m-d H:i:s');
$payload = [
'last_finished_at' => $finishedAt,
'last_status' => $ok ? 'success' : 'error',
'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
'last_message' => $message,
'lock_until' => null,
];
if ($ok) {
if ($ok && !$skipped) {
$payload['last_success_at'] = $finishedAt;
}
$this->persistState($moduleName, (string) $task['name'], $payload);