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 $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')); } }