Sync Gate v0.9: Warum ein Pull jetzt genauso parallel läuft wie ein Push

Ein typischer Sync-Gate-Testlauf sieht so aus: 3 000 Bilder in wp-content/uploads/, im Schnitt 800 KB pro Datei, ein paar Dutzend Kandidaten über 5 MB — Produktfotos, PDFs, ein exportierter Katalog. Push läuft seit v0.5 parallel: drei Chunks gleichzeitig in der Luft, curl_multi macht den Rest. Pull dagegen ging bis vorgestern der Reihe nach, Chunk für Chunk. Bei einem 300-MB-Katalog waren das 60 sequenzielle HTTP-Roundtrips.

Mit v0.9.0 fällt diese Asymmetrie weg. Pull holt Chunks jetzt genauso parallel wie Push — dieselbe Concurrency-Einstellung, derselbe HTTP-Layer, dieselben Sicherheitsgarantien. Der Server hat sich dabei nicht verändert.

Warum Pull vorher sequenziell war

Als Chunking in v0.4 dazukam, war die einfachste korrekte Implementierung: Chunk für Chunk fetchen, an eine Temp-Datei anhängen, am Ende SHA256 vergleichen. Append ist trivial richtig — kein Race, kein Offset-Management, kein Lückenrisiko.

Push zog in v0.5 vor, weil dort ein Server-Change nötig war: der neue ChunkStore speichert Chunks als parts/<offset>.bin statt als append an eine gemeinsame assembly.bin. Erst dieses out-of-order-Layout erlaubt, dass Requests in beliebiger Reihenfolge landen und der commit-Schritt sie sortiert zusammensetzt. Auf der Server-Seite ist Chunk-Reihenfolge damit egal, seit v0.5.

Pull braucht diese Server-Umbauten nicht. /files/fetch-chunk ist stateless — es liest einen Range-Slice aus der Datei, gibt Base64 plus SHA256 zurück. Der Server merkt nicht mal, ob der Client die Chunks in Reihenfolge oder gemischt anfordert. Die Sequenzialität lag ausschließlich auf der Client-Seite: der while-Loop in FileSyncer::pull_one_chunked, der einen Fetch nach dem anderen absetzte.

Das Design der neuen Fetch-Schleife

Die neue Version spiegelt send_chunks_parallel aus dem Push-Pfad. Drei Schritte:

  1. Alle Slices vorab enumerieren. Aus total_size und chunk_size (beide kommen aus der /files/fetch-init-Antwort) wird eine Liste aus {offset, length}-Paaren gebaut. Bei einem 300-MB-File mit 5-MB-Chunks sind das 60 Items — kein Speicher-Problem.
  2. Temp-Datei vorallokieren. Chromium tempnam() liefert einen Pfad, fopen('r+b') öffnet ihn, ftruncate($fh, $total_size) reserviert die volle Größe. Damit ist jeder spätere fseek($fh, $offset) in einen validen Bereich sicher, egal in welcher Reihenfolge die Chunks eintreffen.
  3. Batches parallel schicken. Die Slice-Liste wird in Chunks der Größe chunk_concurrency aufgeteilt (Default 3, per Setting bis 10). Jeder Batch geht durch ParallelHttp::execute — denselben curl_multi-Client, den Push seit v0.5 nutzt. Für jede Antwort: Base64 dekodieren, SHA256 prüfen, fseek auf den Ziel-Offset, fwrite.

Am Ende: hash_file('sha256', $tmp) gegen den total_sha256 aus dem Init-Call. Passt es, wird die Temp-Datei ins Zielverzeichnis atomar umbenannt (der bestehende write_local-Pfad mit .bak.<timestamp>-Backup). Passt es nicht, fliegt eine Exception — die Temp-Datei wird gelöscht, das Original bleibt unberührt.

Warum das keinen Server-Change braucht

Weil /files/fetch-chunk schon seit v0.4 pro-Chunk-SHA in der Antwort mitschickt. Das war ursprünglich nur als Sanity-Check gedacht — wenn der HTTP-Body irgendwo unterwegs bit-verfälscht wird, fällt es beim Client sofort auf, statt erst beim Gesamt-SHA. Mit v0.9 wird derselbe Header plötzlich sicherheitsrelevant: er sagt dem Client, welcher Chunk zu welchem Offset gehört, unabhängig davon, in welcher Reihenfolge die Antworten aus dem curl_multi-Multiplexer purzeln.

Ergebnis: v0.9-Clients können gegen v0.4-Server sprechen. Nichts an der Netzwerk-Schnittstelle hat sich geändert. Kein DB-Schema-Change, keine Migration.

Verifikation

Self-Pair-Test auf wp.itdatex.support: die Site paart sich mit sich selbst (Standard-Testweg für Sync Gate), chunk_size auf 1 MB runtergestellt, 3-MB-Random-Testdatei angelegt (dd if=/dev/urandom). FileSyncer::pull_one_chunked wird per Reflection direkt aufgerufen. Drei Chunks, Concurrency 3 — der komplette Fetch geht in einen einzigen Batch.

Ergebnis: total_sha256 matcht das Original, Wall-Clock 0,21 Sekunden, kein einziger fehlgeschlagener Chunk. Der Test ist bewusst klein — er verifiziert die Assembly-Logik (out-of-order writes, fseek+fwrite, SHA-Verify), nicht das Verhalten bei echten Netzwerkfehlern oder großen Files über 100 MB. Diese Klassen bleiben ungetestet, bis eine zweite WordPress-Installation als echter Pair-Partner zur Verfügung steht.

Was das kostet

Peak-RAM während eines Batches: chunk_concurrency × chunk_size × ~1,33. Mit Defaults sind das drei parallel gehaltene 5-MB-Chunks als Base64-Response-Bodies, also rund 20 MB pro Pull-Prozess. Wer chunk_concurrency auf 10 setzt und chunk_size auf 20 MB, landet bei ~265 MB Peak — daran denken, wenn PHP-memory_limit knapp gesetzt ist.

Die vorallozierte Temp-Datei belegt temporär die volle Ziel-Größe auf der Festplatte, wird aber vor rename nicht doppelt vorgehalten — die Datei entsteht atomar aus der Temp-Datei, nicht zusätzlich zu ihr.

Was noch fehlt

Pull hat mit v0.9 die Symmetrie zum Push erreicht, aber ein paar Constraints stehen unverändert:

  • 500 MB pro Datei bleibt die harte Obergrenze (ChunkStore-Konstante).
  • Kein Row-Level-Sync innerhalb einer Tabelle — Tabellen bleiben all-or-nothing.
  • Kein bidirektionales Konflikt-Handling — last-write-wins.
  • Auto-Resume gibt es nur beim Push, nicht beim Pull. Ein abgebrochener Pull startet komplett neu. Dass Pull jetzt parallel läuft, macht diese Lücke kleiner — aber sie besteht.

Alle Details in den Release-Notes auf GitHub oder auf der Sync-Gate-Plugin-Seite.