# Geplante Aufgaben Letzte Aktualisierung: 2026-02-21 ## Ziel Zentrale Verwaltung geplanter Aufgaben im Admin-Bereich. - Ein zentraler Runner (`bin/scheduler-run.php`) läuft minütlich. - Produktion typischerweise per Cron. - Lokale Docker-Umgebung über den Compose-Service `scheduler`. - Die konkreten Jobs sind Whitelist-basiert (kein frei ausführbarer Bash-Text aus DB). - V1 startet mit `user_lifecycle_run`. ## Tabellen - `scheduled_jobs` - Konfiguration pro Job (enabled, schedule, timezone, next/last run, letzter Fehler). - `scheduled_job_runs` - Historie einzelner Runs (trigger, status, Dauer, error/result summary). - `scheduler_runtime_status` - Einzeilige Runtime-Health des globalen Runners (Heartbeat, letzter Runner-Status, letzter Fehlercode). Run-Log-Retention: - 90 Tage (Purge in Admin verfügbar). ## Rechte - `jobs.view`: Bereich ansehen - `jobs.manage`: Job-Konfiguration speichern - `jobs.run_now`: Job manuell ausführen Purge der Run-Logs bleibt unter `settings.update`. ## Betriebsmodell Cron-Eintrag (Beispiel): ```bash * * * * * docker compose exec php php bin/scheduler-run.php ``` Der Runner: 1. sichert sich globalen Lock (`GET_LOCK('scheduled_jobs_runner', 0)`) – nur ein Runner aktiv 2. holt globale Due-Jobs (`enabled=1`, `next_run_at <= now`) 3. führt Jobs kontrolliert aus 4. schreibt Heartbeat in `scheduler_runtime_status` 5. schreibt Run-Log und aktualisiert Job-Status 6. berechnet `next_run_at` neu 7. gibt den Lock frei ## Runner-Heartbeat - `scheduler_runtime_status` enthält genau eine Zeile (`id = 1`). - Jeder Aufruf von `bin/scheduler-run.php` aktualisiert diese Zeile: - `last_heartbeat_at` - `last_result` (`ok`, `lock_not_acquired`, `unexpected_error`) - `last_error_code` (optional) - Dadurch wächst die Tabelle nicht mit der Zeit; sie ist ein laufendes Statusfenster. Verwendung in Admin/Stats: - Kachel **Cron runner active** ist `Aktiv`, wenn `last_heartbeat_at` jünger als 3 Minuten ist. - Ist der Heartbeat älter oder fehlt, zeigt die Kachel `Inaktiv`. - Manuelle Runs aus der UI (`Run now`) zählen nicht als Cron-Liveness. ## Scheduling (V1) Unterstützte Typen: - `hourly` (Intervall 1..24) - `daily` (Intervall 1..365 + Uhrzeit) - `weekly` (Intervall 1..52 + Uhrzeit + Wochentage) `catchup_once`: - Wenn ein Lauf verpasst wurde, wird genau ein Run nachgeholt. - Danach wird normal in die Zukunft weitergeplant (kein Backlog-Sturm). ## Neuen Job registrieren (Entwickler-Guide) ### Architektur-überblick Jeder Job ist eine eigene Handler-Klasse, die `ScheduledJobHandlerInterface` implementiert. Die Registry ist eine reine Map von `job_key` zu Handler-Klasse – sie enthält keine job-spezifische Logik. ``` lib/Service/Scheduler/ Handler/ ScheduledJobHandlerInterface.php ← Vertrag für alle Handler UserLifecycleJobHandler.php ← Referenz-Implementierung ScheduledJobRegistry.php ← Handler-Map (einzige Datei die bei neuen Jobs angefasst wird) ``` ### Schritt-für-Schritt **1. Handler-Klasse erstellen** Datei: `lib/Service/Scheduler/Handler/MeinJobHandler.php` ```php 'Mein Job', 'description' => 'Kurze Beschreibung was der Job macht', 'default_enabled' => 1, 'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC', 'default_schedule_type' => 'daily', 'default_schedule_interval' => 1, 'default_schedule_time' => '03:00', 'default_schedule_weekdays_csv' => null, 'default_catchup_once' => 1, 'allowed_schedule_types' => ['daily', 'weekly'], ]; } public static function execute(?int $actorUserId): array { $result = MeinService::run($actorUserId); if (!($result['ok'] ?? false)) { $errorCode = (string) ($result['error'] ?? 'job_failed'); $status = $errorCode === 'lock_not_acquired' ? 'skipped' : 'failed'; return [ 'status' => $status, 'error_code' => $errorCode, 'error_message' => null, 'result' => [], ]; } return [ 'status' => 'success', 'error_code' => null, 'error_message' => null, 'result' => [ 'processed_count' => (int) ($result['processed_count'] ?? 0), 'duration_ms' => (int) ($result['duration_ms'] ?? 0), ], ]; } } ``` **2. Job in der Registry eintragen** Datei: `lib/Service/Scheduler/ScheduledJobRegistry.php` ```php // Konstante hinzufügen: public const MEIN_JOB = 'mein_job'; // In handlers() eine Zeile hinzufügen: private static function handlers(): array { return [ self::USER_LIFECYCLE_RUN => UserLifecycleJobHandler::class, self::MEIN_JOB => MeinJobHandler::class, // ← neu ]; } ``` **3. Fertig.** Keine anderen Dateien müssen angefasst werden. `ensureSystemJobs()` legt den Job beim nächsten Scheduler-Aufruf automatisch in der Datenbank an. ### Regeln für Handler **`definition()`** - Kein `job_key`-Feld – der Array-Key in der Registry-Map ist der Job-Key. - `default_timezone`: Am besten `APP_TIMEZONE`-Konstante verwenden, Fallback `'UTC'`. - `allowed_schedule_types`: Nur Typen erlauben, die der Job sinnvoll unterstützt. Ein Job der stundlich keinen Sinn ergibt, sollte `hourly` nicht listen. **`execute()`** - Muss immer ein Array mit `status`, `error_code`, `error_message`, `result` zurückgeben. - `status` darf nur `'success'`, `'failed'` oder `'skipped'` sein. - `error_message` darf maximal 255 Zeichen lang sein (VARCHAR-Limit in DB). - `result` wird als JSON im Run-Log gespeichert – nur JSON-serialisierbare Werte. - Wenn der zugrundeliegende Service einen eigenen Lock hat und `lock_not_acquired` meldet, sollte der Handler `status = 'skipped'` zurückgeben (kein echter Fehler). **Namenskonvention** - Dateiname: `MeinJobHandler.php` (PascalCase + `Handler`-Suffix) - Konstante in Registry: `SCREAMING_SNAKE_CASE` (z.B. `AUDIT_PURGE_RUN`) - Job-Key in DB: `snake_case` (z.B. `audit_purge_run`) ### Referenz-Implementierung `lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php` ist die vollständige Referenz-Implementierung mit internem Service-Lock, Fehlerbehandlung und job-spezifischem `result`-Payload. --- ## Registrierte Jobs | job_key | Handler-Klasse | Default-Schedule | |----------------------|-----------------------------|---------------------| | `user_lifecycle_run` | `UserLifecycleJobHandler` | täglich 02:15 | --- ## Troubleshooting - `lock_not_acquired`: - Ein anderer Runner läuft bereits. - Job bleibt auf `running`: - stale-running Schutz erlaubt nach 2 Stunden wieder neue Runs (Schwellwert in `ScheduledJobRepository::markRunning`). - Kein fälliger Job: - `next_run_at`, `enabled`, `timezone` und schedule Konfiguration prüfen.