EventApplier.ApplyAsync fing bisher nur LiteException ab - jede andere Ausnahme (z.B. eine fehlerhafte Entschlüsselung/Deserialisierung eines einzelnen Ereignisses) fiel unbehandelt aus der Pull-Schleife in SyncEngine.PullAsync heraus, bevor der Fortschritt (SetLastServerSeq) gespeichert wurde. Der nächste Sync-Versuch lud denselben Batch erneut und scheiterte am selben Ereignis wieder - ein dauerhaft blockierter Sync, bei dem selbst bereits erfolgreich angewendete Ereignisse im selben Batch nie als erledigt markiert wurden. Zusätzlich protokollierte weder SyncEngine noch EventApplier irgendetwas, ein Fehlschlag zeigte sich höchstens als knapper Text in der Sync-Statusleiste. EventApplier fängt jetzt jede Ausnahme pro Ereignis ab (geloggt über AppLogger, übersprungen statt den Batch zu blockieren); SyncEngine loggt jeden Sync-Fehlschlag vollständig. Betrifft nur den Desktop-Client, kein API-Redeploy nötig. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
141 lines
5.2 KiB
C#
141 lines
5.2 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);
|
|
try
|
|
{
|
|
var (pushed, _) = await PushAsync();
|
|
await _attachments.UploadPendingAsync(_queue);
|
|
var (pulled, conflicts) = await PullAsync();
|
|
_queue.SetLastSyncAt(DateTime.UtcNow);
|
|
SetState(SyncState.Idle);
|
|
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);
|
|
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
|
|
resp.EnsureSuccessStatusCode();
|
|
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
|
|
if (result is null) return (0, 0);
|
|
_queue.Acknowledge(pending
|
|
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
|
|
.Select(e => e.EventId));
|
|
_queue.SetLastServerSeq(result.ServerSequenceNr);
|
|
return (pending.Count - result.ConflictingEventIds.Count,
|
|
result.ConflictingEventIds.Count);
|
|
}
|
|
|
|
private async Task<(int Pulled, int Conflicts)> PullAsync()
|
|
{
|
|
var since = _queue.GetLastServerSeq();
|
|
var resp = await _http.GetFromJsonAsync<PullResponse>(
|
|
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
|
|
if (resp is null || resp.Events.Count == 0) return (0, 0);
|
|
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++;
|
|
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; }
|
|
}
|