SyncEventPublisher/SyncEngine/EventApplier protokollierten bisher ausschließlich Fehlschläge - ein sauberes Log bewies nur "nichts ist abgestürzt", nicht ob eine Änderung tatsächlich hoch-/heruntergeladen wurde. Jetzt wird auch der Erfolgspfad geloggt: Einreihen in die Outbox (mit SequenceNr), Push/Pull mit Anzahl und Entitätstypen sowie der vom Server bestätigten ServerSequenceNr, und jedes tatsächlich angewendete Ereignis. Damit lässt sich anhand der Log-Dateien beider Geräte nachvollziehen, an welcher Stelle der Kette eine Änderung verloren geht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
158 lines
6.5 KiB
C#
158 lines
6.5 KiB
C#
using System.Net.Http.Json;
|
|
using LehrerApp.Core.Services;
|
|
using LehrerApp.Sync.Models;
|
|
|
|
namespace LehrerApp.Sync;
|
|
|
|
/// <summary>
|
|
/// Push/Pull Orchestrierung.
|
|
/// Automatisch alle N Minuten + manuell per SyncNowAsync().
|
|
/// </summary>
|
|
public class SyncEngine : IDisposable
|
|
{
|
|
private readonly EventQueue _queue;
|
|
private readonly ConflictResolver _resolver;
|
|
private readonly EventApplier _applier;
|
|
private readonly AttachmentSyncer _attachments;
|
|
private readonly HttpClient _http;
|
|
private readonly SyncConfig _config;
|
|
private readonly AppLogger? _logger;
|
|
private readonly Timer _timer;
|
|
|
|
public SyncStatus Status { get; private set; } = new();
|
|
public event Action<SyncStatus>? StatusChanged;
|
|
|
|
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
|
AttachmentSyncer attachments, HttpClient http, SyncConfig config, AppLogger? logger = null)
|
|
{
|
|
_queue = queue;
|
|
_resolver = resolver;
|
|
_applier = applier;
|
|
_attachments = attachments;
|
|
_http = http;
|
|
_config = config;
|
|
_logger = logger;
|
|
_timer = new Timer(
|
|
async _ => await SyncNowAsync(true), null,
|
|
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
|
|
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
|
|
UpdateStatus();
|
|
}
|
|
|
|
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
|
|
{
|
|
if (Status.State == SyncState.Syncing)
|
|
return new() { Skipped = true, Reason = "Sync bereits aktiv" };
|
|
SetState(SyncState.Syncing);
|
|
_logger?.Info($"Sync: Start ({(isAutomatic ? "automatisch" : "manuell")}), Gerät={_config.DeviceId}, " +
|
|
$"{_queue.PendingCount()} lokal ausstehend");
|
|
try
|
|
{
|
|
var (pushed, pushConflicts) = await PushAsync();
|
|
await _attachments.UploadPendingAsync(_queue);
|
|
var (pulled, conflicts) = await PullAsync();
|
|
_queue.SetLastSyncAt(DateTime.UtcNow);
|
|
SetState(SyncState.Idle);
|
|
_logger?.Info($"Sync: Fertig - {pushed} gepusht ({pushConflicts} Push-Konflikte), " +
|
|
$"{pulled} gepullt ({conflicts} Pull-Konflikte)");
|
|
return new() { Success = true, EventsPushed = pushed, EventsPulled = pulled, Conflicts = conflicts };
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
// Sammelt sowohl echte Netzwerkfehler als auch nicht-erfolgreiche HTTP-Antworten
|
|
// (EnsureSuccessStatusCode() in Push/PullAsync) unter demselben "Offline"-Status -
|
|
// ohne Log wäre ein z.B. 401/500 vom Server nicht von "kein Internet" unterscheidbar.
|
|
_logger?.Error("Sync fehlgeschlagen (HTTP)", ex);
|
|
SetState(SyncState.Offline);
|
|
return new() { Reason = "Server nicht erreichbar" };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.Error("Sync fehlgeschlagen", ex);
|
|
SetState(SyncState.Error, ex.Message);
|
|
return new() { Reason = ex.Message };
|
|
}
|
|
}
|
|
|
|
private async Task<(int Pushed, int Conflicts)> PushAsync()
|
|
{
|
|
var pending = _queue.GetPending();
|
|
if (pending.Count == 0) return (0, 0);
|
|
_logger?.Info($"Sync: Push - {pending.Count} Ereignis(se) ausstehend: " +
|
|
string.Join(", ", pending.Select(e => $"{e.EntityType}/{e.Operation}")));
|
|
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
|
resp.EnsureSuccessStatusCode();
|
|
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
|
if (result is null) { _logger?.Warn("Sync: Push - leere Server-Antwort."); return (0, 0); }
|
|
_queue.Acknowledge(pending
|
|
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
|
.Select(e => e.EventId));
|
|
_queue.SetLastServerSeq(result.ServerSequenceNr);
|
|
_logger?.Info($"Sync: Push - vom Server bestätigt bis ServerSequenceNr={result.ServerSequenceNr}, " +
|
|
$"{result.ConflictingEventIds.Count} vom Server abgelehnt (Konflikt).");
|
|
return (pending.Count - result.ConflictingEventIds.Count,
|
|
result.ConflictingEventIds.Count);
|
|
}
|
|
|
|
private async Task<(int Pulled, int Conflicts)> PullAsync()
|
|
{
|
|
var since = _queue.GetLastServerSeq();
|
|
_logger?.Info($"Sync: Pull - frage Server nach Ereignissen seit ServerSequenceNr={since}.");
|
|
var resp = await _http.GetFromJsonAsync<PullResponse>(
|
|
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
|
|
if (resp is null || resp.Events.Count == 0)
|
|
{
|
|
_logger?.Info("Sync: Pull - keine neuen Ereignisse vom Server.");
|
|
return (0, 0);
|
|
}
|
|
_logger?.Info($"Sync: Pull - {resp.Events.Count} Ereignis(se) vom Server erhalten: " +
|
|
string.Join(", ", resp.Events.Select(e => $"{e.EntityType}/{e.Operation}")));
|
|
var conflicts = 0;
|
|
foreach (var evt in resp.Events)
|
|
{
|
|
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
|
if (c is null) { await _applier.ApplyAsync(evt); continue; }
|
|
_queue.AddConflict(c);
|
|
conflicts++;
|
|
_logger?.Info($"Sync: Pull - Konflikt bei {evt.EntityType}/{evt.EntityId}, " +
|
|
$"Auflösung={c.Resolution}");
|
|
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
|
|
}
|
|
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
|
return (resp.Events.Count, conflicts);
|
|
}
|
|
|
|
private void SetState(SyncState state, string? error = null)
|
|
{
|
|
Status = new SyncStatus
|
|
{
|
|
State = state,
|
|
LastSyncAt = _queue.GetLastSyncAt(),
|
|
PendingEvents = _queue.PendingCount(),
|
|
ConflictCount = _queue.ConflictCount(),
|
|
ErrorMessage = error,
|
|
};
|
|
StatusChanged?.Invoke(Status);
|
|
}
|
|
private void UpdateStatus() => SetState(Status.State);
|
|
public void Dispose() { _timer.Dispose(); _queue.Dispose(); }
|
|
}
|
|
|
|
public class SyncConfig
|
|
{
|
|
public string ServerUrl { get; set; } = "";
|
|
public string DeviceId { get; set; } = "";
|
|
public DeviceType DeviceType { get; set; } = DeviceType.Desktop;
|
|
public int AutoSyncIntervalMinutes { get; set; } = 5;
|
|
}
|
|
|
|
public class SyncResult
|
|
{
|
|
public bool Success { get; set; }
|
|
public bool Skipped { get; set; }
|
|
public string? Reason { get; set; }
|
|
public int EventsPushed { get; set; }
|
|
public int EventsPulled { get; set; }
|
|
public int Conflicts { get; set; }
|
|
}
|