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,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Der dritte Weg: ein echter Cronjob oder ein Dauerläufer.
|
||||
*
|
||||
* Für Installationen, die eine Konsole haben. Er tut dasselbe wie der Besuchs-Auslöser, nur ohne
|
||||
* dessen enge Grenzen – und er läuft auch nachts um drei, wenn die Website leer ist.
|
||||
*
|
||||
* * * * * * cd /pfad && php bin/console cms:tasks:run (jede Minute, Standardweg)
|
||||
* php bin/console cms:tasks:run --loop=60 --max-time=3600 (Dauerläufer, z. B. unter Supervisor)
|
||||
* php bin/console cms:tasks:run --task=backup (eine bestimmte, sofort)
|
||||
*/
|
||||
#[AsCommand(name: 'cms:tasks:run', description: 'Führt fällige Aufgaben aus. Für einen echten Cronjob oder als Dauerläufer.')]
|
||||
final class RunTasksCommand extends Command
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('task', null, InputOption::VALUE_REQUIRED, 'Nur diese Aufgabe, unabhängig vom Zeitplan.')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Aufgaben je Durchlauf.', '10')
|
||||
->addOption('budget', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Sekunden je Durchlauf.', '300')
|
||||
->addOption('loop', null, InputOption::VALUE_REQUIRED, 'Als Dauerläufer: Sekunden zwischen zwei Durchläufen.')
|
||||
->addOption('max-time', null, InputOption::VALUE_REQUIRED, 'Der Dauerläufer endet nach so vielen Sekunden.', '3600');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
if (null !== ($name = $input->getOption('task'))) {
|
||||
$result = $this->scheduler->runNow((string) $name, 'konsole');
|
||||
if (null === $result) {
|
||||
$io->error('Diese Aufgabe gibt es nicht oder sie läuft gerade schon. `cms:tasks` zeigt, welche es gibt.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$this->report($io, [$result]);
|
||||
|
||||
return 'ok' === $result['status'] ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$budget = max(1, (int) $input->getOption('budget'));
|
||||
$loop = null !== $input->getOption('loop') ? max(5, (int) $input->getOption('loop')) : null;
|
||||
$deadline = microtime(true) + max(0, (int) $input->getOption('max-time'));
|
||||
|
||||
$failed = false;
|
||||
while (true) {
|
||||
$results = $this->scheduler->runDue('konsole', $limit, $budget);
|
||||
$failed = $failed || [] !== array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']);
|
||||
$this->report($io, $results);
|
||||
if (null === $loop) {
|
||||
break;
|
||||
}
|
||||
// Ein Dauerläufer, der nie endet, sammelt Speicher und hält eine Datenbankverbindung
|
||||
// über Tage. Er endet nach `--max-time`; der Prozessverwalter startet ihn neu.
|
||||
if (microtime(true) + $loop > $deadline) {
|
||||
$io->writeln(sprintf('<comment>Ende nach %d Sekunden – neu starten lassen.</comment>', (int) $input->getOption('max-time')));
|
||||
break;
|
||||
}
|
||||
sleep($loop);
|
||||
}
|
||||
|
||||
return $failed ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<array{name: string, label: string, status: string, message: string, ms: int}> $results */
|
||||
private function report(SymfonyStyle $io, array $results): void
|
||||
{
|
||||
if ([] === $results) {
|
||||
$io->writeln(sprintf('<info>%s</info> nichts fällig.', gmdate('H:i:s')));
|
||||
|
||||
return;
|
||||
}
|
||||
foreach ($results as $result) {
|
||||
$io->writeln(sprintf(
|
||||
'<info>%s</info> %s %s (%d ms): %s',
|
||||
gmdate('H:i:s'),
|
||||
'ok' === $result['status'] ? '<info>ok</info> ' : '<error>fehler</error>',
|
||||
$result['name'],
|
||||
$result['ms'],
|
||||
$result['message'],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Was der Plan vorhat und was zuletzt passiert ist.
|
||||
*
|
||||
* Dieselbe Auskunft wie der Bildschirm „Aufgaben“, nur für den, der ohnehin auf der Konsole
|
||||
* steht – und für den Fall, dass die Verwaltung gerade nicht erreichbar ist.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:tasks', description: 'Zeigt den Aufgabenplan: Abstand, Fälligkeit und das letzte Ergebnis.')]
|
||||
final class TasksCommand extends Command
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('log', null, InputOption::VALUE_NONE, 'Zusätzlich die letzten Protokollzeilen.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
// Erst der Abgleich (er legt fehlende Zeilen an und schreibt die Zustandsdatei neu), dann
|
||||
// die Auskunft daraus - sonst zeigt der erste Aufruf nach einem Update einen Zeitpunkt,
|
||||
// der eine Sekunde spaeter schon nicht mehr stimmt.
|
||||
$tasks = $this->scheduler->overview();
|
||||
$state = $this->scheduler->fastState();
|
||||
$io->writeln(sprintf(
|
||||
'Auslöser: Besuch <info>%s</info>, Verwaltung <info>%s</info>. Konsole und URL-Cron laufen immer.',
|
||||
$state['traffic'] ? 'an' : 'aus',
|
||||
$state['admin'] ? 'an' : 'aus',
|
||||
));
|
||||
$io->writeln(sprintf(
|
||||
'Jetzt ist %s UTC; das nächste Mal steht %s an.',
|
||||
gmdate('Y-m-d H:i:s'),
|
||||
null === $state['next'] ? 'unbekannt' : gmdate('Y-m-d H:i:s', $state['next']),
|
||||
));
|
||||
|
||||
$rows = [];
|
||||
foreach ($tasks as $task) {
|
||||
$rows[] = [
|
||||
$task['name'],
|
||||
$task['enabled'] ? ($task['due'] ? 'fällig' : 'an') : 'aus',
|
||||
$this->minutes($task['interval']),
|
||||
$task['next'],
|
||||
$task['running'] ? 'läuft' : ($task['status'] ?? '–'),
|
||||
mb_substr($task['message'], 0, 60),
|
||||
];
|
||||
}
|
||||
if ([] === $rows) {
|
||||
$io->warning('Keine Aufgaben. Fehlt die Migration? `php vendor/bin/doctrine-migrations migrate`');
|
||||
} else {
|
||||
$io->table(['Name', 'Zustand', 'Abstand', 'Nächster Lauf (UTC)', 'Zuletzt', 'Meldung'], $rows);
|
||||
}
|
||||
|
||||
if ($input->getOption('log')) {
|
||||
$io->section('Protokoll');
|
||||
$lines = $this->scheduler->protocol(30);
|
||||
$io->writeln([] === $lines ? 'Noch nichts protokolliert.' : $lines);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function minutes(int $seconds): string
|
||||
{
|
||||
return $seconds >= 86400 && 0 === $seconds % 86400
|
||||
? sprintf('%d Tag(e)', intdiv($seconds, 86400))
|
||||
: sprintf('%d Min.', max(1, intdiv($seconds, 60)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventSubscriber\TaskWorkerSubscriber;
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Der Cronjob für Hostings, die nur eine URL aufrufen können.
|
||||
*
|
||||
* Viele Tarife bieten keinen Shell-Cron, aber einen „URL-Cronjob": der Hoster ruft alle paar
|
||||
* Minuten eine Adresse auf. Das genügt – und ist gegenüber dem Besuchs-Auslöser der bessere Weg,
|
||||
* weil er auch dann läuft, wenn niemand auf der Website ist.
|
||||
*
|
||||
* Drei Entscheidungen:
|
||||
*
|
||||
* 1. **Der Schlüssel steht in der Adresse**, weil ein URL-Cronjob nichts anderes mitschicken
|
||||
* kann als eine Adresse. Er ist aus dem Anwendungsgeheimnis abgeleitet, 40 Zeichen lang und
|
||||
* auf jeder Installation ein anderer.
|
||||
* 2. **Ein falscher Schlüssel bekommt 404**, nicht 403. Wer probiert, soll nicht erfahren, dass
|
||||
* es hier überhaupt etwas gibt.
|
||||
* 3. **Die Antwort ist Text**, nicht HTML: sie landet im Protokoll des Hosters, und dort ist
|
||||
* eine Zeile je Aufgabe das Nützlichste, was ankommen kann.
|
||||
*/
|
||||
final class CronController extends AbstractController
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler, private string $secret)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/cms-cron/{token}', name: 'cms_cron', requirements: ['token' => '[a-f0-9]{40}'], methods: ['GET', 'POST'])]
|
||||
public function run(Request $request, string $token): Response
|
||||
{
|
||||
if (!hash_equals(Scheduler::cronToken($this->secret), $token)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
|
||||
// Hier wartet kein Mensch: ein Aufruf darf alles abarbeiten, was ansteht.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$began = microtime(true);
|
||||
$results = $this->scheduler->runDue('cron', 20, 240);
|
||||
|
||||
$lines = [sprintf('%s UTC', gmdate('Y-m-d H:i:s'))];
|
||||
foreach ($results as $result) {
|
||||
$lines[] = sprintf('%-14s %-7s %6d ms %s', $result['name'], $result['status'], $result['ms'], $result['message']);
|
||||
}
|
||||
$lines[] = sprintf('%d Aufgabe(n) in %.1f s.', count($results), microtime(true) - $began);
|
||||
|
||||
return new Response(implode("\n", $lines)."\n", Response::HTTP_OK, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
'X-Robots-Tag' => 'noindex, nofollow',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ final class EditorController extends AbstractController
|
||||
|
||||
return $this->back('' === trim($request->request->getString('publishAt'))
|
||||
? 'Die geplante Veröffentlichung wurde entfernt.'
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventSubscriber\TaskWorkerSubscriber;
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Der Bildschirm „Aufgaben“ und der Takt des geöffneten Backends.
|
||||
*
|
||||
* Zwei verschiedene Dinge in einer Datei, weil sie dasselbe Thema haben: hier sieht der Betreiber,
|
||||
* was der Plan vorhat und was zuletzt passiert ist – und hier klopft die geöffnete Verwaltung im
|
||||
* Minutentakt an, damit auf einer Website ohne Besucher trotzdem etwas läuft.
|
||||
*/
|
||||
final class TaskController extends AbstractController
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler, private string $secret)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/aufgaben', name: 'admin_tasks', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$state = $this->scheduler->fastState();
|
||||
|
||||
return $this->render('admin/tasks.html.twig', [
|
||||
'tasks' => $this->scheduler->overview(),
|
||||
'traffic' => $state['traffic'],
|
||||
'admin' => $state['admin'],
|
||||
'next' => $state['next'],
|
||||
'now' => gmdate('Y-m-d H:i:s'),
|
||||
'protocol' => $this->scheduler->protocol(30),
|
||||
// Der Schlüssel für einen URL-Cronjob. Er steht in keiner Datenbank: er wird aus dem
|
||||
// Anwendungsgeheimnis abgeleitet, ist damit auf jedem Server ein anderer und ändert
|
||||
// sich, wenn das Geheimnis gewechselt wird.
|
||||
'cronUrl' => $this->generateUrl('cms_cron', ['token' => Scheduler::cronToken($this->secret)], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/aufgaben/ausfuehren', name: 'admin_tasks_run', methods: ['POST'])]
|
||||
public function run(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$result = $this->scheduler->runNow($request->request->getString('name'));
|
||||
if (null === $result) {
|
||||
return $this->back('Diese Aufgabe gibt es nicht oder sie läuft gerade schon.');
|
||||
}
|
||||
$this->addFlash('ok' === $result['status'] ? 'success' : 'error', sprintf(
|
||||
'%s: %s (%d ms)',
|
||||
$result['label'],
|
||||
$result['message'],
|
||||
$result['ms'],
|
||||
));
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstand, Ein/Aus je Aufgabe und die beiden Auslöser, die in Anfragen stecken.
|
||||
*
|
||||
* Ein Formular für alles, weil es eine Entscheidung ist: wie dieser Server seine Aufgaben
|
||||
* abarbeitet. Zwei getrennte Formulare hätten zwei Speicherknöpfe und dieselbe Wirkung.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/aufgaben/einstellen', name: 'admin_tasks_configure', methods: ['POST'])]
|
||||
public function configure(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->scheduler->setWorkers(
|
||||
$request->request->getBoolean('traffic'),
|
||||
$request->request->getBoolean('admin'),
|
||||
);
|
||||
$enabled = $request->request->all('enabled');
|
||||
foreach ($request->request->all('interval') as $name => $minutes) {
|
||||
$name = (string) $name;
|
||||
$this->scheduler->setInterval($name, max(1, (int) $minutes) * 60);
|
||||
$this->scheduler->enable($name, isset($enabled[$name]));
|
||||
}
|
||||
$this->addFlash('success', 'Der Plan ist gespeichert.');
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/** Eine Sperre, die stehen geblieben ist, weil das Hosting den Prozess mitten im Lauf beendet hat. */
|
||||
#[Route('/%kant.admin_path%/aufgaben/freigeben', name: 'admin_tasks_release', methods: ['POST'])]
|
||||
public function release(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->scheduler->release($request->request->getString('name'));
|
||||
$this->addFlash('success', 'Die Sperre ist aufgehoben. Die Aufgabe darf wieder starten.');
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Takt der geöffneten Verwaltung.
|
||||
*
|
||||
* Damit läuft der Plan auch auf einer Website, die gerade niemand besucht – solange jemand im
|
||||
* Backend arbeitet. Das Skript fragt nur, während der Reiter sichtbar ist; ein im Hintergrund
|
||||
* vergessener Reiter soll keine Sitzung endlos offen halten.
|
||||
*
|
||||
* Die Grenzen sind hier großzügiger als beim Besuch: hier wartet niemand auf eine Seite.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/aufgaben/takt', name: 'admin_tasks_tick', methods: ['POST'])]
|
||||
public function tick(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('tasks-tick', $request->request->getString('_csrf_token'))) {
|
||||
return new JsonResponse(['error' => 'token'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
|
||||
$state = $this->scheduler->fastState();
|
||||
if (!$state['admin']) {
|
||||
return new JsonResponse(['off' => true, 'ran' => []]);
|
||||
}
|
||||
@set_time_limit(120);
|
||||
$ran = $this->scheduler->runDue('verwaltung', 3, 45);
|
||||
|
||||
return new JsonResponse([
|
||||
'ran' => $ran,
|
||||
'next' => $this->scheduler->fastState()['next'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function back(string $error): Response
|
||||
{
|
||||
$this->addFlash('error', $error);
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Der Auslöser, den ein Hosting ohne Cronjob hat: die Anfrage selbst.
|
||||
*
|
||||
* Nach `kernel.terminate`, also nachdem die Antwort das Haus verlassen hat. Unter PHP-FPM ruft
|
||||
* Symfony dafür `fastcgi_finish_request()` auf - der Browser ist fertig, während hier noch
|
||||
* gearbeitet wird. Unter Apache mit mod_php gibt es das nicht: die Seite steht dann zwar schon
|
||||
* im Browser, der Prozess bleibt aber belegt, bis die Aufgabe fertig ist. Deshalb sind die
|
||||
* Grenzen klein - zwei Aufgaben, zwanzig Sekunden -, und deshalb lässt sich dieser Auslöser
|
||||
* abschalten, wenn ein echter Cronjob die Arbeit übernimmt.
|
||||
*
|
||||
* Der Normalfall kostet nichts: eine Datei lesen, Zeitpunkt vergleichen, fertig. Erst wenn
|
||||
* wirklich etwas ansteht, wird die Datenbank gefragt.
|
||||
*/
|
||||
final class TaskWorkerSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/** Anfragen, hinter denen kein Mensch und keine Seite steht. */
|
||||
private const IGNORED = '#^/(assets/|media/|favicon)#';
|
||||
|
||||
/** Ein Aufruf, der die Aufgaben schon selbst abgearbeitet hat, setzt dieses Attribut. */
|
||||
public const HANDLED = 'kant_tasks_handled';
|
||||
|
||||
public function __construct(
|
||||
private Scheduler $scheduler,
|
||||
private string $projectDir,
|
||||
private string $adminPath,
|
||||
private int $limit = 2,
|
||||
private int $budget = 20,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Nach dem Zähler der Statistik (Standardpriorität 0): der schreibt eine Zeile, das hier
|
||||
// kann Sekunden dauern.
|
||||
return [KernelEvents::TERMINATE => ['onTerminate', -64]];
|
||||
}
|
||||
|
||||
public function onTerminate(TerminateEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if ($request->attributes->getBoolean(self::HANDLED)) {
|
||||
return;
|
||||
}
|
||||
$path = $request->getPathInfo();
|
||||
if (1 === preg_match(self::IGNORED, $path)) {
|
||||
return;
|
||||
}
|
||||
// Während eines Updates läuft nichts: die Dateien sind vielleicht schon die neuen und
|
||||
// die Datenbank noch die alte. Und vor der Installation gibt es keine Tabelle.
|
||||
if (is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE) || !is_file($this->projectDir.'/'.InstallSubscriber::MARKER)) {
|
||||
return;
|
||||
}
|
||||
$admin = str_starts_with($path, '/'.$this->adminPath);
|
||||
$state = $this->scheduler->fastState();
|
||||
if (!($admin ? $state['admin'] : $state['traffic'])) {
|
||||
return;
|
||||
}
|
||||
if (null !== $state['next'] && $state['next'] > time()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Die Antwort ist raus; ein abgebrochener Aufruf soll die Arbeit trotzdem zu Ende
|
||||
// bringen, sonst bliebe eine Aufgabe auf halbem Weg mit gesetzter Sperre liegen.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit($this->budget + 60);
|
||||
$this->scheduler->runDue($admin ? 'verwaltung' : 'besuch', $this->limit, $this->budget);
|
||||
} catch (\Throwable) {
|
||||
// Eine Aufgabe darf niemals eine Website kaputtmachen. Was schiefging, steht in der
|
||||
// Zeile der Aufgabe und in var/tasks.log - hier ist niemand mehr, dem man es sagen
|
||||
// könnte: die Antwort ist längst verschickt.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ final class ArchiveUpdate
|
||||
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
|
||||
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
|
||||
*/
|
||||
private const MARKE = 'var/update/offen.json';
|
||||
public const MARKE = 'var/update/offen.json';
|
||||
|
||||
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
|
||||
private const PROTOKOLL = 'var/updates.log';
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@@ -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