Files
KantSites/tests/SchedulerTest.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

260 lines
10 KiB
PHP

<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Scheduler;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/**
* Der Aufgabenplan - und zwar die Eigenschaften, an denen er auf einem echten Hosting scheitern
* würde: läuft eine Aufgabe zweimal, wenn zwei Besucher gleichzeitig kommen? Reißt eine kaputte
* Aufgabe die Website mit? Fängt eine, die nicht laufen kann, bei jedem Seitenaufruf neu an?
*/
final class SchedulerTest extends TestCase
{
private Connection $db;
private string $databaseFile;
private string $projectDir;
protected function setUp(): void
{
$this->databaseFile = tempnam(sys_get_temp_dir(), 'kant-tasks-');
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->databaseFile));
TestSchema::build($this->db);
$this->projectDir = sys_get_temp_dir().'/kant-tasks-'.bin2hex(random_bytes(6));
mkdir($this->projectDir.'/var', 0o775, true);
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
@unlink($this->databaseFile);
foreach (glob($this->projectDir.'/var/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->projectDir.'/var');
@rmdir($this->projectDir);
}
/** @param list<SpyTask> $tasks */
private function scheduler(array $tasks): Scheduler
{
return new Scheduler($this->db, $tasks, $this->projectDir);
}
private function makeDue(string $name): void
{
$this->db->executeStatement('UPDATE scheduled_task SET next_run_at = ? WHERE name = ?', ['2020-01-01 00:00:00', $name]);
}
public function testTheCodeDecidesWhichTasksExist(): void
{
$this->scheduler([new SpyTask('a'), new SpyTask('b')])->sync();
self::assertSame(['a', 'b'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task ORDER BY name'));
// Nach einem Update kennt der Code eine Aufgabe weniger: ihre Zeile geht mit.
$this->scheduler([new SpyTask('a')])->sync();
self::assertSame(['a'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task'));
}
public function testANewTaskDoesNotRunInTheVeryNextRequest(): void
{
// Sonst faellt direkt nach einem Update alles gleichzeitig an - im ersten Aufruf danach.
$task = new SpyTask('a');
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
self::assertSame(0, $task->runs);
}
public function testWhatIsDueRunsAndIsThenRescheduled(): void
{
$task = new SpyTask('a', 600);
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->makeDue('a');
$results = $scheduler->runDue('besuch');
self::assertCount(1, $results);
self::assertSame('ok', $results[0]['status']);
self::assertSame('Lauf 1', $results[0]['message']);
self::assertSame(1, $task->runs);
// Danach ist sie nicht mehr faellig, und ein zweiter Aufruf laesst sie in Ruhe.
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(1, $task->runs);
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame('besuch', $row['last_trigger']);
self::assertGreaterThan(gmdate('Y-m-d H:i:s'), (string) $row['next_run_at']);
self::assertNull($row['locked_until']);
}
/**
* Der eigentliche Zweck der Sperre: zwei Besucher im selben Moment.
*
* Nachgestellt wird der Zustand, den der erste hinterlaesst, waehrend er noch arbeitet -
* faellig, aber gesperrt. Der zweite muss daran vorbeigehen.
*/
public function testALockedTaskIsLeftAloneByTheNextRequest(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', gmdate('Y-m-d H:i:s', time() + 300), 'a'],
);
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(0, $task->runs);
}
/** Eine Sperre, deren Prozess das Hosting abgeschossen hat, blockiert nicht fuer immer. */
public function testAnExpiredLockIsTakenOver(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', '2020-01-01 00:15:00', 'a'],
);
self::assertCount(1, $scheduler->runDue('besuch'));
self::assertSame(1, $task->runs);
}
public function testAFailingTaskIsRecordedAndWaitsLongerEachTime(): void
{
$task = new SpyTask('a', 300, new \RuntimeException('Kein Netz.'));
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->makeDue('a');
$results = $scheduler->runDue('besuch');
self::assertSame('fehler', $results[0]['status']);
self::assertStringContainsString('Kein Netz.', $results[0]['message']);
$first = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame(1, (int) $first['failures']);
// 300 Sekunden Abstand, ein Fehlschlag: der naechste Versuch liegt weiter weg als der
// gewoehnliche Abstand, aber die Sperre ist weg - die Aufgabe haengt nicht fest.
self::assertNull($first['locked_until']);
self::assertGreaterThan(gmdate('Y-m-d H:i:s', time() + 400), (string) $first['next_run_at']);
$this->makeDue('a');
$scheduler->runDue('besuch');
$second = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame(2, (int) $second['failures']);
self::assertGreaterThan((string) $first['next_run_at'], (string) $second['next_run_at']);
}
public function testASwitchedOffTaskNeverRuns(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$scheduler->enable('a', false);
$this->makeDue('a');
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(0, $task->runs);
// Von Hand gestartet laeuft sie trotzdem: wer den Knopf drueckt, meint sie auch.
$result = $scheduler->runNow('a');
self::assertNotNull($result);
self::assertSame(1, $task->runs);
}
public function testOneRequestDoesNotRunEverythingAtOnce(): void
{
$tasks = [new SpyTask('a'), new SpyTask('b'), new SpyTask('c')];
$scheduler = $this->scheduler($tasks);
$scheduler->sync();
foreach (['a', 'b', 'c'] as $name) {
$this->makeDue($name);
}
self::assertCount(2, $scheduler->runDue('besuch', 2));
self::assertSame(2, array_sum(array_map(static fn (SpyTask $t): int => $t->runs, $tasks)));
}
/**
* Der schnelle Weg: eine Datei sagt, wann sich das Fragen der Datenbank ueberhaupt lohnt.
*
* Ohne diese Eigenschaft kostet der Aufgabenplan jeden Seitenaufruf eine Abfrage - genau das,
* was auf geteiltem Hosting am meisten weh tut.
*/
public function testTheStateFileHoldsTheNextMomentAnythingIsDue(): void
{
$scheduler = $this->scheduler([new SpyTask('a', 600)]);
$scheduler->sync();
$state = $scheduler->fastState();
self::assertTrue($state['traffic']);
self::assertNotNull($state['next']);
self::assertGreaterThan(time(), $state['next']);
self::assertFileExists($this->projectDir.'/'.Scheduler::STATE_FILE);
// Die Schalter ueberleben das Nachtragen des naechsten Zeitpunkts.
$scheduler->setWorkers(false, true);
$this->makeDue('a');
$scheduler->runDue('besuch');
self::assertFalse($scheduler->fastState()['traffic']);
self::assertTrue($scheduler->fastState()['admin']);
}
/**
* Waehrend eine Aufgabe laeuft, schickt die Zustandsdatei die uebrigen Anfragen weg.
*
* Sonst waere jeder Seitenaufruf waehrend einer langen Sicherung wieder bei der Datenbank:
* faellig ist sie ja, sie ist nur schon vergeben.
*/
public function testARunningTaskKeepsTheOtherRequestsOutOfTheDatabase(): void
{
$scheduler = $this->scheduler([new SpyTask('a')]);
$scheduler->sync();
$lockEnds = gmdate('Y-m-d H:i:s', time() + 600);
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', $lockEnds, 'a'],
);
$scheduler->runDue('besuch');
self::assertSame(strtotime($lockEnds.' UTC'), $scheduler->fastState()['next']);
}
/** Zwischen hochgeladenem Code und ausgefuehrter Migration darf nichts explodieren. */
public function testAMissingTableIsNotAnError(): void
{
$this->db->executeStatement('DROP TABLE scheduled_task');
$task = new SpyTask('a');
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
self::assertSame([], $this->scheduler([$task])->overview());
self::assertSame(0, $task->runs);
}
public function testTheProtocolKeepsWhatHappened(): void
{
$scheduler = $this->scheduler([new SpyTask('a')]);
$scheduler->sync();
$scheduler->runNow('a');
$lines = $scheduler->protocol();
self::assertCount(1, $lines);
self::assertStringContainsString('a', $lines[0]);
self::assertStringContainsString('ok', $lines[0]);
self::assertStringContainsString('hand', $lines[0]);
}
public function testTheCronKeyDependsOnTheApplicationSecret(): void
{
$one = Scheduler::cronToken('geheimnis-eins');
$two = Scheduler::cronToken('geheimnis-zwei');
self::assertSame(40, strlen($one));
self::assertNotSame($one, $two);
self::assertSame($one, Scheduler::cronToken('geheimnis-eins'));
}
}