Baustein 5: EventApplier (Inbound Apply) + Loop-Prevention (Kapitel 10)
Die zentrale, bisher komplett fehlende Luecke: selbst mit Baustein 1-4 haette SyncEngine.PullAsync empfangene Ereignisse nur zur Konflikterkennung genutzt, nie in die lokale LiteDB geschrieben - ankommende Aenderungen von anderen Geraeten waeren nirgends sichtbar geworden. Neu EventApplier: entschluesselt, dispatcht ueber eine explizite EntityType-Tabelle, schreibt IMMER direkt auf die rohe LiteDB- Collection, nie ueber eine Repository-Save/Delete-Methode - sonst wuerde der OnChange-Hook (Baustein 2) die gerade angewendete Aenderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong). Ein gemeinsames Suppress-Flag wurde geprueft und verworfen: SyncEngine laeuft per Timer nebenlaeufig zum UI-Thread, ein Flag koennte einen echten Nutzer-Save waehrenddessen verschlucken. Der direkte Collection- Zugriff ist zustandslos und dadurch korrekt. Kaskaden-Faelle nutzen dieselben internen LiteDbContext-Hilfsmethoden wie die Repositories (Baustein 4). Neu SyncEventPublisher, der den OnChange-Hook in ein verschluesseltes EventQueue.Enqueue uebersetzt (an LiteDbContext.OnChange gehaengt). Mit dediziertem Loop-Prevention-Test abgesichert: belegt mit echtem LiteDbContext + OnChange-Zaehler, dass Apply keinen neuen Hook-Aufruf ausloest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// EventApplier (Baustein 5) muss dieselben internen Kaskaden-Hilfsmethoden auf LiteDbContext
|
||||
// wiederverwenden können wie die Repositories selbst (siehe LiteDbContext.CascadeDelete*).
|
||||
[assembly: InternalsVisibleTo("LehrerApp.Sync")]
|
||||
@@ -178,9 +178,15 @@ public static class AppBootstrapper
|
||||
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
services.AddSingleton(sp => new EventApplier(
|
||||
sp.GetRequiredService<LiteDbContext>(), sp.GetRequiredService<byte[]>()));
|
||||
services.AddSingleton(sp => new SyncEventPublisher(
|
||||
sp.GetRequiredService<EventQueue>(), deviceId, sp.GetRequiredService<byte[]>()));
|
||||
|
||||
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
|
||||
sp.GetRequiredService<EventQueue>(),
|
||||
sp.GetRequiredService<ConflictResolver>(),
|
||||
sp.GetRequiredService<EventApplier>(),
|
||||
BuildHttp(serverUrl, appData),
|
||||
new SyncConfig
|
||||
{
|
||||
@@ -223,7 +229,15 @@ public static class AppBootstrapper
|
||||
services.AddTransient<AddGroupDialogViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
||||
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
provider.GetRequiredService<LiteDbContext>().OnChange =
|
||||
provider.GetRequiredService<SyncEventPublisher>().Publish;
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Sync.Tests;
|
||||
|
||||
public sealed class EventApplierTests
|
||||
{
|
||||
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
|
||||
private static readonly byte[] Key = SyncCrypto.GenerateKey();
|
||||
|
||||
[Fact]
|
||||
public void Apply_Save_SchreibtEntitaetDirektInDieCollection()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
|
||||
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
|
||||
|
||||
var saved = db.Students.FindById(student.Id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("Anna", saved!.FirstName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_Delete_EntferntDenDatensatz()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
db.Students.Insert(student);
|
||||
|
||||
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
|
||||
|
||||
Assert.Null(db.Students.FindById(student.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_UnbekannterEntityType_TutNichtsUndWirftNicht()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
|
||||
var exception = Record.Exception(() =>
|
||||
applier.Apply(MakeEvent("UnbekannterTyp", Guid.NewGuid().ToString(), "Save", new { Foo = "Bar" })));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_GroupDelete_FuehrtDieselbeKaskadeAusWieDasRepository()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var groupId = Guid.NewGuid();
|
||||
db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" });
|
||||
var gradeId = Guid.NewGuid();
|
||||
db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = Guid.NewGuid() });
|
||||
|
||||
applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
|
||||
|
||||
Assert.Null(db.Groups.FindById(groupId));
|
||||
Assert.Null(db.Grades.FindById(gradeId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_VerletztHartenUniqueIndex_WirdUebersprungenOhneAusnahme()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var studentId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
db.Memberships.Insert(new GroupMembership { StudentId = studentId, GroupId = groupId });
|
||||
// Zweite Mitgliedschaft für dasselbe Schüler/Gruppe-Paar verletzt den ux_student_group-Index.
|
||||
var duplicate = new GroupMembership { Id = Guid.NewGuid(), StudentId = studentId, GroupId = groupId };
|
||||
|
||||
var exception = Record.Exception(() =>
|
||||
applier.Apply(MakeEvent(nameof(GroupMembership), duplicate.Id.ToString(), "Save", duplicate)));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Single(db.Memberships.FindAll());
|
||||
}
|
||||
|
||||
// ── Loop-Prevention: der wichtigste Test in dieser Datei ────────────────────
|
||||
// Ein angewendetes Ereignis darf NIE selbst wieder ein ausgehendes Ereignis auslösen,
|
||||
// sonst entsteht ein Sync-Ping-Pong zwischen den Geräten (siehe EventApplier-Kommentar).
|
||||
|
||||
[Fact]
|
||||
public void Apply_Save_LoestNIEMALSDenOnChangeHookAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var onChangeCallCount = 0;
|
||||
db.OnChange = (_, _, _, _) => onChangeCallCount++;
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
|
||||
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Save", student));
|
||||
|
||||
Assert.Equal(0, onChangeCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_Delete_LoestNIEMALSDenOnChangeHookAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
db.Students.Insert(student);
|
||||
var onChangeCallCount = 0;
|
||||
db.OnChange = (_, _, _, _) => onChangeCallCount++;
|
||||
|
||||
applier.Apply(MakeEvent(nameof(Student), student.Id.ToString(), "Delete", null));
|
||||
|
||||
Assert.Equal(0, onChangeCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_GroupDeleteKaskade_LoestNIEMALSDenOnChangeHookAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var applier = new EventApplier(db, Key);
|
||||
var groupId = Guid.NewGuid();
|
||||
db.Groups.Insert(new LearningGroup { Id = groupId, Name = "8a", SchoolYear = "2025/26" });
|
||||
db.Grades.Insert(new Grade { GroupId = groupId, StudentId = Guid.NewGuid() });
|
||||
db.Exams.Insert(new Exam { GroupId = groupId });
|
||||
var onChangeCallCount = 0;
|
||||
db.OnChange = (_, _, _, _) => onChangeCallCount++;
|
||||
|
||||
applier.Apply(MakeEvent(nameof(LearningGroup), groupId.ToString(), "Delete", null));
|
||||
|
||||
Assert.Equal(0, onChangeCallCount);
|
||||
}
|
||||
|
||||
private static SyncEvent MakeEvent(string entityType, string entityId, string operation, object? payload) => new()
|
||||
{
|
||||
DeviceId = "companion-1",
|
||||
DeviceType = DeviceType.Companion,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Operation = operation,
|
||||
Payload = payload is null ? "" : SyncCrypto.EncryptObject(payload, Key),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using LiteDB;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Wendet ein von <see cref="SyncEngine"/> empfangenes (und nicht durch einen Konflikt
|
||||
/// verlorenes) Ereignis auf die lokale Datenbank an.
|
||||
///
|
||||
/// Schreibt IMMER direkt auf die rohe LiteDB-Collection, nie über eine Repository-
|
||||
/// Save/Delete-Methode — sonst würde <see cref="LiteDbContext.OnChange"/> erneut feuern und die
|
||||
/// gerade angewendete Änderung als neues ausgehendes Ereignis re-enqueuen (Sync-Ping-Pong).
|
||||
/// Ein gemeinsames Suppress-Flag wurde bewusst verworfen: SyncEngine läuft per Timer nebenläufig
|
||||
/// zum UI-Thread, ein Flag könnte während eines laufenden Pulls einen echten Nutzer-Save
|
||||
/// verschlucken. Der direkte Collection-Zugriff ist zustandslos und dadurch korrekt.
|
||||
///
|
||||
/// Weiche Geschäftsregeln (z.B. ArchivedGroupWriteGuard, Namens-Eindeutigkeit) werden auf diesem
|
||||
/// Pfad bewusst NICHT geprüft (v1-Einschränkung, siehe TODO.md 10.3) — nur harte LiteDB-Unique-
|
||||
/// Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
|
||||
/// </summary>
|
||||
public class EventApplier(LiteDbContext db, byte[] syncKey)
|
||||
{
|
||||
private static readonly Dictionary<string, EntityHandler> Handlers = BuildHandlers();
|
||||
|
||||
public void Apply(SyncEvent evt)
|
||||
{
|
||||
if (!Handlers.TryGetValue(evt.EntityType, out var handler)) return;
|
||||
try
|
||||
{
|
||||
var json = evt.Payload.Length == 0 ? "" : Decrypt(evt.Payload);
|
||||
handler(db, evt.Operation, evt.EntityId, json);
|
||||
}
|
||||
catch (LiteException)
|
||||
{
|
||||
// Harte Constraint-Verletzung (z.B. Unique-Index) - dieses eine Ereignis
|
||||
// überspringen, statt den gesamten Sync-Lauf abzubrechen.
|
||||
}
|
||||
}
|
||||
|
||||
private string Decrypt(string payloadBase64) =>
|
||||
Encoding.UTF8.GetString(SyncCrypto.Decrypt(Convert.FromBase64String(payloadBase64), syncKey));
|
||||
|
||||
private delegate void EntityHandler(LiteDbContext db, string operation, string entityId, string json);
|
||||
|
||||
private static Dictionary<string, EntityHandler> BuildHandlers()
|
||||
{
|
||||
var handlers = new Dictionary<string, EntityHandler>();
|
||||
|
||||
void Simple<T>(Func<LiteDbContext, ILiteCollection<T>> collection) where T : class =>
|
||||
handlers[typeof(T).Name] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") collection(context).Delete(new Guid(entityId));
|
||||
else collection(context).Upsert(JsonSerializer.Deserialize<T>(json)!);
|
||||
};
|
||||
|
||||
Simple<Student>(context => context.Students);
|
||||
Simple<SeatingPlan>(context => context.SeatingPlans);
|
||||
Simple<GroupMembership>(context => context.Memberships);
|
||||
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
|
||||
Simple<Grade>(context => context.Grades);
|
||||
Simple<GradingScheme>(context => context.GradingSchemes);
|
||||
Simple<ReportGrade>(context => context.ReportGrades);
|
||||
Simple<Unit>(context => context.Units);
|
||||
Simple<Lesson>(context => context.Lessons);
|
||||
Simple<WorkTask>(context => context.Tasks);
|
||||
Simple<TimeEntry>(context => context.TimeEntries);
|
||||
Simple<ExamResult>(context => context.ExamResults);
|
||||
Simple<ParticipationEntry>(context => context.ParticipationEntries);
|
||||
Simple<ParticipationAspect>(context => context.ParticipationAspects);
|
||||
Simple<ParticipationSection>(context => context.ParticipationSections);
|
||||
Simple<Subject>(context => context.Subjects);
|
||||
Simple<ShorthandCode>(context => context.ShorthandCodes);
|
||||
Simple<AlternativeLessonPath>(context => context.AlternativeLessonPaths);
|
||||
Simple<TimetableSlot>(context => context.TimetableSlots);
|
||||
Simple<SchoolHoliday>(context => context.SchoolHolidays);
|
||||
Simple<SupervisionDuty>(context => context.SupervisionDuties);
|
||||
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
|
||||
Simple<CompetencyDomain>(context => context.CompetencyDomains);
|
||||
|
||||
// Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen
|
||||
// Repositories, damit die Kaskade nur an einer Stelle im Code existiert.
|
||||
handlers[nameof(LearningGroup)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeDeleteGroup(new Guid(entityId));
|
||||
else context.Groups.Upsert(JsonSerializer.Deserialize<LearningGroup>(json)!);
|
||||
};
|
||||
handlers[nameof(Exam)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeDeleteExam(new Guid(entityId));
|
||||
else context.Exams.Upsert(JsonSerializer.Deserialize<Exam>(json)!);
|
||||
};
|
||||
handlers[nameof(ParticipationSession)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeDeleteParticipationSession(new Guid(entityId));
|
||||
else context.ParticipationSessions.Upsert(JsonSerializer.Deserialize<ParticipationSession>(json)!);
|
||||
};
|
||||
// "Delete" ist hier das harte Löschen (samt Anhängen) - das weiche Löschen kommt als
|
||||
// "Save" mit IsDeleted=true und läuft über den generischen Upsert-Zweig.
|
||||
handlers[nameof(Documentation)] = (context, operation, entityId, json) =>
|
||||
{
|
||||
if (operation == "Delete") context.CascadeHardDeleteDocumentation(new Guid(entityId));
|
||||
else context.Documentation.Upsert(JsonSerializer.Deserialize<Documentation>(json)!);
|
||||
};
|
||||
|
||||
return handlers;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public class SyncEngine : IDisposable
|
||||
{
|
||||
private readonly EventQueue _queue;
|
||||
private readonly ConflictResolver _resolver;
|
||||
private readonly EventApplier _applier;
|
||||
private readonly HttpClient _http;
|
||||
private readonly SyncConfig _config;
|
||||
private readonly Timer _timer;
|
||||
@@ -18,11 +19,12 @@ public class SyncEngine : IDisposable
|
||||
public SyncStatus Status { get; private set; } = new();
|
||||
public event Action<SyncStatus>? StatusChanged;
|
||||
|
||||
public SyncEngine(EventQueue queue, ConflictResolver resolver,
|
||||
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
||||
HttpClient http, SyncConfig config)
|
||||
{
|
||||
_queue = queue;
|
||||
_resolver = resolver;
|
||||
_applier = applier;
|
||||
_http = http;
|
||||
_config = config;
|
||||
_timer = new Timer(
|
||||
@@ -75,7 +77,10 @@ public class SyncEngine : IDisposable
|
||||
foreach (var evt in resp.Events)
|
||||
{
|
||||
var c = _resolver.TryResolve(evt, _config.DeviceId);
|
||||
if (c is not null) { _queue.AddConflict(c); conflicts++; }
|
||||
if (c is null) { _applier.Apply(evt); continue; }
|
||||
_queue.AddConflict(c);
|
||||
conflicts++;
|
||||
if (c.Resolution == "RemoteWon") _applier.Apply(evt);
|
||||
}
|
||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||
return (resp.Events.Count, conflicts);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Wandelt <see cref="LiteDbContext.OnChange"/>-Aufrufe in ausgehende Sync-Ereignisse um. Wird in
|
||||
/// AppBootstrapper an <see cref="LiteDbContext.OnChange"/> gehängt, wenn Sync konfiguriert ist —
|
||||
/// dort entsteht aus den ~27 Repository-Aufrufen genau ein verschlüsseltes Ereignis pro Aufruf.
|
||||
/// </summary>
|
||||
public class SyncEventPublisher(EventQueue queue, string deviceId, byte[] syncKey)
|
||||
{
|
||||
public void Publish(string entityType, string entityId, string operation, object? payload)
|
||||
{
|
||||
var encrypted = payload is null ? "" : SyncCrypto.EncryptObject(payload, syncKey);
|
||||
queue.Enqueue(deviceId, DeviceType.Desktop, entityType, entityId, operation, encrypted);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user