fix: Pull-Wasserzeichen sprang über fremde Ereignisse + schärfere Push-Kollisionskontrolle

EventStore.Pull gab bisher den globalen ServerSeq-Höchststand als neuen Cursor zurück statt
den höchsten unter den tatsächlich gelieferten Ereignissen - hatte ein Gerät selbst kurz zuvor
etwas gepusht, sprang sein Pull-Cursor über noch nicht abgeholte Ereignisse anderer Geräte
hinweg und verpasste sie dauerhaft, ohne jeden Fehler.

Ersetzt außerdem die bisherige 30-Sekunden-Heuristik zur Konflikterkennung beim Push durch
exakte BasedOnServerSeq-Prüfung: jedes SyncEvent trägt die ServerSeq, auf der es aufbaut: der
Server lehnt ab, wenn der aktuelle Stand nicht mehr passt. Bei Ablehnung lädt der Client sofort
den neuen Server-Stand nach, löst den Konflikt nach der bestehenden Desktop-vs-Companion/
Timestamp-Politik auf und macht ihn immer in der Konflikt-Review-UI sichtbar, statt die
verworfene Änderung stillschweigend zu verlieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:25:04 +02:00
co-authored by Claude Sonnet 5
parent 270c4d40cd
commit 5bd6967421
13 changed files with 704 additions and 22 deletions
+72
View File
@@ -1557,6 +1557,32 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
nächsten Testlauf anhand der Log-Dateien beider Geräte lückenlos nachvollziehen, an welcher
Stelle der Kette (Einreihen → Push → Server → Pull → Anwenden) eine Änderung tatsächlich
verloren geht — bisher war das reine Spekulation ohne Live-Testgeräte.
**Nachtrag (der eigentliche Bugfix — server-seitig in `EventStore.Pull`):** Die
Erfolgs-Protokollierung von oben hat sofort den Übeltäter gezeigt: Gerät A pusht 2 Ereignisse,
Server bestätigt (`ServerSequenceNr=420`, 0 Konflikte) — Gerät B pullt direkt danach und
bekommt "0 Ereignisse" zurück, obwohl Geräts A's Ereignisse eindeutig für Gerät B bestimmt
waren. Ursache: `EventStore.Pull()` gab als neuen "since"-Cursor bisher IMMER
`LastSeq(col)` zurück — den **globalen** Höchststand über ALLE Geräte hinweg, nicht die
höchste ServerSeq unter den tatsächlich in `Events` zurückgegebenen (nach
`DeviceId != requestingDeviceId` gefilterten) Ereignissen. `SyncEngine.PullAsync` übernimmt
diesen Wert 1:1 als neuen Cursor für den nächsten Pull. Hatte das anfragende Gerät selbst kurz
zuvor etwas gepusht (Push läuft in `SyncNowAsync` immer vor Pull — die eigenen Ereignisse
werden aus der Pull-Antwort korrekt herausgefiltert, da man sie nicht noch mal auf sich selbst
anwenden will), sprang der Cursor über die noch gar nicht abgeholten Ereignisse ANDERER Geräte
hinweg, sobald die eigenen neuer waren — sie wurden **dauerhaft** verpasst, ohne jeden
Fehler, da aus Sicht des Clients ein leeres Pull-Ergebnis ein völlig normaler, erfolgreicher
Zustand ist ("nichts Neues").
**Fix:** `ServerSequenceNr` in der Pull-Antwort ist jetzt `events.Count > 0 ?
events.Max(e => e.SequenceNr) : since` — der Cursor rückt nur noch so weit vor, wie
tatsächlich Ereignisse ausgeliefert wurden, nie darüber hinaus. Neuer Regressionstest
`EventStoreTests.Pull_AnfragendesGeraetHatSelbstNeuereEreignisseGepusht_
UeberspringtFremdeEreignisseNicht` reproduziert exakt dieses Szenario (Gerät A pusht,
dann pusht Gerät B selbst etwas Neueres, dann pullt Gerät B mit einem alten "since") und
belegt, dass Geräts A's Ereignis jetzt zurückkommt und der neue Cursor bei dessen ServerSeq
steht statt beim (höheren) globalen Höchststand. **Wichtig:** betrifft `LehrerApp.Api` — ein
Server-Redeploy ist diesmal nötig (kein reiner Desktop-Client-Fix).
- [x] **10.1.8** Datei-Anhänge (Dokumentation) über den laufenden Sync mitschicken.
**Umsetzung:** Eigener, unverschlüsselt im JSON-Ereigniskanal nicht mitgeführter Binärkanal
@@ -1567,6 +1593,52 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
Anhänge nach Anwenden eines `Documentation`-Ereignisses). Original-`StorageId` bleibt beim
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
das immer eine neue Id vergäbe).
- [x] **10.1.9** Schärfere Kollisionskontrolle beim Push (Konzeptgespräch nach dem
Pull-Watermark-Bugfix, siehe Nachtrag zu 10.1.7): statt der bisherigen 30-Sekunden-
Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst") trägt jedes
`SyncEvent` jetzt ein `BasedOnServerSeq` — die ServerSeq, auf der die lokale Änderung
aufbaut (`null` = Entität hier noch nie synchronisiert).
**Diskutierter Alternativvorschlag (verworfen):** Client-Zeitstempel (UTC-Ticks) statt
server-vergebener ServerSeq als Sync-Cursor. Verworfen, weil unsicher für Offline-First:
ein tagelang offline gewesenes Gerät würde beim Reconnect "alte" logische Zeitstempel
pushen; jedes andere Gerät, dessen Cursor während der Offline-Zeit bereits über diesen
Wall-Clock-Zeitpunkt hinausgelaufen ist, würde die Änderung dauerhaft und lautlos
überspringen. Der als Konfliktmarker vorgeschlagene Teil der Idee ("Stand-vor-Änderung
mitschicken") war dagegen richtig und ist die Grundlage von `BasedOnServerSeq` geworden.
**Umsetzung:**
- Server (`EventStore.Push`): nimmt ein Ereignis nur an, wenn `BasedOnServerSeq` exakt der
aktuellen ServerSeq der Entität entspricht (`GetLatestForEntity`/`LatestForEntity`);
liefert je akzeptiertem Ereignis die neu vergebene ServerSeq in `AssignedServerSeqs`
zurück. Neuer Endpunkt `GET /api/sync/entity/{entityType}/{entityId}` liefert den
aktuellen Server-Stand einer einzelnen Entität.
- Client (`EventQueue`): neue lokale Versionsverfolgung je Entität
(`GetKnownServerSeq`/`SetKnownServerSeq`, Collection `entity_versions`) — aktualisiert
sowohl beim Anwenden eingehender Ereignisse (`EventApplier.ApplyAsync`) als auch nach
erfolgreichem Push (`AssignedServerSeqs`).
- Client (`SyncEngine.PushAsync`): dedupliziert mehrere ausstehende Ereignisse derselben
Entität vor dem Senden auf das jüngste (Payload ist immer ein vollständiger Snapshot,
nie ein Delta — ältere Duplikate sind redundant und würden mit demselben, dann
veralteten `BasedOnServerSeq` unnötig kollidieren). `BasedOnServerSeq` wird erst
unmittelbar vor dem Senden aus der lokalen Versionsverfolgung gesetzt, nicht beim
Einreihen — zwischen Enqueue und Push kann ein Pull den bekannten Stand bereits
aktualisiert haben.
- **Ablehnungsbehandlung (Nutzer-Vorgabe: "Änderungen verwerfen ist das eine, aber man
sollte es wissen"):** bei einer Ablehnung wird der aktuelle Server-Stand sofort über den
neuen Endpunkt nachgeladen (statt auf den nächsten regulären Pull zu warten), nach
derselben Desktop-schlägt-Companion/neuerer-Timestamp-Politik wie `ConflictResolver`
aufgelöst (`ConflictResolver.DetermineWinner`, aus `TryResolve` herausgezogen, damit
Pull- und Push-Konflikte dieselbe Regel nutzen) und **immer** als `ConflictEntry` in der
bestehenden Konflikt-Review-UI (10.1.6) sichtbar gemacht — unabhängig davon, welche Seite
gewinnt. Bei "RemoteWon" wird der Server-Stand sofort angewendet und die lokale Änderung
verworfen; bei "LocalWon" bleibt das Ereignis unbestätigt in der Queue und wird beim
nächsten Sync-Versuch mit dem nun aktualisierten `BasedOnServerSeq` automatisch erneut
versucht.
- Neue Tests: `EventStoreTests` (Annahme/Ablehnung nach `BasedOnServerSeq`,
`AssignedServerSeqs`, `GetLatestForEntity`), `EventQueueTests`
(`GetKnownServerSeq`/`SetKnownServerSeq`), `SyncEngineTests` (Dedup, frisch gesetztes
`BasedOnServerSeq`, Versionsverfolgung nach Erfolg, RemoteWon- und LocalWon-Ablehnung).
### 10.2 Server
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern