`, wenn der Hoster das anbietet. * - **Konsole:** `cms:tasks:run`, auch als Dauerlaeufer mit `--loop`. * * Was das *nicht* kann, und das gehoert an diese Stelle und nicht ins Kleingedruckte: **ohne * Anfrage passiert nichts.** Eine Aufgabe fuer 03:00 Uhr laeuft beim ersten Aufruf danach, auf * einer stillen Website also vielleicht um 07:15 Uhr. Fuer Sitemap, Aufraeumen, Updatepruefung * und Sicherung ist das brauchbar; fuer etwas, das minutengenau sein muss, ist es das nicht. * * Zwei Eigenschaften machen das im Betrieb tragfaehig: * * 1. **Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann das * naechste Mal ueberhaupt etwas ansteht. Solange dieser Zeitpunkt in der Zukunft liegt, ist * ein Seitenaufruf mit einem `file_get_contents` fertig. * 2. **Zwei Besucher fuehren eine Aufgabe nicht zweimal aus.** Wer sie ausfuehren darf, wird mit * einem bedingten UPDATE entschieden - das ist auf MySQL wie auf SQLite atomar. Wer keine * Zeile veraendert hat, war zu spaet und geht weiter. */ final class Scheduler { public const STATE_FILE = 'var/scheduler.json'; public const LOG_FILE = 'var/tasks.log'; /** * Wie lange eine begonnene Aufgabe als laufend gilt. * * Es gibt keinen Weg, das Ende eines Prozesses zu erfahren, den das Hosting mitten im Lauf * abgeschossen hat. Also verfaellt die Sperre von selbst. Fuenfzehn Minuten sind lang genug * fuer eine Sicherung und kurz genug, dass eine haengengebliebene Aufgabe denselben Tag * wieder mitmacht. */ public const LOCK_SECONDS = 900; /** Obergrenze fuer den wachsenden Abstand nach Fehlschlaegen: sechs Stunden. */ private const MAX_BACKOFF = 21600; /** So viele Zeilen behaelt das Protokoll beim Kuerzen. */ private const LOG_LINES = 200; /** @var array|null */ private ?array $registry = null; /** @param iterable $tasks */ public function __construct( private Connection $db, private iterable $tasks, private string $projectDir, ) { } // ---------------------------------------------------------------- Ausfuehren /** * Fuehrt aus, was faellig ist - hoechstens `$limit` Aufgaben und hoechstens `$budget` Sekunden. * * Beide Grenzen sind da, weil dieser Aufruf in einer Webanfrage stecken kann. Sie werden * *zwischen* zwei Aufgaben geprueft, nicht innerhalb einer: eine laufende Sicherung * abzubrechen waere schlimmer, als sie zu Ende laufen zu lassen. * * @return list */ public function runDue(string $trigger, int $limit = 3, int $budget = 20, ?\DateTimeImmutable $now = null): array { $now ??= self::now(); $registry = $this->registry(); $rows = $this->rows(); // Nach einem Update kennt der Code eine Aufgabe mehr oder eine weniger als die Datenbank. // Das faellt hier auf, und zwar ohne eine zusaetzliche Abfrage. if (array_keys($rows) !== array_keys($registry)) { if (!$this->sync($now)) { return []; } $rows = $this->rows(); } $started = microtime(true); $results = []; foreach ($this->due($rows, $now) as $row) { if (count($results) >= $limit || microtime(true) - $started > $budget) { break; } if (!$this->claim((string) $row['name'], $now)) { // Ein anderer Prozess war schneller. Das ist kein Fehler, das ist der Zweck. continue; } $results[] = $this->execute($registry[(string) $row['name']], $row, $trigger, self::now()); } $this->refreshState(); return $results; } /** * Fuehrt eine Aufgabe sofort aus, unabhaengig von ihrem Zeitplan. * * Der Knopf "Jetzt ausfuehren" in der Verwaltung. Abgeschaltete Aufgaben laufen so trotzdem - * wer sie von Hand startet, meint sie auch. Die Sperre gilt aber weiter: zweimal derselbe * Klick soll nicht zwei Sicherungen anlegen. * * Gibt null zurueck, wenn es die Aufgabe nicht gibt oder sie gerade schon laeuft. * * @return array{name: string, label: string, status: string, message: string, ms: int}|null */ public function runNow(string $name, string $trigger = 'hand'): ?array { $now = self::now(); $registry = $this->registry(); if (!isset($registry[$name])) { return null; } $this->sync($now); $rows = $this->rows(); if (!isset($rows[$name]) || !$this->claim($name, $now, false)) { return null; } $result = $this->execute($registry[$name], $rows[$name], $trigger, self::now()); $this->refreshState(); return $result; } /** * Sichert sich eine Aufgabe. * * Der ganze Schutz gegen doppelte Ausfuehrung steckt in dieser einen Anweisung: die * Bedingungen stehen im WHERE, nicht in PHP. Zwischen einem `SELECT ... WHERE faellig` und * einem `UPDATE` liegt ein Moment, in dem ein zweiter Prozess dasselbe liest - hier nicht. * Wer 1 zurueckbekommt, hat sie; wer 0 zurueckbekommt, war zu spaet. */ private function claim(string $name, \DateTimeImmutable $now, bool $onlyWhenDue = true): bool { $stamp = $now->format('Y-m-d H:i:s'); $until = $now->modify('+'.self::LOCK_SECONDS.' seconds')->format('Y-m-d H:i:s'); $sql = 'UPDATE scheduled_task SET locked_until = ?, last_run_at = ? WHERE name = ? AND (locked_until IS NULL OR locked_until <= ?)'; $parameters = [$until, $stamp, $name, $stamp]; if ($onlyWhenDue) { $sql .= ' AND next_run_at <= ?'; $parameters[] = $stamp; } try { return 1 === $this->db->executeStatement($sql, $parameters); } catch (\Throwable) { return false; } } /** * @param array $row * * @return array{name: string, label: string, status: string, message: string, ms: int} */ private function execute(Task $task, array $row, string $trigger, \DateTimeImmutable $now): array { $begin = microtime(true); try { $message = trim($task->run()); $status = 'ok'; $failures = 0; } catch (\Throwable $error) { // Auch ein Fehler ist ein Ergebnis. Er steht in der Zeile, im Protokoll und auf dem // Bildschirm "Aufgaben" - und er verlaengert den Abstand bis zum naechsten Versuch, // damit eine Aufgabe, die nicht laufen *kann*, nicht bei jedem Aufruf neu anfaengt. $status = 'fehler'; $message = sprintf('%s (%s:%d)', $error->getMessage(), basename($error->getFile()), $error->getLine()); $failures = (int) $row['failures'] + 1; } $ms = (int) round((microtime(true) - $begin) * 1000); $interval = max(60, (int) $row['interval_seconds']); $delay = 'ok' === $status ? $interval : (int) min(self::MAX_BACKOFF, $interval * 2 ** min($failures, 6)); $message = '' === $message ? 'Erledigt.' : mb_substr($message, 0, 480); try { $this->db->executeStatement( 'UPDATE scheduled_task SET next_run_at = ?, last_status = ?, last_message = ?, last_duration_ms = ?, last_trigger = ?, failures = ?, locked_until = NULL WHERE name = ?', [ $now->modify('+'.$delay.' seconds')->format('Y-m-d H:i:s'), $status, $message, $ms, $trigger, $failures, $task->name(), ], ); } catch (\Throwable) { // Die Aufgabe ist gelaufen; nur das Nachtragen ging schief. Die Sperre verfaellt von // selbst, und das Protokoll unten haelt fest, was passiert ist. } $this->append(sprintf( '[%s] %-14s %-7s %6d ms %-11s %s', $now->format('Y-m-d H:i:s'), $task->name(), $status, $ms, $trigger, str_replace(["\r", "\n"], ' ', $message), )); return ['name' => $task->name(), 'label' => $task->label(), 'status' => $status, 'message' => $message, 'ms' => $ms]; } // ---------------------------------------------------------------- Zustand und Verwaltung /** * Der schnelle Blick, ohne Datenbank: darf der Besuchs- beziehungsweise Backend-Ausloeser * arbeiten, und wann steht ueberhaupt das naechste Mal etwas an? * * Fehlt die Datei - frische Installation, geleertes `var/`, kein Schreibrecht -, ist die * Antwort "ja, und der Zeitpunkt ist unbekannt". Dann fragt der naechste Aufruf die * Datenbank und schreibt sie neu. Der teure Weg ist der Ausnahmefall, nicht der Normalfall. * * @return array{traffic: bool, admin: bool, next: ?int} */ public function fastState(): array { $raw = @file_get_contents($this->projectDir.'/'.self::STATE_FILE); $data = is_string($raw) ? json_decode($raw, true) : null; if (!is_array($data)) { return ['traffic' => true, 'admin' => true, 'next' => null]; } return [ 'traffic' => (bool) ($data['traffic'] ?? true), 'admin' => (bool) ($data['admin'] ?? true), 'next' => isset($data['next']) ? (int) $data['next'] : null, ]; } /** * Der Schluessel fuer den URL-Cronjob, abgeleitet aus dem Anwendungsgeheimnis. * * Nicht in der Datenbank, weil er dann eine Migration, eine Oberflaeche zum Wechseln und * einen Weg braeuchte, ihn nach einem Restore wiederzufinden. So ist er auf jeder * Installation ein anderer, ueberlebt jedes Update und wechselt genau dann, wenn das * Geheimnis wechselt - also dann, wenn man ihn wechseln will. */ public static function cronToken(string $secret): string { return substr(hash_hmac('sha256', 'cms-cron', $secret), 0, 40); } /** Schaltet die beiden Ausloeser, die in Anfragen stecken. Konsole und URL-Cron laufen unabhaengig davon weiter. */ public function setWorkers(bool $traffic, bool $admin): void { $this->writeState(['traffic' => $traffic, 'admin' => $admin, 'next' => $this->fastState()['next']]); } /** * Legt fehlende Zeilen an und entfernt Zeilen ohne Aufgabe im Code. * * Neue Aufgaben starten mit einem Abstand von zwei Minuten und nicht sofort: sonst faellt * direkt nach einem Update alles gleichzeitig an, und zwar im ersten Seitenaufruf danach. * * @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und * zwischen hochgeladenem Code und ausgefuehrter Migration */ public function sync(?\DateTimeImmutable $now = null): bool { $now ??= self::now(); $registry = $this->registry(); try { $existing = $this->rows(); foreach ($registry as $name => $task) { if (isset($existing[$name])) { continue; } $this->db->insert('scheduled_task', [ 'name' => $name, 'interval_seconds' => $task->interval(), 'enabled' => true, 'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'), 'last_message' => '', 'last_duration_ms' => 0, 'last_trigger' => '', 'failures' => 0, ]); } foreach (array_keys($existing) as $name) { if (!isset($registry[$name])) { $this->db->executeStatement('DELETE FROM scheduled_task WHERE name = ?', [$name]); } } } catch (TableNotFoundException) { // Vor der Installation und zwischen hochgeladenem Code und ausgefuehrter Migration. return false; } catch (\Throwable) { return false; } $this->refreshState(); return true; } /** * Alles, was der Bildschirm "Aufgaben" zeigt. * * @return list */ public function overview(?\DateTimeImmutable $now = null): array { $now ??= self::now(); $this->sync($now); $stamp = $now->format('Y-m-d H:i:s'); $overview = []; foreach ($this->rows() as $name => $row) { $task = $this->registry()[$name] ?? null; if (null === $task) { continue; } $locked = (string) ($row['locked_until'] ?? ''); $overview[] = [ 'name' => $name, 'label' => $task->label(), 'explanation' => $task->explanation(), 'interval' => (int) $row['interval_seconds'], 'enabled' => (bool) $row['enabled'], 'next' => (string) $row['next_run_at'], 'last' => null !== $row['last_run_at'] ? (string) $row['last_run_at'] : null, 'status' => null !== $row['last_status'] ? (string) $row['last_status'] : null, 'message' => (string) $row['last_message'], 'ms' => (int) $row['last_duration_ms'], 'trigger' => (string) $row['last_trigger'], 'failures' => (int) $row['failures'], 'running' => '' !== $locked && $locked > $stamp, 'due' => (bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp, ]; } return $overview; } public function enable(string $name, bool $on): void { $this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]); $this->refreshState(); } /** Der Abstand, in Sekunden. Unter einer Minute ergibt er keinen Sinn, ueber einem Monat auch nicht. */ public function setInterval(string $name, int $seconds): void { $this->db->executeStatement( 'UPDATE scheduled_task SET interval_seconds = ? WHERE name = ?', [max(60, min(2592000, $seconds)), $name], ); $this->refreshState(); } /** Gibt eine haengende Sperre frei - fuer den Fall, dass ein Prozess mitten im Lauf abgebrochen wurde. */ public function release(string $name): void { $this->db->executeStatement('UPDATE scheduled_task SET locked_until = NULL WHERE name = ?', [$name]); $this->refreshState(); } /** * Die letzten Zeilen des Protokolls, neueste zuerst. * * @return list */ public function protocol(int $lines = 40): array { $raw = @file_get_contents($this->projectDir.'/'.self::LOG_FILE); if (!is_string($raw) || '' === trim($raw)) { return []; } return array_slice(array_reverse(array_values(array_filter(explode("\n", trim($raw))))), 0, $lines); } // ---------------------------------------------------------------- Innenleben /** @return array */ private function registry(): array { if (null !== $this->registry) { return $this->registry; } $registry = []; foreach ($this->tasks as $task) { $registry[$task->name()] = $task; } ksort($registry); return $this->registry = $registry; } /** @return array> */ private function rows(): array { try { $rows = $this->db->fetchAllAssociative('SELECT * FROM scheduled_task'); } catch (\Throwable) { return []; } $indexed = []; foreach ($rows as $row) { $indexed[(string) $row['name']] = $row; } ksort($indexed); return $indexed; } /** * @param array> $rows * * @return list> */ private function due(array $rows, \DateTimeImmutable $now): array { $stamp = $now->format('Y-m-d H:i:s'); $due = []; foreach ($rows as $row) { $locked = (string) ($row['locked_until'] ?? ''); if ((bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp && ('' === $locked || $locked <= $stamp)) { $due[] = $row; } } // Die am laengsten ueberfaellige zuerst - sonst kommt eine Aufgabe mit kurzem Abstand // immer wieder dran und eine mit langem nie. usort($due, static fn (array $a, array $b): int => strcmp((string) $a['next_run_at'], (string) $b['next_run_at'])); return $due; } /** * Schreibt in die Zustandsdatei, wann das naechste Mal etwas ansteht. * * Eine laufende Aufgabe zaehlt dabei erst ab dem Ende ihrer Sperre. Ohne das waere waehrend * einer langen Sicherung jeder Seitenaufruf wieder bei der Datenbank: faellig ist sie ja, * sie ist nur schon vergeben. Mit dem Ende der Sperre steht in der Datei ein Zeitpunkt in * der Zukunft, und die uebrigen Anfragen sind nach einem Dateizugriff fertig. */ private function refreshState(): void { $next = null; foreach ($this->rows() as $row) { if (!(bool) $row['enabled']) { continue; } $when = max((string) $row['next_run_at'], (string) ($row['locked_until'] ?? '')); $stamp = strtotime($when.' UTC'); if (false !== $stamp && (null === $next || $stamp < $next)) { $next = $stamp; } } $state = $this->fastState(); $this->writeState(['traffic' => $state['traffic'], 'admin' => $state['admin'], 'next' => $next]); } /** @param array{traffic: bool, admin: bool, next: ?int} $state */ private function writeState(array $state): void { $file = $this->projectDir.'/'.self::STATE_FILE; if (!is_dir(dirname($file))) { @mkdir(dirname($file), 0o775, true); } // Schlaegt das fehl - schreibgeschuetztes var/ -, fragt jeder Aufruf wieder die // Datenbank. Langsamer, aber richtig; ein Fehler ist es nicht. @file_put_contents($file, json_encode($state, JSON_PRETTY_PRINT), LOCK_EX); } private function append(string $line): void { $file = $this->projectDir.'/'.self::LOG_FILE; if (!is_dir(dirname($file))) { @mkdir(dirname($file), 0o775, true); } @file_put_contents($file, $line."\n", FILE_APPEND | LOCK_EX); // Selbst begrenzend, damit das Protokoll auch ohne die Aufraeumaufgabe nie gross wird. if ((int) @filesize($file) > 100000) { $lines = array_values(array_filter(explode("\n", (string) @file_get_contents($file)))); @file_put_contents($file, implode("\n", array_slice($lines, -self::LOG_LINES))."\n", LOCK_EX); } } private static function now(): \DateTimeImmutable { return new \DateTimeImmutable('now', new \DateTimeZone('UTC')); } }