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

316 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
type: ADR
status: ACCEPTED
owner: Lead Architect
last_update: 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
```kotlin
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
- [ADR-0020: LAN-Kommunikation und Daten-Isolierung](0020-lan-communication-isolation-de.md)
- [ADR-0021: Tenant-Resolution-Strategie](0021-tenant-resolution-strategy-de.md)
- [ADR-0004: Event-driven Communication](0004-event-driven-communication-de.md)
- [ADR-0010: SQLDelight for Cross-Platform Persistence](0010-sqldelight-for-cross-platform-persistence.md)
- [ADR-0008: Multiplatform Client Applications](0008-multiplatform-client-applications-de.md)
- Lamport, L. (1978). "Time, Clocks, and the Ordering of Events in a Distributed System." CACM 21(7).