Scheduler Übersicht
Alle Scheduled Tasks werden in routes/console.php definiert und laufen in UTC. Der Laravel Scheduler wird per Cron jede Minute aufgerufen:
php8.4 /home/forge/app.postbox.so/current/artisan schedule:run
Location: routes/console.php
Vollständige Task-Tabelle
Data Collection (Scraping)
| Command | Zeitpunkt(e) | Intervall | Overlap | Heartbeat | Beschreibung |
|---|---|---|---|---|---|
social:queue-daily-instagram | 00:00, 02:00, 04:00, ... | alle 2h (gerade) | 30 min | instagram_scrape | Instagram-Scrapes in Collector-Queue einreihen |
social:scrape-daily-followers | 09:00, 11:00, 13:00, ... | alle 2h (ungerade, ab 09) | 30 min | youtube_scrape | YouTube Daily Scrape via Data API |
health:check-daily-scrapes | 09:30, 11:30, 13:30, ... | alle 2h (:30 ungerade, ab 09:30) | Nein | — | Alert-E-Mail wenn Instagram/YouTube Jobs fehlen |
Nightly Pipeline (pipeline:run)
| Command | Zeitpunkt(e) | Intervall | Overlap | Heartbeat | Beschreibung |
|---|---|---|---|---|---|
notifications:rollup-stats | 00:30 | taeglich | Ja | — | Mail-Log in taegliche Statistiken aggregieren |
pipeline:run | 03:00 | taeglich | Ja | pipeline_run | Zentraler Orchestrator: Scores+Explore → Rollups → Leaderboards → Trending → Tags, Cross-Platform, Explorer Refresh |
Die Pipeline ersetzt 8 einzeln geschedulte Commands (dashboard:rollup-*, scores:calculate, explore:calculate, tags:refresh-cache, cross-platform:queue-related, public-explorer:refresh). Alle laufen jetzt als parallele Bus::batch-Jobs innerhalb von pipeline:run. Phase 3 (Trending Flags, Videos, Categories, Tag Cache, Explorer Refresh) wird als eigenstaendiger RunPipelinePhase3-Job dispatcht, damit er einen frischen Worker-Prozess mit vollem Memory erhaelt. Manueller Re-Run: php artisan pipeline:run --phase=phase3.
SEO Pipeline (nach pipeline:run)
| Command | Zeitpunkt(e) | Intervall | Overlap | Heartbeat | Beschreibung |
|---|---|---|---|---|---|
env:lint --quiet-when-clean | 06:45 | täglich | 30 min | env_lint | Doppelt gesetzte .env-Schlüssel finden — ein Fund endet mit Exit 1 und färbt den Heartbeat, damit die Dublette im Monitoring steht statt unbemerkt zu bleiben |
images:generate-variants --videos --missing --limit=2000 | 05:15 | täglich | 60 min | image_variants_missing | Video-Thumbnail-Varianten neu erzeugen, deren Datei auf der Platte fehlt — siehe Kasten unter der Tabelle |
sitemap:generate | 07:30 | taeglich | Ja | sitemap_generate | XML-Sitemaps generieren (nach Pipeline) |
og-images:generate --missing-only | 08:00 | täglich | 60 min | — | OG-Images für neue Profile generieren (Browsershot) |
seo:sync-search-console | 08:00 | täglich | 30 min | seo_sync_search_console | Google Search Console Metriken synchronisieren |
api-tokens:check-alerts | 08:00 | täglich | Ja | — | Inaktive/ablaufende API Tokens prüfen |
Warum es zwei Varianten-Bahnen gibt
images:generate-variants (04:30) und images:generate-variants --videos --missing (05:15) beantworten verschiedene Fragen, und deshalb ersetzt keine die andere.
Der 04:30-Lauf sucht Bilder, die noch nie verarbeitet wurden. Er erkennt das an einem leeren thumbnail_blurhash — dem einzigen Marker, der seit Plan 84 Phase 7 dafür übrig ist.
Genau daran übersieht er den Fall, der in Produktion auftrat: Ein Video, das einmal erfolgreich verarbeitet wurde, behält seinen Blurhash — auch wenn seine Variantendateien später verschwinden. Für solche Videos gab es bis zum 02.09.2026 überhaupt keinen Reparaturweg, und der Schaden blieb stehen, statt sich über Nacht auszuwachsen. Sichtbar wurde er als graue Fläche statt eines Thumbnails.
Der 05:15-Lauf fragt deshalb die Platte statt die Datenbank und erzeugt fehlende Varianten aus dem gespeicherten Original neu. Die Obergrenze von 2.000 geprüften Zeilen hält den Lauf kurz; ein Rückstand wird über mehrere Nächte abgetragen statt in einer. Fehlt auch das Original, meldet der Lauf die Zeile als „ohne Original" und überspringt sie — dann hilft nur ein erneuter Abruf von YouTube (youtube:backfill-recent-thumbnails).
Die Zeit 05:15 liegt bewusst zwischen den beiden anderen Bild-Bahnen (04:30 und 05:00), damit die drei nacheinander laufen statt gleichzeitig um dieselbe Platte zu konkurrieren.
Abend-Jobs (Retry + Sanitizer)
| Command | Zeitpunkt(e) | Intervall | Overlap | Beschreibung |
|---|---|---|---|---|
profiles:sanitize | 03:30 | taeglich | Ja | Low-Value Profile auto-deaktivieren (Queue-basiert via SanitizeProfileBatch) |
profiles:retry-inactive | 22:00 | taeglich | Ja | Deaktivierte Profile erneut versuchen, archivieren nach 6 Monaten |
YouTube
| Command | Zeitpunkt(e) | Intervall | Overlap | Heartbeat | Beschreibung |
|---|---|---|---|---|---|
youtube:manage-websub --all | 06:00 | täglich | 30 min | websub_manage | WebSub-Subscriptions erneuern, erstellen, bereinigen |
youtube:poll-rss-feeds | :00, :30 | alle 30 Min | 25 min | youtube_rss_poll | RSS-Feeds pollen (WebSub-Fallback für neue Videos) |
youtube:sync-video-stats | 14:00 | täglich | Ja | youtube_video_sync | Video-Statistiken für Auto-Sync-Profile |
youtube:calculate-video-scores | 21:00 | täglich | Ja | — | Video-Performance-Scores berechnen |
AI & Spracherkennung
| Command | Zeitpunkt(e) | Intervall | Overlap | Beschreibung |
|---|---|---|---|---|
social:detect-languages --queue | alle 15 Min | alle 15 Min | Ja | AI-Spracherkennung (Hourly Limit / 4 pro Chunk) |
Hochfrequent (5–10-Minuten-Intervall)
| Command | Intervall | Overlap | Heartbeat | Beschreibung |
|---|---|---|---|---|
watchers:resume-imports | alle 5 Min | Ja | — | Pausierte Watcher-Imports fortsetzen |
youtube:auto-fill-related-channels | alle 5 Min | Ja | — | Related Channels bei freiem Quota füllen |
ProcessPendingYouTubeImports (Job) | alle 5 Min | Ja | — | Quota-blockierte YouTube-Imports verarbeiten |
cross-platform:auto-fill-related | alle 5 Min | Ja | cross_platform_auto_fill | Cross-Platform Related in kleinen Batches (5/Run) |
instagram:auto-fill-related | alle 5 Min | Ja | — | Instagram Related in kleinen Batches (5/Run) |
related:find-stuck --reset --hours=1 | alle 10 Min | 15 min | — (Auto) | Related-Slider-Sweeper: setzt > 1 h in running/pending hängende Related-Berechnungen (YouTube, Instagram, Cross-Platform) auf null zurück, damit die Auto-Fill-Commands sie neu einreihen; set-based (COUNT/bulk-UPDATE ohne Model-Hydration, 128-MB-sicher) |
server:check-alerts | alle 5 Min | Ja | server_alerts | Server-Metriken gegen Alert-Schwellwerte prüfen |
server:snapshot | alle 5 Min | Ja | server_snapshot | Pulse-Metriken → server_metrics Tabelle |
collector:requeue-expired-leases | alle 5 Min | 5 min | collector_requeue | Verwaiste Collector-Jobs mit abgelaufenen Leases requeuen |
Monitoring & Snapshots (15 Min / Stündlich)
| Command | Zeitpunkt(e) | Intervall | Overlap | Heartbeat | Beschreibung |
|---|---|---|---|---|---|
queue:metrics-snapshot | :00, :15, :30, :45 | alle 15 Min | 5 min | queue_metrics | Queue-Metriken für Admin-Charts |
db:snapshot | :00, :15, :30, :45 | alle 15 Min | Ja | db_monitoring | PostgreSQL-Metriken Snapshot |
db:snapshot --slow-queries --top=50 | :00 jede Stunde | stündlich | Ja | — | Top 50 Slow Queries erfassen |
google:sync-api-usage | :00 jede Stunde | stündlich | 10 min | google_api_sync | Google API Quota synchronisieren |
watchers:backfill-admin-workspace | :00 jede Stunde | stündlich | Ja | — | Profile ins Admin-Workspace spiegeln |
error-monitor:check-anomalies | :00 jede Stunde | stündlich | Ja | — | Error-Anomalie-Schwellwerte prüfen (max 1 Mail/h) |
cloudflare:fetch-r2-metrics | :00 jede Stunde | stündlich | 5 min | r2_metrics | R2-Metriken via Cloudflare GraphQL/REST API |
Alle 30 Minuten
| Command | Intervall | Overlap | Heartbeat | Beschreibung |
|---|---|---|---|---|
youtube:poll-rss-feeds | alle 30 Min | 25 min | youtube_rss_poll | RSS-Feeds für Auto-Sync-Profile pollen (WebSub-Fallback) |
Data Retention & Pruning
| Command | Zeitpunkt(e) | Intervall | Overlap | Beschreibung |
|---|---|---|---|---|
db:prune-historical-data | 01:00 | täglich | Ja | Metriken (400d), Rollups (400d), Snapshots (90d). ⚠️ Hat bisher nie etwas gelöscht — die Rollup-Daten sind erst 237 Tage alt, die Retention greift bei 400. Die 400 sind durch Dashboard/Index.php:459 (subDays(364)) belegt |
error-monitor:rollup-daily | 01:10 | täglich | Ja | Error-Logs → Daily Stats aggregieren |
queue:prune-failed --hours=168 | 01:15 | täglich | Ja | Laravel failed_jobs > 7 Tage löschen |
pulse:purge | 02:07, 14:07 | 2× täglich | Ja | TRUNCATE auf pulse_values, pulse_entries, pulse_aggregates — hält KEIN Zeitfenster ein |
vantage:cleanup-stuck --timeout=2 | 01:22 | täglich | Ja | Stuck "processing" Jobs > 2h als failed markieren |
vantage:prune --status=completed --hours=24 | 01:25 | täglich | Ja | Completed Vantage-Jobs > 24h löschen |
vantage:prune --status=failed --days=4 | 01:30 | täglich | Ja | Failed Vantage-Jobs > 4 Tage löschen |
youtube-research:prune | 01:30 | täglich | Ja | Research-Queries > 30 Tage löschen |
server:snapshot --prune | 02:20 | täglich | Ja | Server-Metriken > 90 Tage prunen |
db:snapshot --prune | 02:15 | täglich | Ja | DB-Monitoring-Snapshots > Retention löschen |
server:rollup-hourly --prune | 03:00 | täglich | Ja | 5-Min-Snapshots → stündliche Rollups (365d Retention) |
ai:prune-logs --days=7 | 10:30, 22:30 | 2x täglich | Ja | AI-Detection-Logs > 7 Tage löschen |
notifications:cleanup | 03:00 | täglich | Ja | Abgelaufene Benachrichtigungen + alte Reads löschen |
collector:prune-logs --days=7 | 11:15, 17:15, 23:15, 05:15 | alle 6h | Ja | Collector-Logs > 7 Tage löschen |
⚠️ Löschende Läufe brauchen einen Heartbeat — seit 3. September erzwungen
Ein Cron, der Daten löscht und still ausfällt, ist der teuerste Fall eines fehlenden Heartbeats: Die Tabelle wächst weiter, niemand wird benachrichtigt, und es fällt erst auf, wenn der Platz knapp wird.
Gemessen am 3. September 2026: Von 24 löschenden Zeitplan-Einträgen standen drei auf der kuratierten Alarm-Liste. Die übrigen 21 wurden nachgezogen.
Dazu kam eine zweite, unauffälligere Lücke: elf Läufe schrieben einen Heartbeat unter
einem Schlüssel, der in postbox.health.cron_heartbeats gar nicht stand.
SystemHealthService liest ausschliesslich diese Liste — im Code sah der Lauf überwacht aus
und war es nicht.
Die Ursache war die Doku. Die Pflicht-Checkliste in
docs-agents/scheduled-commands.mdnanntecron_monitoring.heartbeats, einen Konfigurationspfad, den es nie gab. Wer ihr folgte, trug seinen Schlüssel dorthin ein, wo niemand liest.
Es gibt zusätzlich eine automatische Abdeckungsschicht: RecordScheduledCommandHeartbeat
schreibt für jeden Scheduled Command einen Heartbeat unter scheduled:<command>. Die
Läufe waren also erfasst — was fehlte, war der Alarm.
Festgehalten in tests/Feature/Console/CronHeartbeatCoverageTest.php, beide Invarianten mit
Positivkontrollen.
Monatlich
Keiner mehr. storage:purge-orphans war der letzte monatliche Lauf und steht seit dem
3.9.2026 wöchentlich (siehe unten). Nachgemessen an schedule:list: kein Eintrag trägt noch
einen Tag-des-Monats.
Wöchentlich
| Command | Tag / Uhrzeit | Overlap | Beschreibung |
|---|---|---|---|
keywords:update-stopwords | Sonntag 02:00 | Ja | Dynamische Stopword-Liste aktualisieren |
error-monitor:prune | Sonntag 03:00 | Ja | Error-Daten > 365 Tage bereinigen |
seo:prune-metrics | Sonntag 03:30 | Ja | Search Console + Web Vitals Daten > 90 Tage löschen |
og-images:cleanup | Sonntag 04:00 | 30 min | Verwaiste OG-Images löschen |
instagram:thin-post-metrics | Sonntag 02:30 | 360 min | Dünnt die Auflösung von instagram_post_metrics aus, statt Zeilen zu löschen: bis 30 Tage jeder Messpunkt, danach einer je Woche, ab 180 Tagen einer je Monat. Behalten wird je Zeitfenster der jüngste Wert. Der Verlauf bleibt über den ganzen Zeitraum sichtbar, nur gröber, je weiter er zurückliegt |
images:cleanup-profiles | Sonntag 05:45 | 180 min | Überholte Profilbilder samt ihrer Varianten entfernen. Behält je Profil das neueste Bild mit allen Varianten |
storage:sync-r2-backup | Sonntag 04:30 | 120 min | Primary R2 Bucket in Backup Bucket syncen |
ai:retry-failed --limit=50 | Sonntag 08:00 | Ja | Failed AI-Detection Retry (max 50) |
tags:consolidate | Sonntag 18:00 | Ja | AI-Tags via Gemini konsolidieren |
social:discover-from-links --retry-failed --limit=200 | Sonntag 09:00 | Ja | Fehlgeschlagene Profil-Discovery erneut versuchen |
storage:purge-orphans --max-orphan-percent=10 | Samstag 06:15 | 360 min | Dateien ohne Datenbank-Eintrag löschen. Die 10 statt der Vorgabe 40 sind Absicht: im eingeschwungenen Betrieb ist der Waisen-Anteil klein, eine enge Schwelle ist dann kein Katastrophen-Bremse mehr, sondern ein Anomalie-Melder. Der Befehl zählt zuerst und löscht nichts, wenn er auslöst |
error-monitor:weekly-report | Montag 08:00 | Ja | Wöchentlicher Error-Report |
seo:web-vitals-report | Montag 09:00 | Ja | Wöchentlicher Web Vitals Report mit Degradation-Warnungen |
pulse:check-size | Sonntag 03:30 | 30 min | Pulse-Tabellen Größen-Check (> 2 GB Alert) |
Overlap-Schutz
Die meisten Tasks verwenden .withoutOverlapping(minutes) mit einem Mutex-Lock, um parallele Ausführungen zu verhindern. Die Lock-Dauer variiert je nach erwarteter Laufzeit:
| Task-Typ | Lock-Dauer | Begründung |
|---|---|---|
| Instagram/YouTube Scrape | 30 min | Kann bei vielen Profilen lange dauern |
| Google API Sync | 10 min | Relativ kurz, häufige Ausführung |
| Queue Metrics | 5 min | Sehr kurz, alle 15 Min |
| Standard (ohne Angabe) | 1440 min (24h) | Laravel Default |
Cron Heartbeats
Kritische Tasks schreiben nach erfolgreicher Ausführung einen Heartbeat in den database Cache-Store. Der Health-Endpoint /up_system liest diese Heartbeats und meldet den Status an UptimeRobot:
// Scheduler-Callback fuer eigenstaendige Commands:
$cronHeartbeat = function (string $key): \Closure {
return function () use ($key) {
CronStatusService::recordHeartbeat($key);
};
};
Schedule::command('pipeline:run')
->dailyAt('03:00')
->after($cronHeartbeat('pipeline_run'));
Pipeline-interne Heartbeats: Die Pipeline sendet Heartbeats direkt aus den Queue-Jobs/Callbacks:
scores_calculate—then()-Callback nach Batch 1A (Score+Explore)dashboard_rollup—BuildDailyRollupsBatchleaderboard_rollup—BuildLeaderboardsJobglobal_leaderboard_rollup—BuildGlobalLeaderboardsJobexplore_metrics—RunPipelinePhase3pipeline_complete—RunPipelinePhase3
Registrierte Heartbeats: instagram_scrape, youtube_scrape, youtube_video_sync, youtube_rss_poll, websub_manage, google_api_sync, queue_metrics, cross_platform_auto_fill, server_alerts, server_snapshot, db_monitoring, collector_requeue, pipeline_run, scores_calculate, dashboard_rollup, leaderboard_rollup, global_leaderboard_rollup, explore_metrics, pipeline_complete, tag_cache, public_explorer_refresh, sitemap_generate, seo_sync_search_console, seo_prune_metrics, api_token_alerts, profile_retry, profile_sanitize, youtube_publishing_stats, youtube_video_scores, pulse_purge, indexnow_submit, r2_metrics
Wöchentliche Jobs: Heartbeats für wöchentliche Jobs (z.B. youtube_publishing_stats, seo_prune_metrics) brauchen ein max_minutes von mindestens 8 Tagen (8 * 24 * 60 = 11520), nicht die Standard-26h. Sonst werden ab Tag 2 nach dem letzten Lauf False-Positive-Alarme ausgelöst.
Quiet Hours: Heartbeats können quiet_hours konfigurieren (z.B. 'quiet_hours' => ['start' => 2, 'end' => 7]). Während der Quiet Hours gibt der Heartbeat-Check not_required statt failed zurück — keine Alerts, kein Rauschen im Dashboard. Nützlich für Tasks die planmäßig nur zu bestimmten Zeiten laufen (z.B. YouTube Scrape). Die YouTube-Quiet-Hours werden via YouTubeQuotaSchedule dynamisch berechnet (DST-aware: Ende bei 07:00 UTC im Sommer, 08:00 UTC im Winter).
Pipeline-Job-Heartbeats: Die Pipeline-Jobs (BuildDailyRollupsBatch, BuildLeaderboardsJob, BuildGlobalLeaderboardsJob) schreiben eigene Heartbeats direkt am Ende ihrer handle()-Methode: dashboard_rollup, leaderboard_rollup, global_leaderboard_rollup. So erkennt PipelineStatusService ob diese Steps erfolgreich liefen — fehlende Heartbeats zeigen den Step als "Ueberfaellig" an. BuildDailyRollupsBatch hat einen Timeout von 900s (15 Min) und verwendet seit 2026-04-13 ein Single-Statement-Pattern (DELETE + INSERT...SELECT ueber den vollen Watcher-Range), das die Rollup-Berechnung serverseitig in PostgreSQL ausfuehrt — ohne PHP-seitigen Chunk-Loop.
Konfiguration
Relevante ENV-Variablen für den Scheduler:
POSTBOX_INSTAGRAM_ROTATION_DAYS=3 # Tage zwischen Instagram-Scrapes
POSTBOX_YOUTUBE_ROTATION_DAYS=3 # Tage zwischen YouTube-Scrapes
POSTBOX_LEADERBOARD_CANDIDATE_LIMIT=200 # Kandidaten für Priority-Scraping
AI_ENHANCER_HOURLY_LIMIT=100 # Gemini AI Limit pro Stunde
AI_ENHANCER_RATE_PER_MINUTE=15 # Gemini AI Rate Limit
VANTAGE_RETENTION_DAYS=3 # Vantage Job-History Retention
PULSE_STORAGE_KEEP="7 days" # ⚠️ wirkt NUR auf Pulses eigenen Lotterie-Trim,
# NICHT auf pulse:purge — das truncatet immer alles
DB_MONITORING_RETENTION_DAYS=30 # DB-Monitoring-Snapshot Retention
DB_SLOW_QUERY_RETENTION_DAYS=14 # Slow-Query-Log Retention