From 7087ef6a2f484acc996e46ad191c97f7b2324602 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 18:37:20 +0200 Subject: [PATCH] Die Bereitschaftsseite meldet eine Spur, die steht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Spur, die steht, sah von außen aus wie eine Spur, die leer ist — Queue::size() unterscheidet beides nicht. Die neue Prüfung liest das Alter des ältesten wartenden Auftrags je Spur ausdrücklich über die Redis-Verbindung `redis` (dieselbe, mit der der Arbeiter in docker-compose.yml läuft), unabhängig von QUEUE_CONNECTION — und bleibt damit auch unter der auf sync gezwungenen Testsuite ehrlich, weil Redis in beiden Umgebungen erreichbar ist. Die Grenze liegt bei einer Stunde: 20 je 10 Minuten (die langsamste Spur) sind 120 in der Stunde, und ein Lauf dieser Größe steht bei elf Kunden nicht so lange an. Die eigene Seite admin/mail-pace (Task 5) bekam dabei einen eigenen tab-Wert in Readiness::checkUrl() — der bestehende ReadinessPageTest hält jeden Verweis gegen die Stelle, an der sein Feld wirklich liegt, und 'mail' hätte auf die falsche Seite gezeigt. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Readiness.php | 12 +- app/Support/Readiness/DeliveryChecks.php | 87 ++++++++++++ lang/de/readiness.php | 2 + lang/en/readiness.php | 2 + tests/Feature/Readiness/MailPaceCheckTest.php | 126 ++++++++++++++++++ tests/Feature/ReadinessPageTest.php | 1 + 6 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/Readiness/MailPaceCheckTest.php diff --git a/app/Livewire/Admin/Readiness.php b/app/Livewire/Admin/Readiness.php index d63cd6a..63d6f2e 100644 --- a/app/Livewire/Admin/Readiness.php +++ b/app/Livewire/Admin/Readiness.php @@ -149,11 +149,11 @@ class Readiness extends Component /** * Where an operator goes to actually fix ONE entry. * - * Not always Admin\Integrations: six of the nine `tab` values used across + * Not always Admin\Integrations: seven of the ten `tab` values used across * the five check groups name a DIFFERENT admin page entirely — 'hosts', - * 'plans', 'datacenters', 'mail', 'templates' and 'company' are pages of - * their own, not Integrations tabs, and forwarding them as `?tab=` would - * either 404 or (since Integrations::mount() falls back silently to its + * 'plans', 'datacenters', 'mail', 'mail-pace', 'templates' and 'company' + * are pages of their own, not Integrations tabs, and forwarding them as + * `?tab=` would either 404 or (since Integrations::mount() falls back silently to its * own first tab for an unrecognised value) land quietly on the wrong * section. Every OTHER `tab` value that exists in the five check group * files today (checked at the source, Fix-Runde) really IS one of @@ -176,6 +176,10 @@ class Readiness extends Component 'plans' => route('admin.plans'), 'datacenters' => route('admin.datacenters'), 'mail' => route('admin.mail'), + // Die Spuren (Task 5) liegen auf ihrer eigenen Seite, nicht auf + // admin/mail selbst — admin/mail zeigt Server, Postfächer und + // Zwecke, keine Warteschlangen. + 'mail-pace' => route('admin.mail-pace'), 'templates' => route('admin.templates'), // Company details AND the invoice series both live on // Admin\Finance — there is no dedicated "company" page. diff --git a/app/Support/Readiness/DeliveryChecks.php b/app/Support/Readiness/DeliveryChecks.php index 5ec2c9a..2f1fd9a 100644 --- a/app/Support/Readiness/DeliveryChecks.php +++ b/app/Support/Readiness/DeliveryChecks.php @@ -5,9 +5,11 @@ namespace App\Support\Readiness; use App\Mail\Transport\MailboxTransport; use App\Models\MailTemplate; use App\Services\Mail\MailboxResolver; +use App\Services\Mail\MailLane; use App\Services\Mail\MailPurpose; use App\Services\Secrets\SecretVault; use App\Support\MailDelivery; +use Illuminate\Support\Facades\Queue; /** * What has to be in place for the customer to actually find out any of this @@ -18,6 +20,17 @@ final class DeliveryChecks { public const GROUP = 'delivery'; + /** + * Länger als das her ist eine Spur, die nicht mehr fährt, sondern steht. + * + * Die langsamste Spur (mail-ruhig, Task 3) lässt standardmäßig 20 + * Aufträge je 10 Minuten durch — 120 in der Stunde. Ein Lauf dieser + * Größe steht bei elf Kunden nicht eine ganze Stunde lang an. Schlägt die + * Prüfung trotzdem an, klemmt etwas: der Arbeiter läuft nicht mehr, oder + * die Spur ist aus der --queue-Liste in docker-compose.yml gefallen. + */ + private const STUCK_AFTER_MINUTES = 60; + /** @return array */ public static function all(): array { @@ -78,6 +91,80 @@ final class DeliveryChecks tab: 'services', satisfied: filled(app(SecretVault::class)->get('inbound_mail.password')), ), + new Check( + key: 'delivery.mail_lanes_moving', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.delivery.mail_lanes_moving'), + breaks: __('readiness.delivery.mail_lanes_moving_breaks'), + // Eigener Wert, keiner der bestehenden ('mail' zeigt auf + // admin/mail — Server, Postfächer, Zwecke; die Spuren liegen + // seit Task 5 auf der eigenen Seite admin/mail-pace). Der + // ReadinessPageTest hält jeden tab-Wert gegen die Stelle, an + // der sein Feld wirklich liegt — 'mail' wäre hier derselbe + // falsche Verweis, den jene Prüfung an neun anderen Stellen + // schon einmal gefangen hat. + tab: 'mail-pace', + // Eine Spur, die STEHT, sieht von außen aus wie eine, die + // LEER ist — `Queue::size()` (Task 5, admin/mail-pace) meldet + // in beiden Fällen dieselbe Art Zahl und unterscheidet sie + // nicht. Das Alter des ältesten wartenden Auftrags trennt die + // beiden: eine leere Spur hat keinen, eine verstopfte hat + // einen, der immer älter wird. + // + // `connection('redis')` ausdrücklich, nicht die + // Standardverbindung: dieselbe, mit der der Arbeiter in + // docker-compose.yml tatsächlich gestartet wird + // (`queue:work redis --queue=mail-direkt,mail-wichtig, + // mail-ruhig,default`) — unabhängig davon, worauf + // QUEUE_CONNECTION gerade zeigt. Unter der Testsuite zwingt + // phpunit.xml die STANDARDverbindung auf `sync` + // (SyncQueue::size() liefert dort immer 0, egal ob leer oder + // verstopft — siehe die Anmerkung auf admin/mail-pace); die + // NAMENTLICH angeforderte `redis`-Verbindung bleibt davon + // unberührt und bleibt echtes Redis, in beiden Umgebungen + // erreichbar (siehe tests/Feature/Admin/HostTerminalTest.php). + // `creationTimeOfOldestPendingJob()` ist Teil des + // Queue-Vertrags selbst (Illuminate\Contracts\Queue\Queue) — + // jeder Treiber implementiert es, `sync` eingeschlossen, und + // liefert dort ehrlich `null` statt einer erfundenen Zahl. + satisfied: self::laneMoving(MailLane::DIRECT) + && self::laneMoving(MailLane::URGENT) + && self::laneMoving(MailLane::CALM), + ), ]; } + + /** + * Ob der älteste wartende Auftrag einer Spur jünger als die Grenze ist. + * + * Zählt nur die unverzögerte Liste (`creationTimeOfOldestPendingJob` + * schließt `:delayed` ausdrücklich aus) — ein von der Drossel + * zurückgelegter Auftrag dort ist die Drossel bei der Arbeit, kein + * Klemmen. Was hier erkannt wird, ist der Fall, in dem niemand mehr + * abholt: der Arbeiter tot, oder die Spur aus seiner --queue-Liste + * gefallen — dann wächst die unverzögerte Liste, weil neue Mail dort + * ungedrosselt eintrifft (`RidesALane::queue()` reiht ohne Verzögerung + * ein) und nie wieder verschwindet. + */ + private static function laneMoving(string $lane): bool + { + try { + $oldest = Queue::connection('redis')->creationTimeOfOldestPendingJob($lane); + } catch (\Throwable) { + // Wie OperationChecks::isFresh(): eine Seite, deren einziger + // Zweck ist, ruhig zu melden, was fehlt, darf nicht selbst + // ausfallen, weil Redis gerade nicht erreichbar ist. Nicht + // erreichbar heißt nicht erfüllt — nicht "wirft". + return false; + } + + if ($oldest === null) { + // Noch nie etwas eingereiht, oder gerade leer geräumt — beides + // ist gesund. Kein Auftrag heißt: nichts, das stehen könnte. + return true; + } + + return $oldest > now()->subMinutes(self::STUCK_AFTER_MINUTES)->getTimestamp(); + } } diff --git a/lang/de/readiness.php b/lang/de/readiness.php index 5c13d99..09929b5 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -107,6 +107,8 @@ return [ 'mail_templates_breaks' => 'Auf der Kundenseite bleibt die Vorlagen-Auswahl leer, wo sie sonst anklickbare Antworten anbietet — der Support-Mitarbeiter merkt es sofort beim Öffnen, findet aber nichts zum Einfügen und tippt jede Antwort neu. Zwei Kunden mit derselben Frage bekommen dadurch leicht zwei unterschiedliche Auskünfte, ohne dass irgendwo ein Fehler erscheint.', 'inbound_password' => 'Passwort für eingehende Mail', 'inbound_password_breaks' => 'Ohne es liefert der Mail-Abruf jedes Mal eine leere Liste. Der Zeitplan läuft unauffällig weiter, aber keine Kundenantwort erreicht je die Inbox der Konsole, und nirgends erscheint ein Fehler.', + 'mail_lanes_moving' => 'Versandspuren sind in Bewegung', + 'mail_lanes_moving_breaks' => 'Eine Spur, die steht, sieht von außen aus wie eine, die leer ist: der Arbeiter meldet keinen Fehler, und es erscheint keine Meldung. Liegt der älteste Auftrag einer Spur länger als eine Stunde da, klemmt sie — je nachdem, welche, bleibt eine Rechnung, eine Wartungsankündigung oder sogar eine Kennwort-Zurücksetzung unversandt, bis ein Kunde von sich aus anruft.', ], 'operation' => [ diff --git a/lang/en/readiness.php b/lang/en/readiness.php index 57ba13a..db2dd5f 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -102,6 +102,8 @@ return [ 'mail_templates_breaks' => 'On the customer page, the template picker sits empty where it would otherwise offer ready answers to click — the support agent notices immediately on opening it, finds nothing to insert, and types every reply from scratch. Two customers asking the same question can then easily get two different answers, with no error appearing anywhere.', 'inbound_password' => 'Inbound mail password', 'inbound_password_breaks' => 'Without it, fetching mail returns an empty list every time. The schedule keeps running quietly, but no customer reply ever reaches the console inbox, and no error appears anywhere.', + 'mail_lanes_moving' => 'Mail lanes are moving', + 'mail_lanes_moving_breaks' => 'A stalled lane looks, from the outside, exactly like an empty one: the worker raises no error, nothing appears anywhere. If a lane\'s oldest job has sat for more than an hour, it is jammed — depending on the lane, an invoice, a maintenance notice, or even a password reset goes undelivered until a customer calls in asking why.', ], 'operation' => [ diff --git a/tests/Feature/Readiness/MailPaceCheckTest.php b/tests/Feature/Readiness/MailPaceCheckTest.php new file mode 100644 index 0000000..0e6154e --- /dev/null +++ b/tests/Feature/Readiness/MailPaceCheckTest.php @@ -0,0 +1,126 @@ +firstWhere('key', $key); +} + +/** + * Legt einen rohen Redis-Auftrag mit einem bestimmten Einreihungszeitpunkt in + * eine Spur — dieselbe `createdAt`-Nutzlast, die `RedisQueue::push()` selbst + * schreibt (Illuminate\Queue\Queue::createPayloadArray()) und die + * `creationTimeOfOldestPendingJob()` ausliest. Der Inhalt des restlichen + * Auftrags ist für die Prüfung irrelevant — sie liest nie mehr als dieses eine + * Feld —, deshalb bleibt er auf das Nötigste reduziert. + */ +function legeAufTraegInDieSpur(string $lane, DateTimeInterface $eingereihtAm): void +{ + Queue::connection('redis')->pushRaw(json_encode([ + 'uuid' => (string) Str::uuid(), + 'job' => 'unbenutzt', + 'data' => [], + 'id' => Str::random(32), + 'attempts' => 0, + 'createdAt' => $eingereihtAm->getTimestamp(), + ]), $lane); +} + +/** + * DB 0 ist die echte Schlange: derselbe Redis, den `docker-compose.yml`s + * `queue`-Dienst rund um die Uhr mit `queue:work redis --queue=mail-direkt, + * mail-wichtig,mail-ruhig,default` abhört (siehe die laufenden Container in + * dieser Umgebung). Ein hier abgelegter Testauftrag liefe Gefahr, vom echten + * Arbeiter weggeschnappt zu werden, bevor die Prüfung ihn liest — ein + * Wettlauf mit einem fremden Prozess, kein deterministischer Test. DB 15 ist + * in keiner Konfiguration dieses Projekts referenziert und für jenen + * Arbeiter unsichtbar; `creationTimeOfOldestPendingJob()` selbst bleibt der + * echte Weg über echtes Redis (Illuminate\Queue\RedisQueue), nur auf eine + * Datenbank verschoben, die niemand sonst anfasst. + * + * `config()` allein reicht nur, WEIL Laravels TestCase je Testmethode einen + * frischen Application-Container baut (`RedisManager` ist ein Singleton, der + * seine Konfiguration einmal beim ersten `Redis::connection()` dieses + * Containers einliest — in `artisan tinker`, wo EIN Container die ganze + * Sitzung lang lebt, kam genau deshalb beim Nachprüfen trotz gesetzter + * Config die 0 zurück, weil dort schon vorher etwas 'redis' aufgelöst hatte). + * Der Wächter unten prüft die WIRKLICH gewählte Datenbank am Client selbst, + * nicht nur die Einstellung, die sie hätte bewirken sollen — sollte diese + * Annahme durch eine künftige Änderung (z. B. Parallelisierung der Suite) + * doch einmal nicht mehr gelten, bricht der Test laut ab, statt still + * `flushdb()` gegen die echte Datenbank 0 abzufeuern. + */ +beforeEach(function () { + config(['database.redis.default.database' => 15]); +}); + +afterEach(function () { + $tatsaechlicheDb = Redis::connection('default')->client()->getDbNum(); + + if ($tatsaechlicheDb !== 15) { + throw new RuntimeException( + "Erwartete Redis-DB 15 fuer die Aufraeumung, aber die Verbindung steht auf {$tatsaechlicheDb} — flushdb() abgebrochen, um nicht versehentlich die echte Datenbank zu leeren." + ); + } + + Redis::connection('default')->flushdb(); +}); + +/** + * Eine Spur, die steht, sieht von außen aus wie eine Spur, die leer ist. Die + * Grenze liegt bei einer Stunde: 20 je 10 Minuten (der langsamste Takt, Task + * 3) sind 120 in der Stunde, und ein Lauf dieser Größe steht bei elf Kunden + * nicht an. Schlägt die Prüfung trotzdem an, klemmt etwas. + */ +it('meldet eine Spur, in der etwas laenger als eine Stunde liegt', function () { + legeAufTraegInDieSpur(MailLane::CALM, now()->subHours(2)); + + expect(mailPaceCheck('delivery.mail_lanes_moving')->satisfied)->toBeFalse(); +}); + +it('meldet eine Spur mit frischer Arbeit nicht', function () { + legeAufTraegInDieSpur(MailLane::CALM, now()); + + expect(mailPaceCheck('delivery.mail_lanes_moving')->satisfied)->toBeTrue(); +}); + +/** + * Vollständigkeit: die drei Spuren sind gleichberechtigt vor der Prüfung. + * Ohne diesen Test bewiese der obige Fall nur, dass EINE bestimmte Spur + * beobachtet wird — mail-direkt oder mail-wichtig könnten unbeobachtet + * bleiben, und ein Kunde wartet dort ebenso vergeblich auf eine + * Kennwort-Zurücksetzung oder eine Wartungsankündigung. + */ +it('meldet jede der drei Spuren, wenn sie steht', function (string $lane) { + legeAufTraegInDieSpur($lane, now()->subHours(2)); + + expect(mailPaceCheck('delivery.mail_lanes_moving')->satisfied)->toBeFalse(); +})->with([ + 'mail-direkt' => [MailLane::DIRECT], + 'mail-wichtig' => [MailLane::URGENT], + 'mail-ruhig' => [MailLane::CALM], +]); + +/** + * Der Normalzustand: nichts wurde je eingereiht. `creationTimeOfOldestPendingJob()` + * liefert dann `null`, nicht 0 — ein Wert, der von einer erfundenen "leer" + * nicht zu unterscheiden wäre. Die Prüfung muss `null` als gesund lesen, ohne + * je selbst irgendetwas einzureihen. + */ +it('ist erfuellt, wenn noch nie etwas in einer Spur lag', function () { + expect(mailPaceCheck('delivery.mail_lanes_moving')->satisfied)->toBeTrue(); +}); + +it('names what breaks when a mail lane is stuck', function () { + expect(mailPaceCheck('delivery.mail_lanes_moving')->breaks)->not->toBe(''); +}); diff --git a/tests/Feature/ReadinessPageTest.php b/tests/Feature/ReadinessPageTest.php index bab407a..cba07f7 100644 --- a/tests/Feature/ReadinessPageTest.php +++ b/tests/Feature/ReadinessPageTest.php @@ -140,6 +140,7 @@ it('sends every check to the tab or page where its field actually lives', functi 'delivery.mailbox' => 'mail', 'delivery.mail_templates' => 'templates', 'delivery.inbound_mail_password' => 'services', + 'delivery.mail_lanes_moving' => 'mail-pace', 'security.console_restricted' => 'security', 'security.trusted_ranges_clean' => 'security', 'security.ports_loopback' => 'env',