- 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>
316 lines
14 KiB
Markdown
316 lines
14 KiB
Markdown
---
|
||
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 1–3 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 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:**
|
||
|
||
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).
|