Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
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>
This commit is contained in:
1 parent
d1ac9d760b
commit
05ea703655
35 files changed
+2229
-13
No files matched your search
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\Backup;
|
||||
|
||||
/**
|
||||
* Die tägliche Sicherung von Datenbank und Medien.
|
||||
*
|
||||
* Die schwerste Aufgabe im Plan, und die einzige, bei der das Ausführen in einer Webanfrage
|
||||
* wirklich etwas kostet: sie kopiert die Datenbank und den Medienordner. Der Besucher wartet
|
||||
* trotzdem nicht darauf – sie läuft nach der ausgelieferten Antwort. Was sie belegt, ist ein
|
||||
* PHP-Prozess des Hostings, einmal am Tag, für die Dauer einer Kopie.
|
||||
*
|
||||
* Wer einen echten Cronjob hat, sollte sie dort laufen lassen und hier abschalten. Wer keinen
|
||||
* hat, hat mit dieser Aufgabe eine tägliche Sicherung statt gar keiner – und das ist der Grund,
|
||||
* warum sie trotz ihres Gewichts im Plan steht.
|
||||
*/
|
||||
final class BackupTask implements Task
|
||||
{
|
||||
public function __construct(private Backup $backup)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'backup';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Sicherung anlegen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Ohne sie gibt es keinen Rückweg außer der von Hand angelegten Sicherung. Sie rotiert selbst und enthält Datenbank und Medien, aber weder .env noch vendor/.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$result = $this->backup->create('automatisch');
|
||||
$removed = $this->backup->rotate();
|
||||
|
||||
return sprintf(
|
||||
'%s angelegt: %d Seite(n), %d Mediendatei(en).%s',
|
||||
$result['name'],
|
||||
$result['manifest']['inhalt']['seiten'],
|
||||
$result['manifest']['medien']['dateien'],
|
||||
[] === $removed ? '' : sprintf(' %d alte entfernt.', count($removed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
|
||||
/**
|
||||
* Räumt in `var/` auf, was mit der Zeit liegen bleibt.
|
||||
*
|
||||
* Drei Dinge, und ausdrücklich nur diese drei: abgelaufene Sitzungsdateien, alte Arbeitsordner
|
||||
* eines Updates und zu lang gewordene Protokolle. Nichts davon ist Inhalt, nichts davon lässt
|
||||
* sich vermissen.
|
||||
*
|
||||
* Was diese Aufgabe **nicht** anfasst: Sicherungen (die rotiert `cms:backup` selbst und nach
|
||||
* einer anderen Regel), den Cache (den baut Symfony auf, wenn er fehlt, aber ihn mitten im
|
||||
* Betrieb zu löschen macht die nächste Anfrage langsam ohne Grund) und den Ordner eines Updates,
|
||||
* das noch auf seinen zweiten Schritt wartet.
|
||||
*/
|
||||
final class CleanupTask implements Task
|
||||
{
|
||||
/** Sitzungen und Update-Arbeitsordner, die älter sind, werden entfernt. */
|
||||
private const KEEP_DAYS = 30;
|
||||
|
||||
/** So viele Zeilen bleiben in den Protokollen stehen. */
|
||||
private const KEEP_LINES = 200;
|
||||
|
||||
public function __construct(private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'cleanup';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Aufräumen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return sprintf(
|
||||
'Entfernt Sitzungsdateien und Update-Arbeitsordner, die älter als %d Tage sind, und kürzt die Protokolle. Sicherungen und Inhalte bleiben unberührt.',
|
||||
self::KEEP_DAYS,
|
||||
);
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$limit = time() - self::KEEP_DAYS * 86400;
|
||||
|
||||
return sprintf(
|
||||
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
|
||||
$this->sessions($limit),
|
||||
$this->updateFolders($limit),
|
||||
$this->logs(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */
|
||||
private function sessions(int $limit): int
|
||||
{
|
||||
$removed = 0;
|
||||
foreach (glob($this->projectDir.'/var/sessions/*/sess_*') ?: [] as $file) {
|
||||
if (is_file($file) && (int) @filemtime($file) < $limit && @unlink($file)) {
|
||||
++$removed;
|
||||
}
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Arbeitsordner des Archiv-Updates.
|
||||
*
|
||||
* Der jüngste ist der Rückweg aus dem letzten Update, und den lässt diese Aufgabe einen
|
||||
* Monat lang stehen. Wartet ein Update noch auf seinen zweiten Schritt, wird gar nichts
|
||||
* angefasst: dann ist einer dieser Ordner der Stand, auf den zurückgegangen wird.
|
||||
*/
|
||||
private function updateFolders(int $limit): int
|
||||
{
|
||||
if (is_file($this->projectDir.'/'.ArchiveUpdate::MARKE)) {
|
||||
return 0;
|
||||
}
|
||||
$removed = 0;
|
||||
foreach (glob($this->projectDir.'/var/update/*', GLOB_ONLYDIR) ?: [] as $folder) {
|
||||
if ((int) @filemtime($folder) < $limit && $this->removeDirectory($folder)) {
|
||||
++$removed;
|
||||
}
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
private function logs(): int
|
||||
{
|
||||
$shortened = 0;
|
||||
foreach ([Updater::LOG_FILE, Scheduler::LOG_FILE] as $relative) {
|
||||
$file = $this->projectDir.'/'.$relative;
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw)) {
|
||||
continue;
|
||||
}
|
||||
$lines = array_values(array_filter(explode("\n", $raw)));
|
||||
if (count($lines) <= self::KEEP_LINES) {
|
||||
continue;
|
||||
}
|
||||
if (false !== @file_put_contents($file, implode("\n", array_slice($lines, -self::KEEP_LINES))."\n", LOCK_EX)) {
|
||||
++$shortened;
|
||||
}
|
||||
}
|
||||
|
||||
return $shortened;
|
||||
}
|
||||
|
||||
/** Rekursiv löschen, aber nur unterhalb von `var/` – geprüft, nicht angenommen. */
|
||||
private function removeDirectory(string $path): bool
|
||||
{
|
||||
$real = realpath($path);
|
||||
$var = realpath($this->projectDir.'/var');
|
||||
if (false === $real || false === $var || !str_starts_with($real, $var.DIRECTORY_SEPARATOR)) {
|
||||
return false;
|
||||
}
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||
}
|
||||
|
||||
return @rmdir($real);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\Statistics;
|
||||
|
||||
/**
|
||||
* Verdichtet und löscht die Zähler der Statistik.
|
||||
*
|
||||
* Einmal täglich reicht: die Tabelle wächst mit Seiten und Stunden, nicht mit Besuchen, und ein
|
||||
* Tag Verzug ändert an ihrer Größe nichts.
|
||||
*/
|
||||
final class PruneStatisticsTask implements Task
|
||||
{
|
||||
public function __construct(private Statistics $statistics)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'stats-prune';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Statistik verdichten';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return sprintf(
|
||||
'Ohne sie behält die Statistik jede Stunde einzeln, für immer. Verdichtet wird nach %d Tagen, gelöscht nach %d.',
|
||||
Statistics::COMPACT_AFTER_DAYS,
|
||||
Statistics::KEEP_DAYS,
|
||||
);
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$result = $this->statistics->prune();
|
||||
|
||||
return sprintf(
|
||||
'%d Tag(e) zusammengefasst, %d Zeile(n) jenseits von %d Tagen entfernt.',
|
||||
$result['compacted'],
|
||||
$result['removed'],
|
||||
Statistics::KEEP_DAYS,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
|
||||
/**
|
||||
* Veröffentlicht Seiten, deren geplanter Zeitpunkt vergangen ist.
|
||||
*
|
||||
* Die Aufgabe mit dem kürzesten Abstand, weil sie die einzige ist, die jemand *bemerkt*: wer eine
|
||||
* Seite für Montag 9 Uhr plant, schaut Montag um 9 Uhr nach. Fünf Minuten sind der Kompromiss
|
||||
* zwischen „wirkt sofort" und „fällt bei jedem zweiten Seitenaufruf an".
|
||||
*/
|
||||
final class PublishDueTask implements Task
|
||||
{
|
||||
public function __construct(private PageRepository $pages)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'publish-due';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Geplante Veröffentlichungen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Ohne sie bleibt eine geplante Seite ein Entwurf – die sichere Richtung, aber nicht die gewollte.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 300;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$published = $this->pages->publishDue();
|
||||
|
||||
return [] === $published
|
||||
? 'Nichts fällig.'
|
||||
: sprintf('%d Seite(n) veröffentlicht: /%s', count($published), implode(', /', $published));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
/**
|
||||
* Eine wiederkehrende Aufgabe.
|
||||
*
|
||||
* Der Vertrag ist absichtlich winzig, weil der Ausloeser nicht feststeht: dieselbe Aufgabe laeuft
|
||||
* je nach Hosting im Seitenaufruf eines Besuchers, im geoeffneten Backend, ueber einen
|
||||
* URL-Cronjob oder auf der Konsole. Was sie tut, darf davon nicht abhaengen.
|
||||
*
|
||||
* Drei Regeln, die aus dem Ausfuehrungsort folgen:
|
||||
*
|
||||
* 1. **Kurz sein oder unterbrechbar.** Ein Lauf haengt an einem PHP-Prozess, den das Hosting
|
||||
* nach seiner eigenen Zeit abbricht. Wer viel zu tun hat, arbeitet portionsweise und
|
||||
* berichtet, was offen blieb - der naechste Lauf macht weiter.
|
||||
* 2. **Fehler werfen, nicht schlucken.** Eine geworfene Ausnahme wird zum sichtbaren Fehler mit
|
||||
* wachsendem Abstand bis zum naechsten Versuch. Ein stilles `return` sieht aus wie Erfolg.
|
||||
* 3. **Nichts ausgeben.** Es gibt keine Konsole; die Rueckgabe ist der ganze Bericht.
|
||||
*/
|
||||
interface Task
|
||||
{
|
||||
/** Technischer, gleichbleibender Name, zum Beispiel `publish-due`. Er ist der Schluessel in der Datenbank. */
|
||||
public function name(): string;
|
||||
|
||||
/** Die Bezeichnung in der Verwaltung. */
|
||||
public function label(): string;
|
||||
|
||||
/** Ein Satz darueber, was ausbleibt, wenn diese Aufgabe nicht laeuft. */
|
||||
public function explanation(): string;
|
||||
|
||||
/** Vorgabe fuer den Abstand zwischen zwei Laeufen, in Sekunden. In der Verwaltung aenderbar. */
|
||||
public function interval(): int;
|
||||
|
||||
/**
|
||||
* Fuehrt die Aufgabe aus.
|
||||
*
|
||||
* @return string eine kurze Meldung fuer den Betreiber, die auch dann etwas sagt, wenn nichts
|
||||
* zu tun war ("Nichts faellig." ist eine Antwort, "" ist keine)
|
||||
*
|
||||
* @throws \Throwable wenn die Aufgabe ihr Ziel nicht erreicht hat
|
||||
*/
|
||||
public function run(): string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Updater;
|
||||
|
||||
/**
|
||||
* Fragt einmal täglich, ob es eine neuere Fassung gibt.
|
||||
*
|
||||
* Sie prüft nur und installiert nichts. Ein Update tauscht Dateien und führt Migrationen aus;
|
||||
* das gehört an einen Knopf, den ein Mensch drückt, und nicht in den Seitenaufruf eines
|
||||
* Besuchers.
|
||||
*
|
||||
* Ist die Fassungsliste nicht erreichbar, ist das ein Fehler und keine Entwarnung: „keine
|
||||
* Antwort" und „kein Update" sehen sonst gleich aus, und das eine ist eine Nachricht, das andere
|
||||
* eine ausgefallene Prüfung.
|
||||
*/
|
||||
final class UpdateCheckTask implements Task
|
||||
{
|
||||
public function __construct(private ArchiveUpdate $archive, private Updater $updater)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'update-check';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Nach Updates sehen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Fragt beim Repository nach der neuesten Fassung. Sie installiert nichts – das Ergebnis steht hier und auf dem Bildschirm „System".';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$latest = $this->archive->latest();
|
||||
if (null === $latest) {
|
||||
throw new \RuntimeException('Die Fassungsliste war nicht erreichbar. Erlaubt dieses Hosting ausgehende Verbindungen?');
|
||||
}
|
||||
$installed = $this->updater->version();
|
||||
|
||||
return version_compare($latest['version'], $installed, '>')
|
||||
? sprintf('Fassung %s ist verfügbar; installiert ist %s.', $latest['version'], $installed)
|
||||
: sprintf('Aktuell: Fassung %s.', $installed);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user