# 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
<?php

namespace MintyPHP\Service\Scheduler\Handler;

use MintyPHP\Service\MeinBereich\MeinService;

class MeinJobHandler implements ScheduledJobHandlerInterface
{
    public static function definition(): array
    {
        return [
            'label'                         => '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.
