diff --git a/modules/fx-rates/bootstrap.php b/modules/fx-rates/bootstrap.php index e7b9672..a16e237 100644 --- a/modules/fx-rates/bootstrap.php +++ b/modules/fx-rates/bootstrap.php @@ -68,8 +68,6 @@ $mm->registerFunction($moduleName, 'settings', static function (): array { 'preferred_currencies' => $preferredCurrencies, 'currency_catalog' => $currencyCatalog, 'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')), - 'daily_refresh_enabled' => array_key_exists('daily_refresh_enabled', $saved) ? (bool) $saved['daily_refresh_enabled'] : true, - 'daily_refresh_hour' => max(0, min(23, (int) ($saved['daily_refresh_hour'] ?? 18))), 'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? 'Europe/Berlin')) ?: 'Europe/Berlin', ]; }); diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json index 508e36b..29319f2 100644 --- a/modules/fx-rates/module.json +++ b/modules/fx-rates/module.json @@ -22,20 +22,24 @@ { "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." }, { "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "select", "required": false, "help": "Basis fuer die Anzeige der zuletzt gespeicherten Kurse im Modul." }, { "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "multiselect", "required": false, "help": "Auswahl aus dem synchronisierten Waehrungskatalog." }, - { "name": "daily_refresh_enabled", "label": "Taeglichen Abruf aktivieren", "type": "checkbox", "required": false }, - { "name": "daily_refresh_hour", "label": "Taegliche Abrufstunde", "type": "number", "required": false, "help": "Lokale Stunde fuer den Scheduler, standardmaessig 18." }, { "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" } ] }, - "interval_tasks": [ + "cron_tasks": [ { - "name": "daily_refresh", - "label": "Taeglicher FX-Abruf", + "name": "rates_refresh", + "label": "Kursabruf", "callback": "scheduled_refresh", - "enabled_setting": "daily_refresh_enabled", "default_enabled": true, - "default_interval_hours": 24, - "lock_minutes": 120 + "default_cron": "0 18 * * *", + "default_timezone": "Europe/Berlin", + "timezone_setting": "schedule_timezone", + "lock_minutes": 120, + "help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.", + "builder": { + "allow_manual": true, + "presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"] + } } ], "db_defaults": { diff --git a/modules/fx-rates/src/Domain/FxRatesService.php b/modules/fx-rates/src/Domain/FxRatesService.php index 22cbd26..1dcbe1f 100644 --- a/modules/fx-rates/src/Domain/FxRatesService.php +++ b/modules/fx-rates/src/Domain/FxRatesService.php @@ -302,42 +302,10 @@ final class FxRatesService public function runScheduledRefresh(array $context = []): array { - if (!$this->dailyRefreshEnabled()) { - return ['ok' => true, 'message' => 'Taeglicher FX-Abruf ist deaktiviert.', 'skipped' => true]; - } - - $timezone = $this->scheduleTimezone(); - $nowLocal = new DateTimeImmutable('now', $timezone); - $targetHour = $this->dailyRefreshHour(); - - if ((int) $nowLocal->format('G') !== $targetHour) { - return [ - 'ok' => true, - 'message' => 'Kein FX-Abruf: aktuelles Zeitfenster ist ' . $nowLocal->format('H:i') . ', Ziel ist ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.', - 'skipped' => true, - 'context' => $context, - ]; - } - - $latest = $this->repository->getLatestFetch($this->defaultBaseCurrency()); - if (is_array($latest) && trim((string) ($latest['fetched_at'] ?? '')) !== '') { - $latestLocal = new DateTimeImmutable((string) $latest['fetched_at'], new DateTimeZone('UTC')); - $latestLocal = $latestLocal->setTimezone($timezone); - if ($latestLocal->format('Y-m-d') === $nowLocal->format('Y-m-d') && (int) $latestLocal->format('G') >= $targetHour) { - return [ - 'ok' => true, - 'message' => 'Kein FX-Abruf: fuer heute existiert bereits ein Snapshot nach ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.', - 'skipped' => true, - 'fetch' => $this->localizeFetch($latest), - 'context' => $context, - ]; - } - } - $result = $this->refreshLatestRates(null, $this->defaultBaseCurrency()); return [ 'ok' => true, - 'message' => 'Taeglicher FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.', + 'message' => 'Geplanter FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.', 'result' => $result, 'context' => $context, ]; @@ -980,16 +948,6 @@ final class FxRatesService return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR'; } - private function dailyRefreshEnabled(): bool - { - return !empty($this->settings['daily_refresh_enabled']); - } - - private function dailyRefreshHour(): int - { - return max(0, min(23, (int) ($this->settings['daily_refresh_hour'] ?? 18))); - } - private function scheduleTimezone(): DateTimeZone { $timezone = trim((string) ($this->settings['schedule_timezone'] ?? 'Europe/Berlin')); diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index 85f61d5..89cf619 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -43,6 +43,8 @@ foreach ($fields as $field) { $isFxRatesSetup = $moduleName === 'fx-rates'; $current = modules()->settings($moduleName); $intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName); +$cronTaskDefinitions = modules()->cronTasks($moduleName); +$cronTaskStatuses = modules()->cronTaskStatuses($moduleName); $setupActions = modules()->hasFunction($moduleName, 'setup_actions') ? (array) module_fn($moduleName, 'setup_actions') : []; @@ -285,6 +287,16 @@ $formatRunTimestamp = static function (?string $value): string { return date('Y-m-d H:i:s', $ts); }; +$cronWeekdays = [ + '0' => 'Sonntag', + '1' => 'Montag', + '2' => 'Dienstag', + '3' => 'Mittwoch', + '4' => 'Donnerstag', + '5' => 'Freitag', + '6' => 'Samstag', +]; + $renderField = function (array $field) use (&$current, $getNested, $driverOptions): void { $name = (string)($field['name'] ?? ''); if ($name === '') { @@ -371,6 +383,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $current = array_replace_recursive($current, $payload); + if ($cronTaskDefinitions !== []) { + $cronJobs = is_array($current['cron_jobs'] ?? null) ? $current['cron_jobs'] : []; + foreach ($cronTaskDefinitions as $cronTask) { + if (!is_array($cronTask)) { + continue; + } + $cronName = trim((string) ($cronTask['name'] ?? '')); + if ($cronName === '') { + continue; + } + + $prefix = 'cron_job_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $cronName); + $cronJobs[$cronName] = array_merge( + is_array($cronJobs[$cronName] ?? null) ? $cronJobs[$cronName] : [], + [ + 'enabled' => isset($_POST[$prefix . '_enabled']), + 'cron_expression' => trim((string) ($_POST[$prefix . '_cron_expression'] ?? ($cronTask['default_cron'] ?? ''))), + 'timezone' => trim((string) ($_POST[$prefix . '_timezone'] ?? ($cronTask['default_timezone'] ?? 'UTC'))), + 'builder' => [ + 'mode' => trim((string) ($_POST[$prefix . '_builder_mode'] ?? 'builder')), + 'kind' => trim((string) ($_POST[$prefix . '_builder_kind'] ?? 'daily')), + 'time' => trim((string) ($_POST[$prefix . '_builder_time'] ?? '18:00')), + 'interval_days' => max(1, (int) ($_POST[$prefix . '_builder_interval_days'] ?? 2)), + 'weekday' => trim((string) ($_POST[$prefix . '_builder_weekday'] ?? '1')), + 'month_day' => max(1, min(31, (int) ($_POST[$prefix . '_builder_month_day'] ?? 1))), + 'interval_hours' => max(1, (int) ($_POST[$prefix . '_builder_interval_hours'] ?? 6)), + ], + ] + ); + } + $current['cron_jobs'] = $cronJobs; + } + $postedTestGroup = (string)($_POST['test_db'] ?? ''); $postedResetGroup = (string)($_POST['reset_db'] ?? ''); $postedSetupAction = trim((string)($_POST['module_setup_action'] ?? '')); @@ -611,21 +656,14 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
Scheduler -

Taeglicher Abruf

+

Cron-Zeitzone

- -
@@ -736,6 +774,68 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) + +
+
+
+ Automationen +

Cron-Jobs

+

Diese Jobs werden ueber den zentralen Nexus-Scheduler ausgefuehrt. Der System-Cron sollte den CLI-Runner jede Minute starten.

+
+
+
+ + +
+ + + + + + + Cron-Syntax: Minute Stunde Tag Monat Wochentag + + +
+ + + + + + +
+ Letzter Start: + Letzter Erfolg: + Naechster Lauf UTC: + Naechster Lauf lokal: + Status: + + Cron-Fehler: + +
+ +
+
+ +
Zugriff verwalten @@ -1034,6 +1134,68 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) + +
+
+
+ Automationen +

Cron-Jobs

+

Diese Jobs werden ueber den zentralen Nexus-Scheduler ausgefuehrt. Der System-Cron sollte den CLI-Runner jede Minute starten.

+
+
+
+ + +
+ + + + + + + Cron-Syntax: Minute Stunde Tag Monat Wochentag + + +
+ + + + + + +
+ Letzter Start: + Letzter Erfolg: + Naechster Lauf UTC: + Naechster Lauf lokal: + Status: + + Cron-Fehler: + +
+ +
+
+ +
@@ -1172,5 +1334,79 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) Zurück
+ +
diff --git a/public/index.php b/public/index.php index db98d62..235252b 100755 --- a/public/index.php +++ b/public/index.php @@ -174,6 +174,7 @@ if (preg_match('~^api/fx-rates(?:/(.*))?$~', $uriPath, $apiMatches)) { require_once $projectRoot . '/modules/fx-rates/bootstrap.php'; app()->modules()->runDueIntervalTasks('fx-rates'); + app()->modules()->runDueCronTasks('fx-rates'); try { $service = module_fn('fx-rates', 'service'); @@ -261,6 +262,7 @@ if (str_starts_with($uriPath, 'modules/install')) { } if ($modulePage) { app()->modules()->runDueIntervalTasks($module); + app()->modules()->runDueCronTasks($module); $target = $modulePage; } else { http_response_code(404); diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php index e47b0af..536ea39 100644 --- a/src/App/BaseSchema.php +++ b/src/App/BaseSchema.php @@ -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, diff --git a/src/App/CronExpression.php b/src/App/CronExpression.php new file mode 100644 index 0000000..09a3599 --- /dev/null +++ b/src/App/CronExpression.php @@ -0,0 +1,214 @@ + */ + private array $minutes; + /** @var array */ + private array $hours; + /** @var array */ + private array $daysOfMonth; + /** @var array */ + private array $months; + /** @var array */ + 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 */ + 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 + ); + } +} diff --git a/src/App/ModuleCronScheduler.php b/src/App/ModuleCronScheduler.php new file mode 100644 index 0000000..fd82a3d --- /dev/null +++ b/src/App/ModuleCronScheduler.php @@ -0,0 +1,371 @@ +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'); + } + } +} diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php index 059eb7f..c1a771d 100644 --- a/src/App/ModuleManager.php +++ b/src/App/ModuleManager.php @@ -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; + } } diff --git a/src/App/ModuleTaskScheduler.php b/src/App/ModuleTaskScheduler.php index 1786065..7ab4b53 100644 --- a/src/App/ModuleTaskScheduler.php +++ b/src/App/ModuleTaskScheduler.php @@ -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); diff --git a/tools/module_scheduler_run.php b/tools/module_scheduler_run.php new file mode 100644 index 0000000..db2ceb1 --- /dev/null +++ b/tools/module_scheduler_run.php @@ -0,0 +1,38 @@ +#!/usr/bin/env php +modules()->all(); +$results = []; + +foreach ($modules as $name => $meta) { + if (!is_string($name) || $name === '') { + continue; + } + if ($targetModule !== '' && $targetModule !== $name) { + continue; + } + if (empty($meta['enabled'])) { + continue; + } + + $taskDefs = app()->modules()->intervalTasks($name); + if ($taskDefs === []) { + continue; + } + + $results[$name] = [ + 'interval' => app()->modules()->runDueIntervalTasks($name), + 'cron' => app()->modules()->runDueCronTasks($name), + ]; +} + +echo json_encode([ + 'ok' => true, + 'ran_at_utc' => gmdate('Y-m-d H:i:s'), + 'module' => $targetModule !== '' ? $targetModule : null, + 'results' => $results, +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;