using LiteDB; namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; /// /// LiteDB-backed . One file per node (default /// config_cache.db), one collection per snapshot. Corruption surfaces as /// on construction or read — callers should /// delete and re-fetch from the central DB (decision #80). /// public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable { private const string CollectionName = "generations"; private readonly LiteDatabase _db; private readonly ILiteCollection _col; public LiteDbConfigCache(string dbPath) { // LiteDB can be tolerant of header-only corruption at construction time (it may overwrite // the header and "recover"), so we force a write + read probe to fail fast on real corruption. try { _db = new LiteDatabase(new ConnectionString { Filename = dbPath, Upgrade = true }); _col = _db.GetCollection(CollectionName); _col.EnsureIndex(s => s.ClusterId); _col.EnsureIndex(s => s.GenerationId); _ = _col.Count(); } catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException or NotSupportedException or UnauthorizedAccessException or ArgumentOutOfRangeException or FormatException) { _db?.Dispose(); throw new LocalConfigCacheCorruptException( $"LiteDB cache at '{dbPath}' is corrupt or unreadable — delete the file and refetch from the central DB.", ex); } } public Task GetMostRecentAsync(string clusterId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var snapshot = _col .Find(s => s.ClusterId == clusterId) .OrderByDescending(s => s.GenerationId) .FirstOrDefault(); return Task.FromResult(snapshot); } public Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); // upsert by (ClusterId, GenerationId) — replace in place if already cached var existing = _col .Find(s => s.ClusterId == snapshot.ClusterId && s.GenerationId == snapshot.GenerationId) .FirstOrDefault(); if (existing is null) _col.Insert(snapshot); else { snapshot.Id = existing.Id; _col.Update(snapshot); } return Task.CompletedTask; } public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); var doomed = _col .Find(s => s.ClusterId == clusterId) .OrderByDescending(s => s.GenerationId) .Skip(keepLatest) .Select(s => s.Id) .ToList(); foreach (var id in doomed) _col.Delete(id); return Task.CompletedTask; } public void Dispose() => _db.Dispose(); } public sealed class LocalConfigCacheCorruptException(string message, Exception inner) : Exception(message, inner);