meldestelle/docs/01_Architecture/adr/0022-lan-sync-protocol-de.md
Stefan Mogeritsch 2dd5453365 docs: add ADR-0022 for LAN-Sync protocol implementation
- Documented a hybrid "Event-Sourcing Light with Lamport Clocks" approach for offline-first LAN synchronization between Meldestelle and Richter-Turm.
- Included detailed options analysis (Event-Sourcing, CRDT, Timestamp-Sync) and rationale for the selected solution.
- Added specifications: SyncEvent model, Lamport clock rules, WebSocket protocol (handshake, sync, recovery), and domain mastership rules.
- Defined snapshot strategy to ensure scalable logs and efficient replay.
- Outlined implementation plan in four phases, highlighting task breakdown for backend and frontend teams.
- Updated architect, backend, and frontend roadmaps to reflect ADR-0022 integration steps.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-04-03 09:45:55 +02:00

14 KiB
Raw Blame History

type status owner last_update
ADR ACCEPTED Lead Architect 2026-04-03

ADR-0022: LAN-Sync-Protokoll (Meldestelle ↔ Richter-Turm)

Status

Akzeptiert — 2026-04-03


Kontext

Die Desktop-Anwendung "Meldestelle-Biest" operiert auf Reitturnieren in einer LAN-Umgebung ohne garantierte Internetverbindung. Zwei Hauptakteure müssen Daten in Echtzeit synchronisieren:

  • Meldestelle-Desk (Schreibzentrale): Verwaltet Nennungen, Startreihenfolgen, Teilnehmerkonten und Kassendaten.
  • Richter-Turm-Desk (Leseintensiv, gelegentlich schreibend): Zeigt Startreihenfolgen an, erfasst Bewertungen und Ergebnisse.

Problemstellung

Welches Synchronisationsprotokoll eignet sich am besten für folgende Anforderungen?

  1. Offline-First: Beide Seiten müssen auch ohne aktive Verbindung arbeitsfähig bleiben.
  2. Konfliktauflösung: Gleichzeitige Änderungen (z. B. Meldestelle ändert Startnummer, Richter erfasst Ergebnis) müssen deterministisch aufgelöst werden.
  3. Einfachheit: Das System wird von 13 Entwicklern betreut; Komplexität muss beherrschbar bleiben.
  4. Latenz: Ergebnisübertragung vom Richter-Turm zur Meldestelle soll < 500 ms betragen.
  5. Datenmenge: Pro Turnier typisch 50300 Starts; kein Big-Data-Problem.

Analyse der Optionen

Option A: Event-Sourcing (Append-Only Event Log)

Konzept: Alle Zustandsänderungen werden als unveränderliche Events in einem Log gespeichert. Der aktuelle Zustand ergibt sich durch Replay aller Events. Peers tauschen fehlende Events aus (Log-Replikation).

Vorteile:

  • Vollständige Audit-Trail aller Änderungen.
  • Natürliche Konfliktauflösung durch Event-Reihenfolge (Sequenznummern).
  • Gut kombinierbar mit CQRS.
  • Einfaches Nachsynchronisieren: Peer sendet seinen letzten bekannten sequence_number, Gegenseite liefert Delta.

Nachteile:

  • Log wächst unbegrenzt (Snapshots erforderlich).
  • Höhere Implementierungskomplexität (Projektion, Snapshot-Strategie).
  • Replay bei großem Log kann langsam sein (mitigierbar durch Snapshots).

Bewertung für unseren Kontext: Gut geeignet, aber Snapshot-Strategie erhöht Komplexität. Für 50300 Starts pro Turnier ist der Log überschaubar.


Option B: CRDT (Conflict-free Replicated Data Types)

Konzept: Spezielle Datenstrukturen, die mathematisch garantieren, dass parallele Änderungen immer zu einem konsistenten Zustand konvergieren — ohne zentrale Koordination.

Vorteile:

  • Echte Peer-to-Peer-Fähigkeit ohne Master.
  • Automatische, mathematisch korrekte Konfliktauflösung.
  • Keine Koordination nötig.

Nachteile:

  • Sehr hohe Implementierungskomplexität (LWW-Register, OR-Set, RGA für Texte etc.).
  • Kaum Kotlin/KMP-Bibliotheken verfügbar; müsste selbst implementiert werden.
  • Semantische Konflikte (z. B. "Startnummer geändert UND Ergebnis für diese Startnummer erfasst") sind mit CRDTs nicht lösbar — nur strukturelle Konflikte.
  • Debugging und Nachvollziehbarkeit schwierig.

Bewertung für unseren Kontext: Überdimensioniert und zu komplex für ein 13-Personen-Team. Verworfen.


Option C: Timestamp-basierte Synchronisation (Last-Write-Wins)

Konzept: Jede Entität trägt einen updated_at-Timestamp. Bei Konflikten gewinnt die neuere Änderung. Peers tauschen Deltas seit einem bekannten Zeitpunkt aus.

Vorteile:

  • Sehr einfach zu implementieren.
  • Gut verständlich und debuggbar.
  • Funktioniert mit bestehenden SQLDelight-Schemas ohne große Umbauten.

Nachteile:

  • Uhren-Drift zwischen Geräten kann zu falschen Ergebnissen führen (mitigierbar durch NTP oder logische Uhren).
  • Kein vollständiger Audit-Trail.
  • "Last-Write-Wins" kann fachlich falsch sein (z. B. ältere Bewertung überschreibt neuere Korrektur).
  • Keine Möglichkeit, Änderungshistorie nachzuvollziehen.

Bewertung für unseren Kontext: Zu riskant für fachlich kritische Daten (Ergebnisse, Nennungen). Uhren-Drift im LAN-Betrieb ohne NTP ist ein reales Problem. Verworfen als alleinige Strategie.


Option D (Gewählt): Hybridansatz — Event-Sourcing Light mit Lamport-Uhren

Konzept: Vereinfachtes Event-Sourcing ohne vollständigen CQRS-Stack, kombiniert mit logischen Uhren (Lamport Timestamps) statt Wanduhren. Jede Änderung erzeugt ein SyncEvent mit monoton steigender logischer Uhr. Peers tauschen fehlende Events via WebSocket aus (Push bei Verbindung, Pull bei Reconnect).

Kernprinzipien:

  1. Meldestelle ist Master für Nennungs- und Kassendaten.
  2. Richter-Turm ist Master für Bewertungs- und Ergebnisdaten.
  3. Klare Domänentrennung eliminiert 90 % der Konflikte strukturell.
  4. Lamport-Timestamps lösen verbleibende Konflikte deterministisch ohne Uhren-Drift-Problem.
  5. Snapshots alle N Events (konfigurierbar, Standard: 100) begrenzen Log-Größe und Replay-Zeit.

Entscheidung

Wir implementieren Option D: Event-Sourcing Light mit Lamport-Uhren als LAN-Sync-Protokoll.

Architektur-Überblick

┌─────────────────────────────┐         WebSocket (LAN)        ┌─────────────────────────────┐
│     Meldestelle-Desk        │◄──────────────────────────────►│     Richter-Turm-Desk        │
│                             │                                 │                              │
│  Master für:                │   SyncEvent-Stream              │  Master für:                 │
│  - Nennungen                │   (bidirektional)               │  - Bewertungen               │
│  - Startreihenfolge         │                                 │  - Ergebnisse                │
│  - Teilnehmerkonten         │                                 │  - Richter-Notizen           │
│  - Kassendaten              │                                 │                              │
│                             │                                 │                              │
│  SQLDelight (lokal)         │                                 │  SQLDelight (lokal)          │
│  + SyncEvent-Log            │                                 │  + SyncEvent-Log             │
└─────────────────────────────┘                                 └──────────────────────────────┘

SyncEvent-Datenmodell

data class SyncEvent(
    val eventId: String,          // Veranstaltungs-ID (Tenant)
    val turnierId: String,        // Turnier-Scope (gemäß ADR-0020)
    val sequenceNumber: Long,     // Lamport-Timestamp (logische Uhr)
    val originNodeId: String,     // Geräte-ID (UUID, persistent)
    val aggregateType: String,    // z. B. "Nennung", "Bewertung", "Start"
    val aggregateId: String,      // ID der betroffenen Entität
    val eventType: String,        // z. B. "NennungErstellt", "BewertungErfasst"
    val payload: ByteArray,       // JSON/Protobuf-serialisiertes Delta
    val createdAt: Instant,       // Wanduhr (nur für Anzeige/Logging)
    val checksum: String          // SHA-256 des Payloads (Integritätsprüfung)
)

Lamport-Uhr-Regeln

Bei lokaler Änderung:    localClock = localClock + 1
Bei Empfang eines Events: localClock = max(localClock, event.sequenceNumber) + 1
Konflikt-Auflösung:      Höherer sequenceNumber gewinnt.
                         Bei Gleichstand: lexikografisch größere originNodeId gewinnt (deterministisch).

Sync-Protokoll (WebSocket)

Verbindungsaufbau (Handshake)

Client → Server:  HELLO { nodeId, eventId, turnierId, lastKnownSeq }
Server → Client:  HELLO_ACK { nodeId, currentSeq }
Server → Client:  SYNC_DELTA [ SyncEvent, ... ]  (alle Events > lastKnownSeq)
Client → Server:  SYNC_ACK { ackedSeq }

Laufender Betrieb

Änderung lokal:   SYNC_PUSH { SyncEvent }
Gegenseite:       SYNC_ACK { ackedSeq }  oder  SYNC_NACK { reason }
Heartbeat:        PING / PONG alle 30 Sekunden

Reconnect / Offline-Recovery

Reconnect:        HELLO { lastKnownSeq }  → Server liefert Delta seit lastKnownSeq
Snapshot-Request: SNAPSHOT_REQUEST { turnierId }  → Server liefert aktuellen Snapshot + seq

Domänen-Mastership (Konflikt-Prävention)

Aggregat Master Richter-Turm darf
Nennung Meldestelle Lesen
Startreihenfolge Meldestelle Lesen
TeilnehmerKonto Meldestelle Lesen
Kassenbuchung Meldestelle Kein Zugriff
Bewertung Richter-Turm Schreiben
Ergebnis Richter-Turm Schreiben
Richter-Notiz Richter-Turm Schreiben
Veranstaltungs-Chat Beide (CRDT-Set) Schreiben

Hinweis: Schreibt ein Peer in ein Aggregat, für das er nicht Master ist, wird das Event lokal gespeichert aber mit status = PENDING_REVIEW markiert und dem Master zur Bestätigung vorgelegt.

Snapshot-Strategie

  • Snapshot wird nach jeweils 100 Events pro (turnierId, aggregateType) erstellt.
  • Snapshot enthält: vollständiger Zustand des Aggregats + snapshotSeq.
  • Beim Replay: Lade letzten Snapshot, wende nur Events mit seq > snapshotSeq an.
  • Snapshots werden lokal in SQLDelight gespeichert und bei Bedarf übertragen.

Sicherheit (gemäß ADR-0020)

  • WebSocket-Verbindung via wss:// mit Shared Security Key pro Veranstaltung.
  • eventId im Handshake wird gegen lokale Registry validiert.
  • Events mit falscher eventId oder turnierId werden verworfen (kein Silent-Drop, sondern SYNC_NACK).

Konsequenzen

Positiv

  • Offline-First: Beide Peers arbeiten vollständig lokal; Sync erfolgt opportunistisch bei Verbindung.
  • Kein Uhren-Drift-Problem: Lamport-Uhren sind unabhängig von Systemuhren.
  • Audit-Trail: Vollständige Änderungshistorie pro Turnier nachvollziehbar.
  • Einfaches Delta-Sync: lastKnownSeq reicht für vollständiges Nachsynchronisieren.
  • Klare Verantwortlichkeiten: Domänen-Mastership eliminiert strukturell die meisten Konflikte.
  • Skalierbar: Funktioniert für 1 Richter-Turm ebenso wie für 5 parallele Türme.

Negativ / Herausforderungen

  • Log-Management: Snapshot-Strategie muss implementiert und getestet werden.
  • Schema-Evolution: Payload-Format muss versioniert werden (empfohlen: JSON mit schemaVersion-Feld).
  • Mehr Aufwand als reines Timestamp-Sync: Initiale Implementierung aufwändiger, langfristig robuster.
  • WebSocket-Infrastruktur: Meldestelle-Desk fungiert als WebSocket-Server; Richter-Turm als Client (gemäß ADR-0020 Master-Client-Hybrid).

Neutral

  • SQLDelight-Schema muss um sync_events- und sync_snapshots-Tabellen erweitert werden.
  • originNodeId muss beim ersten Start persistent generiert und gespeichert werden.

Betrachtete Alternativen

Option Entscheidung Hauptgrund
A: Event-Sourcing Basis (vereinfacht) Zu komplex mit vollem CQRS-Stack; Light-Variante gewählt
B: CRDT Verworfen Zu hohe Implementierungskomplexität, keine KMP-Bibliotheken
C: Timestamp-Sync Verworfen Uhren-Drift-Risiko, kein Audit-Trail, fachlich unsicher
D: Hybrid (gewählt) Akzeptiert Beste Balance aus Robustheit, Einfachheit und Offline-Fähigkeit

Implementierungsplan

Phase 1 — Fundament (Backend Sprint B / Frontend Sprint B)

  • SyncEvent-Datenmodell in core-Modul definieren (KMP-shared)
  • SQLDelight-Tabellen sync_events, sync_snapshots anlegen
  • LamportClock-Implementierung (thread-safe, persistent)
  • originNodeId-Generierung und -Persistierung beim App-Start

Phase 2 — Transport (Backend Sprint B / Frontend Sprint B)

  • WebSocket-Server auf Meldestelle-Desk (Ktor)
  • WebSocket-Client auf Richter-Turm-Desk (Ktor-Client / KMP)
  • Handshake-Protokoll implementieren (HELLO / HELLO_ACK / SYNC_DELTA)
  • SYNC_PUSH / SYNC_ACK / PING-PONG implementieren

Phase 3 — Konfliktauflösung & Recovery

  • Domänen-Mastership-Validierung im Event-Handler
  • Snapshot-Erstellung und -Wiederherstellung
  • Reconnect-Logik mit Delta-Sync (lastKnownSeq)
  • PENDING_REVIEW-Workflow für Mastership-Verletzungen

Phase 4 — Observability & Tests

  • Sync-Status-UI (verbunden / getrennt / ausstehende Events)
  • Integrationstests: 2 Peers, Offline-Phase, Reconnect, Konflikt
  • Chaos-Tests: Verbindungsabbruch während Sync, Clock-Skew-Simulation

Offene Punkte

  • USB-Stick Fallback: Separate Besprechung (Sprint B/C) — Export/Import des SyncEvent-Logs auf USB als Notfall-Sync-Kanal. Snapshot-Format ist bereits kompatibel.
  • Payload-Serialisierung: JSON (einfacher) vs. Protobuf (kompakter). Empfehlung: JSON mit schemaVersion für Phase 1, Migration zu Protobuf optional in Phase 2.
  • Mehrere Richter-Türme: Protokoll unterstützt N Clients; Broadcast-Strategie auf Meldestelle-Seite noch zu spezifizieren (Fan-out vs. Relay).

Referenzen