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:
@@ -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)]
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,17 @@ public static class Endpoints
|
|||||||
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow });
|
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) ──────────
|
// ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ──────────
|
||||||
|
|||||||
@@ -11,26 +11,58 @@ public class EventStore(string dataPath) : IDisposable
|
|||||||
private readonly Dictionary<string, LiteDatabase> _dbs = new();
|
private readonly Dictionary<string, LiteDatabase> _dbs = new();
|
||||||
private readonly Lock _lock = 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)
|
public PushResponse Push(string userId, List<SyncEvent> events)
|
||||||
{
|
{
|
||||||
var col = GetCol(userId);
|
var col = GetCol(userId);
|
||||||
var seq = LastSeq(col);
|
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))
|
foreach (var e in events.OrderBy(e => e.Timestamp))
|
||||||
{
|
{
|
||||||
var recent = col.FindOne(x =>
|
var currentSeq = LatestForEntity(col, e.EntityType, e.EntityId)?.ServerSeq;
|
||||||
x.EntityType == e.EntityType && x.EntityId == e.EntityId &&
|
if (currentSeq != e.BasedOnServerSeq) { rejects.Add(e.EventId); continue; }
|
||||||
x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30));
|
var newSeq = ++seq;
|
||||||
if (recent is not null) { rejects.Add(e.EventId); continue; }
|
|
||||||
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||||
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
||||||
ClientSeq = e.SequenceNr, ServerSeq = ++seq,
|
ClientSeq = e.SequenceNr, ServerSeq = newSeq,
|
||||||
EntityType = e.EntityType, EntityId = e.EntityId,
|
EntityType = e.EntityType, EntityId = e.EntityId,
|
||||||
Operation = e.Operation, Payload = e.Payload });
|
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)
|
public PullResponse Pull(string userId, long since, string requestingDeviceId)
|
||||||
{
|
{
|
||||||
var col = GetCol(userId);
|
var col = GetCol(userId);
|
||||||
@@ -41,7 +73,18 @@ public class EventStore(string dataPath) : IDisposable
|
|||||||
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
||||||
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
|
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
|
||||||
.ToList();
|
.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)
|
private ILiteCollection<ServerEvent> GetCol(string userId)
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ public static class AppBootstrapper
|
|||||||
{
|
{
|
||||||
services.AddSingleton(sp => new EventApplier(
|
services.AddSingleton(sp => new EventApplier(
|
||||||
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>(),
|
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(
|
services.AddSingleton(sp => new SyncEventPublisher(
|
||||||
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>(),
|
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>(),
|
||||||
sp.GetRequiredService<AppLogger>()));
|
sp.GetRequiredService<AppLogger>()));
|
||||||
|
|||||||
@@ -33,6 +33,50 @@ public sealed class EventQueueTests
|
|||||||
Assert.Null(exception);
|
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()
|
private static SyncEvent MakeEvent() => new()
|
||||||
{
|
{
|
||||||
DeviceId = "desktop-1",
|
DeviceId = "desktop-1",
|
||||||
|
|||||||
@@ -67,6 +67,187 @@ public sealed class SyncEngineTests
|
|||||||
Assert.Equal(2, temp.Queue.GetLastServerSeq());
|
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 sealed class TempEventQueue : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _directory = Path.Combine(
|
private readonly string _directory = Path.Combine(
|
||||||
|
|||||||
@@ -16,17 +16,7 @@ public class ConflictResolver(EventQueue queue)
|
|||||||
&& e.DeviceId != remote.DeviceId);
|
&& e.DeviceId != remote.DeviceId);
|
||||||
if (local is null) return null;
|
if (local is null) return null;
|
||||||
|
|
||||||
var winner = (local.DeviceType, remote.DeviceType) switch
|
var winner = DetermineWinner(local, remote);
|
||||||
{
|
|
||||||
(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,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (winner == remote) queue.Acknowledge([local.EventId]);
|
if (winner == remote) queue.Acknowledge([local.EventId]);
|
||||||
|
|
||||||
@@ -37,4 +27,23 @@ public class ConflictResolver(EventQueue queue)
|
|||||||
Resolution = winner == local ? "LocalWon" : "RemoteWon",
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ namespace LehrerApp.Sync;
|
|||||||
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique-
|
/// 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.
|
/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
|
||||||
/// </summary>
|
/// </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();
|
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);
|
handler(db, evt.Operation, evt.EntityId, json);
|
||||||
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
|
if (evt.EntityType == nameof(Documentation) && evt.Operation != "Delete" && http is not null)
|
||||||
await DownloadMissingAttachmentsAsync(json);
|
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}");
|
logger?.Info($"Sync: Ereignis angewendet - {evt.EntityType} {evt.Operation} EntityId={evt.EntityId}");
|
||||||
}
|
}
|
||||||
catch (LiteException ex)
|
catch (LiteException ex)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class EventQueue : IDisposable
|
|||||||
private readonly ILiteCollection<SyncMeta> _meta;
|
private readonly ILiteCollection<SyncMeta> _meta;
|
||||||
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
||||||
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
|
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
|
||||||
|
private readonly ILiteCollection<EntityVersion> _entityVersions;
|
||||||
private long _currentSeq;
|
private long _currentSeq;
|
||||||
|
|
||||||
public EventQueue(string path)
|
public EventQueue(string path)
|
||||||
@@ -23,6 +24,7 @@ public class EventQueue : IDisposable
|
|||||||
_meta = _db.GetCollection<SyncMeta>("meta");
|
_meta = _db.GetCollection<SyncMeta>("meta");
|
||||||
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
||||||
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
|
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
|
||||||
|
_entityVersions = _db.GetCollection<EntityVersion>("entity_versions");
|
||||||
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
|
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
|
||||||
_queue.EnsureIndex(x => x.SequenceNr);
|
_queue.EnsureIndex(x => x.SequenceNr);
|
||||||
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
||||||
@@ -67,6 +69,20 @@ public class EventQueue : IDisposable
|
|||||||
_conflicts.Update(conflict);
|
_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) ────
|
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
|
||||||
public void QueueAttachmentUpload(string storageId)
|
public void QueueAttachmentUpload(string storageId)
|
||||||
{
|
{
|
||||||
@@ -103,3 +119,10 @@ internal class PendingAttachmentUpload
|
|||||||
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
||||||
public string StorageId { get; set; } = "";
|
public string StorageId { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class EntityVersion
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public long ServerSeq { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ public class SyncEvent
|
|||||||
public string Operation { get; init; } = "";
|
public string Operation { get; init; } = "";
|
||||||
/// <summary>Verschlüsselt (Desktop) oder Klartext (Companion/WebApp).</summary>
|
/// <summary>Verschlüsselt (Desktop) oder Klartext (Companion/WebApp).</summary>
|
||||||
public string Payload { get; init; } = "";
|
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>
|
/// <summary>WebApp/Companion-Event: Payload ist Klartext-JSON.</summary>
|
||||||
@@ -43,6 +51,10 @@ public class PushResponse
|
|||||||
public bool Success { get; init; }
|
public bool Success { get; init; }
|
||||||
public long ServerSequenceNr { get; init; }
|
public long ServerSequenceNr { get; init; }
|
||||||
public List<Guid> ConflictingEventIds { 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
|
public class PullResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,8 +76,13 @@ public class SyncEngine : IDisposable
|
|||||||
|
|
||||||
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
||||||
{
|
{
|
||||||
var pending = _queue.GetPending();
|
var pending = DeduplicatePending();
|
||||||
if (pending.Count == 0) return (0, 0);
|
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: " +
|
_logger?.Info($"Sync: Push - {pending.Count} Ereignis(se) ausstehend: " +
|
||||||
string.Join(", ", pending.Select(e => $"{e.EntityType}/{e.Operation}")));
|
string.Join(", ", pending.Select(e => $"{e.EntityType}/{e.Operation}")));
|
||||||
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
||||||
@@ -87,13 +92,95 @@ public class SyncEngine : IDisposable
|
|||||||
_queue.Acknowledge(pending
|
_queue.Acknowledge(pending
|
||||||
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
||||||
.Select(e => 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);
|
_queue.SetLastServerSeq(result.ServerSequenceNr);
|
||||||
_logger?.Info($"Sync: Push - vom Server bestätigt bis ServerSequenceNr={result.ServerSequenceNr}, " +
|
_logger?.Info($"Sync: Push - vom Server bestätigt bis ServerSequenceNr={result.ServerSequenceNr}, " +
|
||||||
$"{result.ConflictingEventIds.Count} vom Server abgelehnt (Konflikt).");
|
$"{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,
|
return (pending.Count - result.ConflictingEventIds.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()
|
private async Task<(int Pulled, int Conflicts)> PullAsync()
|
||||||
{
|
{
|
||||||
var since = _queue.GetLastServerSeq();
|
var since = _queue.GetLastServerSeq();
|
||||||
|
|||||||
@@ -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
|
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
|
Stelle der Kette (Einreihen → Push → Server → Pull → Anwenden) eine Änderung tatsächlich
|
||||||
verloren geht — bisher war das reine Spekulation ohne Live-Testgeräte.
|
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.
|
- [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
|
**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
|
Anhänge nach Anwenden eines `Documentation`-Ereignisses). Original-`StorageId` bleibt beim
|
||||||
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
|
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
|
||||||
das immer eine neue Id vergäbe).
|
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
|
### 10.2 Server
|
||||||
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
|
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
|
||||||
|
|||||||
Reference in New Issue
Block a user