# Scheduler und Auftragsverwaltung ## Status Die Kette ist geschlossen: **Auftrag → Scheduler → Backup Engine → Repository → Wiederherstellung.** Der Scheduler sichert selbstständig. Real nachgewiesen mit 38 MiB in 24 Objekten: Auftrag über die API angelegt, vom Scheduler ausgeführt, Integritätsprüfung sauber (44 Chunks), Quelle gelöscht, aus der Zusatzsicherung wiederhergestellt — **bitgenau identisch** inklusive Rechten, Symlink und leerem Verzeichnis. Der zweite Lauf las 0,0 MiB in 0,06 s statt 38,1 MiB in 0,57 s. Es fehlen noch Quellen außer Dateisystemen (siehe „Offene Punkte"). ## Die Ausführungsschleife Ein Durchgang (Standardtakt 10 s): 1. **Verwaiste Läufe freigeben** — Selbstheilung nach dem Absturz eines Control-Servers. 2. **Fällige Aufträge übernehmen** und ihre Läufe anlegen. 3. **Anstehende Läufe übernehmen** und ausführen. 4. **Ergebnis festschreiben**, gegebenenfalls Wiederholung einreihen. ### Die Executor-Schnittstelle ```go type Executor interface { Execute(ctx context.Context, req ExecutionRequest) (ExecutionResult, error) } ``` Sie hält die Schleife frei von Backup Engine, Repository und Providern. Das ist nicht nur Ordnung: Ohne sie liesse sich das Zusammenspiel von Zeitplan, Wartungsfenster, Nebenläufigkeit und Wiederholung nur mit einem echten Repository prüfen — also praktisch gar nicht. Alle zwölf Schleifentests laufen gegen einen aufzeichnenden Ersatz. Umgesetzt ist sie in `packages/backupexecutor`. Ohne eingerichteten Executor greift `NotImplementedExecutor` und meldet `EXECUTOR_NOT_CONFIGURED` — ein Executor, der stillschweigend Erfolg meldete, wäre das gefährlichste Fake-Feature der Anlage: grüne Läufe in der Oberfläche, leeres Repository. ## Der Backup-Executor Ablauf je Lauf: Repository auflösen und öffnen → jede Quelle sichern → Backup in der Control Plane vermerken → Kennzahlen zurückgeben. **Das Repository wird einmal für den ganzen Lauf geöffnet** und hält dabei die Schreibsperre. Es je Quelle zu öffnen und zu schließen liefe auf ein Wechselspiel um dieselbe Sperre hinaus. ### Verschlüsselung ist keine Option Ein Executor ohne Schlüsselmaterial wird **beim Einrichten abgelehnt**, nicht erst beim ersten Lauf um zwei Uhr nachts. Wer unverschlüsselt sichern will, muss `AllowUnencrypted` setzen — und bekommt bei jedem Start eine Warnung ins Protokoll. Ein Executor, der ohne Schlüssel trotzdem sicherte, legte unverschlüsselte Backups an, ohne dass es jemandem auffiele. ### Eine gescheiterte Quelle bricht den Lauf nicht ab Die übrigen sollen gesichert werden. Verschwiegen wird sie trotzdem nicht: Sie zählt als übergangenes Objekt, und damit ist der Lauf ein **Teilfehler** — niemals ein Erfolg. Scheitern *alle* Quellen, ist der Lauf gescheitert (`ALL_SOURCES_FAILED`). Real geprüft: Ein Auftrag mit einer guten und einer fehlenden Quelle endet mit `partial_failure`, 24 gesicherten Objekten und einer übergangenen — und die gute Quelle liegt vollständig im Repository. ### Zusatzsicherung ist der Standard Liegt ein Elternbackup in der Kette, wird inkrementell gesichert. Das ist die richtige Voreinstellung: Der Gewinn ist Lesezeit, und die ist bei jedem Lauf nach dem ersten der begrenzende Faktor. Je Quelle und Repository gibt es genau **eine** offene Kette. Zwei Quellen in einer Kette zu führen machte die eigenständige Wiederherstellung einer einzelnen Quelle unmöglich. ### Die Backup-Kennung enthält die Laufkennung ``` run-- ``` Damit lässt sich von einem Backup im Repository aus zurückverfolgen, welcher Lauf es erzeugt hat — **auch dann, wenn die Datenbank verloren ging**. Der Quellanteil ist nur eine Lesehilfe und wird gekürzt; das Repository begrenzt Kennungen auf 64 Zeichen, weil sie zu Dateinamen werden. Pfadtrenner, führende Punkte und `..` werden ersetzt. ### Der Datenbankeintrag ist ein Verweis, keine Kopie `RecordBackup` hält fest, **wo** das Manifest liegt, nicht seinen Inhalt. Das Repository bleibt ohne die Datenbank rekonstruierbar (SYNCOVA_ARCHITECTURE.md §10); der Eintrag beschleunigt nur die Suche. Scheitert er, wird das protokolliert und **nicht** geworfen: Das Backup liegt bereits vollständig im Repository und ist von dort wiederherstellbar. Den Lauf als gescheitert zu melden, obwohl die Daten sicher sind, führte zu einer sinnlosen Wiederholung. ### Ausgetauschtes Repository Beim ersten Öffnen wird die im Repository hinterlegte Kennung übernommen. Weicht sie später ab, wurde das Verzeichnis ausgetauscht. Der Lauf wird deswegen **nicht** abgebrochen — das Repository ist in sich stimmig —, aber die Angaben der Control Plane zu Backups und Belegung beziehen sich dann auf einen fremden Bestand. Das gehört ins Protokoll. ### Übergangene Objekte sind ein Teilfehler Die Auswertung eines Laufs folgt einer festen Reihenfolge, und die ist nicht beliebig: 1. **Abbruch** wiegt am schwersten. Wer abbricht, will kein Ergebnis mehr; ein „gescheitert" löste eine sinnlose Wiederholung aus. 2. **Fehler** des Executors — mit seiner Fehlerklasse, die über die Wiederholung entscheidet. 3. **Übergangene Objekte** — auch **ohne** gemeldeten Fehler wird der Lauf zum Teilfehler. Punkt 3 ist der Kern. Ein Executor kann fehlerfrei durchlaufen und trotzdem 17 Dateien nicht gelesen haben. Dieser Lauf ist kein Erfolg. Die Begründung kommt mit: „17 Objekte übergangen" ist keine Auskunft, „17 Objekte wegen fehlender Leseberechtigung" schon. ### Was nicht wiederholt wird **Ein Teilfehler wird nicht wiederholt.** Die übergangenen Objekte wären beim nächsten Versuch mit hoher Wahrscheinlichkeit dieselben, und der Lauf kostete die volle Zeit für dasselbe Ergebnis. Er verlangt einen Blick, keine Wiederholung. Gescheiterte Läufe werden nach der Wiederholungsstrategie des Auftrags erneut eingereiht — als eigener Lauf mit `trigger=retry` und erhöhter Versuchsnummer, geplant für den Zeitpunkt nach dem Backoff. Der neue Lauf entsteht erst, **nachdem** der gescheiterte abgeschlossen wurde; sonst verhinderte ihn der Teilindex, der nur einen aktiven Lauf je Auftrag zulässt. ### Versäumte Läufe werden übersprungen, nicht nachgeholt War der Server drei Tage aus, liegt der geplante Zeitpunkt drei Tage zurück. Der Auftrag läuft **einmal**, nicht dreimal: Drei Sicherungen desselben Bestands hintereinander kosten Zeit und Platz, ohne einen einzigen zusätzlichen Wiederherstellungspunkt zu schaffen — die Daten von vorgestern gibt es nicht mehr. Die Zahl der übersprungenen Läufe wird protokolliert, nicht verschwiegen. **Kein Drift:** Gerechnet wird vom geplanten Zeitpunkt aus, nicht vom tatsächlichen Beginn. Sonst schöbe sich ein Auftrag mit jeder Verspätung weiter nach hinten. Und der nächste Zeitpunkt wird **beim Übernehmen** fortgeschrieben, nicht nach dem Lauf: Sonst bliebe der Auftrag bei einem langen Backup fällig und würde im nächsten Durchgang erneut übernommen. ### Selbstheilung nach einem Absturz Das ist der wichtigste Mechanismus der ganzen Ausführung. Stirbt ein Control-Server mitten im Lauf, bleibt dessen Zeile auf `running` stehen. Der Teilindex lässt dann keinen weiteren Lauf dieses Auftrags zu — die Sicherung fiele **dauerhaft** aus, ohne dass jemand einen Fehler sähe. Es gäbe nur einen Auftrag, der nie wieder läuft. Deshalb meldet jeder laufende Vorgang sich alle 30 Sekunden lebendig. Bleibt die Meldung fünf Minuten aus, gilt der Lauf als verwaist und wird freigegeben — als **gescheitert** vermerkt, nicht gelöscht: Der Abbruch ist ein Ereignis, das ins Protokoll gehört. Die Fehlerklasse `transient` sorgt dafür, dass die Wiederholungsstrategie greift. **Die Frist muss deutlich über dem Meldeabstand liegen.** Andernfalls gäbe ein kurzer Aussetzer — eine langsame Datenbank, ein überlasteter Server — einen noch laufenden Lauf frei, und derselbe Auftrag liefe zweimal. Die Schleife lehnt eine Frist unterhalb des Meldeabstands beim Start ab; der Fehler wäre im Betrieb kaum zu finden. ### Geordnetes Beenden Bei SIGTERM werden die laufenden Vorgänge **abgebrochen**, nicht zu Ende geführt. Sie laufen zu lassen hiesse, das Herunterfahren an das längste Backup zu hängen — bei einem mehrstündigen Lauf ein Neustart, der nie endet. Das Ergebnis wird trotzdem geschrieben, mit eigenem Kontext: Sonst bliebe die Zeile auf `running` stehen und blockierte den Auftrag bis zum Ablauf der Frist. Verstreicht die Frist von 30 Sekunden, während noch Läufe aktiv sind, wird das als Warnung gemeldet — sie werden dann von der Freigabe verwaister Läufe eingesammelt. Das ist der ehrliche Ausgang: Wir wissen nicht, ob sie noch etwas schreiben. ## Der Backup-Wizard Zehn Schritte von Name bis Anlegen (`apps/web/src/features/jobs`). Der Aufbau folgt drei Entscheidungen: **Die Logik liegt getrennt von der Darstellung** (`wizardModel.ts`). Welcher Schritt vollständig ist und was in die Anfrage wandert, ist reine Berechnung — und damit ohne gerenderte Maske prüfbar. 23 der 34 Frontend-Tests laufen gegen das Modell. **Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern.** Sonst wäre jeder Blick zurück ein Datenverlust. Vorwärts geht es nur über einen vollständigen Schritt; ein noch nicht erreichter Schritt ist in der Leiste nicht anklickbar — er würde eine Prüfung überspringen, die der Assistent gerade führen soll. **Fehler erscheinen erst beim Versuch weiterzugehen.** Sie von Anfang an zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren. ### Drei Schritte fragen nichts ab Aufbewahrung, Prüfung und Benachrichtigung haben keine Umsetzung im Backend. Sie erscheinen trotzdem — der Plan nennt zehn Schritte —, aber **ohne Eingabefelder**. Eine Maske, die Werte sammelt, die niemand auswertet, ist ein vorgetäuschtes Funktionsversprechen (PROMPT.md §138). Stattdessen steht dort, was ohne diese Einstellung tatsächlich geschieht: > **Noch nicht verfügbar** — Aufbewahrungsregeln sind noch nicht umgesetzt. Backups bleiben bis auf Weiteres vollständig erhalten und werden nicht automatisch gelöscht — der Speicherbedarf wächst also mit jedem Lauf. Die Übersicht in Schritt 9 wiederholt das: „Aufbewahrung: unbegrenzt (Regeln noch nicht umgesetzt)". Eine leere Zeile ließe offen, ob nichts eingestellt oder nichts möglich ist. Ebenso im Quellschritt: Proxmox-, Windows- und Linux-Quellen stehen in der Auswahl, sind aber deaktiviert und als „noch nicht verfügbar" gekennzeichnet — statt sie wegzulassen und den Anwender rätseln zu lassen, ob sie fehlen oder nur versteckt sind. ### Was der Wizard nicht entscheidet **Verschlüsselung ist keine Wahl.** Der Executor verweigert den Dienst ohne Schlüsselmaterial; eine Schaltfläche zum Abschalten wäre eine Einstellung, die es nicht gibt. Der Schritt „Sicherheit" sagt das und fragt stattdessen nach Dringlichkeit und Bandbreitengrenze. **Ob ein Repository Sicherungen annimmt, entscheidet der Server.** Die API liefert `accepts_backups`; die Oberfläche müsste sonst wissen, welche Zustände schreibend sind. Gesperrte Ziele erscheinen in der Liste, sind aber nicht wählbar und tragen ihren Grund — sie wegzulassen ließe den Anwender ein Repository suchen, das er nicht findet. ### Die Zeitzone kommt aus dem Browser Voreingestellt ist `Intl.DateTimeFormat().resolvedOptions().timeZone`. Ohne Angabe rechnet der Server in UTC — derselbe Auftrag liefe dann je nach Standort zu einer anderen Uhrzeit. Bei einem Intervallplan wird die Zeitzone weggelassen: Er zählt Abstände, keine Uhrzeiten. ### Der Repository-Endpunkt `GET /api/v1/repositories` (Recht `repositories.read`), rein lesend und ohne Pagination — ein Betrieb hat eine Handvoll Ziele, nicht tausende. Das **Anlegen** bleibt bei `syncova-repo create`: Ein Repository entsteht auf einem Datenträger, nicht in einer Datenbankzeile. ### Nachgewiesen Genau die Anfrage, die der Wizard baut, gegen den laufenden Dienst: Auftrag angelegt (`täglich um 02:00 Uhr (Europe/Berlin)`, nächster Lauf berechnet), ausgeführt (4,8 MiB, 5 Objekte, 0,16 s), wiederhergestellt — und die im Wizard eingetragene Ausschlussregel `*.tmp` hat gewirkt: Die Datei fehlt im Backup. ## Die Zeitumstellung Das ist der Punkt, an dem die meisten Scheduler stillschweigend danebenliegen, und er kostet genau einen Backup-Lauf im Jahr — den niemand vermisst, bis er gebraucht wird. **Frühjahr, die Uhr springt von 02:00 auf 03:00.** Ein Auftrag „täglich um 02:30" hat an diesem Tag keinen Zeitpunkt. Wer den Tag überspringt, lässt die Sicherung ausfallen. Syncova nimmt den nächsten gültigen Zeitpunkt (03:30) — der Lauf findet statt. **Herbst, 02:00 bis 03:00 wiederholt sich.** „Täglich um 02:30" gäbe es zweimal. Es läuft nur einmal, und zwar beim ersten Vorkommen: Ein Backup lieber eine Stunde früher als zwei Backups, die zwei Ketten anlegen. Beides ist mit `Europe/Berlin` gegen die echten Umstellungstermine 2026 geprüft (`TestDailyScheduleSurvivesSpringForward`, `TestDailyScheduleRunsOnceOnFallBack`). Daraus folgt auch die Suchweise: Kalenderzeitpläne werden **tageweise mit `AddDate`** gesucht, nicht mit `Add(24h)`. An Umstellungstagen hat ein Tag 23 oder 25 Stunden; mit einer festen Stundenzahl verschöbe sich die Suche zweimal im Jahr. **Ohne Zeitzone gilt UTC**, nicht die Ortszeit des Servers. Andernfalls liefe dieselbe Konfiguration auf zwei Servern zu verschiedenen Zeiten — der häufigste Grund für ein Backup, das „mal um zwei und mal um vier" läuft. ## Zeitplanarten | Art | Beispiel | | --- | --- | | `manual` | nur auf Anforderung | | `interval` | alle 6 Stunden (mindestens 1 Minute) | | `hourly` | stündlich zur Minute 30 | | `daily` | täglich um 02:00 | | `weekly` | samstags und sonntags um 22:00 | | `monthly` | am 1. und am **letzten** Tag des Monats | | `cron` | `*/15 9-17 * * mon-fri` | **Der Monatsletzte (`-1`) ist kein Luxus.** Wer „am 31." einträgt, bekommt in vier Monaten des Jahres keine Sicherung. Wer den Monatsabschluss sichern will, meint den letzten Tag. ### Der eigene Cron-Parser Cron ist klein; die Fallstricke liegen nicht im Zerlegen, sondern in Zeitzonen und einer Sonderregel, die ohnehin selbst gelöst werden muss: **Sind Tag-des-Monats **und** Wochentag eingeschränkt, gilt ODER statt UND.** `0 0 13 * 5` bedeutet „am 13. **oder** freitags", nicht „an Freitagen, die der 13. sind". Wer das als UND umsetzt, baut einen Zeitplan, der fast nie läuft — und es fällt erst nach Monaten auf. Sekunden gibt es bewusst nicht: Ein Sicherungsauftrag im Sekundentakt ist kein Zeitplan, sondern ein Tippfehler. Sonntag darf `0` oder `7` heißen, Namen (`mon`, `jan`) sind zugelassen, `@daily` und Verwandte auch. ## Wartungsfenster Zwei Wirkungen: `blackout` unterdrückt Läufe im Fenster, `allowed` lässt sie **nur** im Fenster zu. **Ein durch ein Fenster verhinderter Lauf wird verschoben, nicht übergangen.** Ihn ausfallen zu lassen erzeugte eine Lücke in der Sicherungskette, von der niemand erführe: Dass ein Lauf verspätet ist, sieht man; dass er fehlt, nicht. Zwei Feinheiten: - **Ein wiederkehrendes Fenster hat eine Länge, keine Endzeit.** Sonst bräuchte „22:00 bis 02:00" eine Sonderregel, die gern vergessen wird. Die Auswertung schaut acht Tage zurück: Ein Fenster, das samstags um 22:00 beginnt und zwölf Stunden dauert, reicht bis Sonntag 10:00 — wer nur den Tag des Zeitpunkts prüft, hielte Sonntag 09:00 für frei. - **Ein Erlaubnisfenster für Auftrag A sperrt Auftrag B nicht.** Ohne diese Einschränkung fielen sämtliche Sicherungen aus, sobald irgendwo ein Erlaubnisfenster existiert. Findet sich binnen eines Monats kein freier Zeitpunkt, ist das ein **Fehler** und kein stiller Ausfall — fast immer eine Fehlkonfiguration. ## Warteschlange ### Verhungerungsschutz Ein wartender Auftrag gewinnt 50 Punkte je Stunde, gedeckelt bei 100. Ohne Alterung verhungert ein Auftrag niedriger Priorität in einer Anlage mit genug dringenden Aufträgen **für immer** — und niemand bemerkt es, weil er technisch „wartet" statt zu scheitern. Der Deckel entspricht dem Abstand zweier Stufen: Ein wartender Auftrag steigt höchstens um eine Stufe auf und überholt damit nie eine kritische Sicherung. Eine unbekannte Prioritätsstufe gilt als `normal`, nicht als höchste: Ein Tippfehler soll keinen Auftrag an die Spitze befördern. ### Nebenläufigkeitsgrenzen Global, je Repository und je Quelle. Die letzten beiden sind keine Feinheit: - **Je Repository:** Das Repository hält beim Schreiben eine Sperre. Mehrere gleichzeitige Läufe warten ohnehin aufeinander und belegen dabei nur Arbeitsspeicher. - **Je Quelle:** Zwei gleichzeitige Sicherungen derselben VM lasten den Wirt aus, ohne zusätzlichen Nutzen. Jeder zurückgehaltene Auftrag trägt eine **Begründung**. Ein wartender Auftrag ohne Begründung ist für den Betrieb wertlos: Man sieht, dass nichts geschieht, aber nicht warum. ### Abhängigkeiten Eine Abhängigkeit ist erfüllt, wenn der Vorgänger zuletzt **vollständig** gelungen ist. **Ein Teilfehler genügt nicht:** Wer eine Datenbank sichert und danach das Anwendungsverzeichnis, will nicht das Verzeichnis zu einer halben Datenbank. Ringschlüsse werden beim Anlegen erkannt, nicht beim Ausführen. Ein Ring bliebe sonst unbemerkt: Jeder Auftrag wartete auf einen anderen, keiner liefe je an, und die Oberfläche zeigte lauter „wartende" Aufträge ohne erkennbaren Grund. ## Wiederholungen Wiederholt wird **nur** bei `transient`, `network`, `repository` und `source`. Ein Anmeldefehler behebt sich nicht durch Warten; ein Integritätsfehler wird durch Wiederholen nicht besser, sondern nur später bemerkt; ein sicherheitsrelevanter Fehler gehört gemeldet, nicht verschluckt. Voreinstellung: drei Versuche, 30 s → 60 s → 120 s, gedeckelt bei 15 Minuten. **Der Deckel ist wesentlich:** Ohne ihn wären es beim achten Versuch über eine Stunde und beim zehnten mehr als vier — ein nächtliches Sicherungsfenster wäre längst vorbei. Mehr als zehn Versuche sind nicht zugelassen; sie verschleiern ein dauerhaftes Problem, statt es zu melden. **Die Streuung wirkt nur nach unten.** Nach oben verlängerte sie die Wartezeit über die vereinbarte Grenze — der Deckel wäre dann keiner. Ihr Zweck ist der Gleichlauf: Fallen zwanzig Aufträge derselben Störung zum Opfer, liefen sie ohne Streuung alle gleichzeitig wieder an und überlasteten die eben erholte Gegenstelle sofort erneut. Gerechnet wird in Gleitkomma statt mit Schiebeoperationen: Ein Schieben um mehr als 62 Stellen liefe über und machte aus einer langen Wartezeit eine negative. Eine unbekannte Fehlerart gilt als **dauerhaft**, also „nicht wiederholen". Der umgekehrte Standard wäre gefährlicher: Ein falsch als vorübergehend eingeordneter Fehler liesse einen aussichtslosen Auftrag immer wieder anlaufen und verdeckte dabei die Ursache. ## Bandbreitengrenze Token-Bucket, kein festes Zeitfenster: Ein Fenster erlaubt an seiner Grenze die doppelte Rate — 10 MB am Ende des einen und 10 MB am Anfang des nächsten ergeben 20 MB in kurzer Folge. ### Was begrenzt wird **Das Lesen von der Quelle**, nicht das Schreiben ins Repository. Weil die Pipeline mit Gegendruck arbeitet, bremst das den gesamten Ablauf: Was nicht gelesen wird, wird auch nicht gehasht, komprimiert, verschlüsselt und abgelegt. Ein Begrenzer an dieser einen Stelle bindet damit die gesamte Last. Das ist auch die Größe, die ein Betreiber meint: „Lies höchstens 50 MB/s vom Dateiserver." Die Last auf dem produktiven System entsteht beim Lesen. **Nicht begrenzt wird der Schreibweg zum Repository.** Bei einem lokalen Repository fällt das zusammen; bei einem entfernten (NFS, später Objektspeicher) wäre es eine eigene Grenze — und wegen der Deduplizierung eine ganz andere Zahl: Von 19 MiB gelesener Daten gehen bei einem zweiten Lauf 0 MiB über die Leitung. ### Ein Begrenzer je Lauf Er gilt für den **gesamten** Lauf und wird über alle Quellen und alle Arbeiter geteilt. Je Quelle oder je Arbeiter einen eigenen zu führen ergäbe ein Vielfaches der vereinbarten Rate — genau der Fehler, der eine Bandbreitengrenze wirkungslos macht. Ein Test prüft das mit drei Quellen: Sie brauchen zusammen die Zeit, die eine einzelne Quelle dreifacher Größe bräuchte. Beim Lesen wird **nach** der Übertragung gewartet (am Dateiende zahlte man sonst für Bytes, die es nicht mehr gibt), beim Schreiben **davor** (die Menge steht fest, und die Leitung wäre sonst schon belegt). ### Gemessen 19,1 MiB in einem Repository, derselbe Bestand zweimal über den Scheduler: | | Dauer | Durchsatz | | --- | --- | --- | | ohne Grenze | 0,37 s | 51,7 MiB/s | | `bandwidth_limit_bps: 1048576` | 18,17 s | **1,05 MiB/s** | Der Eimer liefert eine Sekunde Vorrat, deshalb liegt der gemessene Durchschnitt bei kurzen Läufen etwas über der Grenze — bei 2 MiB gegen 1 MiB/s sind es 1,79 MiB/s. Über längere Läufe verschwindet der Effekt, wie die Messung oben zeigt. ### Bit und Byte `100Mbit` ist ein Achtel von `100MB`. Netzwerkleute rechnen in Bit, Speicherleute in Byte; wer das verwechselt, vergibt das Achtfache. Beide Schreibweisen werden angenommen, ebenso `50MB/s`, `1,5MB` und `1.5MB`. ```bash syncova-agent backup --repository /backup/repo --path /daten --bandwidth 50MB syncova-agent backup --repository /backup/repo --path /daten --bandwidth 400Mbit ``` Im Auftrag: `"bandwidth_limit_bps": 52428800`. Die Angabe wird vor dem Öffnen des Repositorys geprüft — ein Tippfehler soll nicht erst nach dem Sperren auffallen. ## Datenbank Migration `000004_jobs`. Zehn Tabellen; drei Entscheidungen lohnen die Erwähnung: **Ein Lauf ist eine eigene Zeile, kein Zustandsfeld am Auftrag.** Ohne Historie ist nicht feststellbar, ob eine Sicherung regelmäßig gelingt. **Ein Teilindex erzwingt einen aktiven Lauf je Auftrag:** ```sql CREATE UNIQUE INDEX backup_job_runs_single_active_idx ON backup_job_runs (job_id) WHERE status IN ('queued','running'); ``` Zwei Control-Server, die gleichzeitig denselben fälligen Auftrag sehen, prüfen beide erfolgreich und starten beide — die Datenbank lässt nur einen durch. Der Übernahmemechanismus nutzt zusätzlich `FOR UPDATE SKIP LOCKED`: Der erste Server sperrt die Zeilen, der zweite überspringt sie, statt zu warten. Geprüft mit vier gleichzeitigen Servern auf zehn Aufträge (`TestClaimDueJobsNeverHandsOutSameJobTwice`). **Regeln stehen als CHECK in der Datenbank, nicht nur im Code:** ```sql CONSTRAINT backup_job_runs_skipped_is_not_success CHECK ( files_skipped = 0 OR status <> 'succeeded' ) ``` Ein Lauf mit übergangenen Objekten kann nicht als Erfolg dastehen. Im Code müsste jede Stelle die Regel einhalten — und eine davon vergisst es. Ebenso abgesichert: Zusatzsicherung ohne Elternbackup, Selbstabhängigkeit, Bandbreitengrenze 0, Wartungsfenster mit einmaliger *und* wiederkehrender Angabe. Alle acht Regeln sind gegen die echte Datenbank geprüft. **Aufträge werden weich gelöscht.** Ein hart gelöschter Auftrag risse seine Läufe mit — und damit den Nachweis, dass gesichert wurde. ## API ``` GET /api/v1/jobs jobs.read Filter: status, repository, search; Pagination POST /api/v1/jobs jobs.write GET /api/v1/jobs/{id} jobs.read DELETE /api/v1/jobs/{id} jobs.write weich, immer auditiert POST /api/v1/jobs/{id}/pause jobs.run auditiert POST /api/v1/jobs/{id}/resume jobs.run auditiert, berechnet next_run_at neu POST /api/v1/jobs/{id}/run jobs.run 202: reiht einen Lauf ein, auditiert GET /api/v1/jobs/{id}/runs jobs.read Historie mit Pagination POST /api/v1/backup-runs/{id}/cancel jobs.run bricht einen Lauf ab, auditiert ``` Beispiel: ```json { "name": "Nächtliche Sicherung", "priority": "high", "schedule": {"type": "daily", "time": "02:00", "time_zone": "Europe/Berlin"}, "sources": [ {"type": "filesystem", "id": "/daten", "exclude_patterns": ["*.tmp"]}, {"type": "proxmox_vm", "id": "qemu/100"} ], "repository_id": "…", "bandwidth_limit_bps": 52428800 } ``` Die Antwort enthält `schedule_description` („täglich um 02:00 Uhr (Europe/Berlin)"). Ein Cron-Ausdruck sagt einem Anwender wenig; die Erklärung entsteht auf dem Server, damit sie in Oberfläche und Benachrichtigung gleich lautet. **Der nächste Zeitpunkt wird beim Anlegen sofort berechnet.** Ohne ihn stünde der Auftrag als aktiv da, ohne je zu laufen. Beim Fortsetzen wird er neu berechnet: Ein Auftrag, der eine Woche ausgesetzt war, liefe sonst sofort los und danach zur falschen Zeit weiter. **Das Aussetzen wird auditiert wie eine Löschung.** Es lässt den Schutz still auslaufen, ohne dass etwas kaputtgeht — genau deshalb gehört es ins Protokoll. ### Widersprüchliche Schutzziele werden abgelehnt ``` VALIDATION_FAILED: der zeitplan läuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt von 4 Stunden nicht einhalten ``` Ein RPO, den der Zeitplan nicht einhalten kann, ist keine Kleinigkeit: Der Betreiber glaubt, höchstens vier Stunden zu verlieren, während der Auftrag nur täglich läuft. Das gehört beim Anlegen gesagt, nicht nach dem Ausfall. ## Rettung nach abgebrochener Migration Bricht eine Migration mitten in der Datei ab, merkt golang-migrate die Datenbank als „dirty" vor und verweigert jeden weiteren Schritt. Bisher gab es dafür keinen Weg zurück außer von Hand in der Datenbank — was ein Migrationswerkzeug gerade ersparen soll. ```bash syncova-migrate status # zeigt den abgebrochenen Zustand # Schema von Hand auf einen bekannten Stand bringen SYNCOVA_MIGRATE_CONFIRM_FORCE=yes syncova-migrate force 3 ``` **`force` führt kein SQL aus. Es behauptet nur einen Stand.** Wer es falsch anwendet, lässt die Anwendung gegen ein Schema arbeiten, das sie für ein anderes hält. Deshalb: ausdrückliche Bestätigung über die Umgebungsvariable, Ausgabe des Vorher/Nachher, und eine Ablehnung auf sauberer Datenbank — dort gibt es nichts zu retten, und die Folge wäre ein übersprungenes Schema. ### Warum 202 und nicht 201 `POST /jobs/{id}/run` reiht den Lauf ein; die Schleife holt ihn im nächsten Durchgang. Die Sicherung hat also noch nicht begonnen — 201 („angelegt") behauptete mehr, als geschehen ist. Ebenso beim Abbruch: Er wird vermerkt, ein bereits laufender Vorgang endet erst beim nächsten Durchgang. Auch das wird gesagt, statt einen beendeten Lauf vorzutäuschen. Ein **ausgesetzter** Auftrag wird nicht heimlich reaktiviert: Wer ihn ausführen will, setzt ihn zuerst fort. Sonst liefe er einmal und schwiege danach wieder, ohne dass es jemandem auffiele. Ein zweiter Anstoß bei laufendem Auftrag ergibt **409**, keinen Serverfehler: Der Aufrufer hat nichts falsch gemacht, der Auftrag läuft nur bereits. Die Laufhistorie weist `delay_seconds` aus — die Abweichung zwischen geplantem und tatsächlichem Beginn. Ein Lauf, der regelmäßig eine Stunde zu spät beginnt, hat ein Problem, das man ohne diesen Vergleich nicht sieht. ## Offene Punkte - **Aufbewahrung, Prüfung und Benachrichtigung** — im Wizard als „noch nicht verfügbar" gekennzeichnet, weil dahinter nichts liegt. - **Nur Dateisystemquellen.** `proxmox_vm`, `windows_system` und `linux_system` werden abgelehnt, nicht stillschweigend übergangen — eine übergangene Quelle ergäbe ein Backup, das vollständig aussieht und es nicht ist. Für Proxmox fehlt der `ArchiveTransport` (siehe `proxmox.md`), für die Systemquellen die Auftragsübermittlung an den Agent. - **Der Schreibweg zum Repository ist nicht begrenzt.** Bei einem lokalen Repository fällt das mit dem Lesen zusammen; für ein entferntes Ziel braucht es eine eigene Grenze. - **Die Wiederherstellung ist nicht begrenzt.** Ein Restore kann die Leitung voll belegen. - **`logical_bytes` einer Zusatzsicherung ist die gelesene, nicht die gesicherte Menge.** Bei einem Lauf ohne Änderungen steht dort 0, obwohl das Backup den vollen Bestand beschreibt. Für die Belegungsrechnung ist das irreführend und gehört korrigiert. - **Keine Wiederherstellung über die API.** Sie geht nur über `syncova-agent restore`. - **`PATCH /jobs/{id}`** — Ändern ist noch nicht umgesetzt; ein Auftrag muss gelöscht und neu angelegt werden. - **Wartungsfenster nur im Code**, ohne API und ohne Anbindung an die Auftragsübernahme. - **Kein `Idempotency-Key`** bei `POST /jobs` (SYNCOVA_API.md verlangt ihn). - **Retention-Regeln** sind als Tabelle angelegt, aber ohne Auswertung.