Troubleshooting
Diagnose- und Notfall-Anleitungen fuer nginx, PHP-FPM und PostgreSQL auf dem Forge-Server.
PHP-FPM
Symptome: nginx antwortet nicht / 502 Bad Gateway
Wenn nginx keine Requests mehr annimmt, obwohl der Server CPU-seitig nicht ausgelastet ist, liegt das fast immer an PHP-FPM Worker Exhaustion: Alle Worker haengen in langsamen DB-Queries und es bleibt keiner fuer neue Requests.
graph TD
A["Browser-Request / Livewire wire:poll"] --> B[nginx]
B --> C{PHP-FPM Worker frei?}
C -->|Ja| D["Worker verarbeitet Request"]
C -->|Nein| E["nginx queued Request"]
E --> F["Timeout → 502 Bad Gateway"]
D --> G["DB-Query an PostgreSQL"]
G -->|"DB unter Last (5-8s statt <100ms)"| H["Worker blockiert"]
H -->|"Alle 20 Worker blockiert"| C
Diagnose
# 1. FPM-Status pruefen (active vs. idle Workers)
sudo systemctl status php8.4-fpm
# Ausgabe: "Processes active: 20, idle: 0" → Problem!
# 2. Aktive FPM-Worker zaehlen
ps aux | grep php-fpm | grep -c pool
# 3. Laufzeit der Worker pruefen (Worker die Stunden laufen sind STUCK)
ps -eo pid,etime,args | grep "php-fpm: pool" | grep -v grep
# Normal: Sekunden bis Minuten. Kritisch: Worker > 1 Stunde
# 4. Pool-Konfiguration pruefen
grep -E "^pm\." /etc/php/8.4/fpm/pool.d/www.conf
# 5. nginx Error-Log auf Upstream-Timeouts pruefen
tail -50 /var/log/nginx/error.log | grep -i "upstream\|fpm\|502"
Ursachen-Kette
| Schicht | Problem | Diagnose-Befehl |
|---|---|---|
| PostgreSQL | Schwere Queries (Rollups, Explore) belasten DB | pg_stat_activity (siehe unten) |
| Queue Workers | 10+ ai-detection Worker + Scheduled Commands lasten DB aus | ps aux | grep artisan |
| PHP-FPM | Worker warten auf langsame DB-Queries | systemctl status php8.4-fpm |
| nginx | Keine freien FPM-Worker → queued → 502 | tail /var/log/nginx/error.log |
Sofortmassnahmen
# PHP-FPM neustarten (killt haengende Worker, schnellste Loesung)
sudo systemctl restart php8.4-fpm
# Oder: nginx + FPM zusammen neustarten
sudo systemctl restart nginx php8.4-fpm
FPM Pool-Konfiguration optimieren
Location: /etc/php/8.4/fpm/pool.d/www.conf
| Setting | Default (Forge) | Empfohlen (48 Kerne) | Beschreibung |
|---|---|---|---|
pm | dynamic | dynamic | Pool-Modus |
pm.max_children | 20 | 150 | Max. gleichzeitige Worker |
pm.start_servers | 2 | 30 | Worker beim Start |
pm.min_spare_servers | 1 | 15 | Mindest-Idle-Worker |
pm.max_spare_servers | 3 | 50 | Max. Idle-Worker |
pm.max_requests | 0 (unlimited) | 1000 | Requests pro Worker vor Respawn (Memory-Leak-Schutz) |
# Anpassen
sudo nano /etc/php/8.4/fpm/pool.d/www.conf
# Aenderungen anwenden
sudo systemctl restart php8.4-fpm
# Verifizieren
sudo systemctl status php8.4-fpm
Faustregeln:
max_children= Verfuegbarer RAM (GB) / 0.1 GB (bei ~100 MB pro Worker)- Auf einem 48-Kern-Server mit 64 GB RAM:
max_children = 150-300 pm.max_requests = 1000verhindert Memory-Bloat durch langlebige Worker
Alte PHP-Versionen bereinigen
Forge installiert bei Upgrades die neue PHP-Version, stoppt aber nicht immer die alte:
# Pruefen ob mehrere PHP-FPM Master laufen
ps -eo pid,etime,args | grep "php-fpm: master"
# Beispiel-Output:
# 3862716 1-03:50:48 php-fpm: master process (/etc/php/8.3/fpm/php-fpm.conf) ← ALT
# 3862728 1-03:50:48 php-fpm: master process (/etc/php/8.4/fpm/php-fpm.conf) ← AKTIV
# Alte Version stoppen und deaktivieren
sudo systemctl stop php8.3-fpm
sudo systemctl disable php8.3-fpm
nginx
Symptome: Seite laedt nicht, aber Server reagiert via SSH
# 1. nginx-Status pruefen
sudo systemctl status nginx
# 2. Error-Log pruefen
tail -100 /var/log/nginx/error.log
# 3. Offene Verbindungen zaehlen
ss -s
# oder spezifisch:
ss -tnp | grep :443 | wc -l
# 4. nginx-Konfiguration testen
sudo nginx -t
Haeufige Fehler im Error-Log
| Fehler | Ursache | Loesung |
|---|---|---|
connect() to unix:/run/php/php-fpm.sock failed (11: Resource temporarily unavailable) | FPM-Worker erschoepft | max_children erhoehen |
upstream timed out (110: Connection timed out) | FPM-Worker haengt in DB-Query | PostgreSQL-Last reduzieren |
no live upstreams | PHP-FPM nicht erreichbar | systemctl restart php8.4-fpm |
worker_connections are not enough | nginx-Limit erreicht | worker_connections in nginx.conf erhoehen |
nginx Worker-Konfiguration
Location: /etc/nginx/nginx.conf
# Empfohlen fuer 48-Kern-Server:
worker_processes auto; # = Anzahl CPU-Kerne
worker_connections 4096; # Pro Worker (Default: 768)
nginx-Neustart
# Graceful Reload (bestehende Connections laufen aus)
sudo systemctl reload nginx
# Hard Restart (trennt alle Connections sofort)
sudo systemctl restart nginx
PostgreSQL
Symptome: Seite langsam, FPM-Worker haengen
Wenn Seiten 5-10+ Sekunden laden oder gar nicht antworten, sind oft schwere PostgreSQL-Queries die Ursache — ausgeloest durch Scheduled Commands oder Queue-Worker.
Aktive Queries anzeigen
-- Alle aktiven Queries mit Laufzeit (laengste zuerst)
SELECT
pid,
now() - pg_stat_activity.query_start AS duration,
state,
wait_event_type,
LEFT(query, 200) AS query
FROM pg_stat_activity
WHERE state != 'idle'
AND datname = 'postbox_db3_prod'
ORDER BY duration DESC;
# Als One-Liner via psql
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT pid, now() - query_start AS duration, state, LEFT(query, 200) AS query
FROM pg_stat_activity WHERE state != 'idle' AND query NOT LIKE '%pg_stat%'
ORDER BY duration DESC LIMIT 20;
"
Oder per Artisan (DB-Monitoring)
# Top 50 langsamste Queries erfassen
php artisan db:snapshot --slow-queries --top=50
Connections zaehlen
-- Gesamte Connection-Uebersicht
SELECT
state,
COUNT(*) as count,
COALESCE(usename, 'system') as user
FROM pg_stat_activity
GROUP BY state, usename
ORDER BY count DESC;
-- Maximale Connections pruefen
SHOW max_connections;
-- Default: 200. Bei Exhaustion: "FATAL: too many connections"
Blockierte Queries identifizieren
-- Queries die auf Locks warten
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype
AND kl.database IS NOT DISTINCT FROM bl.database
AND kl.relation IS NOT DISTINCT FROM bl.relation
AND kl.page IS NOT DISTINCT FROM bl.page
AND kl.tuple IS NOT DISTINCT FROM bl.tuple
AND kl.pid != bl.pid
JOIN pg_stat_activity blocking ON blocking.pid = kl.pid
WHERE NOT bl.granted;
Parallel Workers erkennen
PostgreSQL startet bei grossen Queries automatisch Parallel Workers. Diese erscheinen in top als separate Prozesse mit 100% CPU:
-- Parallel Workers und ihre Parent-Queries identifizieren
SELECT
pid,
leader_pid,
state,
LEFT(query, 200) AS query,
now() - query_start AS duration
FROM pg_stat_activity
WHERE leader_pid IS NOT NULL
ORDER BY leader_pid, pid;
Schwere Queries abbrechen
# Einzelne Query beenden (graceful)
SELECT pg_cancel_backend(<PID>);
# Einzelne Query beenden (force — trennt Connection)
SELECT pg_terminate_backend(<PID>);
# ALLE Queries die laenger als 3 Stunden laufen beenden
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '3 hours'
AND query NOT LIKE '%pg_stat%';
# ALLE Queries die laenger als X Minuten laufen beenden
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '30 minutes'
AND query NOT LIKE '%pg_stat%';
Haeufige Last-Verursacher (Scheduled Commands)
Diese Commands erzeugen die schwersten DB-Queries. Wenn mehrere gleichzeitig laufen, kann die DB ueberlastet werden:
| Command | Schedule (UTC) | Typische Laufzeit | DB-Impact |
|---|---|---|---|
dashboard:rollup-daily-metrics | 00:45 | ~2 Stunden | Sehr hoch (DISTINCT ON, SUM, JOINs) |
dashboard:rollup-leaderboards | 02:45 | 10-30 Min | Hoch |
dashboard:rollup-global-leaderboards | 04:45 | 10-30 Min | Hoch |
scores:calculate | 05:45 | 15-45 Min | Mittel-Hoch |
explore:calculate --type=all | 06:00 | 15-45 Min | Mittel-Hoch |
social:scrape-daily-followers | 09:00, 11:00, ... | 30-60 Min | Mittel (viele kleine Queries) |
social:queue-daily-instagram | 08:00, 10:00, ... | 5-15 Min | Niedrig-Mittel |
# Pruefen welche Commands aktuell laufen
ps aux | grep artisan | grep -v grep | grep -v "queue:work\|pulse:\|reverb:\|nightwatch:"
Queue Worker DB-Last
Queue Worker halten permanente DB-Connections. Bei vielen Workern summiert sich die Last:
# Alle Queue Worker zaehlen
ps aux | grep "queue:work" | grep -v grep | wc -l
# Worker nach Queue gruppiert zaehlen
ps aux | grep "queue:work" | grep -v grep | awk -F'--queue=' '{print $2}' | sort | uniq -c | sort -rn
Typische Worker-Verteilung (Postbox):
| Queue | Anzahl Worker | DB-Impact |
|---|---|---|
ai-detection | 10 | Hoch (Gemini + DB pro Job) |
imports-youtube | 1 | Niedrig |
imports-youtube-priority | 1 | Niedrig |
imports-youtube-video | 1 | Niedrig |
imports-youtube-video-priority | 1 | Niedrig |
youtube-related-channels | 1 | Niedrig |
instagram-related-profiles | 1 | Niedrig-Mittel |
cross-platform-related | 1 | Niedrig-Mittel |
emails | 1 | Minimal |
matomo | 1 | Minimal |
default | 1 | Niedrig |
Tabellen-Groesse pruefen
# Top 20 groesste Tabellen
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT relname,
pg_size_pretty(pg_total_relation_size(relid)) as total,
pg_size_pretty(pg_relation_size(relid)) as data,
n_live_tup as live_rows,
n_dead_tup as dead_rows
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 20;
"
Dead Tuples und Autovacuum
# Tabellen mit vielen Dead Tuples (brauchen VACUUM)
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT relname, n_dead_tup, last_vacuum, last_autovacuum,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) as dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
"
# Manuelles VACUUM (non-blocking, markiert Platz als wiederverwendbar)
VACUUM ANALYZE <table_name>;
# VACUUM FULL (blocking, gibt Platz ans OS zurueck — nur bei grossem Bloat)
VACUUM FULL <table_name>;
Index-Nutzung pruefen
-- Tabellen mit schlechter Index-Nutzung (viele Seq-Scans)
SELECT relname,
seq_scan,
idx_scan,
ROUND(100.0 * idx_scan / NULLIF(seq_scan + idx_scan, 0), 1) as idx_pct
FROM pg_stat_user_tables
WHERE seq_scan + idx_scan > 1000
ORDER BY seq_scan DESC
LIMIT 20;
Kompletter Diagnose-Workflow
Bei "Seite laedt nicht" oder "502 Bad Gateway" systematisch vorgehen:
graph TD
A["502 / Seite laedt nicht"] --> B["1. PHP-FPM Status"]
B --> C{"active: 20, idle: 0?"}
C -->|Ja| D["FPM Worker exhausted"]
C -->|Nein| E["nginx Error-Log pruefen"]
D --> F["2. Worker-Laufzeit pruefen"]
F --> G{"Worker > 1h?"}
G -->|Ja| H["Worker haengen in DB-Queries"]
G -->|Nein| I["Kurzfristige Last-Spitze"]
H --> J["3. pg_stat_activity pruefen"]
J --> K{"Schwere Queries aktiv?"}
K -->|Ja| L["4. Verursacher identifizieren"]
K -->|Nein| M["Connection-Pool oder Lock-Problem"]
L --> N["ps aux | grep artisan"]
N --> O["Scheduled Command oder Queue Worker"]
O --> P["Sofortmassnahmen"]
P --> P1["sudo systemctl restart php8.4-fpm"]
P --> P2["max_children erhoehen"]
P --> P3["Ggf. schwere Queries abbrechen"]
I --> P1
Schritt-fuer-Schritt Checkliste
# 1. PHP-FPM Status (idle Workers?)
sudo systemctl status php8.4-fpm
# 2. FPM Worker Laufzeiten (> 1h = stuck)
ps -eo pid,etime,args | grep "php-fpm: pool" | grep -v grep
# 3. Aktive DB-Queries (was blockiert die Worker?)
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT pid, now() - query_start AS duration, state, LEFT(query, 200) AS query
FROM pg_stat_activity WHERE state != 'idle' AND query NOT LIKE '%pg_stat%'
ORDER BY duration DESC LIMIT 20;
"
# 4. Laufende Artisan-Commands (Scheduled Jobs?)
ps aux | grep artisan | grep -v grep | grep -v "queue:work\|pulse:\|reverb:\|nightwatch:"
# 5. Queue Worker zaehlen
ps aux | grep "queue:work" | grep -v grep | wc -l
# 6. nginx Error-Log
tail -50 /var/log/nginx/error.log
# 7. Connections-Uebersicht
sudo -u postgres psql -d postbox_db3_prod -c "
SELECT state, COUNT(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC;
"
Sofortmassnahmen (Eskalationsstufen)
| Stufe | Aktion | Befehl | Auswirkung |
|---|---|---|---|
| 1 | FPM neustarten | sudo systemctl restart php8.4-fpm | Alle laufenden Requests abgebrochen |
| 2 | nginx + FPM neustarten | sudo systemctl restart nginx php8.4-fpm | Alle Connections getrennt |
| 3 | Schwere Queries killen | SELECT pg_terminate_backend(pid) ... | Query wird abgebrochen, Job schlaegt fehl |
| 4 | Scheduled Command stoppen | kill <PID> des Artisan-Prozesses | Command wird abgebrochen |
| 5 | Queue Worker neustarten | php artisan queue:restart | Graceful Restart aller Worker |
Praeventive Massnahmen
PHP-FPM: Ausreichend Worker konfigurieren
Siehe FPM Pool-Konfiguration optimieren weiter oben.
PostgreSQL: Statement Timeout setzen
Verhindert, dass einzelne Queries die DB stundenlang blockieren:
-- Global: Queries nach 5 Minuten abbrechen
ALTER DATABASE postbox_db3_prod SET statement_timeout = '300s';
-- Oder nur fuer den web-User (FPM-Requests)
ALTER ROLE forge SET statement_timeout = '60s';
-- Queue-Worker-Queries brauchen laenger:
-- → Statement Timeout nur fuer FPM sinnvoll, nicht global
PostgreSQL: Connection Limits
-- Maximale Connections pruefen
SHOW max_connections; -- Default: 200
-- Aktuelle Nutzung
SELECT COUNT(*) FROM pg_stat_activity;
-- Per-User-Limit setzen (optional)
ALTER ROLE forge CONNECTION LIMIT 100;
Monitoring-Endpunkt
Der Health-Endpunkt /up_system prueft automatisch DB, Queue und Cron-Heartbeats:
curl -s -H "X-Health-Token: <token>" https://app.postbox.so/up_system
Location: app/Http/Controllers/SystemHealthController.php
PHP Memory-Limit: OOM bei Artisan Commands
Hintergrund
Der Server hat ein PHP memory_limit von 128 MB. Bei ~1M Social Profiles, Millionen Daily Metrics und Hunderttausenden YouTube Videos koennen Artisan Commands leicht an diese Grenze stossen.
Haeufige OOM-Ursachen
| Anti-Pattern | Warum problematisch | Sichere Alternative |
|---|---|---|
$query->get() | Laedt alle Rows als Eloquent-Modelle (~200 Bytes/Stueck) | chunkById(1000, ...) oder cursor() |
$query->pluck('id')->all() | Laedt alle IDs in ein Array | chunkById() mit Callback, oder Subquery |
collect(DB::select(...))->pluck() | Materialisiert grosses Resultset in PHP | Temp-Table + JOIN, oder CTE |
$localDisk->allFiles() | Laedt alle Dateipfade (820K+) in Array | RecursiveDirectoryIterator |
$array[] = ... in chunkById | Akkumuliert Daten ueber alle Chunks | Pro Chunk verarbeiten und freigeben |
| Fehlender Date-Filter | Laedt historische Daten statt aktuellem Fenster | Immer WHERE date >= ? hinzufuegen |
Diagnose
# Memory-Limit pruefen
php -i | grep memory_limit
# Artisan Command mit Memory-Tracking ausfuehren
php -d memory_limit=128M artisan <command> 2>&1
# Memory-Peak eines laufenden Commands beobachten
watch -n1 'ps -o pid,rss,command -p $(pgrep -f "artisan <command>")'
Praeventive Patterns (Pflicht bei neuen Commands)
DB::disableQueryLog()— Global inAppServiceProvider::boot()fuer alle Console-Commands aktiv. Kein manuelles Disablen noetig.chunkById(N)stattget()— Fuer alle Queries ueber Tabellen mit >10K Rows.cursor()fuer Read-Only — Wenn Rows nur einmal gelesen werden (kein Update noetig).gc_collect_cycles()— Nach jedem Chunk bei Eloquent-Modellen (zirkulaere Referenzen).toBase()fuer leichtgewichtige Queries — GibtstdClassstatt Eloquent-Modell zurueck (~10x weniger Memory).- Temp-Tables fuer grosse ID-Listen — Statt 100K+ IDs in PHP-Array:
CREATE TEMP TABLE, dann JOIN. unset($var)fuer grosse Variablen — Explizit freigeben wenn nicht mehr benoetigt.select(['id', 'name'])— Nur benoetigte Spalten laden, nieSELECT *bei grossen Tabellen.
Referenz: Durchgefuehrte Fixes (2026-03-23)
Alle 99+ Commands wurden in zwei Deep Audits auf OOM-Risiken geprueft. 15 Commands/Services wurden gefixt:
| Command/Service | Problem | Fix |
|---|---|---|
ExploreMetricsCalculator | Fehlender Date-Filter (365x zu viele Rows) | Exakter (id, date) IN (...) Lookup |
AggregateVideoStatsTracking | 100K+ Video-IDs im Array | Temp-Table + JOIN |
RecalculateRelatedScores | 448K Profile-IDs via pluck() | chunkById(1000) |
ConsolidateTags | 1M+ Profiles via get() | cursor() + SQL-Filter |
VerifyR2Storage | 820K Dateipfade via allFiles() | RecursiveDirectoryIterator |
IndexNowSubmitUpdated | 60K URLs akkumuliert | Chunk-weiser Submit |
QueueDailyInstagramScrapes | 200K Profile-IDs via pluck() | count() (IDs nicht benoetigt) |
RetryFailedCollectorJobs | Alle Failed Jobs via get() | SQL-Aggregation + chunkById() |
RetryFailedQueueJobs | Alle Job-IDs via pluck() | chunkById(500) |
PruneWatchersToMax | Orphans mit eager-loaded Images | chunkById(100) + GC |
ServerRollupHourly | get() im While-Loop (8760x) | SQL-Aggregation |
UpdateKeywordStopwords | pluck()->all() | cursor()->each() |
AggregatePublishingStats | Eloquent + get() + groupBy() | Cursor + leichtgewichtige Arrays |
RunNightlyPipeline | Trending-Job-Array ohne GC | gc_collect_cycles() pro Chunk |
Bekannte Probleme
FPM-Worker haengen waehrend naechtlicher Rollups (00:45-07:00 UTC)
Problem: Die naechtlichen Dashboard-Rollups (dashboard:rollup-daily-metrics, ~2h) belasten PostgreSQL stark. Livewire wire:poll Requests, die waehrenddessen eintreffen, brauchen 5-10s statt <100ms. Bei nur 20 FPM-Workern werden alle Worker blockiert.
Loesung:
pm.max_childrenauf mindestens 150 erhoehen- Langfristig: Rollup-Queries optimieren (Indizes, Partitioning)
YouTube-Worker mit --tries=3 statt --tries=0
Problem: Forge-Worker fuer YouTube-Queues laufen mit --tries=3. Jeder release() Aufruf (z.B. bei Quota-Pause) zaehlt als Versuch. Nach 3 Releases → MaxAttemptsExceededException.
Loesung: Worker-Config in Forge auf --tries=0 aendern (Jobs steuern Retries selbst via $tries, $maxExceptions, retryUntil()).
# RICHTIG (Jobs steuern Retries selbst):
php8.4 artisan queue:work database --tries=0 --queue=imports-youtube
# FALSCH (release() zaehlt als Versuch):
php8.4 artisan queue:work database --tries=3 --queue=imports-youtube
Doppelte PHP-FPM Master-Prozesse nach PHP-Upgrade
Problem: Nach einem PHP-Versions-Upgrade (z.B. 8.3 → 8.4) laeuft der alte FPM-Master-Prozess weiter und haelt Ressourcen.
Diagnose:
ps -eo pid,etime,args | grep "php-fpm: master"
Loesung:
sudo systemctl stop php8.3-fpm
sudo systemctl disable php8.3-fpm
Aggregations-Lücken nach DB-Restart
Symptome
Auf /admin/youtube zeigt die "Aufschlüsselung"-Tabelle Lücken: Daten enden vor dem aktuellen Tag, der Status-Badge sagt "Verzögert". Im Application-Log:
production.ERROR: AggregateVideoStatsTracking: Aggregation failed for date
SQLSTATE[08006] [7] connection to server failed: FATAL: the database system is shutting down
Ursache
Der Command youtube:aggregate-video-stats-tracking läuft täglich um 22:00 UTC für --date=yesterday. Wenn PostgreSQL zur Lauf-Zeit neu startet (oder einen kurzen Outage hat), bricht der Command ab. Der nächste Tageslauf erzeugt nur die nächste Aggregation — der ausgefallene Tag wird nicht automatisch nachgeholt.
Lösung — manueller Backfill
# Variante 1: Single-Befehl, nutzt den existing --backfill-Mode.
# Findet alle Tage in youtube_video_daily_metrics, fuer die noch keine Aggregation
# existiert, und holt sie nach.
php artisan youtube:aggregate-video-stats-tracking --backfill
# Variante 2: gezielt fuer einzelne Tage:
php artisan youtube:aggregate-video-stats-tracking --date=2026-04-28
php artisan youtube:aggregate-video-stats-tracking --date=2026-04-29
# Cache flushen, damit das Admin-Dashboard die neuen Daten zeigt:
php artisan cache:forget admin:youtube-mgmt:stats-chart:30d
php artisan cache:forget admin:youtube-mgmt:stats-chart:7d
php artisan cache:forget admin:youtube-mgmt:coverage-kpi
Dauerhafte Lösung (geplant — noch nicht umgesetzt)
Status (2026-04-30): Plan 74 ist als Konzept dokumentiert, aber noch nicht in Code umgesetzt. Bis dahin bleibt der manuelle Backfill nötig.
Plan 74 (docs-agents/plans/aktiv/74-scheduled-commands-db-resilience.md) sieht zwei Mechanismen vor:
- Trait
RetriesOnDbDisconnectmit Exponential-Backoff bei transient-DB-Fehlern (SQLSTATE 08006, "shutting down", Deadlock 40P01) und automatischemDB::reconnect()zwischen Versuchen. - Pre-Run-Schedule (10 min vor jedem regulären Lauf), der den
--backfill-missing-Modus aufruft und Lücken der letzten 7 Tage automatisch füllt.
Bis Plan 74 umgesetzt ist, bleibt der manuelle Backfill (siehe oben) der Workaround für DB-Restart-Vorfälle.
OG-Images: viele "failed" oder "ohne Profilbild"
Symptome
Auf /admin/open-graph viele rot angezeigte "Failed"-Logs mit Meldung "Generator returned null — siehe application log" ODER viele amber "ohne Profilbild"-Notes nach einem Storage-Move.
Ursache
Profile haben kein lokales social_profile_images.latestImage. Häufigster Auslöser ist eine Storage-Migration (z.B. /opt/postbox Symlink), bei der der RefreshMissingProfileImages-Job einen Schwung Image-Downloads als image_fetch_failed_at markiert hat. Diese Profile sind danach 30 Tage von erneuten Versuchen ausgesperrt (Monthly-Cooldown).
Diagnose
# Wie viele Profile sind tatsaechlich betroffen?
php artisan tinker --execute="
\$total = \App\Models\SocialProfile::where('tracking_enabled', true)->count();
\$withImages = \App\Models\SocialProfile::where('tracking_enabled', true)->whereHas('images')->count();
\$inCooldown = \App\Models\SocialProfile::where('tracking_enabled', true)
->whereDoesntHave('images')
->whereNotNull('image_fetch_failed_at')
->where('image_fetch_failed_at', '>=', now()->subMonth())
->count();
\$pending = \App\Models\SocialProfile::where('tracking_enabled', true)
->whereDoesntHave('images')
->where(function(\$q){
\$q->whereNull('image_fetch_failed_at')->orWhere('image_fetch_failed_at', '<', now()->subMonth());
})
->count();
dump([
'tracking_enabled' => \$total,
'with_images' => \$withImages,
'without_images' => \$total - \$withImages,
'in_cooldown' => \$inCooldown,
'pending_for_next_run' => \$pending,
]);"
Lösung — Cooldown ignorieren
# Erst dry-run, um die Zahlen zu pruefen:
php artisan images:refresh-missing --force --dry-run
# Wenn plausibel, den echten Run starten:
php artisan images:refresh-missing --force
# YouTube-Quota-Guard greift wie immer (< 10% frei → Skip → naechster Tag).
Sobald die latestImage-Records nachgesynct sind, ändert sich der Profile-Hash (computeProfileHash() enthält path_thumbnail), und die OG-Images werden beim nächsten regulären Generation-Lauf automatisch mit dem echten Profilbild ersetzt — kein manueller Force-Run nötig.
Alte Failed-Logs prunen
Vor dem Placeholder-Fallback-Fix (Commit f8351a5, 2026-04-30) wurden Profile ohne latestImage als failed geloggt. Diese alten Logs erscheinen weiterhin im Admin-Counter — sie verschwinden mit dem nächsten og-images:cleanup-Lauf (Sonntag 04:00 UTC) automatisch nach 7 Tagen Retention. Wenn die Stats sofort sauber sein sollen:
php artisan og-images:cleanup