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
+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(