through()` an den Auftrag weiterreicht. * * Der Takt sitzt hier statt im Trait, weil er dann auch für Mailklassen greift, * die den Trait NICHT benutzen (Pakete, Fortify) — und weil er sich mit einer * Mailable-eigenen `middleware()` verträgt, statt sie zu verdrängen: die * Bindung im Container tauscht den Auftrag für jede Mail aus, ohne dass eine * einzige Absendestelle sich ändert. * * Zur Falle mit den Versuchen: der Arbeiter läuft mit --tries=3, und eine * gedrosselte Rückstellung zählt als Versuch. Deshalb `retryUntil()` statt * eines Versuchszählers — aber nur dort, wo wirklich gedrosselt wird. */ class PacedMail extends SendQueuedMailable { /** @return array */ public function middleware(): array { $spur = $this->gedrosselteSpur(); return $spur === null ? [] : [new RateLimited($spur)]; } /** * Sechs Stunden statt eines Versuchszählers — auf den gedrosselten Spuren. * * Der Arbeiter prüft `retryUntil` VOR `--tries`: liegt sie in der Zukunft, * steigt er aus, bevor er `attempts()` gegen die Versuchszahl hält. Genau * das wird hier gebraucht, denn ein Zähler könnte nicht zwischen „dreimal * zurückgelegt" und „dreimal gescheitert" unterscheiden — die Rechnung, die * am Abend an der Drossel ansteht, wäre nach dem dritten Mal endgültig * gescheitert statt verschickt. * * Und genau deshalb hängt die Grenze an derselben Bedingung wie die * Drossel. Ein Zeitfenster hebelt `--tries` nicht bloß für Rückstellungen * aus, sondern GANZ: `Worker::markJobAsFailedIfWillExceedMaxAttempts` * prüft die Versuchszahl ausdrücklich nur `if (! $job->retryUntil())`. Wo * nie gedrosselt wird — Direktspur, Notschalter aus —, gibt es keine * Rückstellungen abzufangen, wohl aber eine Mail, die aus echtem Grund * wirft: die würde sechs Stunden lang wiederholt statt nach drei Versuchen * abgelegt. Ohne Pause obendrein, weil der Arbeiter ohne `--backoff` läuft * und `Worker::calculateBackoff` dann auf null Sekunden fällt. Ihr Fehler * stünde sechs Stunden lang nicht in `failed_jobs` und wäre für jede * Alarmierung nicht vorhanden; auf der Direktspur zöge der Arbeiter sie * obendrein bei jedem Durchlauf zuerst — sechs Stunden lang keine * Kennwort-Zurücksetzung. * * Sechs Stunden ist weit genug, dass auch ein Lauf über die ganze Nacht * durchkommt, und eng genug, dass eine Mail, die dann noch nicht draußen * ist, nicht am nächsten Tag zwischen den neuen auftaucht. * * Ohne eigene Rückgabeangabe, wie die Elternklasse: die lässt neben einem * Zeitpunkt auch einen Zeitstempel zu (`Queue::getJobExpiration` behandelt * beides), und eine engere Angabe hier zerbräche eine Mailklasse, die von * diesem Spielraum Gebrauch macht. * * @return \DateTimeInterface|int|null */ public function retryUntil() { // Die Mailklasse zuerst: die Elternklasse reicht an // `$this->mailable->retryUntil` weiter. Heute deklariert das keine der // vierzehn — aber eine Überschreibung, die nicht zurückfragt, nähme // der nächsten ihre Entscheidung still weg. if (($eigene = parent::retryUntil()) !== null) { return $eigene; } return $this->gedrosselteSpur() === null ? null : now()->addHours(6); } /** * Die Spur, deren Drossel für diesen Auftrag gilt — oder null. * * Eine Stelle für beide Fragen, weil `middleware()` und `retryUntil()` * sonst auseinanderlaufen könnten. Beide Richtungen tun weh: ein * Zeitfenster ohne Drossel nimmt die Versuchszahl weg, ohne etwas dafür zu * geben, und eine Drossel ohne Zeitfenster ist genau die Falle, um die es * in dieser Aufgabe geht. */ private function gedrosselteSpur(): ?string { if (! Settings::bool('mail.pace.enabled', true)) { return null; } // Zuerst die Schlange am Mailable: das ist der Name, den // `Mailable::queue()` an `pushOn()` gegeben hat — die Spur, in der // dieser Auftrag TATSÄCHLICH liegt. `MailLane::for(...)` allein wäre // hier falsch, weil es die Einstellungen erst im Arbeiter läse: würde // der Betreiber die Rechnung von ruhig auf wichtig schieben, während // zweihundert davon in der ruhigen Schlange warten, drosselte sie ab // diesem Moment das falsche Kontingent. // // Steht dort nichts, kommt die Mail von auswärts — aus einem Paket, // aus Fortify, aus irgendetwas, das jemand eingebunden hat. Sie kennt // `RidesALane` nicht, hat also keine Spur und liegt auf `default`. // Dann entscheidet `MailLane::for(...)`, und das ist Absicht: Aufgabe 1 // lässt eine unbekannte Klasse ausdrücklich in die ruhige Spur fallen, // weil eine zu langsam verschickte Mail ein kleinerer Fehler ist als // ein ungedrosselter Schub, den niemand vorhergesehen hat. Ohne diesen // Rückgriff fiele sie am Auftrag in die Gegenrichtung. // // Schlange und Takt fallen für so eine Mail auseinander — sie bleibt // auf `default` und fährt trotzdem im ruhigen Takt. Das ist kein // Versehen, sondern die Arbeitsteilung: die Schlange wählt der Trait, // den Takt wählt dieser Auftrag, und für eine fremde Mailklasse greift // nur das zweite. $spur = $this->mailable->queue ?? MailLane::for($this->mailable::class); return match ($spur) { MailLane::URGENT => MailLane::URGENT, MailLane::CALM => MailLane::CALM, default => null, }; } }