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
+10
View File
@@ -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)]
+184
View File
@@ -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);
}
}
}
+11
View File
@@ -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) ──────────
+51 -8
View File
@@ -11,26 +11,58 @@ public class EventStore(string dataPath) : IDisposable
private readonly Dictionary<string, LiteDatabase> _dbs = new();
private readonly Lock _lock = new();
/// <summary>
/// Nimmt Ereignisse an, wenn ihre <see cref="SyncEvent.BasedOnServerSeq"/> 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).
/// </summary>
public PushResponse Push(string userId, List<SyncEvent> events)
{
var col = GetCol(userId);
var seq = LastSeq(col);
var rejects = new List<Guid>();
var rejects = new List<Guid>();
var assigned = new Dictionary<Guid, long>();
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 };
}
/// <summary>Aktuellstes Ereignis einer einzelnen Entität — für Clients, deren Push wegen eines
/// neueren Server-Stands abgelehnt wurde (siehe <see cref="Push"/>), um sofort den aktuellen
/// Stand nachzuladen, statt auf den nächsten regulären Pull zu warten.</summary>
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<ServerEvent> 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<ServerEvent> GetCol(string userId)
+2 -1
View File
@@ -200,7 +200,8 @@ public static class AppBootstrapper
{
services.AddSingleton(sp => new EventApplier(
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
BuildHttp(serverUrl, syncSettings), sp.GetRequiredService<AppLogger>()));
BuildHttp(serverUrl, syncSettings), sp.GetRequiredService<AppLogger>(),
sp.GetRequiredService<EventQueue>()));
services.AddSingleton(sp => new SyncEventPublisher(
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>(),
sp.GetRequiredService<AppLogger>()));
+44
View File
@@ -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",
+181
View File
@@ -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<SyncEvent>? pushed = null;
var handler = new FakeHttpMessageHandler(req =>
{
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
{
pushed = req.Content!.ReadFromJsonAsync<List<SyncEvent>>().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<SyncEvent>? pushed = null;
var handler = new FakeHttpMessageHandler(req =>
{
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
{
pushed = req.Content!.ReadFromJsonAsync<List<SyncEvent>>().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(
+20 -11
View File
@@ -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",
};
}
/// <summary>
/// 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.
/// </summary>
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,
};
}
+6 -1
View File
@@ -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.
/// </summary>
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<string, EntityHandler> 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)
+23
View File
@@ -14,6 +14,7 @@ public class EventQueue : IDisposable
private readonly ILiteCollection<SyncMeta> _meta;
private readonly ILiteCollection<ConflictEntry> _conflicts;
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
private readonly ILiteCollection<EntityVersion> _entityVersions;
private long _currentSeq;
public EventQueue(string path)
@@ -23,6 +24,7 @@ public class EventQueue : IDisposable
_meta = _db.GetCollection<SyncMeta>("meta");
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
_entityVersions = _db.GetCollection<EntityVersion>("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; }
}
+12
View File
@@ -21,6 +21,14 @@ public class SyncEvent
public string Operation { get; init; } = "";
/// <summary>Verschlüsselt (Desktop) oder Klartext (Companion/WebApp).</summary>
public string Payload { get; init; } = "";
/// <summary>
/// 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.
/// </summary>
public long? BasedOnServerSeq { get; set; }
}
/// <summary>WebApp/Companion-Event: Payload ist Klartext-JSON.</summary>
@@ -43,6 +51,10 @@ public class PushResponse
public bool Success { get; init; }
public long ServerSequenceNr { get; init; }
public List<Guid> ConflictingEventIds { get; init; } = [];
/// <summary>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.</summary>
public Dictionary<Guid, long> AssignedServerSeqs { get; init; } = [];
}
public class PullResponse
{
+88 -1
View File
@@ -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<SyncEvent> 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;
}
/// <summary>
/// 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
/// <see cref="ConflictResolver"/> 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.
/// </summary>
private async Task HandleRejectedAsync(IEnumerable<SyncEvent> rejected)
{
foreach (var local in rejected)
{
SyncEvent? remote;
try
{
remote = await _http.GetFromJsonAsync<SyncEvent>(
$"/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();
+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