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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user