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:
2026-08-17 11:27:12 +02:00
co-authored by Claude Sonnet 5
parent 1240a2cd8a
commit ce4dfb0197
6 changed files with 305 additions and 3 deletions
+5
View File
@@ -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")]
+15 -1
View File
@@ -178,9 +178,15 @@ public static class AppBootstrapper
if (!string.IsNullOrEmpty(serverUrl)) 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( services.AddSingleton<SyncEngine>(sp => new SyncEngine(
sp.GetRequiredService<EventQueue>(), sp.GetRequiredService<EventQueue>(),
sp.GetRequiredService<ConflictResolver>(), sp.GetRequiredService<ConflictResolver>(),
sp.GetRequiredService<EventApplier>(),
BuildHttp(serverUrl, appData), BuildHttp(serverUrl, appData),
new SyncConfig new SyncConfig
{ {
@@ -223,7 +229,15 @@ public static class AppBootstrapper
services.AddTransient<AddGroupDialogViewModel>(); services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<SettingsViewModel>(); 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 ───────────────────────────────────────────────────────── // ── Hilfsmethoden ─────────────────────────────────────────────────────────
+147
View File
@@ -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,
};
}
+112
View File
@@ -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;
}
}
+7 -2
View File
@@ -11,6 +11,7 @@ public class SyncEngine : IDisposable
{ {
private readonly EventQueue _queue; private readonly EventQueue _queue;
private readonly ConflictResolver _resolver; private readonly ConflictResolver _resolver;
private readonly EventApplier _applier;
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly SyncConfig _config; private readonly SyncConfig _config;
private readonly Timer _timer; private readonly Timer _timer;
@@ -18,11 +19,12 @@ public class SyncEngine : IDisposable
public SyncStatus Status { get; private set; } = new(); public SyncStatus Status { get; private set; } = new();
public event Action<SyncStatus>? StatusChanged; public event Action<SyncStatus>? StatusChanged;
public SyncEngine(EventQueue queue, ConflictResolver resolver, public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
HttpClient http, SyncConfig config) HttpClient http, SyncConfig config)
{ {
_queue = queue; _queue = queue;
_resolver = resolver; _resolver = resolver;
_applier = applier;
_http = http; _http = http;
_config = config; _config = config;
_timer = new Timer( _timer = new Timer(
@@ -75,7 +77,10 @@ public class SyncEngine : IDisposable
foreach (var evt in resp.Events) foreach (var evt in resp.Events)
{ {
var c = _resolver.TryResolve(evt, _config.DeviceId); 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); _queue.SetLastServerSeq(resp.ServerSequenceNr);
return (resp.Events.Count, conflicts); return (resp.Events.Count, conflicts);
+19
View File
@@ -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);
}
}