Files
LehrerApp/LehrerApp.Sync/SyncEngine.cs
2026-03-29 23:47:31 +02:00

187 lines
6.5 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.Http.Json;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Orchestriert Push, Pull, EventApply und Snapshot-Export.
/// Automatisch alle N Minuten + manuell auslösbar.
/// </summary>
public class SyncEngine : IDisposable
{
private readonly EventQueue _queue;
private readonly ConflictResolver _resolver;
private readonly EventApplier? _applier;
private readonly ReadableSnapshotService? _snapshotExport;
private readonly HttpClient _http;
private readonly SyncConfig _config;
private readonly Timer _timer;
public SyncStatus Status { get; private set; } = new();
public event Action<SyncStatus>? StatusChanged;
public SyncEngine(
EventQueue queue,
ConflictResolver resolver,
HttpClient http,
SyncConfig config,
EventApplier? applier = null,
ReadableSnapshotService? snapshotExport = null)
{
_queue = queue;
_resolver = resolver;
_http = http;
_config = config;
_applier = applier;
_snapshotExport = snapshotExport;
_timer = new Timer(
async _ => await SyncNowAsync(isAutomatic: true),
null,
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
UpdateStatus();
}
// ── Öffentliche API ───────────────────────────────────────────────────────
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
{
if (Status.State == SyncState.Syncing)
return new SyncResult { Skipped = true, Reason = "Sync bereits aktiv" };
SetState(SyncState.Syncing);
try
{
// 1. Ausstehende Events pushen
var pushResult = await PushAsync();
// 2. Neue Events vom Server holen
var pullResult = await PullAsync();
// 3. Geholte Events auf LiteDB anwenden
if (_applier is not null && pullResult.Events.Count > 0)
_applier.ApplyAll(pullResult.Events);
// 4. Nach erfolgreichem Sync: lesbaren Snapshot exportieren
// (nur bei automatischem Sync oder explizit nicht bei jedem
// manuellen Push um Traffic zu sparen)
if (_snapshotExport is not null && (isAutomatic || pushResult.Pushed > 0))
await _snapshotExport.ExportAndPushAsync();
_queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle);
return new SyncResult
{
Success = true,
EventsPushed = pushResult.Pushed,
EventsPulled = pullResult.Events.Count,
Conflicts = pullResult.Conflicts,
};
}
catch (HttpRequestException)
{
SetState(SyncState.Offline);
return new SyncResult { Success = false, Reason = "Server nicht erreichbar" };
}
catch (Exception ex)
{
SetState(SyncState.Error, ex.Message);
return new SyncResult { Success = false, Reason = ex.Message };
}
}
// ── Push ──────────────────────────────────────────────────────────────────
private async Task<(int Pushed, int Conflicts)> PushAsync()
{
var pending = _queue.GetPending(maxBatch: 200);
if (pending.Count == 0) return (0, 0);
var response = await _http.PostAsJsonAsync("/api/sync/push", pending);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<PushResponse>();
if (result is null) return (0, 0);
var sentIds = pending
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
.Select(e => e.EventId);
_queue.Acknowledge(sentIds);
_queue.SetLastServerSequenceNr(result.ServerSequenceNr);
return (pending.Count - result.ConflictingEventIds.Count,
result.ConflictingEventIds.Count);
}
// ── Pull ──────────────────────────────────────────────────────────────────
private async Task<(List<SyncEvent> Events, int Conflicts)> PullAsync()
{
var since = _queue.GetLastServerSequenceNr();
var response = await _http.GetFromJsonAsync<PullResponse>(
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
if (response is null || response.Events.Count == 0)
return ([], 0);
var conflicts = 0;
foreach (var remoteEvent in response.Events)
{
var conflict = _resolver.TryResolve(remoteEvent, _config.DeviceId);
if (conflict is not null)
{
_queue.AddConflict(conflict);
conflicts++;
}
}
_queue.SetLastServerSequenceNr(response.ServerSequenceNr);
return (response.Events, conflicts);
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
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; }
}