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>
148 lines
6.3 KiB
PHP
148 lines
6.3 KiB
PHP
<?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');
|
||
}
|
||
}
|