Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen, geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend. Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht. Vor der Veroeffentlichung geprueft, nicht bloss eingepackt: - phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig. - Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an. - Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr. Dabei zwei Fehler behoben: - config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony, aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1 zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt. - AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von php-cs-fixer; composer lint gab 8 zurueck. Behoben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
499 lines
20 KiB
PHP
499 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Ops;
|
|
|
|
use App\Ops\Tasks\Task;
|
|
use Doctrine\DBAL\Connection;
|
|
use Doctrine\DBAL\Exception\TableNotFoundException;
|
|
|
|
/**
|
|
* Wiederkehrende Aufgaben auf einem Hosting, das keinen Cronjob hat.
|
|
*
|
|
* Das Problem ist nicht, *was* zu tun ist, sondern *wer es anstoesst*. Auf geteiltem Webhosting
|
|
* gibt es oft weder SSH noch Cron; es gibt nur Anfragen. Also uebernehmen die Anfragen die
|
|
* Arbeit - nach der Antwort, in `kernel.terminate`, damit kein Besucher darauf wartet. Vier
|
|
* Ausloeser, dieselbe Mechanik:
|
|
*
|
|
* - **Besuch:** jeder Seitenaufruf schaut nach, ob etwas faellig ist (`TaskWorkerSubscriber`).
|
|
* - **Verwaltung:** das geoeffnete Backend fragt im Takt nach, auch ohne Besucher.
|
|
* - **URL-Cron:** ein Aufruf von `/cms-cron/<Schluessel>`, 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<string, Task>|null */
|
|
private ?array $registry = null;
|
|
|
|
/** @param iterable<Task> $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<array{name: string, label: string, status: string, message: string, ms: int}>
|
|
*/
|
|
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<string, mixed> $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<array{name: string, label: string, explanation: string, interval: int,
|
|
* enabled: bool, next: string, last: ?string, status: ?string, message: string,
|
|
* ms: int, trigger: string, failures: int, running: bool, due: bool}>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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<string, Task> */
|
|
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<string, array<string, mixed>> */
|
|
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<string, array<string, mixed>> $rows
|
|
*
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
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'));
|
|
}
|
|
}
|