CluPilotCloud/app/Jobs/PacedMail.php

121 lines
5.5 KiB
PHP

<?php
namespace App\Jobs;
use App\Services\Mail\MailLane;
use App\Support\Settings;
use Illuminate\Mail\SendQueuedMailable;
use Illuminate\Queue\Middleware\RateLimited;
/**
* Der Auftrag, der eine Mail im Takt ihrer Spur verschickt.
*
* `SendQueuedMailable` selbst hat keine `middleware()` — nachgezählt im
* Framework: handle, backoff, retryUntil, failed, displayName, __clone. Der
* eigene Auftrag ist trotzdem nicht der einzige Weg: eine `middleware()` auf
* der MAILKLASSE wird sehr wohl gelesen, weil `Mailable::newQueuedJob()` sie
* einsammelt und per `->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<int, object> */
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;
}
// Die Schlange am Mailable, nicht `MailLane::for(...)`: das hier ist
// der Name, den `Mailable::queue()` an `pushOn()` gegeben hat — die
// Spur, in der dieser Auftrag TATSÄCHLICH liegt. `MailLane::for(...)`
// läse die Einstellungen erst im Arbeiter: 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.
//
// `?? null` statt eines nackten Zugriffs, weil `$this->mailable` nur
// die Schnittstelle zusichert: eine Mailklasse, die den Trait nicht
// benutzt, hat die Eigenschaft womöglich gar nicht — und liegt dann
// auch in keiner Spur, gehört also nicht gedrosselt.
return match ($this->mailable->queue ?? null) {
MailLane::URGENT => MailLane::URGENT,
MailLane::CALM => MailLane::CALM,
default => null,
};
}
}