Nach einem Login mit korrigierter Groß-/Kleinschreibung (siehe vorheriger Fix zur userId- Stabilität) bezog sich die lokale Versionsverfolgung je Entität (BasedOnServerSeq-Cache) und der Pull-Cursor weiterhin auf das alte Konto - ServerSeq-Werte sind aber nur innerhalb des Event-Logs EINES Kontos gültig. Der Push wurde zu Recht abgelehnt, der Server kannte die Entität unter der neuen userId aber gar nicht (404 beim Nachladen), und HandleRejectedAsync gab bei einem 404 bisher einfach auf, ohne den veralteten Cache-Eintrag zu bereinigen - derselbe Fehlschlag bei jedem weiteren Sync-Versuch. Dreiteiliger Fix: (1) ein 404 beim Nachladen löscht jetzt den stale Cache-Eintrag, sodass der nächste Push die Entität korrekt als neu behandelt und selbstheilend durchgeht; (2) der "Vollständigen Sync erzwingen"-Button setzt jetzt auch die Push-Versionsverfolgung zurück, nicht nur den Pull-Cursor; (3) SyncLogin erkennt einen echten Kontowechsel künftig proaktiv anhand der kanonischen userId aus der Server-Antwort und resettet automatisch, bevor der Folgefehler überhaupt auftreten kann. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143 lines
6.2 KiB
C#
143 lines
6.2 KiB
C#
using LiteDB;
|
|
using LehrerApp.Sync.Models;
|
|
|
|
namespace LehrerApp.Sync;
|
|
|
|
/// <summary>
|
|
/// Lokale Event-Queue in LiteDB. Puffert Events bis sie
|
|
/// erfolgreich zum Server gepusht wurden.
|
|
/// </summary>
|
|
public class EventQueue : IDisposable
|
|
{
|
|
private readonly LiteDatabase _db;
|
|
private readonly ILiteCollection<SyncEvent> _queue;
|
|
private readonly ILiteCollection<SyncMeta> _meta;
|
|
private readonly ILiteCollection<ConflictEntry> _conflicts;
|
|
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
|
|
private readonly ILiteCollection<EntityVersion> _entityVersions;
|
|
private long _currentSeq;
|
|
|
|
public EventQueue(string path)
|
|
{
|
|
_db = new LiteDatabase(path);
|
|
_queue = _db.GetCollection<SyncEvent>("queue");
|
|
_meta = _db.GetCollection<SyncMeta>("meta");
|
|
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
|
|
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
|
|
_entityVersions = _db.GetCollection<EntityVersion>("entity_versions");
|
|
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
|
|
_queue.EnsureIndex(x => x.SequenceNr);
|
|
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
|
|
}
|
|
|
|
public SyncEvent Enqueue(string deviceId, DeviceType deviceType,
|
|
string entityType, string entityId, string operation, string payload)
|
|
{
|
|
var evt = new SyncEvent
|
|
{
|
|
DeviceId = deviceId,
|
|
DeviceType = deviceType,
|
|
Timestamp = DateTime.UtcNow,
|
|
SequenceNr = ++_currentSeq,
|
|
EntityType = entityType,
|
|
EntityId = entityId,
|
|
Operation = operation,
|
|
Payload = payload,
|
|
};
|
|
_queue.Insert(evt);
|
|
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSeq });
|
|
return evt;
|
|
}
|
|
|
|
public List<SyncEvent> GetPending(int max = 200) =>
|
|
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
|
|
public int PendingCount() => _queue.Count();
|
|
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
|
|
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
|
|
public void SetLastServerSeq(long nr) => _meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
|
|
public DateTime? GetLastSyncAt() => _meta.FindById("lastSync")?.Timestamp;
|
|
public void SetLastSyncAt(DateTime dt) =>
|
|
_meta.Upsert(new SyncMeta { Id = "lastSync", Value = 0, Timestamp = dt });
|
|
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
|
|
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
|
|
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
|
|
public void MarkReviewed(Guid id)
|
|
{
|
|
var conflict = _conflicts.FindById(id);
|
|
if (conflict is null) return;
|
|
conflict.Reviewed = true;
|
|
_conflicts.Update(conflict);
|
|
}
|
|
|
|
// ── Lokale Versionsverfolgung je Entität (optimistische Nebenläufigkeitskontrolle) ──────
|
|
// Merkt sich pro Entität die zuletzt bekannte ServerSeq — Grundlage für SyncEvent.
|
|
// BasedOnServerSeq beim Push (siehe SyncEngine.PushAsync) und dafür, wie ein abgelehnter
|
|
// Push nach dem Nachladen des aktuellen Server-Stands aufgelöst wird.
|
|
|
|
public long? GetKnownServerSeq(string entityType, string entityId) =>
|
|
_entityVersions.FindById(EntityVersionKey(entityType, entityId))?.ServerSeq;
|
|
|
|
public void SetKnownServerSeq(string entityType, string entityId, long serverSeq) =>
|
|
_entityVersions.Upsert(new EntityVersion
|
|
{ Key = EntityVersionKey(entityType, entityId), ServerSeq = serverSeq });
|
|
|
|
/// <summary>Löscht den bekannten Stand EINER Entität — z.B. wenn der Server auf eine
|
|
/// BasedOnServerSeq-Ablehnung hin meldet, die Entität gar nicht zu kennen (404 bei
|
|
/// GetLatestForEntity): der lokale Cache war dann stale, siehe SyncEngine.HandleRejectedAsync
|
|
/// und TODO 10.3.5.</summary>
|
|
public void ClearKnownServerSeq(string entityType, string entityId) =>
|
|
_entityVersions.Delete(EntityVersionKey(entityType, entityId));
|
|
|
|
/// <summary>Verwirft die GESAMTE lokale Versionsverfolgung — nötig nach einem Kontowechsel
|
|
/// (siehe SettingsViewModel.SyncLogin/SyncForceFullResync), da ServerSeq-Werte ausschließlich
|
|
/// innerhalb des Event-Logs EINES Server-Kontos bedeutungsvoll sind (TODO 10.3.5). Sicher: der
|
|
/// nächste Push behandelt jede Entität dann als "erstmals für dieses Konto", der Server nimmt
|
|
/// sie an, solange er sie unter der aktuellen userId selbst noch nicht kennt.</summary>
|
|
public void ResetKnownServerSeqs() => _entityVersions.DeleteAll();
|
|
|
|
private static string EntityVersionKey(string entityType, string entityId) => $"{entityType}:{entityId}";
|
|
|
|
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
|
|
public void QueueAttachmentUpload(string storageId)
|
|
{
|
|
if (!_attachmentUploads.Exists(a => a.StorageId == storageId))
|
|
_attachmentUploads.Insert(new PendingAttachmentUpload { StorageId = storageId });
|
|
}
|
|
public List<string> GetPendingAttachmentUploads() =>
|
|
_attachmentUploads.FindAll().Select(a => a.StorageId).ToList();
|
|
public void MarkAttachmentUploaded(string storageId) =>
|
|
_attachmentUploads.DeleteMany(a => a.StorageId == storageId);
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
}
|
|
|
|
public class ConflictEntry
|
|
{
|
|
public Guid Id { get; init; } = Guid.NewGuid();
|
|
public DateTime DetectedAt { get; init; } = DateTime.UtcNow;
|
|
public SyncEvent LocalEvent { get; init; } = null!;
|
|
public SyncEvent RemoteEvent { get; init; } = null!;
|
|
public string Resolution { get; init; } = "";
|
|
public bool Reviewed { get; set; }
|
|
}
|
|
|
|
internal class SyncMeta
|
|
{
|
|
public string Id { get; set; } = "";
|
|
public long Value { get; set; }
|
|
public DateTime? Timestamp { get; set; }
|
|
}
|
|
|
|
internal class PendingAttachmentUpload
|
|
{
|
|
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
|
|
public string StorageId { get; set; } = "";
|
|
}
|
|
|
|
internal class EntityVersion
|
|
{
|
|
[BsonId]
|
|
public string Key { get; set; } = "";
|
|
public long ServerSeq { get; set; }
|
|
}
|