Update Sync

This commit is contained in:
2026-08-25 21:30:00 +02:00
parent 7245196689
commit 5e16ebf1bf
5 changed files with 154 additions and 17 deletions
+53 -14
View File
@@ -86,14 +86,37 @@ public class SyncEngine : IDisposable
private async Task<SyncResult> RunSyncAsync(bool isAutomatic)
{
var pulledDataChanged = false;
SetState(SyncState.Syncing);
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
$"{_queue.PendingCount()} lokal ausstehend");
try
{
var (pushed, pushConflicts) = await PushAsync();
var pushed = 0;
var pushConflicts = 0;
var deferredPushEvents = new HashSet<Guid>();
PushBatchResult pushBatch;
do
{
pushBatch = await PushAsync(deferredPushEvents);
pushed += pushBatch.Pushed;
pushConflicts += pushBatch.Conflicts;
deferredPushEvents.UnionWith(pushBatch.DeferredEventIds);
} while (pushBatch.SourceEventCount == SyncProtocol.PushBatchSize);
await _attachments.UploadPendingAsync(_queue);
var (pulled, conflicts) = await PullAsync();
var pulled = 0;
var conflicts = 0;
PullBatchResult pullBatch;
do
{
pullBatch = await PullAsync();
pulled += pullBatch.Pulled;
conflicts += pullBatch.Conflicts;
pulledDataChanged |= pullBatch.Pulled > 0;
} while (pullBatch.Pulled == SyncProtocol.PullBatchSize);
_queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle);
_logger?.Info($"Sync: Fertig - {pushed} gepusht ({pushConflicts} Push-Konflikte), " +
@@ -121,12 +144,19 @@ public class SyncEngine : IDisposable
SetState(SyncState.Error, ex.Message);
return new() { Reason = ex.Message };
}
finally
{
// Auch wenn ein späterer Batch fehlschlägt, wurden frühere Batches bereits dauerhaft
// angewendet. Die Oberfläche muss diesen erfolgreich übernommenen Zwischenstand dann
// trotzdem neu laden; bei einem erfolgreichen Drain feuert der Hook genau einmal.
if (pulledDataChanged) DataChanged?.Invoke();
}
}
private async Task<(int Pushed, int Conflicts)> PushAsync()
private async Task<PushBatchResult> PushAsync(IReadOnlySet<Guid> excludedEventIds)
{
var pending = DeduplicatePending();
if (pending.Count == 0) return (0, 0);
var pending = DeduplicatePending(excludedEventIds, out var sourceEventCount);
if (pending.Count == 0) return new(0, 0, sourceEventCount, []);
// 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).
@@ -139,7 +169,11 @@ public class SyncEngine : IDisposable
using var resp = await _http.SendAsync(request);
await SyncProtocol.EnsureCompatibleSuccessAsync(resp);
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
if (result is null) { _logger?.Warn("Sync: Push - leere Server-Antwort."); return (0, 0); }
if (result is null)
{
_logger?.Warn("Sync: Push - leere Server-Antwort.");
return new(0, 0, sourceEventCount, pending.Select(e => e.EventId).ToList());
}
_queue.Acknowledge(pending
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
.Select(e => e.EventId));
@@ -161,8 +195,8 @@ public class SyncEngine : IDisposable
$"{result.ConflictingEventIds.Count} 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);
return new(pending.Count - result.ConflictingEventIds.Count,
result.ConflictingEventIds.Count, sourceEventCount, result.ConflictingEventIds);
}
// Payload ist immer ein vollständiges Entitäts-Snapshot (nie ein Delta, siehe
@@ -171,9 +205,11 @@ public class SyncEngine : IDisposable
// 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()
private List<SyncEvent> DeduplicatePending(IReadOnlySet<Guid> excludedEventIds,
out int sourceEventCount)
{
var pending = _queue.GetPending();
var pending = _queue.GetPending(SyncProtocol.PushBatchSize, excludedEventIds);
sourceEventCount = pending.Count;
if (pending.Count == 0) return pending;
var latest = pending
.GroupBy(e => (e.EntityType, e.EntityId))
@@ -259,7 +295,7 @@ public class SyncEngine : IDisposable
}
}
private async Task<(int Pulled, int Conflicts)> PullAsync()
private async Task<PullBatchResult> PullAsync()
{
var since = _queue.GetLastServerSeq();
_logger?.Info($"Sync: Pull - frage Server nach Ereignissen seit ServerSequenceNr={since}.");
@@ -271,7 +307,7 @@ public class SyncEngine : IDisposable
if (resp is null || resp.Events.Count == 0)
{
_logger?.Info("Sync: Pull - keine neuen Ereignisse vom Server.");
return (0, 0);
return new(0, 0);
}
_logger?.Info($"Sync: Pull - {resp.Events.Count} Ereignis(se) vom Server erhalten: " +
string.Join(", ", resp.Events.Select(e => $"{e.EntityType}/{e.Operation}")));
@@ -287,10 +323,13 @@ public class SyncEngine : IDisposable
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
}
_queue.SetLastServerSeq(resp.ServerSequenceNr);
DataChanged?.Invoke();
return (resp.Events.Count, conflicts);
return new(resp.Events.Count, conflicts);
}
private readonly record struct PushBatchResult(int Pushed, int Conflicts,
int SourceEventCount, IReadOnlyCollection<Guid> DeferredEventIds);
private readonly record struct PullBatchResult(int Pulled, int Conflicts);
private void SetState(SyncState state, string? error = null)
{
Status = new SyncStatus