fix: eigener Push ließ Client seinen Pull-Cursor über fremde Ereignisse springen
SyncEngine.PushAsync setzte den lokalen Pull-Cursor bisher aus PushResponse.ServerSequenceNr - dem globalen Zähler über alle Geräte nach dem eigenen Push, nicht dem tatsächlich zugestellten Stand. War beim Server zu diesem Zeitpunkt bereits ein noch nicht abgeholtes Ereignis eines anderen Geräts mit niedrigerer ServerSeq vorhanden, sprang der Cursor darüber hinweg und der direkt folgende Pull bekam 0 Ereignisse, ohne es je angewendet zu haben - ein zweiter, unabhängiger Cursor-Bug mit demselben Symptom wie der vorherige Wasserzeichen-Fix, diesmal client- statt serverseitig. Ergänzt außerdem einen "Vollständigen Sync erzwingen"-Button in den Sync-Einstellungen, damit bereits durch diesen Bug zu weit vorgerückte Geräte ihren Fortschritt manuell zurücksetzen und alle Ereignisse erneut laden können. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -195,6 +195,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _syncLoginError = "";
|
[ObservableProperty] private string _syncLoginError = "";
|
||||||
[ObservableProperty] private bool _syncIsLoggedIn;
|
[ObservableProperty] private bool _syncIsLoggedIn;
|
||||||
[ObservableProperty] private string _syncConnectionStatus = "";
|
[ObservableProperty] private string _syncConnectionStatus = "";
|
||||||
|
[ObservableProperty] private string _syncForceResyncStatus = "";
|
||||||
|
[ObservableProperty] private bool _syncForceResyncBusy;
|
||||||
|
|
||||||
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
|
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
|
||||||
|
|
||||||
@@ -229,6 +231,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly SyncSettingsService _syncSettings;
|
private readonly SyncSettingsService _syncSettings;
|
||||||
private readonly SyncAuthService _syncAuth;
|
private readonly SyncAuthService _syncAuth;
|
||||||
private readonly EventQueue _eventQueue;
|
private readonly EventQueue _eventQueue;
|
||||||
|
private readonly SyncEngine? _syncEngine;
|
||||||
private readonly SnapshotService? _snapshotService;
|
private readonly SnapshotService? _snapshotService;
|
||||||
private readonly CompetencyCatalogImportService _catalogImport;
|
private readonly CompetencyCatalogImportService _catalogImport;
|
||||||
private readonly AppLogger _logger;
|
private readonly AppLogger _logger;
|
||||||
@@ -243,7 +246,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||||
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
AiSettingsService aiSettings, AiPlanningService aiPlanning,
|
||||||
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
|
||||||
AppLogger logger, SnapshotService? snapshotService = null)
|
AppLogger logger, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_subjects = subjects;
|
_subjects = subjects;
|
||||||
@@ -270,6 +273,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_syncAuth = syncAuth;
|
_syncAuth = syncAuth;
|
||||||
_eventQueue = eventQueue;
|
_eventQueue = eventQueue;
|
||||||
_snapshotService = snapshotService;
|
_snapshotService = snapshotService;
|
||||||
|
_syncEngine = syncEngine;
|
||||||
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
LoadShorthandCodes();
|
LoadShorthandCodes();
|
||||||
@@ -489,6 +493,31 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
AppBootstrapper.RestartApplication();
|
AppBootstrapper.RestartApplication();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt den lokalen Sync-Fortschritt zurück und lädt danach alle Ereignisse dieses Nutzers
|
||||||
|
/// erneut vom Server - manueller Reparaturweg, falls dieses Gerät wiederholt keine Änderungen
|
||||||
|
/// eines anderen Geräts erhält (z.B. nach einem lokal bereits zu weit vorgerückten Cursor,
|
||||||
|
/// siehe TODO 10.1.10). Sicher wiederholbar: EventApplier wendet jedes Ereignis über Upsert/
|
||||||
|
/// Delete-by-Id idempotent an.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SyncForceFullResync()
|
||||||
|
{
|
||||||
|
if (_syncEngine is null) { SyncForceResyncStatus = "Sync nicht konfiguriert."; return; }
|
||||||
|
SyncForceResyncBusy = true;
|
||||||
|
SyncForceResyncStatus = "Lade alle Ereignisse erneut…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_eventQueue.SetLastServerSeq(0);
|
||||||
|
var result = await _syncEngine.SyncNowAsync();
|
||||||
|
SyncForceResyncStatus = result.Success
|
||||||
|
? $"Abgeschlossen - {result.EventsPulled} Ereignis(se) erneut geladen."
|
||||||
|
: $"Fehlgeschlagen: {result.Reason}";
|
||||||
|
LoadSyncConflicts();
|
||||||
|
}
|
||||||
|
finally { SyncForceResyncBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Synchronisation: Konflikte ────────────────────────────────────────────
|
// ── Synchronisation: Konflikte ────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
|
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
|
||||||
|
|||||||
@@ -775,6 +775,17 @@
|
|||||||
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
|
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
|
||||||
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
|
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="6" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
|
||||||
|
<Separator/>
|
||||||
|
<TextBlock Text="Vollständigen Sync erzwingen" FontSize="14" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||||
|
Text="Falls dieses Gerät wiederholt keine Änderungen eines anderen Geräts erhält, kann hier der lokale Sync-Fortschritt zurückgesetzt werden — beim nächsten Sync werden alle Ereignisse vom Server erneut geladen."/>
|
||||||
|
<Button Content="Jetzt zurücksetzen und synchronisieren" Command="{Binding SyncForceFullResyncCommand}"
|
||||||
|
IsEnabled="{Binding !SyncForceResyncBusy}" HorizontalAlignment="Left"/>
|
||||||
|
<TextBlock Text="{Binding SyncForceResyncStatus}" FontSize="12" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding SyncForceResyncStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
|
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>
|
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>
|
||||||
|
|||||||
@@ -67,6 +67,60 @@ public sealed class SyncEngineTests
|
|||||||
Assert.Equal(2, temp.Queue.GetLastServerSeq());
|
Assert.Equal(2, temp.Queue.GetLastServerSeq());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: PushAsync setzte den lokalen Pull-Cursor bisher direkt aus PushResponse.
|
||||||
|
/// ServerSequenceNr - dem GLOBALEN Zähler über alle Geräte NACH diesem Push, nicht dem Stand,
|
||||||
|
/// den DIESES Gerät tatsächlich per Pull erhalten hat. Hatte der Server zum Push-Zeitpunkt
|
||||||
|
/// bereits ein noch nicht abgeholtes Ereignis eines ANDEREN Geräts mit niedrigerer ServerSeq,
|
||||||
|
/// sprang der Cursor beim eigenen Push darüber hinweg - der direkt anschließende PullAsync
|
||||||
|
/// fragte dann schon mit einem "since" danach und bekam 0 Ereignisse, obwohl das fremde
|
||||||
|
/// Ereignis nie angewendet wurde. Genau das vom Nutzer beobachtete Symptom ("Client 1 konnte
|
||||||
|
/// übermitteln, Client 2 bekommt weiterhin 'keine Änderungen'"), diesmal über einen anderen
|
||||||
|
/// Pfad als die bereits behobene EventStore.Pull-Wasserzeichen-Berechnung.
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncNowAsync_EigenerPushWaehrendFremdesEreignisNochAussteht_LiefertFremdesEreignisTrotzdem()
|
||||||
|
{
|
||||||
|
using var temp = new TempEventQueue();
|
||||||
|
using var db = NewInMemoryContext();
|
||||||
|
var applier = new EventApplier(db, Key, versions: temp.Queue);
|
||||||
|
var goodStudent = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||||
|
var foreignEvent = new SyncEvent
|
||||||
|
{
|
||||||
|
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
|
||||||
|
EntityType = nameof(Student), EntityId = goodStudent.Id.ToString(),
|
||||||
|
Operation = "Save", Payload = SyncCrypto.EncryptObject(goodStudent, Key),
|
||||||
|
SequenceNr = 5,
|
||||||
|
};
|
||||||
|
temp.Queue.Enqueue("this-device", DeviceType.Desktop, "Lesson", Guid.NewGuid().ToString(), "Save", "x");
|
||||||
|
var handler = new FakeHttpMessageHandler(req =>
|
||||||
|
{
|
||||||
|
if (req.RequestUri!.AbsolutePath == "/api/sync/push")
|
||||||
|
// Globaler Höchststand (10) schließt das fremde, von DIESEM Gerät noch nicht
|
||||||
|
// abgeholte Ereignis (Seq 5) bereits mit ein - genau das durfte SyncEngine NICHT
|
||||||
|
// als eigenen Pull-Cursor übernehmen.
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{ Content = JsonContent.Create(new PushResponse { ServerSequenceNr = 10 }) };
|
||||||
|
if (req.RequestUri!.AbsolutePath == "/api/sync/pull")
|
||||||
|
{
|
||||||
|
var query = req.RequestUri.Query.TrimStart('?').Split('&')
|
||||||
|
.Select(p => p.Split('=')).ToDictionary(p => p[0], p => p[1]);
|
||||||
|
var since = long.Parse(query["since"]);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(since < 5
|
||||||
|
? new PullResponse { Events = [foreignEvent], ServerSequenceNr = 5 }
|
||||||
|
: new PullResponse { ServerSequenceNr = since }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
});
|
||||||
|
var engine = MakeEngine(temp, handler, applier);
|
||||||
|
|
||||||
|
var result = await engine.SyncNowAsync();
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.NotNull(db.Students.FindById(goodStudent.Id));
|
||||||
|
}
|
||||||
|
|
||||||
// ── PushAsync: Dedup, BasedOnServerSeq, AssignedServerSeqs (TODO 10.3.4) ────────────────
|
// ── PushAsync: Dedup, BasedOnServerSeq, AssignedServerSeqs (TODO 10.3.4) ────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -95,9 +95,19 @@ public class SyncEngine : IDisposable
|
|||||||
foreach (var evt in pending)
|
foreach (var evt in pending)
|
||||||
if (result.AssignedServerSeqs.TryGetValue(evt.EventId, out var seq))
|
if (result.AssignedServerSeqs.TryGetValue(evt.EventId, out var seq))
|
||||||
_queue.SetKnownServerSeq(evt.EntityType, evt.EntityId, seq);
|
_queue.SetKnownServerSeq(evt.EntityType, evt.EntityId, seq);
|
||||||
_queue.SetLastServerSeq(result.ServerSequenceNr);
|
// ANDERS ALS FRÜHER wird der lokale Pull-Cursor (_queue.SetLastServerSeq) hier NICHT aus
|
||||||
_logger?.Info($"Sync: Push - vom Server bestätigt bis ServerSequenceNr={result.ServerSequenceNr}, " +
|
// result.ServerSequenceNr gesetzt: das ist der GLOBALE Zähler über ALLE Geräte NACH diesem
|
||||||
$"{result.ConflictingEventIds.Count} vom Server abgelehnt (Konflikt).");
|
// Push, nicht der Stand, den DIESES Gerät tatsächlich per Pull erhalten hat. Hatte der
|
||||||
|
// Server zu diesem Zeitpunkt bereits ein noch nicht abgeholtes Ereignis eines ANDEREN
|
||||||
|
// Geräts mit niedrigerer ServerSeq, würde der Cursor hier darüber hinwegspringen - der
|
||||||
|
// direkt anschließende PullAsync würde dann schon mit einem "since" danach fragen und 0
|
||||||
|
// Ereignisse zurückbekommen, ohne das fremde Ereignis je angewendet zu haben (exakt das
|
||||||
|
// Symptom, das die Ereignis-Anzahl-Korrektur in EventStore.Pull eigentlich beheben sollte -
|
||||||
|
// hier aber über einen anderen Pfad wieder hereinkam). PullAsync pflegt den Cursor bereits
|
||||||
|
// korrekt selbst, ausschließlich anhand tatsächlich zugestellter Ereignisse.
|
||||||
|
_logger?.Info($"Sync: Push - {pending.Count - result.ConflictingEventIds.Count} vom Server " +
|
||||||
|
$"angenommen (aktueller globaler Stand ServerSequenceNr={result.ServerSequenceNr}), " +
|
||||||
|
$"{result.ConflictingEventIds.Count} abgelehnt (Konflikt).");
|
||||||
if (result.ConflictingEventIds.Count > 0)
|
if (result.ConflictingEventIds.Count > 0)
|
||||||
await HandleRejectedAsync(pending.Where(e => result.ConflictingEventIds.Contains(e.EventId)));
|
await HandleRejectedAsync(pending.Where(e => result.ConflictingEventIds.Contains(e.EventId)));
|
||||||
return (pending.Count - result.ConflictingEventIds.Count,
|
return (pending.Count - result.ConflictingEventIds.Count,
|
||||||
|
|||||||
@@ -1639,6 +1639,36 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
|
|||||||
`AssignedServerSeqs`, `GetLatestForEntity`), `EventQueueTests`
|
`AssignedServerSeqs`, `GetLatestForEntity`), `EventQueueTests`
|
||||||
(`GetKnownServerSeq`/`SetKnownServerSeq`), `SyncEngineTests` (Dedup, frisch gesetztes
|
(`GetKnownServerSeq`/`SetKnownServerSeq`), `SyncEngineTests` (Dedup, frisch gesetztes
|
||||||
`BasedOnServerSeq`, Versionsverfolgung nach Erfolg, RemoteWon- und LocalWon-Ablehnung).
|
`BasedOnServerSeq`, Versionsverfolgung nach Erfolg, RemoteWon- und LocalWon-Ablehnung).
|
||||||
|
- [x] **10.1.10** Zweiter, unabhängiger Pull-Cursor-Bug (Nutzer-Bug-Report direkt nach dem
|
||||||
|
Deploy von 10.1.9): "Client 1 hat übermitteln können. Der 2. PC erhält immer noch die
|
||||||
|
Mitteilung, dass es keine Änderungen für ihn gibt."
|
||||||
|
|
||||||
|
**Ursache:** `SyncEngine.PushAsync` setzte den lokalen Pull-Cursor bisher direkt aus
|
||||||
|
`PushResponse.ServerSequenceNr` — das ist bei `EventStore.Push` der **globale** Zähler über
|
||||||
|
ALLE Geräte NACH diesem Push (`seq = LastSeq(col)`, dann je akzeptiertem Ereignis
|
||||||
|
hochgezählt), nicht der Stand, den DIESES Gerät tatsächlich per Pull erhalten hat. Hatte der
|
||||||
|
Server zum eigenen Push-Zeitpunkt bereits ein noch nicht abgeholtes Ereignis eines ANDEREN
|
||||||
|
Geräts mit niedrigerer ServerSeq, sprang der Cursor beim eigenen Push darüber hinweg — der
|
||||||
|
direkt anschließende `PullAsync` (läuft in `SyncNowAsync` immer sofort danach) fragte dann
|
||||||
|
schon mit einem zu hohen "since" und bekam 0 Ereignisse, obwohl das fremde Ereignis nie
|
||||||
|
angewendet wurde. Ein zweiter, unabhängiger Bug mit demselben Symptom wie die
|
||||||
|
Wasserzeichen-Korrektur zu 10.1.7 — diesmal nicht in `EventStore.Pull` selbst, sondern im
|
||||||
|
Client, der sich mit dem PUSH-Antwortwert seinen eigenen (fixen) Pull-Cursor kaputtmachte.
|
||||||
|
|
||||||
|
**Fix:** Die Zeile `_queue.SetLastServerSeq(result.ServerSequenceNr)` in `PushAsync`
|
||||||
|
ersatzlos entfernt — `PullAsync` pflegt den Cursor bereits korrekt selbst, ausschließlich
|
||||||
|
anhand tatsächlich zugestellter Ereignisse (siehe 10.1.7). Neuer Regressionstest
|
||||||
|
`SyncEngineTests.SyncNowAsync_EigenerPushWaehrendFremdesEreignisNochAussteht_
|
||||||
|
LiefertFremdesEreignisTrotzdem` reproduziert exakt dieses Szenario (eigener Push während ein
|
||||||
|
fremdes Ereignis mit niedrigerer ServerSeq noch aussteht) und schlägt ohne den Fix fehl.
|
||||||
|
|
||||||
|
**Reparatur bereits betroffener Geräte:** Da der Cursor ein reines Vorwärts-Wasserzeichen
|
||||||
|
ist, kann ein durch diesen Bug bereits zu weit vorgerückter lokaler Stand sich nicht von
|
||||||
|
selbst heilen — der Fix verhindert nur künftige Fälle. Neuer Button "Vollständigen Sync
|
||||||
|
erzwingen" im Settings-Tab „Synchronisation" (`SettingsViewModel.SyncForceFullResync`)
|
||||||
|
setzt `EventQueue.SetLastServerSeq(0)` und stößt danach `SyncEngine.SyncNowAsync()` an —
|
||||||
|
sicher wiederholbar, da `EventApplier` jedes Ereignis idempotent per Upsert/Delete-by-Id
|
||||||
|
anwendet.
|
||||||
|
|
||||||
### 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