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>
260 lines
10 KiB
PHP
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'));
|
|
}
|
|
}
|