From 6ca3e6ef4ab77ff9ddffb406624a296d0e352b63 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 14:32:09 +0200 Subject: [PATCH] Notice when nobody is picking up the queue Co-Authored-By: Claude Opus 5 --- .../Jobs/RecordProvisioningHeartbeat.php | 39 ++++++++++ app/Support/Readiness.php | 2 + app/Support/Readiness/OperationChecks.php | 78 +++++++++++++++++++ lang/de/readiness.php | 7 ++ lang/en/readiness.php | 7 ++ routes/console.php | 18 +++++ tests/Feature/Readiness/HeartbeatTest.php | 50 ++++++++++++ 7 files changed, 201 insertions(+) create mode 100644 app/Provisioning/Jobs/RecordProvisioningHeartbeat.php create mode 100644 app/Support/Readiness/OperationChecks.php create mode 100644 tests/Feature/Readiness/HeartbeatTest.php diff --git a/app/Provisioning/Jobs/RecordProvisioningHeartbeat.php b/app/Provisioning/Jobs/RecordProvisioningHeartbeat.php new file mode 100644 index 0000000..cdebc6d --- /dev/null +++ b/app/Provisioning/Jobs/RecordProvisioningHeartbeat.php @@ -0,0 +1,39 @@ +onQueue('provisioning'); + } + + public function handle(): void + { + Settings::set('heartbeat.queue_provisioning', now()->toIso8601String()); + } +} diff --git a/app/Support/Readiness.php b/app/Support/Readiness.php index ce6822f..fc68d1b 100644 --- a/app/Support/Readiness.php +++ b/app/Support/Readiness.php @@ -6,6 +6,7 @@ use App\Support\Readiness\BillingChecks; use App\Support\Readiness\Check; use App\Support\Readiness\DeliveryChecks; use App\Support\Readiness\OnboardingChecks; +use App\Support\Readiness\OperationChecks; use App\Support\Readiness\ProvisioningChecks; /** @@ -36,6 +37,7 @@ final class Readiness ...OnboardingChecks::all(), ...ProvisioningChecks::all(), ...DeliveryChecks::all(), + ...OperationChecks::all(), ]; } diff --git a/app/Support/Readiness/OperationChecks.php b/app/Support/Readiness/OperationChecks.php new file mode 100644 index 0000000..8d01d4e --- /dev/null +++ b/app/Support/Readiness/OperationChecks.php @@ -0,0 +1,78 @@ +everyMinute()` schedule — a deploy restarting the container, a GC + * pause, one skipped tick — without also covering up a worker that has + * actually stopped. + */ + private const STALE_AFTER_MINUTES = 5; + + /** @return array */ + public static function all(): array + { + return [ + new Check( + key: 'operation.scheduler', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.operation.scheduler'), + breaks: __('readiness.operation.scheduler_breaks'), + tab: 'integrations', + satisfied: self::isFresh('heartbeat.scheduler'), + ), + new Check( + key: 'operation.queue_provisioning', + group: self::GROUP, + severity: Check::SEVERITY_BLOCKING, + label: __('readiness.operation.queue_provisioning'), + breaks: __('readiness.operation.queue_provisioning_breaks'), + tab: 'integrations', + // Written by RecordProvisioningHeartbeat, which the scheduler + // enqueues but only the worker actually runs. A fresh + // timestamp here is proof of a running worker, not just of a + // running scheduler. + satisfied: self::isFresh('heartbeat.queue_provisioning'), + ), + ]; + } + + private static function isFresh(string $key): bool + { + $recordedAt = Settings::get($key); + + if (blank($recordedAt)) { + return false; + } + + // Not diffInMinutes(): App\Models\Incident::durationMinutes() already + // documents that it is SIGNED, and `now()->diffInMinutes($recordedAt)` + // with $recordedAt in the past returns a NEGATIVE number — which + // would compare as "less than 5" for a heartbeat six hours stale, and + // report a dead worker as healthy. A direct threshold comparison has + // no sign to get backwards. + return Carbon::parse($recordedAt)->greaterThan(now()->subMinutes(self::STALE_AFTER_MINUTES)); + } +} diff --git a/lang/de/readiness.php b/lang/de/readiness.php index b3fb0dc..f6be55c 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -62,4 +62,11 @@ return [ '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.', ], + + 'operation' => [ + 'scheduler' => 'Zeitplaner läuft', + 'scheduler_breaks' => 'Ohne ihn läuft kein einziger Zeitplan aus routes/console.php — nicht die Rechnungsarchivierung, nicht die Vertragsprüfung, nicht dieser Herzschlag selbst. Eine bezahlte Bestellung wartet auf TickProvisioning, das nie angestoßen wird, und die Konsole zeigt dieselbe Ruhe wie an einem Tag ohne Bestellungen.', + 'queue_provisioning' => 'Bereitstellungs-Worker läuft', + 'queue_provisioning_breaks' => 'Der Zeitplaner kann laufen und Aufträge auf die provisioning-Warteschlange stellen, ohne dass sie je jemand abholt. Eine bezahlte Bestellung bleibt dann liegen — kein Fehler, keine Meldung, einfach nichts, bis jemand von Hand nachsieht.', + ], ]; diff --git a/lang/en/readiness.php b/lang/en/readiness.php index ebd716a..a6a37a2 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -62,4 +62,11 @@ return [ '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.', ], + + 'operation' => [ + 'scheduler' => 'Scheduler is running', + 'scheduler_breaks' => 'Without it, not a single schedule in routes/console.php runs — not invoice archiving, not the contract check, not this heartbeat itself. A paid order waits on TickProvisioning, which is never invoked, and the console shows the same calm as a day with no orders at all.', + 'queue_provisioning' => 'Provisioning worker is running', + 'queue_provisioning_breaks' => 'The scheduler can be running and putting jobs on the provisioning queue without anyone ever picking them up. A paid order then just sits there — no error, no notice, simply nothing, until someone happens to look.', + ], ]; diff --git a/routes/console.php b/routes/console.php index 0137430..b21fd77 100644 --- a/routes/console.php +++ b/routes/console.php @@ -4,8 +4,10 @@ use App\Console\TickProvisioning; use App\Models\StripePendingEvent; use App\Provisioning\Jobs\CollectInstanceTraffic; use App\Provisioning\Jobs\PingHosts; +use App\Provisioning\Jobs\RecordProvisioningHeartbeat; use App\Provisioning\Jobs\SyncMonitoringStatus; use App\Provisioning\Jobs\SyncVpnPeers; +use App\Support\Settings; use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schedule; @@ -20,6 +22,22 @@ Schedule::call(fn () => app(TickProvisioning::class)()) ->name('provisioning-tick') ->withoutOverlapping(); +// Two signs of life for the readiness page (App\Support\Readiness\ +// OperationChecks). Written through Settings, not the cache: a heartbeat that +// vanishes on a Redis restart would report an outage that never happened. +// +// Two of them, because the scheduler and the provisioning queue worker fail +// independently. The dangerous case is the second one: the scheduler runs and +// enqueues jobs (this one included) that nobody ever picks up. A heartbeat +// only the scheduler writes would call that state healthy. +Schedule::call(fn () => Settings::set('heartbeat.scheduler', now()->toIso8601String())) + ->everyMinute() + ->name('heartbeat-scheduler'); + +Schedule::job(new RecordProvisioningHeartbeat) + ->everyMinute() + ->name('heartbeat-provisioning'); + // Refresh the VPN peer state (handshakes, traffic) so the console does not go // stale while nobody has it open. The job itself runs on the provisioning // queue — that worker owns wg0. diff --git a/tests/Feature/Readiness/HeartbeatTest.php b/tests/Feature/Readiness/HeartbeatTest.php new file mode 100644 index 0000000..5635697 --- /dev/null +++ b/tests/Feature/Readiness/HeartbeatTest.php @@ -0,0 +1,50 @@ +firstWhere('key', $key); +} + +/** + * „Ohne Worker passiert schlicht nichts, ohne Fehlermeldung." Genau dieser + * Zustand war in der Konsole bisher nicht von „alles ruhig" zu unterscheiden. + * + * Zwei getrennte Herzschläge, weil Zeitplaner und Warteschlangen-Worker + * getrennt ausfallen: der Zeitplaner kann laufen und Aufträge einstellen, die + * niemand abholt. + */ +it('reports a missing heartbeat as not satisfied', function () { + expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse(); +}); + +it('accepts a fresh heartbeat', function () { + Settings::set('heartbeat.scheduler', now()->toIso8601String()); + + expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue(); +}); + +it('rejects a heartbeat older than five minutes', function () { + Settings::set('heartbeat.scheduler', now()->subMinutes(6)->toIso8601String()); + + expect(operationCheck('operation.scheduler')->satisfied)->toBeFalse(); +}); + +it('proves the worker, not just the scheduler', function () { + // Der Zeitplaner läuft, der Bereitstellungs-Worker nicht: der eine + // Herzschlag ist frisch, der andere nicht. + Settings::set('heartbeat.scheduler', now()->toIso8601String()); + + expect(operationCheck('operation.scheduler')->satisfied)->toBeTrue(); + expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeFalse(); +}); + +it('records the worker heartbeat when the job runs', function () { + (new RecordProvisioningHeartbeat)->handle(); + + expect(operationCheck('operation.queue_provisioning')->satisfied)->toBeTrue(); +});