- 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>
14 KiB
| 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?
- Offline-First: Beide Seiten müssen auch ohne aktive Verbindung arbeitsfähig bleiben.
- Konfliktauflösung: Gleichzeitige Änderungen (z. B. Meldestelle ändert Startnummer, Richter erfasst Ergebnis) müssen deterministisch aufgelöst werden.
- Einfachheit: Das System wird von 1–3 Entwicklern betreut; Komplexität muss beherrschbar bleiben.
- Latenz: Ergebnisübertragung vom Richter-Turm zur Meldestelle soll < 500 ms betragen.
- Datenmenge: Pro Turnier typisch 50–300 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 50–300 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 1–3-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:
- Meldestelle ist Master für Nennungs- und Kassendaten.
- Richter-Turm ist Master für Bewertungs- und Ergebnisdaten.
- Klare Domänentrennung eliminiert 90 % der Konflikte strukturell.
- Lamport-Timestamps lösen verbleibende Konflikte deterministisch ohne Uhren-Drift-Problem.
- 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_REVIEWmarkiert 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 > snapshotSeqan. - Snapshots werden lokal in SQLDelight gespeichert und bei Bedarf übertragen.
Sicherheit (gemäß ADR-0020)
- WebSocket-Verbindung via
wss://mit Shared Security Key pro Veranstaltung. eventIdim Handshake wird gegen lokale Registry validiert.- Events mit falscher
eventIdoderturnierIdwerden verworfen (kein Silent-Drop, sondernSYNC_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:
lastKnownSeqreicht 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- undsync_snapshots-Tabellen erweitert werden. originNodeIdmuss 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 incore-Modul definieren (KMP-shared)- SQLDelight-Tabellen
sync_events,sync_snapshotsanlegen 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
schemaVersionfü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
- ADR-0020: LAN-Kommunikation und Daten-Isolierung
- ADR-0021: Tenant-Resolution-Strategie
- ADR-0004: Event-driven Communication
- ADR-0010: SQLDelight for Cross-Platform Persistence
- ADR-0008: Multiplatform Client Applications
- Lamport, L. (1978). "Time, Clocks, and the Ordering of Events in a Distributed System." CACM 21(7).