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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user