Horizon fuer Live vorbereitet

- Supervisor bedient alle Queues der Jobs (high-priority, default, dhl-*),
  production mit 3 statt 10 Prozessen
- Redis-Queue mit after_commit: der Stripe-Paid-Pfad plant den
  Rechnungsnummer-Sync innerhalb der Transaktion ein
- /horizon nur fuer System-Admins (admin >= 4)
- horizon:snapshot alle 5 Minuten, queue:prune-failed nach 30 Tagen
- deploy.sh: QUEUE_WORKER (horizon | none), Anleitung Abschnitt F

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-10-07 10:32:26 +00:00
parent e1c6178bc7
commit 21daab259f
7 changed files with 95 additions and 25 deletions

View file

@ -98,6 +98,10 @@ class Kernel extends ConsoleKernel
// Cleanup old log files weekly (keeps logs for 30 days)
$schedule->command('logs:cleanup --days=30')->weekly()->sundays()->at('05:00');
// Horizon: Kennzahlen fuer das Dashboard, fehlgeschlagene Jobs nach 30 Tagen loeschen
$schedule->command('horizon:snapshot')->everyFiveMinutes();
$schedule->command('queue:prune-failed --hours=720')->dailyAt('05:15');
// DHL Tracking Update: Stündlich mit status-basierten Intervallen und Batch-API
$schedule->command('dhl:update-tracking --days=30 --send-emails')
->hourly()

View file

@ -27,10 +27,6 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return in_array(optional($user)->email, [
//
]);
});
Gate::define('viewHorizon', fn ($user = null): bool => (bool) $user?->isSySAdmin());
}
}

View file

@ -182,7 +182,7 @@ return [
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'queue' => ['high-priority', 'default', 'dhl-shipments', 'dhl-returns', 'dhl-cancellations', 'dhl-tracking'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
@ -198,7 +198,7 @@ return [
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'maxProcesses' => 3,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],

View file

@ -63,6 +63,8 @@ return [
'queue' => 'default',
'retry_after' => 90,
'block_for' => null,
// Jobs aus einer DB-Transaktion (z. B. Stripe-Paid-Pfad) erst nach dem Commit einstellen
'after_commit' => true,
],
],

View file

@ -8,7 +8,8 @@ von Hand per SSH aus und bricht ab, **bevor** es etwas verändert, wenn etwas ni
| | Wert |
|---|---|
| Server | Ploi, Benutzer `ploi`, PHP-FPM 8.4 (`php8.4-fpm`), Horizon als Ploi-Daemon |
| Server | Ploi, Benutzer `ploi`, PHP-FPM 8.4 (`php8.4-fpm`), Redis lokal |
| Queue | Horizon als Ploi-Daemon, `QUEUE_DRIVER=redis` (bis zur Umstellung F: `sync`) |
| Live-Verzeichnis | `/home/ploi/mivita.care` |
| Git-Server (Deploy liest hier) | `origin` = `ssh://git@forge.adametz.media:222/kadmin/mivita.git` |
| Release-Branch | `master` — Tags werden auf `master` gesetzt |
@ -75,15 +76,7 @@ sleep 2; ps -o pid,lstart,args -C php-fpm8.4 | head -3 # master-PID gleich,
Fragt `sudo` nach einem Passwort: beim Deploy `DEPLOY_FPM_RELOAD= bash dev/betrieb/deploy.sh …`,
das Skript wartet dann auf Enter, bis FPM in Ploi neu geladen ist.
**Horizon und Supervisor prüfen:**
```bash
systemctl is-enabled supervisor # enabled
php artisan horizon:status # running
grep -E '^(QUEUE_DRIVER|QUEUE_CONNECTION)=' .env # QUEUE_DRIVER=redis (config/queue.php liest QUEUE_DRIVER!)
```
In Ploi: Horizon als Daemon (`php artisan horizon`), **„Quick Deploy“ und Deploy-Webhook aus**.
In Ploi: **„Quick Deploy“ und Deploy-Webhook aus.** Horizon einrichten: Abschnitt F.
## B — Einmalig: Live-Verzeichnis an Ort und Stelle zum Checkout machen
@ -175,9 +168,9 @@ git push nas master --follow-tags # Spiegel auf dem NAS
| Schritt im Skript | Bei Fehler |
|---|---|
| Arbeitsverzeichnis sauber? Tag auf origin? Neue Migrationen ohne `--migrations`? | Abbruch, nichts verändert |
| `down` → Checkout → `RELEASE` → `composer install --no-dev` → `migrate` → Caches leeren → `horizon:terminate` + `queue:restart` → FPM-Reload → `up` | Seite bleibt im Wartungsmodus, Skript nennt den Rollback-Befehl |
| `down` → Checkout → `RELEASE` → `composer install --no-dev` → `migrate` → Caches leeren → `horizon:terminate` → FPM-Reload → `up` | Seite bleibt im Wartungsmodus, Skript nennt den Rollback-Befehl |
| `about --only=mivita` (Version) | — |
| `monitoring:queue-probe --wait=60` | **Exit 3**: live, aber Horizon holt keine Jobs ab — `horizon:status`, Daemon in Ploi prüfen |
| `monitoring:queue-probe --wait=60` (entfällt bei `QUEUE_WORKER="none"`) | **Exit 3**: live, aber Horizon holt keine Jobs ab — `horizon:status`, Daemon in Ploi prüfen |
Exit-Codes: `0` fertig · `1` abgebrochen · `2` falscher Aufruf · `3` kein Worker.
Option `--clear-schedule` setzt `withoutOverlapping`-Sperren zurück (nur wenn nötig).
@ -197,11 +190,40 @@ bash dev/betrieb/deploy.sh <vorheriger Tag>
Baut `vendor/` für den alten Tag neu auf. Nur bei Datenproblemen das DB-Backup einspielen.
## F — Einmalig: Horizon aktivieren (Umstellung von `QUEUE_DRIVER=sync`)
Bis Oktober 2026 lief Live mit `QUEUE_DRIVER=sync`: Jobs liefen im Request mit (Stripe-Webhook
wartete z. B. auf den Rechnungsnummer-Sync und SMTP). Mit Horizon laufen sie im Hintergrund.
Betroffen: `SyncStripeInvoiceReference`, Fehlermails aus `MyLog` (`MailLog`), `PaymentIncidentAlert`,
DHL-Jobs (nur bei `DHL_USE_QUEUE`).
Voraussetzung: Der Tag enthält die Horizon-Einstellungen (alle Queues inkl. `high-priority`/`dhl-*`,
`after_commit` an der Redis-Verbindung, Gate für System-Admins, `horizon:snapshot`).
```bash
cd /home/ploi/mivita.care
redis-cli ping # PONG
grep -E '^(APP_ENV|QUEUE_DRIVER|REDIS_HOST)=' .env # APP_ENV=production
```
1. In Ploi → Site → **Daemons**: Befehl `php8.4 artisan horizon`, Verzeichnis `/home/ploi/mivita.care`,
Benutzer `ploi`, 1 Prozess. Prüfen: `php artisan horizon:status` → `running`.
2. In `.env`: `QUEUE_DRIVER=redis` (`config/queue.php` liest `QUEUE_DRIVER`, nicht `QUEUE_CONNECTION`).
Ohne Config-Cache wirkt das sofort für Web-Requests; Horizon neu starten: `php artisan horizon:terminate`.
3. `php artisan monitoring:queue-probe --wait=60` → „Queue-Worker läuft“.
4. `/horizon` als System-Admin (admin ≥ 4) öffnen: Supervisor aktiv, keine Failed Jobs.
5. Nach der nächsten Stripe-Zahlung: Rechnungsnummer in den PaymentIntent-Metadaten.
Zurück: `QUEUE_DRIVER=sync` in `.env`, in `deploy.sh` `QUEUE_WORKER="none"`. Liegen gebliebene Jobs
vorher mit `php artisan queue:work redis --stop-when-empty` abarbeiten.
## Fallstricke
- **FPM-Restart statt Reload** → 502 für Sekunden.
- **Horizon nicht neu gestartet** → Worker arbeiten mit altem Code (neue Job-Klassen unbekannt).
Das Skript ruft `horizon:terminate`; die Worker-Prüfung am Ende fängt einen nicht zurückgekehrten Horizon ab.
- **Neue Queue** (`onQueue('…')`) → in `config/horizon.php` unter `queue` eintragen, sonst bleibt der Job
liegen. Ein Test (`HorizonSetupTest`) prüft das für `app/Jobs`.
- **Nie `queue:retry all`**: alte Jobs tragen den Klassenstand von damals. Erst `queue:failed`, dann gezielt.
- **Push ohne Tags** → `deploy.sh` findet den Tag nicht. Immer `--follow-tags`.
- **`.gitignore` enthält `Icon`** (macOS). Bei `core.ignorecase=true` trifft das auch Ordner `icon/` —

View file

@ -20,7 +20,7 @@
# entfernt kurz den Socket, Nginx liefert 502.
#
# Exit-Codes: 0 fertig, 1 abgebrochen (siehe Ausgabe), 2 falscher Aufruf,
# 3 live, aber Horizon hat den Test-Job nicht abgeholt.
# 3 live, aber kein Worker hat den Test-Job abgeholt.
#
# Rollback: dasselbe Skript mit dem vorherigen Tag.
#
@ -37,7 +37,7 @@ set -euo pipefail
DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad dieses Skripts im Repository
PHP_FPM_SERVICE="php8.4-fpm" # systemd-Name des FPM-Dienstes
ABOUT_SECTION="mivita" # Abschnitt aus AboutCommand::add (AppServiceProvider)
QUEUE_PROBE=1 # 1 = monitoring:queue-probe am Ende
QUEUE_WORKER="horizon" # horizon = Ploi-Daemon „php artisan horizon“, none = QUEUE_DRIVER=sync
# -----------------------------------------------------------------------------
# Git ersetzt diese Datei beim Checkout. Bash liest Skripte stückweise, deshalb
@ -136,11 +136,12 @@ if [[ "$clear_schedule" -eq 1 ]]; then
php artisan schedule:clear-cache
fi
step "Horizon und PHP-FPM"
step "Worker und PHP-FPM"
# Horizon hält den alten Code im Speicher; terminate beendet es nach den
# laufenden Jobs, Supervisor (Ploi-Daemon) startet es neu.
php artisan horizon:terminate
php artisan queue:restart
if [[ "$QUEUE_WORKER" == "horizon" ]]; then
php artisan horizon:terminate
fi
default_fpm_reload="echo '' | sudo -S service $PHP_FPM_SERVICE reload"
fpm_reload="${DEPLOY_FPM_RELOAD-$default_fpm_reload}"
if [[ -n "$fpm_reload" ]]; then
@ -161,7 +162,7 @@ trap - ERR
# Läuft Horizon wieder? `horizon:terminate` oben beendet es; neu starten muss
# es Supervisor. Läuft Supervisor nicht, steht die Queue still, ohne dass es
# jemand merkt.
if [[ "$QUEUE_PROBE" -eq 1 ]]; then
if [[ "$QUEUE_WORKER" != "none" ]]; then
step "Queue-Worker"
if ! php artisan monitoring:queue-probe --wait=60; then
echo

View file

@ -0,0 +1,45 @@
<?php
use App\User;
use Illuminate\Support\Facades\Gate;
use Tests\TestCase;
uses(TestCase::class);
/**
* Horizon auf Live (QUEUE_DRIVER=redis): jede Queue, auf die ein Job sich
* legt, muss ein Supervisor bedienen — sonst bleibt der Job still liegen.
*/
test('horizon serves every queue the jobs are pushed to', function () {
$queues = collect(glob(app_path('Jobs/*.php')))
->flatMap(function (string $file): array {
preg_match_all("/onQueue\\('([^']+)'\\)/", file_get_contents($file), $matches);
return $matches[1];
})
->push('default')
->unique();
$served = collect(config('horizon.defaults'))->pluck('queue')->flatten();
expect($queues->diff($served)->values()->all())->toBe([]);
});
test('jobs dispatched inside a transaction wait for the commit', function () {
expect(config('queue.connections.redis.after_commit'))->toBeTrue();
});
test('only system admins may open the horizon dashboard', function (int $admin, bool $allowed) {
$user = new User;
$user->admin = $admin;
expect(Gate::forUser($user)->allows('viewHorizon'))->toBe($allowed);
})->with([
'system admin' => [4, true],
'super admin' => [3, false],
'consultant' => [0, false],
]);
test('guests may not open the horizon dashboard', function () {
expect(Gate::allows('viewHorizon'))->toBeFalse();
});