nummer); } public function content(): Content { return new Content(htmlString: '

Takt

'); } } /** * Eine Mail, die aus echtem Grund kippt — nicht wegen der Drossel. * * Der Anhang, weil genau dort der teuerste echte Fehler dieses Projekts sitzt: * `InvoiceMail::attachments()` rendert das PDF beim Senden. Kippt das, ist der * Auftrag nicht gedrosselt, sondern kaputt, und gehört nach drei Versuchen ins * Fehlerprotokoll statt sechs Stunden lang wiederholt. */ class GiftigeDirektMail extends Mailable implements ShouldQueue { use Queueable, RidesALane, SerializesModels; public function envelope(): Envelope { return new Envelope(from: 'takt@example.test', subject: 'Giftig'); } public function content(): Content { return new Content(htmlString: '

Giftig

'); } /** @return array */ public function attachments(): array { throw new RuntimeException('Das Rendern des Anhangs ist gekippt.'); } } /** * Eine Mailklasse, die ihre eigene zeitliche Grenze mitbringt. * * Heute tut das keine der vierzehn. Aber `SendQueuedMailable::retryUntil()` * reicht an die Mailklasse weiter, und eine Überschreibung, die das still * wegnimmt, würde die nächste ohne einen Hinweis übergehen. */ class EigeneGrenzeMail extends Mailable implements ShouldQueue { use Queueable, RidesALane, SerializesModels; public function retryUntil(): DateTimeInterface { return now()->addMinutes(11); } public function envelope(): Envelope { return new Envelope(from: 'takt@example.test', subject: 'Eigene Grenze'); } public function content(): Content { return new Content(htmlString: '

Eigene Grenze

'); } } /** * Eine Mailklasse aus der Fremde: kein Trait, keine Spur am Mailable. * * So sieht jede Mail aus, die nicht aus `app/Mail` kommt — Fortify, ein * künftiges Abhängigkeitspaket, irgendetwas, das jemand einbindet. Sie kennt * `RidesALane` nicht, also steht in `$mailable->queue` nichts, und sie liegt * auf `default`. */ class FremdeMail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; public function envelope(): Envelope { return new Envelope(from: 'takt@example.test', subject: 'Fremd'); } public function content(): Content { return new Content(htmlString: '

Fremd

'); } } /** * Der Auftrag, so wie ihn das Einreihen erzeugt. * * Nicht `new PacedMail(...)`: dass überhaupt ein PacedMail entsteht, ist die * halbe Aufgabe — die Bindung im Container tauscht ihn für jede Mail aus, ohne * dass eine Absendestelle davon weiß. Und die Spur steht erst da, weil der * Trait sie VOR `newQueuedJob()` setzt; ein von Hand gebauter Auftrag hätte sie * nie gesehen und der Test bewiese die falsche Sache. */ function taktAuftrag(Mailable $mailable): object { Queue::fake(); Mail::to('kunde@example.test')->queue($mailable); return Queue::pushed(PacedMail::class)->first() ?? throw new RuntimeException('Es wurde kein PacedMail eingereiht.'); } /** * Der Name der Drossel, an der dieser Auftrag hängt. * * `RateLimited` hält ihn geschützt und bietet keinen Weg heraus. Die Alternative * wäre, die Spur am Verhalten abzulesen — zwei Kontingente setzen und schauen, * welches greift; das prüft aber die Drossel des Frameworks mit und nicht die * eine Entscheidung, um die es hier geht: welche Spur welche Drossel bekommt. */ function drosselName(RateLimited $middleware): string { return (new ReflectionProperty($middleware, 'limiterName'))->getValue($middleware); } /** Ein Durchlauf des echten Arbeiters, so wie er im Betrieb läuft. */ function einDurchlaufDesArbeiters(): void { Artisan::call('queue:work', [ '--once' => true, '--tries' => 3, '--sleep' => 0, '--queue' => implode(',', [MailLane::DIRECT, MailLane::URGENT, MailLane::CALM]), ]); } /** Die Nutzlast des einen Auftrags, der gerade in der Schlange liegt. */ function nutzlastDesAuftrags(): array { return json_decode((string) DB::table('jobs')->value('payload'), true); } /** * Die Drossel darf keine Mail verlieren. * * Der Arbeiter läuft mit --tries=3, und eine zurückgelegte Mail zählt als * Versuch. Ohne Vorkehrung wäre jede Rechnung nach dem dritten Drosseln * gescheitert statt verschickt — still, im Fehlerprotokoll, ohne dass jemand * etwas merkt. Das ist der Test, der vor der ersten Zeile Drossel steht. * * Über den ECHTEN Arbeiter, nicht über das Verhalten der Zwischenschicht: die * Falle sitzt nicht in der Drossel, sondern in `Worker::markJobAsFailedIf…`, * das `--tries` gegen `attempts()` hält. Nur ein Lauf, der wirklich durch diese * Stelle geht, kann belegen, dass sie nicht zuschlägt. Die Schlange ist deshalb * `database` statt `sync` — `sync` legt nicht zurück, sondern lässt eine * gedrosselte Mail spurlos verschwinden, und der Test wäre grün ohne Grund. * * Acht Mails auf zwei je Fenster: die letzten beiden werden dreimal * zurückgelegt und kommen im vierten Anlauf dran — ein Versuch mehr, als * `--tries=3` durchgehen ließe. Genau das sichert die letzte Zusicherung ab, * damit der Test nicht eines Tages grün bleibt, weil gar nicht mehr gedrosselt * wird. */ it('verliert keine Mail, wenn das Kontingent kleiner ist als der Lauf', function () { Settings::set('mail.pace.calm.count', 2); Settings::set('mail.pace.calm.minutes', 1); MailLane::assign(TaktProbeMail::class, MailLane::CALM); config(['queue.default' => 'database']); $versuche = []; Event::listen(JobProcessing::class, function (JobProcessing $event) use (&$versuche) { $versuche[] = $event->job->attempts(); }); foreach (range(1, 8) as $nummer) { Mail::to("kunde{$nummer}@example.test")->queue(new TaktProbeMail($nummer)); } // Der Arbeiter, so wie er im Betrieb läuft: eine Mail je Aufruf, --tries=3. // Liegt nichts Fälliges mehr da, wird die Uhr auf den nächsten // Fälligkeitszeitpunkt gestellt — die Drossel legt zurück, sie wirft nicht, // und ohne Uhrstellen stünde der Test eine Minute lang still. $notbremse = 100; while (DB::table('jobs')->count() > 0) { if (--$notbremse <= 0) { throw new RuntimeException('Die Schlange leert sich nicht — Abbruch statt Endlosschleife.'); } if (! DB::table('jobs')->where('available_at', '<=', now()->getTimestamp())->exists()) { $this->travelTo(Carbon::createFromTimestamp((int) DB::table('jobs')->min('available_at'))); continue; } einDurchlaufDesArbeiters(); } expect(DB::table('failed_jobs')->count())->toBe(0) ->and(Mail::mailer()->getSymfonyTransport()->messages())->toHaveCount(8) ->and(max($versuche))->toBeGreaterThan(3); }); /** * Die Kehrseite: wo nicht gedrosselt wird, muss `--tries` weiter greifen. * * Eine zeitliche Grenze schaltet den Versuchszähler nicht bloß für die Drossel * ab, sondern ganz: `Worker::markJobAsFailedIfWillExceedMaxAttempts` prüft die * Versuche ausdrücklich nur `if (! $job->retryUntil())`. Eine Mail, die aus * ECHTEM Grund wirft, würde damit sechs Stunden lang wiederholt — ohne Pause, * weil der Arbeiter ohne `--backoff` läuft und `calculateBackoff` dann auf null * Sekunden fällt. * * Zwei Schäden: der Fehler ist sechs Stunden lang nicht in `failed_jobs`, also * für jede Alarmierung nicht vorhanden. Und sobald der Arbeiter die Spuren in * Prioritätsreihenfolge liest, zieht er diese eine giftige Direkt-Mail bei * JEDEM Durchlauf zuerst — sechs Stunden lang keine Kennwort-Zurücksetzung. */ it('lässt eine Direkt-Mail, die aus echtem Grund wirft, nach drei Versuchen scheitern', function () { MailLane::assign(GiftigeDirektMail::class, MailLane::DIRECT); config(['queue.default' => 'database']); Mail::to('kunde@example.test')->queue(new GiftigeDirektMail); // Ohne Zeitfenster wird der Auftrag beim dritten Versuch abgelegt und die // Schlange ist leer. Mit Zeitfenster käme er endlos wieder — die Schranke // bei zehn ist der Unterschied zwischen einem roten Test und einem Test, // der nie fertig wird. $durchlaeufe = 0; while (DB::table('jobs')->where('available_at', '<=', now()->getTimestamp())->exists()) { if (++$durchlaeufe > 10) { throw new RuntimeException('Der Auftrag hört nicht auf — die Versuchszahl greift nicht.'); } einDurchlaufDesArbeiters(); } expect($durchlaeufe)->toBe(3) ->and(DB::table('failed_jobs')->count())->toBe(1) ->and(DB::table('jobs')->count())->toBe(0); }); it('gibt einem Auftrag ohne Drossel gar kein Zeitfenster', function (string $aufbau) { config(['queue.default' => 'database']); $aufbau === 'direkt' ? MailLane::assign(TaktProbeMail::class, MailLane::DIRECT) : Settings::set('mail.pace.enabled', false); Mail::to('kunde@example.test')->queue(new TaktProbeMail(1)); expect(nutzlastDesAuftrags()['retryUntil'])->toBeNull(); })->with(['direkt', 'notschalter aus']); /** * Bringt die Mailklasse ihre eigene Grenze mit, gilt ihre. * * `SendQueuedMailable::retryUntil()` reicht an `$this->mailable->retryUntil` * weiter. Eine Überschreibung, die das nicht zurückfragt, nähme der nächsten * Mailklasse ihre Entscheidung still weg. */ it('lässt der Mailklasse ihre eigene zeitliche Grenze', function () { config(['queue.default' => 'database']); Mail::to('kunde@example.test')->queue(new EigeneGrenzeMail); // Auf einer gedrosselten Spur — sonst bewiese der Test nur, dass ohne // Drossel nichts passiert, und nicht, dass die eigene Grenze vorgeht. expect(MailLane::for(EigeneGrenzeMail::class))->toBe(MailLane::CALM) ->and(nutzlastDesAuftrags()['retryUntil'])->toBe(now()->addMinutes(11)->getTimestamp()); }); /** * Was der Arbeiter liest, ist der Auftrag in der Schlange, nicht das Objekt. * * `retryUntil` wird beim Einreihen ausgerechnet und in die Nutzlast * geschrieben; von dort holt `Worker::markJobAsFailedIfAlreadyExceedsMaxAttempts` * sie und steigt aus, bevor `--tries` überhaupt zur Sprache kommt. Steht die * Zeile nicht in der Nutzlast, hilft die schönste `retryUntil()`-Methode nichts. * * `maxTries` bleibt leer: ein eigener Versuchszähler am Auftrag würde den des * Arbeiters ersetzen und die Falle nur an eine andere Zahl hängen. */ it('schreibt die zeitliche Grenze in den Auftrag, den der Arbeiter liest', function () { config(['queue.default' => 'database']); Mail::to('kunde@example.test')->queue(new TaktProbeMail(1)); $nutzlast = json_decode((string) DB::table('jobs')->value('payload'), true); expect($nutzlast['retryUntil'])->toBe(now()->addHours(6)->getTimestamp()) ->and($nutzlast['maxTries'])->toBeNull(); }); it('erzeugt für jede Mail den gedrosselten Auftrag', function () { expect(taktAuftrag(new TaktProbeMail(1)))->toBeInstanceOf(PacedMail::class); }); it('hängt jede gedrosselte Spur an ihre eigene Drossel', function (string $spur) { MailLane::assign(TaktProbeMail::class, $spur); $middleware = taktAuftrag(new TaktProbeMail(1))->middleware(); expect($middleware)->toHaveCount(1) ->and($middleware[0])->toBeInstanceOf(RateLimited::class) ->and(drosselName($middleware[0]))->toBe($spur); })->with([MailLane::URGENT, MailLane::CALM]); /** * Eine fremde Mailklasse fährt im ruhigen Takt, nicht ungebremst. * * Aufgabe 1 hat sich für diese Richtung entschieden: eine unbekannte Klasse * fällt in die ruhige Spur, weil eine zu langsam verschickte Mail ein kleinerer * Fehler ist als ein ungedrosselter Schub, den niemand vorhergesehen hat. Am * Auftrag fiel sie bis hierher in die Gegenrichtung — ohne Spur am Mailable * griff gar keine Drossel. * * Schlange und Takt fallen dabei auseinander, und das ist kein Versehen: die * Schlange wählt der Trait, den diese Klasse nicht hat, den Takt wählt der * Auftrag. Sie liegt also weiter auf `default` und fährt trotzdem im ruhigen * Takt. */ it('drosselt eine Mailklasse ohne den Trait wie die ruhige Spur', function () { $auftrag = taktAuftrag(new FremdeMail); $middleware = $auftrag->middleware(); expect($auftrag->mailable->queue)->toBeNull() ->and($middleware)->toHaveCount(1) ->and(drosselName($middleware[0]))->toBe(MailLane::CALM) ->and($auftrag->retryUntil())->not->toBeNull(); }); /** * Auf der direkten Spur wartet gerade ein Mensch, der eben geklickt hat. * Drosseln nützt dort nichts — die Mails entstehen einzeln und können gar * keinen Schub bilden — und kostet einen Supportfall je verzögertem Kennwort. */ it('drosselt die direkte Spur nicht', function () { $mail = new ResetPasswordMail(User::factory()->create(), 'https://example.test/reset', 60); expect(taktAuftrag($mail)->middleware())->toBe([]); }); it('meldet für die direkte Spur gar keine Drossel an', function () { expect(RateLimiter::limiter(MailLane::DIRECT))->toBeNull(); }); /** * `Limit::perMinutes($minuten, $anzahl)` — Minuten zuerst. Die Reihenfolge ist * anders herum, als man sie liest, und ein vertauschtes Paar wäre ein Takt von * fünf Mails in dreißig Minuten statt dreißig in fünf. Deshalb stehen hier * beide Zahlen einzeln, und zwar in Sekunden, wie die Drossel sie sieht. */ it('nimmt beide Kontingente aus den Einstellungen', function () { Settings::set('mail.pace.urgent.count', 12); Settings::set('mail.pace.urgent.minutes', 3); Settings::set('mail.pace.calm.count', 7); Settings::set('mail.pace.calm.minutes', 9); $wichtig = RateLimiter::limiter(MailLane::URGENT)(null); $ruhig = RateLimiter::limiter(MailLane::CALM)(null); expect($wichtig->maxAttempts)->toBe(12) ->and($wichtig->decaySeconds)->toBe(180) ->and($ruhig->maxAttempts)->toBe(7) ->and($ruhig->decaySeconds)->toBe(540); }); /** * Eine unbrauchbare Einstellung darf kein Kontingent von null ergeben. * * Die Vorgabe in `Settings::get()` greift nur bei FEHLENDER Zeile. Eine * gespeicherte `null` — oder ein Wort statt einer Zahl — kommt als Wert durch * und wird von `(int)` zu 0. Ein Kontingent von 0 hieße: `tooManyAttempts` ist * immer wahr, jede Mail wird endlos zurückgelegt und nach sechs Stunden * gesammelt zu `failed_jobs` — genau der Ausgang, den dieser Takt verhindern * soll, nur über einen anderen Weg. Ein Fenster von 0 Minuten hieße * umgekehrt: gar keine Drossel. */ it('macht aus einer unbrauchbaren Einstellung kein Kontingent von null', function () { Settings::set('mail.pace.urgent.count', null); Settings::set('mail.pace.urgent.minutes', null); Settings::set('mail.pace.calm.count', 'viele'); Settings::set('mail.pace.calm.minutes', -3); $wichtig = RateLimiter::limiter(MailLane::URGENT)(null); $ruhig = RateLimiter::limiter(MailLane::CALM)(null); expect($wichtig->maxAttempts)->toBe(1) ->and($wichtig->decaySeconds)->toBe(60) ->and($ruhig->maxAttempts)->toBe(1) ->and($ruhig->decaySeconds)->toBe(60); }); it('taktet ohne Einstellung dreißig je fünf und zwanzig je zehn Minuten', function () { $wichtig = RateLimiter::limiter(MailLane::URGENT)(null); $ruhig = RateLimiter::limiter(MailLane::CALM)(null); expect($wichtig->maxAttempts)->toBe(30) ->and($wichtig->decaySeconds)->toBe(300) ->and($ruhig->maxAttempts)->toBe(20) ->and($ruhig->decaySeconds)->toBe(600); }); /** * Der Notschalter für den Abend, an dem etwas anderes klemmt und zweihundert * Rechnungen trotzdem heute raus müssen. */ it('lässt sich mit dem Notschalter ganz abstellen', function () { Settings::set('mail.pace.enabled', false); MailLane::assign(TaktProbeMail::class, MailLane::CALM); expect(taktAuftrag(new TaktProbeMail(1))->middleware())->toBe([]); });