Sync Gate v0.10: Pull-Auto-Resume — warum ordnerbasiert, nicht WP-Option

Ein 400-MB-Uploads-Diff auf einer wackligen Baustellen-DSL. Beim Kilometer 380 fällt die Verbindung. Bis vor zwei Tagen bedeutete das: neu starten, alle 380 MB verlieren, hoffen dass es diesmal durchgeht. Push hatte seit v0.5 Auto-Resume, Pull nicht — inkonsequent, aber pragmatisch, weil die Persistenz-Frage bei Pull grundlegend anders liegt als bei Push.

Mit v0.10.0 ist auch diese Asymmetrie weg. Abgebrochene Pulls setzen bei den unfertigen Offsets fort. Kein Neustart, kein verlorener Bandbreiten-Traffic.

Warum Pull-Resume nicht einfach Push-Resume mit anderem Vorzeichen ist

Push-Resume lebt größtenteils auf dem Server. Wenn der Client Chunks hochlädt, landen sie im server-seitigen ChunkStore als parts/<offset>.bin. Nach einem Verbindungsabbruch fragt der Client per /files/put-status “welche Offsets hast du schon?” — der Server antwortet mit der Liste, der Client sendet nur die fehlenden, danach kommt der commit-Schritt. Der teure Zustand (die schon übertragenen Bytes) liegt beim Server.

Pull ist umgekehrt. /files/fetch-chunk ist stateless — der Server streamt jeden Range-Slice frisch aus der Original-Datei und hält keinen Kontext zwischen Requests. Der teure Zustand (die schon empfangenen Bytes) liegt beim Client, in einer Temp-Datei, die er selbst verwalten muss.

In v0.9 war das noch ein “einmal-Temp-File”: tempnam() in sys_get_temp_dir(), per ftruncate vorallokiert, per fseek+fwrite aus parallelen Chunks befüllt, am Ende SHA-verifiziert und ins Ziel umbenannt. Wenn zwischendurch die Verbindung wegbrach, war die Temp-Datei weg — spätestens beim nächsten Reboot, weil systemd-tmpfiles /tmp alle paar Tage leerräumt.

Der neue PullSessionStore

Die v0.10-Lösung ist eine neue Klasse PullSessionStore, die spiegelbildlich zum Server-ChunkStore arbeitet, aber komplett auf dem Client lebt. Layout:

wp-content/uploads/itdatex-sync-gate/pulls/
  <session-id>/
    assembly.bin    (sparse, ftruncate'd auf total_size)
    meta.json       {pair_id, relpath, total_size, total_sha256,
                     chunk_size, mtime, created_at,
                     received_offsets: [0, 1048576, ...]}

Der zentrale Trick sitzt in FileSyncer::fetch_chunks_parallel: Nach jedem erfolgreichen fwrite ruft der Client PullSessionStore::record_offset() auf. Das meta.json-Update ist per flock(LOCK_EX) serialisiert, damit parallele Chunk-Writer sich nicht gegenseitig überschreiben.

Wenn der Prozess dann crasht — Verbindungsabbruch, OOM-Kill, User drückt Ctrl-C — bleibt der Zustand konsistent: die geschriebenen Offsets sind in meta.json, der geschriebene Inhalt in assembly.bin. Beim nächsten Sync-Lauf ruft pull_one_chunked zuerst PullSessionStore::find(pair_id, relpath), ein neuer fetch-init prüft ob sich total_sha256 und total_size nicht geändert haben, und wenn ja: nur die noch fehlenden Offsets werden gefetcht.

Warum Ordner statt WP-Option

Push-Resume nutzt get_option('itdatex_sg_pending_sessions') — eine simple Liste von (pair, relpath, session_id, created_at). Der eigentliche Chunk-Zustand liegt beim Server, die Client-Option ist nur ein Pointer.

Beim Pull-Resume liegt der Zustand vollständig am Client — assembly.bin plus Offset-Liste. Zwei Konsistenz-Optionen:

  • WP-Option als Index, Datei als Payload: doppelter Zustand, Race-Gefahr wenn Option und Datei auseinanderdriften.
  • Nur Ordner-Struktur: meta.json pro Session ist die Truth, keine Option. Lookup by (pair_id, relpath) per glob. Bei typisch <10 offenen Sessions kein Perf-Problem.

Zweite Option gewinnt: eine Truth statt zwei. Praktischer Nebeneffekt: der Cleanup-Cron muss nur den Ordner-mtime gegen session_ttl_hours checken, statt Option und Ordner separat pflegen.

Was passiert, wenn sich die Datei remote ändert

Der interessanteste Edge-Case. Angenommen: Pull bricht bei Offset 3, ein Kollege lauft über die Remote-Site und lud dieselbe Datei durch eine neuere Version ersetzen. Der nächste Pull sieht die alte Session, aber der neue fetch-init liefert einen anderen total_sha256.

In dem Fall wird die alte Session verworfen — assembly.bin und meta.json gelöscht — und komplett neu gestartet. Kein Versuch, die zwei Versionen zu mischen. Die Semantik ist “Pull holt die aktuelle Version, wenn sich die aktuelle Version ändert wollen wir die neue, nicht die alte plus Delta”.

Concurrent-Pulls für dieselbe (pair, relpath) sind theoretisch möglich, aber unwahrscheinlich (typisch startet ein Sync-Lauf sequenziell). Wenn es passiert: flock auf meta.json serialisiert Offset-Writes, aber zwei parallele pull_one_chunked-Aufrufe koennten sich gegenseitig die assembly.bin beschreiben. Last-writer-wins, das total_sha256 am Ende faengt inconsistency ab. Kein expliziter Lock, wie schon beim Push.

Verifikation

Self-Pair-Test auf wp.itdatex.support: 5-MB-Random-Datei, chunk_size=1 MB (also 5 Chunks). Zwei Runden:

  1. Runde 1: Manuell abgebrochen nach 2 Chunks (per Reflection direkt fetch_chunks_parallel mit nur zwei Slices aufgerufen). meta.json zeigt received_offsets = [0, 1048576], assembly.bin ist 5 MB gross (sparse) mit 2 MB Inhalt an den ersten zwei Chunk-Positionen.
  2. Runde 2: Normaler pull_one_chunked-Aufruf. find() liefert die Session zurueck, es werden nur die restlichen 3 Chunks gefetcht, 0,20 s Wall-Clock, total_sha256 matcht das Original, Session-Ordner nach Commit weg.

Was der Test nicht abdeckt: echter Netzwerk-Abbruch mid-fetch (nur ein echter zweiter WP-Host würde das exerzieren), Cross-Site-Sync mit unterschiedlichen URLs im Base-URL-Rewrite, sehr grosse Dateien über 100 MB. Diese Klassen bleiben auf der v0.11+-Roadmap.

Was v0.10 nicht loest

  • Table-Sync ist weiter all-or-nothing — eine unterbrochene Tabellen-Übertragung startet komplett neu, nur File-Sync hat Resume.
  • Bidirektionales Konflikt-Handling bleibt last-write-wins. Wenn Push und Pull kollidieren, verliert einer.
  • Der Sparse-assembly.bin-Ansatz braucht bei einer 400-MB-Datei temporär 400 MB Disk (plus die 400 MB der finalen Datei, kurzzeitig gleichzeitig während Rename). Wer knappe uploads/-Partition hat, sollte im Auge behalten.

Details im GitHub-Release oder auf der Plugin-Seite.