Zum Hauptinhalt springen

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)​

CommandZeitpunkt(e)IntervallOverlapHeartbeatBeschreibung
social:queue-daily-instagram00:00, 02:00, 04:00, ...alle 2h (gerade)30 mininstagram_scrapeInstagram-Scrapes in Collector-Queue einreihen
social:scrape-daily-followers09:00, 11:00, 13:00, ...alle 2h (ungerade, ab 09)30 minyoutube_scrapeYouTube Daily Scrape via Data API
health:check-daily-scrapes09: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)​

CommandZeitpunkt(e)IntervallOverlapHeartbeatBeschreibung
notifications:rollup-stats00:30taeglichJa—Mail-Log in taegliche Statistiken aggregieren
pipeline:run03:00taeglichJapipeline_runZentraler 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)​

CommandZeitpunkt(e)IntervallOverlapHeartbeatBeschreibung
env:lint --quiet-when-clean06:45täglich30 minenv_lintDoppelt 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=200005:15täglich60 minimage_variants_missingVideo-Thumbnail-Varianten neu erzeugen, deren Datei auf der Platte fehlt — siehe Kasten unter der Tabelle
sitemap:generate07:30taeglichJasitemap_generateXML-Sitemaps generieren (nach Pipeline)
og-images:generate --missing-only08:00täglich60 min—OG-Images für neue Profile generieren (Browsershot)
seo:sync-search-console08:00täglich30 minseo_sync_search_consoleGoogle Search Console Metriken synchronisieren
api-tokens:check-alerts08:00täglichJa—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)​

CommandZeitpunkt(e)IntervallOverlapBeschreibung
profiles:sanitize03:30taeglichJaLow-Value Profile auto-deaktivieren (Queue-basiert via SanitizeProfileBatch)
profiles:retry-inactive22:00taeglichJaDeaktivierte Profile erneut versuchen, archivieren nach 6 Monaten

YouTube​

CommandZeitpunkt(e)IntervallOverlapHeartbeatBeschreibung
youtube:manage-websub --all06:00täglich30 minwebsub_manageWebSub-Subscriptions erneuern, erstellen, bereinigen
youtube:poll-rss-feeds:00, :30alle 30 Min25 minyoutube_rss_pollRSS-Feeds pollen (WebSub-Fallback für neue Videos)
youtube:sync-video-stats14:00täglichJayoutube_video_syncVideo-Statistiken für Auto-Sync-Profile
youtube:calculate-video-scores21:00täglichJa—Video-Performance-Scores berechnen

AI & Spracherkennung​

CommandZeitpunkt(e)IntervallOverlapBeschreibung
social:detect-languages --queuealle 15 Minalle 15 MinJaAI-Spracherkennung (Hourly Limit / 4 pro Chunk)

Hochfrequent (5–10-Minuten-Intervall)​

CommandIntervallOverlapHeartbeatBeschreibung
watchers:resume-importsalle 5 MinJa—Pausierte Watcher-Imports fortsetzen
youtube:auto-fill-related-channelsalle 5 MinJa—Related Channels bei freiem Quota füllen
ProcessPendingYouTubeImports (Job)alle 5 MinJa—Quota-blockierte YouTube-Imports verarbeiten
cross-platform:auto-fill-relatedalle 5 MinJacross_platform_auto_fillCross-Platform Related in kleinen Batches (5/Run)
instagram:auto-fill-relatedalle 5 MinJa—Instagram Related in kleinen Batches (5/Run)
related:find-stuck --reset --hours=1alle 10 Min15 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-alertsalle 5 MinJaserver_alertsServer-Metriken gegen Alert-Schwellwerte prüfen
server:snapshotalle 5 MinJaserver_snapshotPulse-Metriken → server_metrics Tabelle
collector:requeue-expired-leasesalle 5 Min5 mincollector_requeueVerwaiste Collector-Jobs mit abgelaufenen Leases requeuen

Monitoring & Snapshots (15 Min / Stündlich)​

CommandZeitpunkt(e)IntervallOverlapHeartbeatBeschreibung
queue:metrics-snapshot:00, :15, :30, :45alle 15 Min5 minqueue_metricsQueue-Metriken für Admin-Charts
db:snapshot:00, :15, :30, :45alle 15 MinJadb_monitoringPostgreSQL-Metriken Snapshot
db:snapshot --slow-queries --top=50:00 jede StundestündlichJa—Top 50 Slow Queries erfassen
google:sync-api-usage:00 jede Stundestündlich10 mingoogle_api_syncGoogle API Quota synchronisieren
watchers:backfill-admin-workspace:00 jede StundestündlichJa—Profile ins Admin-Workspace spiegeln
error-monitor:check-anomalies:00 jede StundestündlichJa—Error-Anomalie-Schwellwerte prüfen (max 1 Mail/h)
cloudflare:fetch-r2-metrics:00 jede Stundestündlich5 minr2_metricsR2-Metriken via Cloudflare GraphQL/REST API

Alle 30 Minuten​

CommandIntervallOverlapHeartbeatBeschreibung
youtube:poll-rss-feedsalle 30 Min25 minyoutube_rss_pollRSS-Feeds für Auto-Sync-Profile pollen (WebSub-Fallback)

Data Retention & Pruning​

CommandZeitpunkt(e)IntervallOverlapBeschreibung
db:prune-historical-data01:00täglichJaMetriken (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-daily01:10täglichJaError-Logs → Daily Stats aggregieren
queue:prune-failed --hours=16801:15täglichJaLaravel failed_jobs > 7 Tage löschen
pulse:purge02:07, 14:072× täglichJaTRUNCATE auf pulse_values, pulse_entries, pulse_aggregates — hält KEIN Zeitfenster ein
vantage:cleanup-stuck --timeout=201:22täglichJaStuck "processing" Jobs > 2h als failed markieren
vantage:prune --status=completed --hours=2401:25täglichJaCompleted Vantage-Jobs > 24h löschen
vantage:prune --status=failed --days=401:30täglichJaFailed Vantage-Jobs > 4 Tage löschen
youtube-research:prune01:30täglichJaResearch-Queries > 30 Tage löschen
server:snapshot --prune02:20täglichJaServer-Metriken > 90 Tage prunen
db:snapshot --prune02:15täglichJaDB-Monitoring-Snapshots > Retention löschen
server:rollup-hourly --prune03:00täglichJa5-Min-Snapshots → stündliche Rollups (365d Retention)
ai:prune-logs --days=710:30, 22:302x täglichJaAI-Detection-Logs > 7 Tage löschen
notifications:cleanup03:00täglichJaAbgelaufene Benachrichtigungen + alte Reads löschen
collector:prune-logs --days=711:15, 17:15, 23:15, 05:15alle 6hJaCollector-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.md nannte cron_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​

CommandTag / UhrzeitOverlapBeschreibung
keywords:update-stopwordsSonntag 02:00JaDynamische Stopword-Liste aktualisieren
error-monitor:pruneSonntag 03:00JaError-Daten > 365 Tage bereinigen
seo:prune-metricsSonntag 03:30JaSearch Console + Web Vitals Daten > 90 Tage löschen
og-images:cleanupSonntag 04:0030 minVerwaiste OG-Images löschen
instagram:thin-post-metricsSonntag 02:30360 minDü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-profilesSonntag 05:45180 minÜberholte Profilbilder samt ihrer Varianten entfernen. Behält je Profil das neueste Bild mit allen Varianten
storage:sync-r2-backupSonntag 04:30120 minPrimary R2 Bucket in Backup Bucket syncen
ai:retry-failed --limit=50Sonntag 08:00JaFailed AI-Detection Retry (max 50)
tags:consolidateSonntag 18:00JaAI-Tags via Gemini konsolidieren
social:discover-from-links --retry-failed --limit=200Sonntag 09:00JaFehlgeschlagene Profil-Discovery erneut versuchen
storage:purge-orphans --max-orphan-percent=10Samstag 06:15360 minDateien 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-reportMontag 08:00JaWöchentlicher Error-Report
seo:web-vitals-reportMontag 09:00JaWöchentlicher Web Vitals Report mit Degradation-Warnungen
pulse:check-sizeSonntag 03:3030 minPulse-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-TypLock-DauerBegründung
Instagram/YouTube Scrape30 minKann bei vielen Profilen lange dauern
Google API Sync10 minRelativ kurz, häufige Ausführung
Queue Metrics5 minSehr 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 — BuildDailyRollupsBatch
  • leaderboard_rollup — BuildLeaderboardsJob
  • global_leaderboard_rollup — BuildGlobalLeaderboardsJob
  • explore_metrics — RunPipelinePhase3
  • pipeline_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