Zum Inhalt

Konzept: Geplante Aufgaben in calServer V2 (eigene Steuerung statt V1-Cronjobs)

Frage: Muss es das Cronjob-System in V2 so noch geben, oder gibt es eine modernere Anzeigeart dafür?

Kurzantwort: Nicht so, aber auch nicht nur als Anzeige. Der heutige Cronjob-Editor unter /admin/operations bearbeitet eine V1-Tabelle, die in V2 niemand ausführt. V1-Werte anzuzeigen nützt der V2-Anwendung nichts. Sobald V1 abgeschaltet wird, braucht V2 eine eigene Steuerung für seine geplanten Aufgaben, und die gibt es heute nicht: Der Zeitplan steht fest im Image, der Scheduler ist im Auslieferungszustand aus, und beides ist in der Oberfläche unsichtbar.

Der Entwurf, der das löst, ohne V1s Fehler zu wiederholen, steht in einem Satz: Der Katalog kommt aus dem Code, der Zeitplan aus der Datenbank.


1. Ist-Zustand

1.1 V1: Tabelle erzeugt Datei erzeugt crontab

In V1 ist die Kette vollständig und funktioniert:

Schritt Ort
Datensatz in cron_job Adminpanel, Tab „Administration"
afterSave()/afterDelete() schreiben die crontab-Datei AdminCronJob::updateCronJobFile() (httpdocs/protected/modules/adminpanel/models/AdminCronJob.php:208)
/var/local/calServer-cron wird nach /etc/cron.d/calServer-cron kopiert ebd. Zeile 264
Daemon lädt neu AdminCronJob::restartCron() bzw. docker/app/restart_cron.sh
Ausführung cron im V1-Container, Kommandos sind yiic <command> run

Sechs Namen sind fest verdrahtet (DEFAULT_NAMES) und dürfen nicht umbenannt werden: change_status, dms_inbox_check, mailer_action, copy_file_to_inbox, backup, start_symmetricDS. Der Eintrag mailer_action erzeugt beim Schreiben der Datei stillschweigend drei weitere crontab-Zeilen (exportReport, sendEmailQueue, deleteReport), die in der Oberfläche nirgends auftauchen. Das V1-Dockerfile trägt an der cron-Einrichtung den Kommentar # FIXME: cron not working! (Dockerfile:110).

1.2 V2: der Zeitplan steht im Code, nicht in der Datenbank

V2 plant 13 verschiedene Kommandos in 14 Registrierungen, alle statisch:

  • laravel/routes/console.php: zwölf Einträge (counters:reset, mailer:process, notepad:send-reminders, notifications:prune, updates:check, license:check, status:apply-rules, status:rebuild-processing-times, metteam:pull, metteam:push, filter-statistics:save, demo:data autoreseed)
  • laravel/bootstrap/app.php: dms:process-inbox (nur bei dms.inbox.enabled) und ein zweiter demo:data autoreseed

Ausgeführt werden sie von php artisan schedule:work als Supervisor-Programm laravel-schedule, und das steht auf autostart=false (docker/supervisor/laravel.conf:44). Bewusst so, begründet in docs-v2/entwicklung/scheduler.md: der erste Lauf gegen gewachsene Bestandsdaten ist der riskante Schritt.

1.3 Der Cronjob-Editor in V2 hängt an nichts

CronJobController bietet vollständiges CRUD auf cron_job plus POST /admin/cron-jobs/restart. Was fehlt, ist der gesamte Rest der Kette:

Voraussetzung Zustand in V2
Jemand liest cron_job zur Ausführung Nein. CronJobController ist der einzige Verwender des Models im gesamten laravel/app/-Baum
Jemand schreibt /var/local/calServer-cron Nein. Kein Gegenstück zu updateCronJobFile()
cron im Container installiert Nein. Dockerfile.laravel installiert supervisor, kein cron
/app/restart_cron.sh im Image Nein. Wird nicht hineinkopiert, der Restart-Endpunkt antwortet dort immer mit 422
Vorbelegte Zeilen bei Neuinstallation Nein. Weder db:install noch ein Seeder legt Cronjobs an; die Migration erzeugt nur die leere Tabelle

Daraus drei Fehlbilder:

a) Leer auf frischer V2-Installation. „Keine Cron Jobs eingerichtet" ist die korrekte Aussage über eine Tabelle, die niemand füllt, und die falsche Aussage über die Anlage: 13 Aufgaben sind geplant.

b) Falsch auf einer aus V1 migrierten Installation. v1:sync kopiert cron_job mit (SyncV1ToV2.php:317). Angezeigt werden dann yiic-Kommandos, die im V2-Container nicht existieren, mit Zeitplänen, nach denen in V2 nichts läuft.

c) Bearbeiten ist schlimmer als wirkungslos. Läuft V1 parallel, schreibt V2 die Tabellenzeile, ohne die crontab zu erneuern. Sobald in V1 das nächste Mal irgendein Cronjob gespeichert wird, regeneriert updateCronJobFile() die Datei aus der Tabelle und wendet die V2-Änderung nachträglich an. Sie wirkt also, nur zu einem unvorhersehbaren Zeitpunkt und in der falschen Anwendung.

Dazu eine Falschaussage in der Doku: docs-v2/admin/betrieb.md Zeile 22 sagt „Die mitgelieferten Jobs lassen sich nicht umbenennen — der Scheduler findet sie über ihren Namen." Kein V2-Scheduler liest diese Tabelle. Die Namenssperre stammt aus V1, wo sie die Log-Pfad-Zuordnung (DEFAULT_LOG_FILES) zusammenhält.


2. Bewertung unter der Autarkie-Regel

Die Wurzelregel steht in der Root-CLAUDE.md: V2 löst V1 ab, statt es zu begleiten. Damit ist die Frage nicht „Editor oder Anzeige", sondern: Was muss V2 können, wenn V1 dunkel ist?

Die V1-Tabelle ist der falsche Gegenstand. Sie beschreibt yiic-Kommandos in einem Container, den es nach dem Cutover nicht mehr gibt. Sie in V2 anzuzeigen ist bestenfalls Dekoration und schlimmstenfalls irreführend, sie in V2 zu bearbeiten ist der Fehler aus 1.3c. Der Gegenstand gehört nicht modernisiert, er gehört ersetzt.

Eine eigene Steuerung ist Pflicht, nicht Komfort. Ohne sie hat der Betreiber nach dem Cutover weniger Kontrolle als vorher:

V1 heute V2 heute V2 nötig
Sehen, was geplant ist ja nein ja
Takt ändern ja nur per Redeploy ja
Aufgabe abschalten ja nur per Redeploy ja
Aufgabe jetzt ausführen Testlauf nein (nur Shell) ja
Lief es, mit welchem Ergebnis Log-Datei je Job nein ja
Läuft der Scheduler überhaupt nein ja

Ein Kunde mit nächtlichem Wartungsfenster, der status:apply-rules nicht zur vollen Stunde laufen lassen will, braucht heute ein eigenes Image. Das ist für ein selbst gehostetes Produkt die falsche Antwort.

Der eine Teil von V1, der nicht mitkommt, ist das freie Kommandofeld. Ein Formular, in das man ein beliebiges Kommando schreibt, das anschließend als Systembenutzer ausgeführt wird, ist eine Rechteausweitung mit UI davor: Wer administration_management hält, verschafft sich darüber alles, was der Container kann. Das ist kein Grund gegen eine Steuerung, sondern die Anforderung an ihren Zuschnitt.


3. Der Entwurf: Katalog aus dem Code, Zeitplan aus der Datenbank

Die Trennung, die alles Weitere trägt:

  • Was laufen kann, liefert das Image. Jede planbare Aufgabe ist im Code registriert, mit fachlichem Namen, Beschreibung, Vorgabetakt und der Angabe, ob sie ohne Konfiguration wirkungslos ist. Kein Freitextfeld, aus dem ein Kommando wird.
  • Ob und wann sie läuft, ist Mandantendaten. Die Datenbank hält Zustand und Abweichung vom Vorgabetakt, nicht den Katalog selbst.

Das löst die drei Probleme, an denen eine reine DB-Lösung sonst scheitert:

  1. Sicherheit. Planbar ist nur, was der Katalog kennt. db:clear und demo:data remove stehen nicht darin, counters:reset schon.
  2. Upgrades. Eine in Release N+1 hinzugekommene Aufgabe erscheint auf einer bestehenden Installation automatisch, weil der Katalog aus dem Code kommt. Läge er in der Datenbank, fehlte sie dort still — dieselbe Falle, die laravel/CLAUDE.md bei translations:reset beschreibt: Migrationen laufen einmal, ein später ergänzter Schlüssel erreicht eine bereits migrierte Datenbank nie.
  3. Nachvollziehbarkeit. Der Auslieferungszustand ist versioniert und testbar. Die Datenbank sagt nur, wo diese Installation davon abweicht, und „Auf Vorgabe zurücksetzen" ist eine Zeile löschen.

3.1 Datenmodell

Zwei neue V2-Tabellen, nach den Regeln aus laravel/CLAUDE.md (uuid-PK, sprechende Spalten, DB-agnostische Typen, timestamps()):

scheduled_task — je Katalogeintrag höchstens eine Zeile, und nur, wenn diese Installation abweicht:

Spalte Zweck
id (uuid) PK
task_key (string, unique) Schlüssel aus dem Katalog, z. B. mailer.process. Nicht das Artisan-Kommando, damit ein Umbenennen im Code die Daten nicht bricht
enabled (bool, nullable) null = Vorgabe des Katalogs
cron_expression (string, nullable) null = Vorgabetakt. Ein Feld, kein Fünferpack
timezone (string, nullable) für Wartungsfenster; Vorgabe ist die Anwendungszeitzone
updated_by (uuid, nullable) wer zuletzt gestellt hat
timestamps

scheduled_task_run — die Laufhistorie, die V1s Log-Dateien ersetzt:

Spalte Zweck
id (uuid) PK
task_key (string, index)
started_at, finished_at
duration_ms (unsigned int)
status (string) success, failed, skipped
exit_code (int, nullable)
output (text, nullable) gekappt, nicht unbegrenzt
triggered_by (string) schedule oder manual
user_id (uuid, nullable) bei manuellem Start

Aufbewahrung gehört von Anfang an dazu: Bei einem 15-Sekunden-Takt entstehen je Aufgabe rund 5.700 Zeilen am Tag. Vorschlag: Erfolge kurz halten, Fehlläufe lang, plus je Aufgabe immer den letzten Erfolg. Vorbild ist notifications:prune.

3.2 Registrierung

Der Katalog ist eine Klasse (app/Support/ScheduledTaskCatalog.php, analog zum bestehenden OperationCatalog) mit je Eintrag: task_key, Artisan-Kommando, de/en-Label, Beschreibung, Vorgabe-Ausdruck, withoutOverlapping, und ein Prüfer für „ohne Konfiguration wirkungslos" (MET/TEAM ohne Instanz, Demo-Reseed ohne Demo-Modus).

withSchedule() in bootstrap/app.php iteriert den Katalog, holt die Abweichungen in einer Abfrage und registriert daraus. routes/console.php verliert seine feste Liste. Wichtig: Die Registrierung darf beim Bootstrap keine fehlende Datenbank zum Fehler machen — vor der ersten Migration und in CI-Läufen ohne DB muss sie stillschweigend auf die Vorgaben zurückfallen.

Ein Test, der Katalog und schedule:list gegeneinander hält, hält beides zusammen (Vorbild: ReferenceLabelCoverageTest).

3.3 Steuerung über die API

Klein gehaltene Admin-Endpunkte, administration_management:

GET    /admin/scheduled-tasks            Katalog + Zustand + letzter/nächster Lauf
PUT    /admin/scheduled-tasks/{key}      enabled / cron_expression / timezone
DELETE /admin/scheduled-tasks/{key}      auf Katalogvorgabe zurücksetzen
POST   /admin/scheduled-tasks/{key}/run  jetzt ausführen (Queue, nicht im Request)
GET    /admin/scheduled-tasks/{key}/runs Laufhistorie
GET    /admin/scheduler                  Gesamtzustand: Prozess, Pause, letzte Aktivität
POST   /admin/scheduler/pause|resume     schedule:pause / schedule:resume

cron_expression wird gegen einen Cron-Parser validiert und zusätzlich gegen eine Mindestfrequenz, damit niemand versehentlich * * * * * auf einen Vollbestandslauf setzt. „Jetzt ausführen" läuft über die Queue mit TrackableJob, nicht synchron im Request — die vorhandene long_running_tasks-Strecke ist genau dafür da.

Bewusst kein Endpunkt zum Anlegen freier Aufgaben. Wer eine neue geplante Aufgabe braucht, bekommt sie im nächsten Release als Katalogeintrag.

3.4 Oberfläche

Statt einer Grid-Tabelle mit fünf crontab-Spalten eine Liste, die sich wie ein Statusbericht liest und die Steuerung dort anbietet, wo der Zustand steht:

Geplante Aufgaben

  ┌──────────────────────────────────────────────────────────────────┐
  │  Der Scheduler läuft nicht.                        [ Starten ]   │
  │  Keine der 13 geplanten Aufgaben wird ausgeführt.                │
  └──────────────────────────────────────────────────────────────────┘

  E-Mail-Strecke                                  [●] alle 5 Minuten ▾
  ● Bereit    zuletzt vor 3 Min. (0,4 s)     nächster 14:35   [ Jetzt ]

  Zeitbasierte Statusregeln                       [●] stündlich      ▾
  ● Fehler    zuletzt vor 52 Min. (fehlgeschlagen)              [ Details ]

  MET/TEAM-Abgleich (eingehend)                   [○] alle 15 Sek.   ▾
  ○ Inaktiv   nicht konfiguriert

Bestandteile:

  1. Banner zum Gesamtzustand. Läuft laravel-schedule? Ist per schedule:pause angehalten? Das ist die wichtigste Einzelinformation der Seite und gehört nach oben, nicht in eine Spalte.
  2. Ein Schalter je Aufgabe für an/aus. Kein Dialog dazwischen.
  3. Takt als Auswahl mit Klartext, nicht als fünf Textfelder: „alle 5 Minuten", „stündlich", „täglich um 03:20", „wöchentlich", plus „eigener Ausdruck" für den, der ihn braucht. Der rohe Ausdruck als Tooltip. Abweichungen von der Vorgabe sind als solche gekennzeichnet, mit „Zurücksetzen".
  4. Fachlicher Name statt Kommandoname. „E-Mail-Strecke", nicht mailer:process. Das Kommando steht im Detail.
  5. Zustand mit vier Werten: Bereit, Läuft gerade, Fehler, Inaktiv (Aufgabe registriert, aber ohne Konfiguration wirkungslos).
  6. Letzter Lauf relativ plus Dauer, nächster Lauf absolut.
  7. Detailbereich mit den letzten Läufen samt Ausgabe. Das ersetzt V1s Log-Dateien, ohne dass jemand Pfade konfiguriert.
  8. „Jetzt ausführen" je Aufgabe, mit Bestätigung bei bestandsweit wirkenden Aufgaben.

Das Muster ist im Haus etabliert und muss nicht erfunden werden: long_running_tasks mit useLongRunningTask (Fortschritt und Zustandsautomat), report_run_log und metteam_sync_log (Laufhistorie) sind dieselbe Form.

3.5 Technische Grundlage

Alles Bordmittel von Laravel 13, kein eigener Scheduler:

Bedarf Mittel
Was ist registriert, mit welchem Ausdruck, wann als Nächstes app(Schedule::class)->events(), je Event nextRunDate()
Lief es, wie lange, mit welchem Ergebnis Listener auf ScheduledTaskStarting / ScheduledTaskFinished / ScheduledTaskFailed / ScheduledTaskSkipped schreibt nach scheduled_task_run
Angehalten? Cache-Schlüssel illuminate:schedule:paused (schedule:pause / schedule:resume)
Läuft der Prozess? Heartbeat des Listeners, ergänzend supervisorctl status
Einzelne Aufgabe jetzt ausführen Dispatch über die Queue als TrackableJob

4. Autarkie-Lücken: was fehlt, bevor V1 dunkel werden kann

Die sechs V1-Standard-Cronjobs, jeder mit seinem V2-Stand:

V1-Cronjob V2-Äquivalent Zustand
change_status status:apply-rules vorhanden
dms_inbox_check dms:process-inbox vorhanden
mailer_action (+3 verdeckte Zeilen) mailer:process vorhanden
copy_file_to_inbox entfällt in V2 (Entscheidung 2026-08-11). Kein Ersatz nötig
backup muss gebaut werden, siehe 4.1
start_symmetricDS calserver-sync eigenständiger Client, bewusst außerhalb

4.1 Sicherungen: konfigurierbar, aber ohne Ausführer

Sicherungen bleiben Bestandteil von V2 und müssen funktionieren. Heute tun sie das nicht. Was vorhanden ist und was fehlt:

Teil Zustand
Tabellen backup_action, backup_borg vorhanden (aus V1 übernommen)
CRUD-Endpunkte /admin/backup-actions vorhanden
Tab „Sicherungen" in /admin/operations vorhanden
Klonen einer Konfiguration vorhanden
Ausführer fehlt. Nichts liest backup_action zur Ausführung
Testlauf Attrappe. POST /admin/backup-actions/{id}/test-run lädt den Datensatz und antwortet „Backup test run initiated.", ohne etwas anzustoßen (AdminBackupActionController.php:97). Die Oberfläche meldet dem Benutzer Erfolg
Wiederherstellung fehlt
Aufbewahrung (max_backup) fehlt, wird gepflegt und nicht ausgewertet

Die Attrappe gehört zuerst weg. Eine Erfolgsmeldung ohne Wirkung ist der schlechteste Zustand von allen: Der Betreiber glaubt, geprüft zu haben, dass seine Sicherung läuft.

Die Doku beschreibt einen Stand, den es nicht gibt. docs-v2/admin/backup.md nennt vier Kommandos — backup:run, backup:test, backup:restore, config:import — von denen keines existiert (php artisan kennt sie nicht). Auch die dort beschriebenen Felder (Name, Typ, Zeitplan, Speicherort, Aufbewahrung) stimmen nicht mit der tatsächlichen Tabelle überein (description, start, end, dow, mo, dom, max_backup, published). Das erklärt, warum der Stand „müsste doch laufen" wirkt: Die Dokumentation sagt genau das.

Der Bauplan liegt im Repo. V1 hat die Umsetzung in BackupCommand (liest backup_action, prüft Fenster und checkRunCron(), ruft BackupDatabase + BackupFile), dazu CreateBackupCommand, DownloadBackupCommand, RestoreDatabaseCommand und das jbackup-Modul mit Borg. Und V2 hat für die Fälligkeitslogik bereits ein direktes Vorbild: MailerActionRunner wertet mit dueActions() und matchesSchedule() exakt dieselben Spalten aus (published, start, end, dow, mo, dom) und trägt V1s checkRunCron-Semantik schon nach. Ein BackupActionRunner ist das Schwestermodell dazu, kein neues Verfahren.

Damit fügt sich die Sicherung sauber in den Entwurf aus Abschnitt 3 ein: Der Katalog bekommt eine Aufgabe backup.run mit einem Takt (Vorgabe: alle fünf Minuten, wie V1), und die je Sicherung gepflegten Fenster und Aufbewahrungsgrenzen bleiben da, wo sie hingehören — in backup_action, nicht im Zeitplan der Aufgabe. Dasselbe Verhältnis wie zwischen mailer:process und mailer_action.

Offen bleibt der Ablageort: V1 schreibt in ein per Property konfiguriertes Verzeichnis, V2 leitet seine Pfade aus der Container-Konfiguration ab. Für einen brauchbaren Betrieb gehört die Sicherung ohnehin aus dem Container heraus (Volume, S3, SFTP). Das ist zu entscheiden, bevor gebaut wird, und nicht Teil dieses Konzepts.

4.2 Der Scheduler ist im Auslieferungszustand aus

laravel-schedule steht auf autostart=false. Ein V2, das V1 ablöst, muss den Scheduler an haben. Die Steuerung aus Abschnitt 3 macht den Trockenlauf aus docs-v2/entwicklung/scheduler.md überprüfbar statt nur beschrieben und damit autostart=true vertretbar: Der Betreiber sieht vor dem Einschalten, was geplant ist, kann die riskanten Aufgaben einzeln aus lassen und sie nach dem Trockenlauf gezielt dazuschalten. Heute ist es alles oder nichts.


5. Was mit dem Alten passiert

Gegenstand Vorschlag
Tab „Cron Jobs" in /admin/operations ersetzen durch „Geplante Aufgaben" mit der Steuerung aus Abschnitt 3
POST /admin/cron-jobs/restart entfernen. Im V2-Image nicht ausführbar, im V1-Image die falsche Anwendung
GET/POST/PUT/DELETE /admin/cron-jobs entfernen. Schreibzugriff zuerst, er ist der schädliche Teil (1.3c). V1-Werte anzuzeigen nützt der V2-Anwendung nichts, ein Read-only-Rest wäre also kein Kompromiss, sondern nur weniger Schaden
Model App\Models\CronJob entfällt mit den Endpunkten
Tabelle cron_job stehen lassen. Sie gehört V1, dort funktioniert sie, und die Autarkie-Regel verbietet Destruktives auf V1-Tabellen. Kein DROP, auch nach dem Cutover nicht — die V1→V2-Datenübernahme muss lauffähig bleiben
cron_job in SyncV1ToV2 kann bleiben (kopiert V1-Daten für V1), ist nach dem Umbau aber wirkungslos für V2
Cache-Bereich im selben Tab bleibt, unabhängig und funktionsfähig
docs-v2/admin/betrieb.md, Abschnitt „Cron Jobs" neu schreiben; die Aussage über den Scheduler ist falsch
docs-v2/entwicklung/scheduler.md fortschreiben; nennt zwölf Aufgaben, listet elf, und license:check sowie filter-statistics:save fehlen ganz
docs-v2/admin/backup.md (+ .en.md) neu schreiben. Beschreibt vier Kommandos, die es nicht gibt, und Felder, die die Tabelle nicht hat (4.1). Bis die Sicherung wirklich läuft, gehört dorthin der tatsächliche Stand, nicht der geplante

Ein weiterer Nebenbefund, unabhängig von der Entscheidung: demo:data autoreseed ist doppelt geplant, alle 15 Minuten in routes/console.php:73 und stündlich in bootstrap/app.php:32. Beide tragen withoutOverlapping(), die Verdopplung ist trotzdem unbeabsichtigt und verschwindet mit dem Katalog von selbst.


6. Umsetzungsreihenfolge

Jeder Schritt ist für sich nutzbar, keiner setzt den nächsten voraus.

  1. Katalog + dynamische Registrierung. routes/console.php verliert die feste Liste, das Verhalten bleibt identisch. Ab hier existiert eine maschinenlesbare Liste der Aufgaben. Behebt nebenbei die Verdopplung.
  2. Laufhistorie. Listener plus scheduled_task_run plus Aufbewahrung. Ab hier ist beantwortbar, ob etwas lief.
  3. Leseendpunkte + Seite. Katalog, Zustand, Historie, Gesamtzustand des Schedulers. Ab hier ist der Betreiber nicht mehr blind, und der alte Tab kann weg.
  4. Steuerung. An/aus, Takt, Zurücksetzen, Jetzt ausführen, Pause/Resume.
  5. Sicherungen bauen (4.1): Attrappe entfernen, BackupActionRunner nach dem Vorbild MailerActionRunner, Katalogeintrag backup.run, Aufbewahrung, Wiederherstellung. Läuft unabhängig von 1 bis 4 und kann parallel entstehen.
  6. autostart=true setzen, wenn 1 bis 5 stehen.

Schritt 1 bis 3 sind die Voraussetzung dafür, Schritt 6 überhaupt verantworten zu können. copy_file_to_inbox entfällt ersatzlos und taucht in keinem Schritt auf.

7. Offene Punkte

  • Wohin schreiben Sicherungen? V1 nimmt ein per Property konfiguriertes Verzeichnis, V2 leitet Pfade aus der Container-Konfiguration ab. Für brauchbaren Betrieb gehört die Sicherung aus dem Container heraus. Zu entscheiden, bevor 4.1 gebaut wird.
  • Braucht die Taktänderung eine eigene Operation statt administration_management? Wer Takte stellen darf, kann eine Aufgabe abschalten, die fachlich Pflicht ist (Mailversand, Statusregeln). Ein Audit-Eintrag je Änderung ist in jedem Fall angebracht.
  • Wie viel Laufhistorie, und wird sie mitgesichert oder ist sie wegwerfbar?
  • Eigener Ausdruck oder nur Auswahl? Ein freier cron-Ausdruck ist mächtig und schwer zu validieren; eine reine Auswahl ist sicher, deckt aber Wartungsfenster wie „werktags 02:15" nicht ab. Vorschlag: Auswahl als Vorgabe, freier Ausdruck hinter „Erweitert" mit Mindestfrequenz-Prüfung.