From 5bd6967421dde10648c5f1a1fcfd606e42f609b0 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 18 Aug 2026 23:25:04 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Pull-Wasserzeichen=20sprang=20=C3=BCber?= =?UTF-8?q?=20fremde=20Ereignisse=20+=20sch=C3=A4rfere=20Push-Kollisionsko?= =?UTF-8?q?ntrolle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LehrerApp.Api.Tests/AssemblyInfo.cs | 10 ++ LehrerApp.Api.Tests/EventStoreTests.cs | 184 ++++++++++++++++++++++++ LehrerApp.Api/Endpoints/Endpoints.cs | 11 ++ LehrerApp.Api/EventStore.cs | 59 ++++++-- LehrerApp.Desktop/AppBootstrapper.cs | 3 +- LehrerApp.Sync.Tests/EventQueueTests.cs | 44 ++++++ LehrerApp.Sync.Tests/SyncEngineTests.cs | 181 +++++++++++++++++++++++ LehrerApp.Sync/ConflictResolver.cs | 31 ++-- LehrerApp.Sync/EventApplier.cs | 7 +- LehrerApp.Sync/EventQueue.cs | 23 +++ LehrerApp.Sync/Models/SyncModels.cs | 12 ++ LehrerApp.Sync/SyncEngine.cs | 89 +++++++++++- TODO.md | 72 ++++++++++ 13 files changed, 704 insertions(+), 22 deletions(-) create mode 100644 LehrerApp.Api.Tests/AssemblyInfo.cs create mode 100644 LehrerApp.Api.Tests/EventStoreTests.cs diff --git a/LehrerApp.Api.Tests/AssemblyInfo.cs b/LehrerApp.Api.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..97ce305 --- /dev/null +++ b/LehrerApp.Api.Tests/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using Xunit; + +// LiteDB nutzt einen statischen, geteilten BsonMapper.Global für die Reflection-basierte +// Index-Auflösung (EnsureIndex mit Lambda-Ausdrücken). Bei paralleler Testausführung über +// mehrere Testklassen hinweg (xUnit-Standard) konkurrieren mehrere Threads beim erstmaligen +// Aufbau der Typ-Metadaten für verschiedene Modelle — das führt zu sporadischen +// "Member X not found on BsonMapper"-Fehlern, die mit dem eigentlichen Testinhalt nichts zu +// tun haben (gleiches Muster wie in LehrerApp.Data.Tests/LehrerApp.Desktop.Tests). Tests in +// diesem Projekt laufen deshalb sequenziell. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/LehrerApp.Api.Tests/EventStoreTests.cs b/LehrerApp.Api.Tests/EventStoreTests.cs new file mode 100644 index 0000000..253feab --- /dev/null +++ b/LehrerApp.Api.Tests/EventStoreTests.cs @@ -0,0 +1,184 @@ +using LehrerApp.Sync.Models; +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class EventStoreTests +{ + private static SyncEvent MakeEvent(string deviceId, string entityType = "Lesson", string operation = "Save", + string? entityId = null, long? basedOnServerSeq = null) => new() + { + DeviceId = deviceId, DeviceType = DeviceType.Desktop, + EntityType = entityType, EntityId = entityId ?? Guid.NewGuid().ToString(), + Operation = operation, Payload = "encrypted", BasedOnServerSeq = basedOnServerSeq, + }; + + [Fact] + public void Pull_KeineNeuenEreignisse_LiefertUnverändertesSinceAlsWatermark() + { + using var temp = new TempEventStore(); + temp.Events.Push("user", [MakeEvent("device-a")]); + + var result = temp.Events.Pull("user", since: 5, requestingDeviceId: "device-b"); + + Assert.Empty(result.Events); + Assert.Equal(5, result.ServerSequenceNr); + } + + [Fact] + public void Pull_LiefertEreignisseAnderesGeraets_WatermarkEntsprichtHoechsterZurueckgegebenerServerSeq() + { + using var temp = new TempEventStore(); + temp.Events.Push("user", [MakeEvent("device-a"), MakeEvent("device-a")]); + + var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b"); + + Assert.Equal(2, result.Events.Count); + Assert.Equal(2, result.ServerSequenceNr); + } + + /// Regression: das genau beobachtete Symptom — Gerät A pusht erfolgreich (Server bestätigt), + /// Gerät B pullt und bekommt 0 Ereignisse zurück, obwohl Gerät A's Ereignisse eigentlich für + /// Gerät B bestimmt waren. Ursache: Pull() gab bisher IMMER den globalen Höchststand + /// (LastSeq, inkl. der Ereignisse des ANFRAGENDEN Geräts selbst) als neuen "since"-Cursor + /// zurück — SyncEngine.PullAsync übernimmt den 1:1. Hat das anfragende Gerät SELBST kurz vorher + /// etwas gepusht (dessen Ereignisse hier bewusst per "DeviceId != requestingDeviceId" + /// herausgefiltert werden), sprang der Cursor über die noch gar nicht abgeholten Ereignisse + /// eines ANDEREN Geräts hinweg, wenn die eigenen neuer waren — sie wurden dauerhaft verpasst. + [Fact] + public void Pull_AnfragendesGeraetHatSelbstNeuereEreignisseGepusht_UeberspringtFremdeEreignisseNicht() + { + using var temp = new TempEventStore(); + // Gerät A pusht zuerst (ServerSeq 1) - das ist die Änderung, die Gerät B eigentlich abholen soll. + temp.Events.Push("user", [MakeEvent("device-a")]); + // Gerät B hat selbst (z.B. im selben Sync-Zyklus, Push läuft immer vor Pull) etwas Neueres + // gepusht (ServerSeq 2) - dieses Ereignis gehört B selbst und wird unten aus der Pull-Antwort + // herausgefiltert. + temp.Events.Push("user", [MakeEvent("device-b")]); + + // Gerät B pullt mit einem "since" von VOR seinem eigenen Push (0) - Geräts A's Ereignis + // (ServerSeq 1) muss zurückkommen. + var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b"); + + var evt = Assert.Single(result.Events); + Assert.Equal("device-a", evt.DeviceId); + // Der Bug: hier stand vorher 2 (globaler Höchststand, inkl. Geräts B eigenem Ereignis) - + // ein zweiter Pull mit since=2 hätte Geräts A's Ereignis (ServerSeq 1) nie wieder gefunden. + Assert.Equal(1, result.ServerSequenceNr); + } + + // ── Exakte Kollisionsprüfung (BasedOnServerSeq statt 30s-Heuristik) ───────── + + [Fact] + public void Push_NeueEntitaetOhneBasedOnServerSeq_WirdAngenommen() + { + using var temp = new TempEventStore(); + + var result = temp.Events.Push("user", [MakeEvent("device-a", basedOnServerSeq: null)]); + + Assert.Empty(result.ConflictingEventIds); + } + + [Fact] + public void Push_ZweitesEreignisMitFalschemBasedOnServerSeq_WirdAbgelehnt() + { + using var temp = new TempEventStore(); + var entityId = Guid.NewGuid().ToString(); + temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); // -> ServerSeq 1 + + // Gerät B kennt die Entität noch gar nicht (oder einen veralteten Stand) und behauptet fälschlich, sie sei neu. + var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null); + var result = temp.Events.Push("user", [second]); + + Assert.Contains(second.EventId, result.ConflictingEventIds); + } + + [Fact] + public void Push_ZweitesEreignisMitKorrektemBasedOnServerSeq_WirdAngenommen() + { + using var temp = new TempEventStore(); + var entityId = Guid.NewGuid().ToString(); + var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); + var assignedSeq = first.AssignedServerSeqs.Values.Single(); // ServerSeq 1 + + var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: assignedSeq); + var result = temp.Events.Push("user", [second]); + + Assert.Empty(result.ConflictingEventIds); + } + + [Fact] + public void Push_AkzeptierteEreignisse_LiefernJeEventIdDieVergebeneServerSeq() + { + using var temp = new TempEventStore(); + var evt1 = MakeEvent("device-a"); + var evt2 = MakeEvent("device-a"); + + var result = temp.Events.Push("user", [evt1, evt2]); + + Assert.Equal(2, result.AssignedServerSeqs.Count); + Assert.True(result.AssignedServerSeqs.ContainsKey(evt1.EventId)); + Assert.True(result.AssignedServerSeqs.ContainsKey(evt2.EventId)); + Assert.Equal([1L, 2L], result.AssignedServerSeqs.Values.OrderBy(v => v)); + } + + [Fact] + public void Push_AbgelehntesEreignis_TauchtNichtInAssignedServerSeqsAuf() + { + using var temp = new TempEventStore(); + var entityId = Guid.NewGuid().ToString(); + temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); + var rejected = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null); + + var result = temp.Events.Push("user", [rejected]); + + Assert.False(result.AssignedServerSeqs.ContainsKey(rejected.EventId)); + } + + // ── GetLatestForEntity (Sofort-Nachladen nach abgelehntem Push) ───────────── + + [Fact] + public void GetLatestForEntity_UnbekannteEntitaet_LiefertNull() + { + using var temp = new TempEventStore(); + + var result = temp.Events.GetLatestForEntity("user", "Lesson", Guid.NewGuid().ToString()); + + Assert.Null(result); + } + + [Fact] + public void GetLatestForEntity_LiefertDenAktuellstenStandDieserEntitaet() + { + using var temp = new TempEventStore(); + var entityId = Guid.NewGuid().ToString(); + var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); + var seq1 = first.AssignedServerSeqs.Values.Single(); + temp.Events.Push("user", [MakeEvent("device-b", entityId: entityId, basedOnServerSeq: seq1)]); + + var result = temp.Events.GetLatestForEntity("user", "Lesson", entityId); + + Assert.NotNull(result); + Assert.Equal("device-b", result!.DeviceId); + Assert.Equal(seq1 + 1, result.SequenceNr); + } + + private sealed class TempEventStore : IDisposable + { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"lehrerapp-api-tests-eventstore-{Guid.NewGuid():N}"); + public EventStore Events { get; } + + public TempEventStore() + { + Directory.CreateDirectory(_directory); + Events = new EventStore(_directory); + } + + public void Dispose() + { + Events.Dispose(); + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index f409df5..51e5e48 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -47,6 +47,17 @@ public static class Endpoints var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow }); }); + // Für Clients, deren Push wegen eines neueren Server-Stands abgelehnt wurde (10.3.4- + // Nachtrag: exakte Kollisionsprüfung statt 30s-Heuristik) — sofortiges Nachladen des + // aktuellen Stands EINER Entität, statt auf den nächsten regulären Pull zu warten. + g.MapGet("/entity/{entityType}/{entityId}", (string entityType, string entityId, + ClaimsPrincipal user, EventStore store) => + { + var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (uid is null) return Results.Unauthorized(); + var result = store.GetLatestForEntity(uid, entityType, entityId); + return result is null ? Results.NotFound() : Results.Ok(result); + }); } // ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ────────── diff --git a/LehrerApp.Api/EventStore.cs b/LehrerApp.Api/EventStore.cs index 82e4999..3e1c317 100644 --- a/LehrerApp.Api/EventStore.cs +++ b/LehrerApp.Api/EventStore.cs @@ -11,26 +11,58 @@ public class EventStore(string dataPath) : IDisposable private readonly Dictionary _dbs = new(); private readonly Lock _lock = new(); + /// + /// Nimmt Ereignisse an, wenn ihre exakt der aktuellen + /// ServerSeq der jeweiligen Entität entspricht (null == Entität hier noch nie gesehen, z.B. + /// Neuanlage) — echte optimistische Nebenläufigkeitskontrolle statt der früheren 30-Sekunden- + /// Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst"), die sowohl falsch- + /// positiv (zwei Geräte bearbeiten zufällig kurz hintereinander verschiedene Felder) als auch + /// falsch-negativ (echter Konflikt liegt außerhalb des 30s-Fensters) sein konnte. + /// Mehrere Ereignisse derselben Entität IM SELBEN Aufruf bauen bewusst aufeinander auf (das + /// zweite prüft gegen den vom ersten gerade neu vergebenen Stand) — der Client schickt ohnehin + /// nur noch das jüngste ausstehende Ereignis je Entität (siehe SyncEngine.PushAsync). + /// public PushResponse Push(string userId, List events) { var col = GetCol(userId); var seq = LastSeq(col); - var rejects = new List(); + var rejects = new List(); + var assigned = new Dictionary(); foreach (var e in events.OrderBy(e => e.Timestamp)) { - var recent = col.FindOne(x => - x.EntityType == e.EntityType && x.EntityId == e.EntityId && - x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30)); - if (recent is not null) { rejects.Add(e.EventId); continue; } + var currentSeq = LatestForEntity(col, e.EntityType, e.EntityId)?.ServerSeq; + if (currentSeq != e.BasedOnServerSeq) { rejects.Add(e.EventId); continue; } + var newSeq = ++seq; col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId, DeviceType = e.DeviceType, Timestamp = e.Timestamp, - ClientSeq = e.SequenceNr, ServerSeq = ++seq, + ClientSeq = e.SequenceNr, ServerSeq = newSeq, EntityType = e.EntityType, EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload }); + assigned[e.EventId] = newSeq; } - return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects }; + return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects, + AssignedServerSeqs = assigned }; } + /// Aktuellstes Ereignis einer einzelnen Entität — für Clients, deren Push wegen eines + /// neueren Server-Stands abgelehnt wurde (siehe ), um sofort den aktuellen + /// Stand nachzuladen, statt auf den nächsten regulären Pull zu warten. + public SyncEvent? GetLatestForEntity(string userId, string entityType, string entityId) + { + var col = GetCol(userId); + var e = LatestForEntity(col, entityType, entityId); + // SequenceNr trägt hier (wie bei Pull) die ServerSeq — dieselbe Konvention wie sonst im + // Sync-Protokoll: bei vom Server stammenden Ereignissen ist SequenceNr immer die ServerSeq. + return e is null ? null : new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId, + DeviceType = e.DeviceType, Timestamp = e.Timestamp, SequenceNr = e.ServerSeq, + EntityType = e.EntityType, EntityId = e.EntityId, Operation = e.Operation, + Payload = e.Payload }; + } + + private static ServerEvent? LatestForEntity(ILiteCollection col, string entityType, string entityId) => + col.Find(x => x.EntityType == entityType && x.EntityId == entityId) + .OrderByDescending(x => x.ServerSeq).FirstOrDefault(); + public PullResponse Pull(string userId, long since, string requestingDeviceId) { var col = GetCol(userId); @@ -41,7 +73,18 @@ public class EventStore(string dataPath) : IDisposable SequenceNr = e.ServerSeq, EntityType = e.EntityType, EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload }) .ToList(); - return new() { Events = events, ServerSequenceNr = LastSeq(col) }; + // ServerSequenceNr MUSS die höchste ServerSeq unter den tatsächlich zurückgegebenen + // Ereignissen sein, NICHT der globale Höchststand (LastSeq(col)) — der schließt auch + // Ereignisse ANDERER Geräte ein, die z.B. gerade erst (nach dem obigen Find-Aufruf, aber + // vor dieser Zeile) eingetroffen sind, oder — der Bug, der hier tatsächlich beobachtet + // wurde — Ereignisse des anfragenden Geräts selbst, die oben bewusst per + // "DeviceId != requestingDeviceId" herausgefiltert wurden. SyncEngine.PullAsync übernimmt + // ServerSequenceNr 1:1 als neuen "since"-Cursor für den nächsten Pull; mit dem globalen + // Höchststand würde der Client seinen Cursor über Ereignisse hinweg vorrücken, die er nie + // erhalten hat, und sie dauerhaft verpassen — genau das vom Nutzer beobachtete Symptom + // (Push meldet Erfolg, Pull liefert 0 Ereignisse, obwohl welche ausstehen). + var newWatermark = events.Count > 0 ? events.Max(e => e.SequenceNr) : since; + return new() { Events = events, ServerSequenceNr = newWatermark }; } private ILiteCollection GetCol(string userId) diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index d775704..eeab833 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -200,7 +200,8 @@ public static class AppBootstrapper { services.AddSingleton(sp => new EventApplier( sp.GetRequiredService(), sp.GetRequiredService(), - BuildHttp(serverUrl, syncSettings), sp.GetRequiredService())); + BuildHttp(serverUrl, syncSettings), sp.GetRequiredService(), + sp.GetRequiredService())); services.AddSingleton(sp => new SyncEventPublisher( sp.GetRequiredService(), deviceId, sp.GetRequiredService(), sp.GetRequiredService())); diff --git a/LehrerApp.Sync.Tests/EventQueueTests.cs b/LehrerApp.Sync.Tests/EventQueueTests.cs index 95ccc8d..6da81eb 100644 --- a/LehrerApp.Sync.Tests/EventQueueTests.cs +++ b/LehrerApp.Sync.Tests/EventQueueTests.cs @@ -33,6 +33,50 @@ public sealed class EventQueueTests Assert.Null(exception); } + [Fact] + public void GetKnownServerSeq_UnbekannteEntitaet_LiefertNull() + { + using var temp = new TempEventQueue(); + + Assert.Null(temp.Queue.GetKnownServerSeq("Lesson", Guid.NewGuid().ToString())); + } + + [Fact] + public void SetKnownServerSeq_GefolgtVonGet_LiefertDenGesetztenWert() + { + using var temp = new TempEventQueue(); + var entityId = Guid.NewGuid().ToString(); + + temp.Queue.SetKnownServerSeq("Lesson", entityId, 42); + + Assert.Equal(42, temp.Queue.GetKnownServerSeq("Lesson", entityId)); + } + + [Fact] + public void SetKnownServerSeq_ErneutesSetzen_UeberschreibtDenAltenWert() + { + using var temp = new TempEventQueue(); + var entityId = Guid.NewGuid().ToString(); + temp.Queue.SetKnownServerSeq("Lesson", entityId, 42); + + temp.Queue.SetKnownServerSeq("Lesson", entityId, 43); + + Assert.Equal(43, temp.Queue.GetKnownServerSeq("Lesson", entityId)); + } + + [Fact] + public void SetKnownServerSeq_GleicheEntityIdAndererEntityType_BleibtGetrennt() + { + using var temp = new TempEventQueue(); + var entityId = Guid.NewGuid().ToString(); + temp.Queue.SetKnownServerSeq("Lesson", entityId, 42); + + temp.Queue.SetKnownServerSeq("Unit", entityId, 7); + + Assert.Equal(42, temp.Queue.GetKnownServerSeq("Lesson", entityId)); + Assert.Equal(7, temp.Queue.GetKnownServerSeq("Unit", entityId)); + } + private static SyncEvent MakeEvent() => new() { DeviceId = "desktop-1", diff --git a/LehrerApp.Sync.Tests/SyncEngineTests.cs b/LehrerApp.Sync.Tests/SyncEngineTests.cs index 09d6c24..62a6caf 100644 --- a/LehrerApp.Sync.Tests/SyncEngineTests.cs +++ b/LehrerApp.Sync.Tests/SyncEngineTests.cs @@ -67,6 +67,187 @@ public sealed class SyncEngineTests Assert.Equal(2, temp.Queue.GetLastServerSeq()); } + // ── PushAsync: Dedup, BasedOnServerSeq, AssignedServerSeqs (TODO 10.3.4) ──────────────── + + [Fact] + public async Task PushAsync_MehrereAusstehendeEreignisseDerselbenEntitaet_SendetNurDasJuengste() + { + using var temp = new TempEventQueue(); + var entityId = Guid.NewGuid().ToString(); + temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "alt"); + var newest = temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "neu"); + List? pushed = null; + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri!.AbsolutePath == "/api/sync/push") + { + pushed = req.Content!.ReadFromJsonAsync>().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PushResponse { ServerSequenceNr = 1 }) }; + } + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse()) }; + }); + var engine = MakeEngine(temp, handler); + + var result = await engine.SyncNowAsync(); + + Assert.True(result.Success); + var evt = Assert.Single(pushed!); + Assert.Equal(newest.EventId, evt.EventId); + Assert.Equal("neu", evt.Payload); + Assert.Equal(0, temp.Queue.PendingCount()); + } + + [Fact] + public async Task PushAsync_SetztBasedOnServerSeqAusLokalerVersionsverfolgung() + { + using var temp = new TempEventQueue(); + var entityId = Guid.NewGuid().ToString(); + temp.Queue.SetKnownServerSeq("Lesson", entityId, 5); + temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "x"); + List? pushed = null; + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri!.AbsolutePath == "/api/sync/push") + { + pushed = req.Content!.ReadFromJsonAsync>().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PushResponse { ServerSequenceNr = 6 }) }; + } + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse()) }; + }); + var engine = MakeEngine(temp, handler); + + await engine.SyncNowAsync(); + + Assert.Equal(5, Assert.Single(pushed!).BasedOnServerSeq); + } + + [Fact] + public async Task PushAsync_ErfolgreicherPush_AktualisiertLokaleVersionsverfolgung() + { + using var temp = new TempEventQueue(); + var entityId = Guid.NewGuid().ToString(); + var evt = temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", entityId, "Save", "x"); + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri!.AbsolutePath == "/api/sync/push") + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new PushResponse + { ServerSequenceNr = 7, AssignedServerSeqs = new() { [evt.EventId] = 7 } }), + }; + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse()) }; + }); + var engine = MakeEngine(temp, handler); + + await engine.SyncNowAsync(); + + Assert.Equal(7, temp.Queue.GetKnownServerSeq("Lesson", entityId)); + } + + /// Regression: ein wegen neuerem Server-Stand abgelehnter Push blieb bisher unsichtbar - das + /// Ereignis verschwand einfach nicht aus der Queue, ohne dass der Nutzer je erfuhr, dass es + /// bereits neuere Daten gab (siehe TODO 10.3.4). RemoteWon-Fall: der Server-Stand gewinnt, wird + /// sofort angewendet, und die lokale Änderung wird verworfen. + [Fact] + public async Task PushAsync_AbgelehnterPushRemoteWon_WendetServerStandAnUndVerwirftLokal() + { + using var temp = new TempEventQueue(); + using var db = NewInMemoryContext(); + var entityId = Guid.NewGuid(); + // Companion verliert immer gegen Desktop (siehe ConflictResolver.DetermineWinner). + var local = temp.Queue.Enqueue("companion-device", DeviceType.Companion, + nameof(Student), entityId.ToString(), "Save", "veraltete-lokale-payload"); + var remoteStudent = new Student { Id = entityId, FirstName = "Anna", LastName = "Beispiel" }; + var remoteEvent = new SyncEvent + { + DeviceId = "other-device", DeviceType = DeviceType.Desktop, + EntityType = nameof(Student), EntityId = entityId.ToString(), + Operation = "Save", Payload = SyncCrypto.EncryptObject(remoteStudent, Key), + SequenceNr = 99, + }; + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri!.AbsolutePath == "/api/sync/push") + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new PushResponse + { ServerSequenceNr = 99, ConflictingEventIds = [local.EventId] }), + }; + if (req.RequestUri!.AbsolutePath == $"/api/sync/entity/{nameof(Student)}/{entityId}") + return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(remoteEvent) }; + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse()) }; + }); + var applier = new EventApplier(db, Key, versions: temp.Queue); + var engine = MakeEngine(temp, handler, applier); + + await engine.SyncNowAsync(); + + var conflict = Assert.Single(temp.Queue.GetUnreviewed()); + Assert.Equal("RemoteWon", conflict.Resolution); + Assert.NotNull(db.Students.FindById(entityId)); + Assert.Equal(0, temp.Queue.PendingCount()); + Assert.Equal(99, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString())); + } + + /// Gegenstück: LocalWon-Fall - die lokale Änderung bleibt (unbestätigt) in der Queue, damit der + /// nächste Sync-Versuch sie mit dem soeben aktualisierten BasedOnServerSeq erneut versucht. + [Fact] + public async Task PushAsync_AbgelehnterPushLocalWon_BleibtUnbestaetigtInDerQueue() + { + using var temp = new TempEventQueue(); + using var db = NewInMemoryContext(); + var entityId = Guid.NewGuid(); + // Desktop gewinnt immer gegen Companion (siehe ConflictResolver.DetermineWinner). + var local = temp.Queue.Enqueue("this-device", DeviceType.Desktop, + nameof(Student), entityId.ToString(), "Save", SyncCrypto.EncryptObject( + new Student { Id = entityId, FirstName = "Lokal", LastName = "Beispiel" }, Key)); + var remoteEvent = new SyncEvent + { + DeviceId = "other-device", DeviceType = DeviceType.Companion, + EntityType = nameof(Student), EntityId = entityId.ToString(), + Operation = "Save", Payload = "irrelevant-verliert-ohnehin", + SequenceNr = 42, + }; + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri!.AbsolutePath == "/api/sync/push") + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new PushResponse + { ServerSequenceNr = 42, ConflictingEventIds = [local.EventId] }), + }; + if (req.RequestUri!.AbsolutePath == $"/api/sync/entity/{nameof(Student)}/{entityId}") + return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(remoteEvent) }; + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse()) }; + }); + var applier = new EventApplier(db, Key, versions: temp.Queue); + var engine = MakeEngine(temp, handler, applier); + + await engine.SyncNowAsync(); + + var conflict = Assert.Single(temp.Queue.GetUnreviewed()); + Assert.Equal("LocalWon", conflict.Resolution); + Assert.Equal(1, temp.Queue.PendingCount()); + Assert.Equal(42, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString())); + } + + private static SyncEngine MakeEngine(TempEventQueue temp, FakeHttpMessageHandler handler, + EventApplier? applier = null) + { + var db = NewInMemoryContext(); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://example.invalid") }; + return new SyncEngine(temp.Queue, new ConflictResolver(temp.Queue), + applier ?? new EventApplier(db, Key), new AttachmentSyncer(db, http, Key), http, + new SyncConfig { DeviceId = "this-device" }); + } + private sealed class TempEventQueue : IDisposable { private readonly string _directory = Path.Combine( diff --git a/LehrerApp.Sync/ConflictResolver.cs b/LehrerApp.Sync/ConflictResolver.cs index 6c05607..5ed69f4 100644 --- a/LehrerApp.Sync/ConflictResolver.cs +++ b/LehrerApp.Sync/ConflictResolver.cs @@ -16,17 +16,7 @@ public class ConflictResolver(EventQueue queue) && e.DeviceId != remote.DeviceId); if (local is null) return null; - var winner = (local.DeviceType, remote.DeviceType) switch - { - (DeviceType.Desktop, DeviceType.Companion) => local, - (DeviceType.Companion, DeviceType.Desktop) => remote, - // ToUniversalTime(): LiteDB liefert DateTime beim Auslesen aus der Queue als Kind=Local - // zurück (Ticks werden dabei um die lokale Zeitzone verschoben). DateTime-Vergleiche - // berücksichtigen Kind nicht, sondern vergleichen nur rohe Ticks — ein direkter Vergleich - // von local.Timestamp (Local, aus der Queue) mit remote.Timestamp (Utc, vom Server) wäre - // daher außerhalb von UTC+0 falsch. - _ => local.Timestamp.ToUniversalTime() >= remote.Timestamp.ToUniversalTime() ? local : remote, - }; + var winner = DetermineWinner(local, remote); if (winner == remote) queue.Acknowledge([local.EventId]); @@ -37,4 +27,23 @@ public class ConflictResolver(EventQueue queue) Resolution = winner == local ? "LocalWon" : "RemoteWon", }; } + + /// + /// Desktop schlägt Companion; bei gleichem Gerätetyp gewinnt der spätere Timestamp. Auch von + /// SyncEngine für die Ablehnungsbehandlung eines per BasedOnServerSeq abgelehnten Push + /// genutzt — dieselbe Politik unabhängig davon, ob der Konflikt beim Pull (gleichzeitig + /// eingetroffenes fremdes Ereignis) oder erst durch eine Server-Ablehnung entdeckt wurde. + /// + public static SyncEvent DetermineWinner(SyncEvent local, SyncEvent remote) => + (local.DeviceType, remote.DeviceType) switch + { + (DeviceType.Desktop, DeviceType.Companion) => local, + (DeviceType.Companion, DeviceType.Desktop) => remote, + // ToUniversalTime(): LiteDB liefert DateTime beim Auslesen aus der Queue als Kind=Local + // zurück (Ticks werden dabei um die lokale Zeitzone verschoben). DateTime-Vergleiche + // berücksichtigen Kind nicht, sondern vergleichen nur rohe Ticks — ein direkter Vergleich + // von local.Timestamp (Local, aus der Queue) mit remote.Timestamp (Utc, vom Server) wäre + // daher außerhalb von UTC+0 falsch. + _ => local.Timestamp.ToUniversalTime() >= remote.Timestamp.ToUniversalTime() ? local : remote, + }; } diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs index 7083e7d..4d0d7ad 100644 --- a/LehrerApp.Sync/EventApplier.cs +++ b/LehrerApp.Sync/EventApplier.cs @@ -24,7 +24,8 @@ namespace LehrerApp.Sync; /// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique- /// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses. /// -public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null, AppLogger? logger = null) +public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = null, AppLogger? logger = null, + EventQueue? versions = null) { private static readonly Dictionary Handlers = BuildHandlers(); @@ -42,6 +43,10 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n handler(db, evt.Operation, evt.EntityId, json); if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null) await DownloadMissingAttachmentsAsync(json); + // evt.SequenceNr trägt bei einem vom Server empfangenen Ereignis immer dessen + // ServerSeq (siehe EventStore.Pull) — Grundlage für BasedOnServerSeq beim nächsten + // eigenen Push dieser Entität (optimistische Nebenläufigkeitskontrolle, TODO 10.3.4). + versions?.SetKnownServerSeq(evt.EntityType, evt.EntityId, evt.SequenceNr); logger?.Info($"Sync: Ereignis angewendet - {evt.EntityType} {evt.Operation} EntityId={evt.EntityId}"); } catch (LiteException ex) diff --git a/LehrerApp.Sync/EventQueue.cs b/LehrerApp.Sync/EventQueue.cs index a0bff87..379bf1c 100644 --- a/LehrerApp.Sync/EventQueue.cs +++ b/LehrerApp.Sync/EventQueue.cs @@ -14,6 +14,7 @@ public class EventQueue : IDisposable private readonly ILiteCollection _meta; private readonly ILiteCollection _conflicts; private readonly ILiteCollection _attachmentUploads; + private readonly ILiteCollection _entityVersions; private long _currentSeq; public EventQueue(string path) @@ -23,6 +24,7 @@ public class EventQueue : IDisposable _meta = _db.GetCollection("meta"); _conflicts = _db.GetCollection("conflicts"); _attachmentUploads = _db.GetCollection("attachment_uploads"); + _entityVersions = _db.GetCollection("entity_versions"); _attachmentUploads.EnsureIndex(x => x.StorageId, unique: true); _queue.EnsureIndex(x => x.SequenceNr); _currentSeq = _meta.FindById("seq")?.Value ?? 0; @@ -67,6 +69,20 @@ public class EventQueue : IDisposable _conflicts.Update(conflict); } + // ── Lokale Versionsverfolgung je Entität (optimistische Nebenläufigkeitskontrolle) ────── + // Merkt sich pro Entität die zuletzt bekannte ServerSeq — Grundlage für SyncEvent. + // BasedOnServerSeq beim Push (siehe SyncEngine.PushAsync) und dafür, wie ein abgelehnter + // Push nach dem Nachladen des aktuellen Server-Stands aufgelöst wird. + + public long? GetKnownServerSeq(string entityType, string entityId) => + _entityVersions.FindById(EntityVersionKey(entityType, entityId))?.ServerSeq; + + public void SetKnownServerSeq(string entityType, string entityId, long serverSeq) => + _entityVersions.Upsert(new EntityVersion + { Key = EntityVersionKey(entityType, entityId), ServerSeq = serverSeq }); + + private static string EntityVersionKey(string entityType, string entityId) => $"{entityType}:{entityId}"; + // ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ──── public void QueueAttachmentUpload(string storageId) { @@ -103,3 +119,10 @@ internal class PendingAttachmentUpload public ObjectId Id { get; set; } = ObjectId.NewObjectId(); public string StorageId { get; set; } = ""; } + +internal class EntityVersion +{ + [BsonId] + public string Key { get; set; } = ""; + public long ServerSeq { get; set; } +} diff --git a/LehrerApp.Sync/Models/SyncModels.cs b/LehrerApp.Sync/Models/SyncModels.cs index 042fdfb..e909956 100644 --- a/LehrerApp.Sync/Models/SyncModels.cs +++ b/LehrerApp.Sync/Models/SyncModels.cs @@ -21,6 +21,14 @@ public class SyncEvent public string Operation { get; init; } = ""; /// Verschlüsselt (Desktop) oder Klartext (Companion/WebApp). public string Payload { get; init; } = ""; + /// + /// ServerSeq, auf der die lokale Änderung aufbaut (null = Entität wurde hier noch nie + /// synchronisiert, z.B. Neuanlage). Wird von SyncEngine.PushAsync erst unmittelbar vor dem + /// Senden aus der lokalen Versionsverfolgung (EventQueue) gesetzt, nicht beim Einreihen — + /// so verwendet ein zweiter Push desselben Ereignisses (falls der erste abgelehnt wurde) + /// automatisch den inzwischen aktualisierten Stand. + /// + public long? BasedOnServerSeq { get; set; } } /// WebApp/Companion-Event: Payload ist Klartext-JSON. @@ -43,6 +51,10 @@ public class PushResponse public bool Success { get; init; } public long ServerSequenceNr { get; init; } public List ConflictingEventIds { get; init; } = []; + /// Je akzeptiertem Ereignis die tatsächlich vergebene ServerSeq — der Client braucht + /// das, um seine lokale Versionsverfolgung je Entität (EventQueue) auf den neuen Stand zu + /// bringen. + public Dictionary AssignedServerSeqs { get; init; } = []; } public class PullResponse { diff --git a/LehrerApp.Sync/SyncEngine.cs b/LehrerApp.Sync/SyncEngine.cs index ad20d41..9142c83 100644 --- a/LehrerApp.Sync/SyncEngine.cs +++ b/LehrerApp.Sync/SyncEngine.cs @@ -76,8 +76,13 @@ public class SyncEngine : IDisposable private async Task<(int Pushed, int Conflicts)> PushAsync() { - var pending = _queue.GetPending(); + var pending = DeduplicatePending(); if (pending.Count == 0) return (0, 0); + // BasedOnServerSeq erst unmittelbar vor dem Senden setzen (nicht beim Enqueue) - zwischen + // Enqueue und Push kann ein Pull den lokal bekannten Stand dieser Entität aktualisiert + // haben (siehe EventApplier.ApplyAsync). + foreach (var evt in pending) + evt.BasedOnServerSeq = _queue.GetKnownServerSeq(evt.EntityType, evt.EntityId); _logger?.Info($"Sync: Push - {pending.Count} Ereignis(se) ausstehend: " + string.Join(", ", pending.Select(e => $"{e.EntityType}/{e.Operation}"))); var resp = await _http.PostAsJsonAsync("/api/sync/push", pending); @@ -87,13 +92,95 @@ public class SyncEngine : IDisposable _queue.Acknowledge(pending .Where(e => !result.ConflictingEventIds.Contains(e.EventId)) .Select(e => e.EventId)); + foreach (var evt in pending) + if (result.AssignedServerSeqs.TryGetValue(evt.EventId, out var seq)) + _queue.SetKnownServerSeq(evt.EntityType, evt.EntityId, seq); _queue.SetLastServerSeq(result.ServerSequenceNr); _logger?.Info($"Sync: Push - vom Server bestätigt bis ServerSequenceNr={result.ServerSequenceNr}, " + $"{result.ConflictingEventIds.Count} vom Server abgelehnt (Konflikt)."); + if (result.ConflictingEventIds.Count > 0) + await HandleRejectedAsync(pending.Where(e => result.ConflictingEventIds.Contains(e.EventId))); return (pending.Count - result.ConflictingEventIds.Count, result.ConflictingEventIds.Count); } + // Payload ist immer ein vollständiges Entitäts-Snapshot (nie ein Delta, siehe + // SyncEventPublisher) - mehrere ausstehende lokale Änderungen derselben Entität lassen sich + // deshalb gefahrlos auf das jüngste zusammenfassen, bevor gepusht wird. Wichtig auch für die + // exakte BasedOnServerSeq-Prüfung: ohne Dedup könnten zwei Ereignisse derselben Entität im + // selben Batch mit demselben (veralteten) BasedOnServerSeq ankommen und sich gegenseitig ins + // Aus laufen. + private List DeduplicatePending() + { + var pending = _queue.GetPending(); + if (pending.Count == 0) return pending; + var latest = pending + .GroupBy(e => (e.EntityType, e.EntityId)) + .Select(g => g.OrderBy(e => e.SequenceNr).Last()) + .ToList(); + var superseded = pending.Except(latest).Select(e => e.EventId).ToList(); + if (superseded.Count > 0) + { + _queue.Acknowledge(superseded); + _logger?.Info($"Sync: Push - {superseded.Count} veraltete Ereignis(se) derselben " + + "Entität lokal zusammengefasst (Full-Snapshot)."); + } + return latest; + } + + /// + /// Ein Push wurde abgelehnt, weil der Server für diese Entität bereits einen neueren Stand + /// hat (BasedOnServerSeq-Mismatch). Lädt den aktuellen Server-Stand sofort nach (statt auf + /// den nächsten regulären Pull zu warten), löst den Konflikt nach derselben Politik wie + /// auf und legt in jedem Fall einen ConflictEntry an, damit der + /// Nutzer sieht, dass hier bereits neuere Daten vorlagen - unabhängig davon, ob die lokale + /// Änderung verworfen wird oder nicht. + /// + private async Task HandleRejectedAsync(IEnumerable rejected) + { + foreach (var local in rejected) + { + SyncEvent? remote; + try + { + remote = await _http.GetFromJsonAsync( + $"/api/sync/entity/{local.EntityType}/{local.EntityId}"); + } + catch (Exception ex) + { + _logger?.Error($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId} - " + + "aktueller Server-Stand konnte nicht nachgeladen werden.", ex); + continue; + } + if (remote is null) + { + _logger?.Warn($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId}, aber " + + "kein Server-Stand gefunden - übersprungen."); + continue; + } + + _queue.SetKnownServerSeq(local.EntityType, local.EntityId, remote.SequenceNr); + var winner = ConflictResolver.DetermineWinner(local, remote); + _queue.AddConflict(new ConflictEntry + { + LocalEvent = local, + RemoteEvent = remote, + Resolution = winner == local ? "LocalWon" : "RemoteWon", + }); + _logger?.Info($"Sync: Push-Konflikt bei {local.EntityType}/{local.EntityId} aufgelöst - " + + $"Server hatte bereits neueren Stand (ServerSeq={remote.SequenceNr}), " + + $"Auflösung={(winner == local ? "LocalWon" : "RemoteWon")}."); + + if (winner == remote) + { + await _applier.ApplyAsync(remote); + _queue.Acknowledge([local.EventId]); + } + // LocalWon: Ereignis bleibt unbestätigt in der Queue - der nächste PushAsync-Lauf + // versucht es erneut, jetzt mit dem soeben aktualisierten BasedOnServerSeq. + } + } + private async Task<(int Pulled, int Conflicts)> PullAsync() { var since = _queue.GetLastServerSeq(); diff --git a/TODO.md b/TODO.md index 954fa87..28d58f3 100644 --- a/TODO.md +++ b/TODO.md @@ -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