using LiteDB; namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; /// /// Generation-sealed LiteDB cache per docs/v2/plan.md decision #148 and Phase 6.1 /// Stream D.1. Each published generation writes one read-only LiteDB file under /// <cache-root>/<clusterId>/<generationId>.db. A per-cluster /// CURRENT text file holds the currently-active generation id; it is updated /// atomically (temp file + ) only after /// the sealed file is fully written. /// /// /// Mixed-generation reads are impossible: any read opens the single file pointed to /// by CURRENT, which is a coherent snapshot. Corruption of the CURRENT file or the /// sealed file surfaces as — the reader /// fails closed rather than silently falling back to an older generation. Recovery path /// is to re-fetch from the central DB (and the Phase 6.1 Stream C UsingStaleConfig /// flag goes true until that succeeds). /// /// This cache is the read-path fallback when the central DB is unreachable. The /// write path (draft edits, publish) bypasses the cache and fails hard on DB outage per /// Stream D.2 — inconsistent writes are worse than a temporary inability to edit. /// public sealed class GenerationSealedCache { private const string CollectionName = "generation"; private const string CurrentPointerFileName = "CURRENT"; private readonly string _cacheRoot; /// Root directory for all clusters' sealed caches. public string CacheRoot => _cacheRoot; public GenerationSealedCache(string cacheRoot) { ArgumentException.ThrowIfNullOrWhiteSpace(cacheRoot); _cacheRoot = cacheRoot; Directory.CreateDirectory(_cacheRoot); } /// /// Seal a generation: write the snapshot to <cluster>/<generationId>.db, /// mark the file read-only, then atomically publish the CURRENT pointer. Existing /// sealed files for prior generations are preserved (prune separately). /// public async Task SealAsync(GenerationSnapshot snapshot, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(snapshot); ct.ThrowIfCancellationRequested(); var clusterDir = Path.Combine(_cacheRoot, snapshot.ClusterId); Directory.CreateDirectory(clusterDir); var sealedPath = Path.Combine(clusterDir, $"{snapshot.GenerationId}.db"); if (File.Exists(sealedPath)) { // Already sealed — idempotent. Treat as no-op + update pointer in case an earlier // seal succeeded but the pointer update failed (crash recovery). WritePointerAtomically(clusterDir, snapshot.GenerationId); return; } var tmpPath = sealedPath + ".tmp"; try { using (var db = new LiteDatabase(new ConnectionString { Filename = tmpPath, Upgrade = false })) { var col = db.GetCollection(CollectionName); col.Insert(snapshot); } File.Move(tmpPath, sealedPath); File.SetAttributes(sealedPath, File.GetAttributes(sealedPath) | FileAttributes.ReadOnly); WritePointerAtomically(clusterDir, snapshot.GenerationId); } catch { try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* best-effort */ } throw; } await Task.CompletedTask; } /// /// Read the current sealed snapshot for . Throws /// when the pointer is missing /// (first-boot-no-snapshot case) or when the sealed file is corrupt. Never silently /// falls back to a prior generation. /// public Task ReadCurrentAsync(string clusterId, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); ct.ThrowIfCancellationRequested(); var clusterDir = Path.Combine(_cacheRoot, clusterId); var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName); if (!File.Exists(pointerPath)) throw new GenerationCacheUnavailableException( $"No sealed generation for cluster '{clusterId}' at '{clusterDir}'. First-boot case: the central DB must be reachable at least once before cache fallback is possible."); long generationId; try { var text = File.ReadAllText(pointerPath).Trim(); generationId = long.Parse(text, System.Globalization.CultureInfo.InvariantCulture); } catch (Exception ex) { throw new GenerationCacheUnavailableException( $"CURRENT pointer at '{pointerPath}' is corrupt or unreadable.", ex); } var sealedPath = Path.Combine(clusterDir, $"{generationId}.db"); if (!File.Exists(sealedPath)) throw new GenerationCacheUnavailableException( $"CURRENT points at generation {generationId} but '{sealedPath}' is missing — fails closed rather than serving an older generation."); try { using var db = new LiteDatabase(new ConnectionString { Filename = sealedPath, ReadOnly = true }); var col = db.GetCollection(CollectionName); var snapshot = col.FindAll().FirstOrDefault() ?? throw new GenerationCacheUnavailableException( $"Sealed file '{sealedPath}' contains no snapshot row — file is corrupt."); return Task.FromResult(snapshot); } catch (GenerationCacheUnavailableException) { throw; } catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException or NotSupportedException or FormatException) { throw new GenerationCacheUnavailableException( $"Sealed file '{sealedPath}' is corrupt or unreadable — fails closed rather than falling back to an older generation.", ex); } } /// Return the generation id the CURRENT pointer points at, or null if no pointer exists. public long? TryGetCurrentGenerationId(string clusterId) { ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); var pointerPath = Path.Combine(_cacheRoot, clusterId, CurrentPointerFileName); if (!File.Exists(pointerPath)) return null; try { return long.Parse(File.ReadAllText(pointerPath).Trim(), System.Globalization.CultureInfo.InvariantCulture); } catch { return null; } } private static void WritePointerAtomically(string clusterDir, long generationId) { var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName); var tmpPath = pointerPath + ".tmp"; File.WriteAllText(tmpPath, generationId.ToString(System.Globalization.CultureInfo.InvariantCulture)); if (File.Exists(pointerPath)) File.Replace(tmpPath, pointerPath, destinationBackupFileName: null); else File.Move(tmpPath, pointerPath); } } /// Sealed cache is unreachable — caller must fail closed. public sealed class GenerationCacheUnavailableException : Exception { public GenerationCacheUnavailableException(string message) : base(message) { } public GenerationCacheUnavailableException(string message, Exception inner) : base(message, inner) { } }