Robuster Sync für Local First

Als ich in den 80ern mit dem Programmieren begann, war die Situation einfach: Daten wurden in eine Datei auf einem lokalen Datenträger geschrieben. Sollten Dateien ausgetauscht werden, wurde eine Diskette einfach in einen anderen Computer geschoben und bearbeitet.

Später verschwanden die Disketten und CD-Laufwerke und Dateien wurden entweder per E-Mail ausgetauscht oder die Daten direkt auf einem Server gespeichert. Selbst Microsoft Word wanderte irgendwann in die Cloud.

Doch mit der Verlagerung der Daten vom lokalen Rechner in die Cloud entstanden neue Probleme. Was, wenn der Anbieter seinen Dienst schloss? Aber nicht nur Datenverlust, sondern auch das Gefangen sein beim Anbieter, weil die Daten nicht mehr aus dem Dienst herauszubekommen waren, entwickelte sich zunehmend zu einem Problem für die einen und ein Geschäftsmodell für die anderen.

Local-First

Die Local-First-Bewegung möchte die Daten wieder zum Nutzer zurückbringen und trotzdem die Vorteile des Internets erhalten. Für das gleichzeitige Bearbeiten von Daten wurde mit CRDT in eine robuste und weithin akzeptierte Lösung gefunden, für den Austausch der Daten jedoch noch nicht. Denn ganz ohne Dienste auf einem Server geht es (noch) nicht. Selbst bei Peer-to-Peer, wo die Geräte überwiegend direkt miteinander kommunizieren, muss am Anfang die Verbindung vermittelt werden.

In einer idealen Local-First Welt sollten folgende Kriterien erfüllt sein in Bezug auf den Austausch der Daten:

  1. Daten werden lokal bearbeitet und gespeichert (Offline).
  2. Der Sync kann für längere Zeit unterbrochen werden, Daten können jedoch währenddessen lokal bearbeitet werden und trotzdem nach erneutem Sync überall wieder identisch sein (CRDT).
  3. Daten sollten unterwegs nicht einsehbar sein (E2EE).

Und ich würde noch gerne hinzufügen:

  1. Das Ganze sollte auch mit der Technik der 80er Jahre funktionieren.

Warum? Aus Gründen der Resilienz, denn es gibt viele Gründe, warum wir offline gehen könnten.

Resilient Sync

Aus diesem Grund schlage ich ein Datenaustauschformat vor, das sowohl im Dateisystem als auch im Internet funktioniert. Es soll so einfach sein, dass die Implementierung auf jedem zugrundeliegenden Datenträger oder Service funktionieren kann.

Log der Änderungen

Zunächst schreibt jeder Client eine Art einfaches, fortlaufendes Log mit den Änderungen. Die Position der Änderungen nennen wir index und sie beginnt bei 0 und wird in ganzen Zahlen fortgeführt: 0, 1, 2, 3, .... Üblicherweise sind diese Änderungen CRDT konform, aber das ist nicht erheblich für das Protokoll. Ebenso können diese Daten verschlüsselt abgelegt werden. Nennen wir diese Daten data.

Jeder Client erhält eine eindeutige Kennung, üblicherweise eine Unique ID (UUID), wir nennen sie clientId. Die Änderungs-Logs werden mit der clientId verbunden abgelegt. Eine Sammlung solcher Logs wollen wir workspace nennen.

Wir können weitere Daten anreichern, wie z. B. einen timestamp, was nützlich sein kann, wenn wir die Datenveränderungen historisch aufbereiten wollen oder eine Art endloses “Undo” implementieren wollen.

Auch ist es denkbar, dass jeder neue Eintrag einen Hash auf den vorherigen Eintrag enthält, um so eine Absicherung der Datenkonsistenz im Stile einer Blockchain zu erhalten. Weitere Verfeinerungen Richtung Merkle-Tree sind ebenfalls denkbar. Aber auch ein Hash auf den Inhalt des aktuellen Eintrags kann zur Datensicherheit beitragen.

Assets, Blobs, die großen binären Brocken

Zur Realität gehört auch, dass es größere Daten gibt, die sich selten verändern: Bilder, Videos, Audios, also Dateien. Diese gehören nicht zu den inhaltlichen Änderungen und würden einen Datenabgleich schnell ins Stocken bringen. Außerdem werden sie nicht immer sofort gebraucht und könnten auch “on demand” geladen werden. Daher schlage ich vor, diese Dateien getrennt von den Änderungen zu behandeln. Nennen wir sie asset.

Aber auch hier sollen die Daten nach clientId und in aufsteigender Reihenfolge mit index abgelegt werden. Die Datensätze können so auf ein Asset verweisen, z.B. mit einem Dateneintrag in Form einer URL, die alle notwendigen Informationen enthält: asset:///<clientId>/<index>/<filename>?size=<sizeInBytes>&type=<mimeType>&hash=<checksumOfContents>. Ein Beispiel könnte wie folgt aussehen asset:///abc/1/test.txt?size=100&type=text%2Fplain&hash=1a2b3c.

Vorteile

Der entscheidende Vorteil dieser Methode ist, dass wir immer wissen, wo die nächsten Daten auftauchen werden. Denn hat ein Client gerade den Index 123 geschrieben, wird der nächste 124 sein.

Warum ist das wichtig? Aus folgenden Gründen:

  1. Wir sind nicht darauf angewiesen, auf neue Daten hingewiesen zu werden (“push”), sondern können auch selber danach fragen (“pull”).
  2. Wir können die einzelnen Einträge laden, auch ohne etwas über ihren Inhalt zu wissen. Ein Sync kann sogar ohne Zwischenstelle stattfinden, die die Daten “verstehen” muss.
  3. Wir merken sofort, wenn Daten fehlen sollten und können diese nochmals anfordern.
  4. Die Daten können beliebig oft repliziert werden, es muss nicht nur eine Sync-Quelle geben. Eine Nutzung als Backup ist somit sinnvoll.
  5. Clients die keine direkte Verbindung zueinander haben, können durch “dumme Kopiervorgänge”.
  6. Der Datenaustausch lässt sich einfach und verständlich dokumentieren, was wichtig sein kann bei der Anerkennung rechtssicher abgelegter Daten für Steuer und Compliance.
  7. Es gibt keine Konflikte bei der Ablage der Daten, weil nur fortgeschrieben wird, aber nie gelöscht (“append only”).

Datenbanken

Beginnen wir zunächst mit der Implementierung als Datenbank. Wie beschrieben, würde eine Tabelle mit folgenden Feldern reichen:

  • index: Integer
  • clientId: String oder Integer
  • data: String oder Binär
  • timestamp: Integer (optional)
  • prevHash: String oder Integer (optional)

Für die Assets würde das ähnlich aussehen.

Bei Datenbank kann sowohl die lokale Ablage des Clients, z. B. in der IndexedDB, gemeint sein, als auch die auf einem Synchronisations-Server.

Dateisysteme

In einem Dateisystem werden die Daten in einem Verzeichnis abgelegt. Metadaten, wie der clientId des Erstellers und Angaben wie z. B. zur verwendeten Verschlüsselung, werden in einer JSON-Datei namens index.json abgelegt.
Des Weiteren gibt es für jeden Client ein Verzeichnis, das nach der clientId benannt ist.

In diesen wiederum befinden sich jeweils zwei Verzeichnisse:

  1. changes
  2. assets

In changes werden fortlaufend nummeriert die Änderungen erfasst. In assets dasselbe mit den beschriebenen Binärdaten.

Moderne Dateisysteme haben keine nennenswerten Beschränkungen der Anzahl der Dateien pro Directory, aber es kann nicht schaden, trotzdem die Anzahl zu begrenzen. Folgender Algorithmus in TypeScript sorgt für eine gleichmäßige Verteilung:

/**
 * Distribute file, named by natural numbers, in a way that each folder only
 * contains `maxEntriesPerFolder` subfolders or files. Returns a list of
 * names, where the last one is the file name, all others are folder names.
 * 
 * Example: `distributedFilePath(1003)` results in `['2', '1', '3']` which 
 * could be translated to the file path `2/1/3.json`.
 */
export function distributedFilePath(index: number, maxEntriesPerFolder: number = 1000): string[] {
  if (index < 0)
    throw new Error('Only numbers >= 0 supported')
  const names: string[] = []
  do {
    names.unshift((index % maxEntriesPerFolder).toString())
    index = Math.floor(index / maxEntriesPerFolder)
  } while (index > 0)
  names.unshift(names.length.toString())
  return names
}

Quelle: zeed Framework

Online Services und Peer-To-Peer

Für Online Services kann eine passende Mischung aus dem Datenbank- oder dem Dateisystem-Ansatz gewählt werden, je nachdem, was besser zu dem ausgewählten Service passt. Bei Dropbox oder WebDAV wäre das z. B. der Dateisystem-Ansatz. Bei Apple CloudKit eher der Datenbank-Ansatz.

Aber es ist auch ein einfacher eigener Service denkbar, mit einer REST, WebSocket oder anderen sinnvollen Schnittstellen.

Es spricht auch nichts gegen einen Abgleich der Daten via Peer-to-Peer oder einem anderen lokalen Kommunikationsweg. Die Daten sind ja identisch und so kann ein Abgleich mit anderen Clients jeweils über den schnelleren Weg durchgeführt werden. Die Redundanz ist also von Vorteil und macht die Sache nicht weiter kompliziert. Theoretisch ist bei CRDT auch die Reihenfolge und die Mehrfachanwendung der Änderungen unproblematisch.

Verfeinerungen

An einigen Stellen gibt es noch Potenzial zu Verbesserungen:

  • Steuerung der Datengröße pro Log-Eintrag. Sammeln mehrerer Änderungen für größere Pakete oder Aufteilen einer Änderung in mehrere Pakete, falls der Umfang zu groß wird.
  • Komprimierung oder Zusammenfassung der Daten.
  • Bekanntmachung neuer Clients, z. B. durch spezielle Logeinträge. Evaluierung durch kryptografische Methoden.
  • Durch Hinzufügen einer logischen Uhr, wie der Lamport-Clock, können Einträge logisch sortiert werden und dadurch in die Chronologie eines Eintrages verbessert werden.
  • Das Schreiben der Daten in einer einzigen Datei pro Client aus Gründen der Ressourcen-Optimierung.
  • Durch geschickten Einsatz von kryptografischen Mitteln lässt sich evtl. sogar ein Rechte-Management implementieren (Cryptree).

Ausblick

Ich verwende diese Technik seit einigen Jahren in meinen Apps, wie z. B. in dem mittlerweile beendeten Projekt Onepile. In Kürze sollen neue Projekte mit diesem Ansatz veröffentlicht werden.

Die Einfachheit und Flexibilität erscheinen mir die größten Pluspunkte bei diesem Ansatz zu sein. Dadurch sollte es auch zukunftssicher sein und sich schnell neuen technischen Gegebenheiten anpassen können.

Das folgende Diagramm stellt exemplarisch ein Ökosystem für eine Web-App dar:

R2v3.png

Veröffentlicht am 24. Juni 2024

 
Zurück zur Liste der Beiträge