Files
KantSites/src/Command/RunTasksCommand.php
T
kantarciandClaude Opus 5 05ea703655
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
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>
2026-09-14 08:30:13 +02:00

99 lines
4.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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'],
));
}
}
}