From 2bae1b4c9fe6455d1429205f584b180ee3013113 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Jul 2026 10:46:07 -0400 Subject: [PATCH] refactor(localdb): delete the dormant LiteDB LocalCache (superseded by ZB.MOM.WW.LocalDb) Never registered in DI and dead since Phase 6.1. Keeping two unwired cache designs invites the next reader to wire the wrong one. Deletes all three test files - LiteDbConfigCacheTests.cs was missing from the plan's list and would have failed to compile (it calls new LiteDB.LiteDatabase directly). StaleConfigFlagTests lives inside ResilientConfigReaderTests.cs, so it goes too. The XML-doc reference in ILdapGroupRoleMappingService is rewritten rather than removed: it now records that no sign-in fallback exists and that reviving one means an admin-side cache on LocalDb. --- Directory.Packages.props | 1 - docs/plans/2026-07-20-localdb-phase1-recon.md | 14 + .../LocalCache/GenerationSealedCache.cs | 197 ---------- .../LocalCache/GenerationSnapshot.cs | 20 - .../LocalCache/ILocalConfigCache.cs | 32 -- .../LocalCache/LiteDbConfigCache.cs | 129 ------- .../LocalCache/ResilientConfigReader.cs | 140 ------- .../LocalCache/StaleConfigFlag.cs | 20 - .../Services/ILdapGroupRoleMappingService.cs | 16 +- .../ZB.MOM.WW.OtOpcUa.Configuration.csproj | 1 - .../GenerationSealedCacheTests.cs | 168 --------- .../LiteDbConfigCacheTests.cs | 192 ---------- .../ResilientConfigReaderTests.cs | 355 ------------------ 13 files changed, 26 insertions(+), 1259 deletions(-) delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSnapshot.cs delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ILocalConfigCache.cs delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/LiteDbConfigCache.cs delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ResilientConfigReader.cs delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/StaleConfigFlag.cs delete mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationSealedCacheTests.cs delete mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs delete mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ResilientConfigReaderTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9d08279f..33c1c4a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,7 +33,6 @@ - diff --git a/docs/plans/2026-07-20-localdb-phase1-recon.md b/docs/plans/2026-07-20-localdb-phase1-recon.md index d9e23b07..b2f90df4 100644 --- a/docs/plans/2026-07-20-localdb-phase1-recon.md +++ b/docs/plans/2026-07-20-localdb-phase1-recon.md @@ -469,6 +469,20 @@ Plan `…phase1.md:484-485` names only `GenerationSealedCacheTests.cs` and See D-3. +### 7.3b Outcome (Task 9, executed 2026-07-20) + +Deleted: the 6 `LocalCache/` sources and **all three** test files (including +`LiteDbConfigCacheTests.cs`, which the plan omitted — D-3). `LiteDB` removed from both +`Configuration.csproj` and `Directory.Packages.props`. The stale XML-doc reference at +`ILdapGroupRoleMappingService.cs:14` was rewritten to state the present truth (no fallback exists; +reviving it means an admin-side cache on LocalDb, not restoring the old pipeline) rather than +deleted, per the DoD's "explanatory prose may remain". Configuration tests: 92/92 green. + +**Follow-up found while executing:** `Polly.Core` in `Configuration.csproj` is now orphaned too — +`ResilientConfigReader` was its only real consumer in that project (remaining `Polly` mentions are +comments). Left in place deliberately: removing it is outside Task 9's scope and could break a +consumer relying on the transitive flow. Worth a separate cleanup. + ### 7.4 Package removal - `PackageReference`: `Configuration.csproj:29` — only consumer. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs deleted file mode 100644 index 220b9f9b..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs +++ /dev/null @@ -1,197 +0,0 @@ -using LiteDB; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -/// -/// Generation-sealed LiteDB cache per docs/v2/plan.md 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; - - // Private per-database BsonMapper with the entity pre-registered. LiteDB's default - // BsonMapper.Global is a process-wide singleton whose lazy per-type member registration is - // not thread-safe across concurrently-constructed LiteDatabase instances; a seal racing a - // read (or this cache racing LiteDbConfigCache) corrupts the global mapper, surfacing as - // "Member … not found on BsonMapper" or a bogus duplicate-_id insert. - private static BsonMapper BuildMapper() - { - var mapper = new BsonMapper(); - mapper.Entity(); - return mapper; - } - - /// Root directory for all clusters' sealed caches. - public string CacheRoot => _cacheRoot; - - /// Initializes a new instance of the GenerationSealedCache class. - /// The root directory for the cache. - 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). - /// - /// The generation snapshot to seal. - /// The cancellation token. - /// A task representing the asynchronous operation. - 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 }, BuildMapper())) - { - 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. - /// - /// The cluster ID to read the snapshot for. - /// The cancellation token. - /// A task representing the asynchronous operation containing the generation snapshot. - 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 }, BuildMapper()); - 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. - /// The cluster ID to get the current generation ID for. - /// The generation ID, 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 -{ - /// Initializes a new instance of the GenerationCacheUnavailableException class. - /// The error message. - public GenerationCacheUnavailableException(string message) : base(message) { } - /// Initializes a new instance of the GenerationCacheUnavailableException class with an inner exception. - /// The error message. - /// The inner exception. - public GenerationCacheUnavailableException(string message, Exception inner) : base(message, inner) { } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSnapshot.cs deleted file mode 100644 index b883719d..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSnapshot.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -/// -/// A self-contained snapshot of one generation — enough to rebuild the address space on a node -/// that has lost DB connectivity. The payload is the JSON-serialized sp_GetGenerationContent -/// result; the local cache doesn't inspect the shape, it just round-trips bytes. -/// -public sealed class GenerationSnapshot -{ - /// Gets or sets the auto-generated LiteDB ID. - public int Id { get; set; } // LiteDB auto-ID - /// Gets or sets the cluster identifier. - public required string ClusterId { get; set; } - /// Gets or sets the generation identifier. - public required long GenerationId { get; set; } - /// Gets or sets the time this snapshot was cached. - public required DateTime CachedAt { get; set; } - /// Gets or sets the JSON-serialized payload content. - public required string PayloadJson { get; set; } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ILocalConfigCache.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ILocalConfigCache.cs deleted file mode 100644 index 9483de8d..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ILocalConfigCache.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -/// -/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the -/// address space when the central DB is unreachable (degraded-but-running). -/// -/// -/// Concurrency contract: implementations must serialize writes — specifically, -/// for the same (ClusterId, GenerationId) from concurrent -/// callers must not produce duplicate rows. Reads may run concurrently with reads and writes. -/// The implementation enforces this via an instance-level -/// around the find-then-insert/update window. -/// -public interface ILocalConfigCache -{ - /// Retrieves the most recent generation snapshot for the specified cluster. - /// The cluster identifier. - /// The cancellation token. - /// The most recent generation snapshot, or null if none exists. - Task GetMostRecentAsync(string clusterId, CancellationToken ct = default); - /// Stores a generation snapshot in the local cache. - /// The generation snapshot to store. - /// The cancellation token. - /// A task that represents the asynchronous operation. - Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default); - /// Removes old generations, keeping only the most recent N. - /// The cluster identifier. - /// The number of latest generations to keep. - /// The cancellation token. - /// A task that represents the asynchronous operation. - Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default); -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/LiteDbConfigCache.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/LiteDbConfigCache.cs deleted file mode 100644 index 4a597b12..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/LiteDbConfigCache.cs +++ /dev/null @@ -1,129 +0,0 @@ -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. -/// -public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable -{ - private const string CollectionName = "generations"; - - // LiteDB's default BsonMapper.Global is a process-wide singleton whose per-type member - // registration is lazy and NOT thread-safe across concurrently-constructed LiteDatabase - // instances. When several caches (this one + GenerationSealedCache) initialise in parallel - // the global mapper races, surfacing as "Member ClusterId not found on BsonMapper" or a - // bogus "duplicate key _id = 0" (the int auto-id mapping was lost so Insert writes a literal - // 0 twice). Give each database a private, pre-registered mapper so member - // resolution happens once, single-threaded, at construction and never touches the global. - private static BsonMapper BuildMapper() - { - var mapper = new BsonMapper(); - mapper.Entity(); - return mapper; - } - - private readonly LiteDatabase _db; - private readonly ILiteCollection _col; - // PutAsync is a find-then-insert/update; without serialization, two concurrent puts for the - // same (ClusterId, GenerationId) can both observe `existing is null` and both Insert, - // producing duplicate rows. Serialize writes through this semaphore so - // the read-modify-write block is atomic for a given instance. LiteDB itself only locks the - // page-level write, not the find-then-insert window. - private readonly SemaphoreSlim _writeGate = new(initialCount: 1, maxCount: 1); - - /// Initializes a new instance of the class. - /// Path to the LiteDB database file. - 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 }, BuildMapper()); - _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 async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - // Serialize the find-then-insert/update so concurrent callers do not observe a stale - // `existing is null` and both Insert. LiteDB's per-call lock is not enough — the - // read and the write are independent calls. - await _writeGate.WaitAsync(ct).ConfigureAwait(false); - try - { - // 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); - } - } - finally - { - _writeGate.Release(); - } - } - - /// - 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; - } - - /// Releases all resources used by the cache. - public void Dispose() - { - _writeGate.Dispose(); - _db.Dispose(); - } -} - -public sealed class LocalConfigCacheCorruptException(string message, Exception inner) - : Exception(message, inner); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ResilientConfigReader.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ResilientConfigReader.cs deleted file mode 100644 index 2c55b199..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ResilientConfigReader.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; -using Polly; -using Polly.Retry; -using Polly.Timeout; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -/// -/// Wraps a central-DB fetch function with Phase 6.1 Stream D.2 resilience: -/// timeout 2 s → retry 3× jittered → fallback to sealed cache. Maintains the -/// — fresh on central-DB success, stale on cache fallback. -/// -/// -/// Read-path only per plan. The write path (draft save, publish) bypasses this -/// wrapper entirely and fails hard on DB outage so inconsistent writes never land. -/// -/// Fallback is triggered by any exception the fetch raises (central-DB -/// unreachable, SqlException, timeout). If the sealed cache also fails (no pointer, -/// corrupt file, etc.), surfaces — caller -/// must fail the current request (InitializeAsync for a driver, etc.). -/// -public sealed class ResilientConfigReader -{ - private readonly GenerationSealedCache _cache; - private readonly StaleConfigFlag _staleFlag; - private readonly ResiliencePipeline _pipeline; - private readonly ILogger _logger; - - /// Initializes a resilient config reader with the given cache and options. - /// The sealed cache for fallback. - /// The stale config flag to manage. - /// The logger instance. - /// The timeout for central fetch (default 2s). - /// The number of retries (default 3). - public ResilientConfigReader( - GenerationSealedCache cache, - StaleConfigFlag staleFlag, - ILogger logger, - TimeSpan? timeout = null, - int retryCount = 3) - { - _cache = cache; - _staleFlag = staleFlag; - _logger = logger; - var builder = new ResiliencePipelineBuilder() - .AddTimeout(new TimeoutStrategyOptions { Timeout = timeout ?? TimeSpan.FromSeconds(2) }); - - if (retryCount > 0) - { - builder.AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = retryCount, - BackoffType = DelayBackoffType.Exponential, - UseJitter = true, - Delay = TimeSpan.FromMilliseconds(100), - MaxDelay = TimeSpan.FromSeconds(1), - // Handle ALL exceptions including OperationCanceledException. A SQL command-level - // timeout surfaces as TaskCanceledException (derives from OperationCanceledException) - // when the caller's token is NOT cancelled, and must be retried just like any other - // transient error. Polly itself checks the cancellation token between retries and - // stops with OperationCanceledException on genuine caller cancellation regardless of - // this predicate. - ShouldHandle = new PredicateBuilder().Handle(), - }); - } - - _pipeline = builder.Build(); - } - - /// - /// Redacts connection-string fragments (Password, User Id, Pwd, etc.) - /// that a caller's exception message could carry. Conservative regex pass — anything - /// matching Key=Value with a known credential key gets its value replaced. - /// - private static readonly Regex SecretsRegex = new( - @"(?ix)\b(Password|Pwd|User\s*Id|Uid|AccessToken|Authorization|Api[-_]?Key)\s*=\s*[^;,)\s]*", - RegexOptions.Compiled); - - /// Redacts sensitive credential information from a message. - /// The message to scrub. - /// The message with redacted credentials. - internal static string ScrubSecrets(string? message) - { - if (string.IsNullOrEmpty(message)) return message ?? string.Empty; - // Replace the entire matched fragment (key + value) with a redaction marker so the - // key name itself doesn't leak — log scrapers grep for "Password=" too. - return SecretsRegex.Replace(message, "[redacted credential]"); - } - - /// - /// Executes a central fetch through the resilience pipeline. On full failure - /// (post-retry), reads the sealed cache and extracts the requested shape. - /// - /// The type of configuration to read. - /// The cluster ID to fetch for. - /// Function to fetch from central DB. - /// Function to extract the config from a snapshot. - /// Cancellation token. - /// The configuration of type T. - public async ValueTask ReadAsync( - string clusterId, - Func> centralFetch, - Func fromSnapshot, - CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); - ArgumentNullException.ThrowIfNull(centralFetch); - ArgumentNullException.ThrowIfNull(fromSnapshot); - - try - { - var result = await _pipeline.ExecuteAsync(centralFetch, cancellationToken).ConfigureAwait(false); - _staleFlag.MarkFresh(); - return result; - } - // Catch all exceptions that are NOT genuine caller cancellations. A SQL command-level - // timeout surfaces as TaskCanceledException (derives from OperationCanceledException) - // but the caller's token is NOT cancelled — we must fall back to the sealed cache for - // that case, not propagate. Only rethrow if the caller actually requested cancellation. - catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) - { - // Do NOT pass the raw exception object — it carries the stack - // and inner-exception chain, and SqlException/wrapping delegates can surface - // connection-string fragments (Password=…, User Id=…) embedded in messages. - // Log only the exception type and a scrubbed message so secrets stay out of logs. - _logger.LogWarning( - "Central-DB read failed after retries ({ExceptionType}: {SanitizedMessage}); falling back to sealed cache for cluster {ClusterId}", - ex.GetType().Name, - ScrubSecrets(ex.Message), - clusterId); - // GenerationCacheUnavailableException surfaces intentionally — fails the caller's - // operation. StaleConfigFlag stays unchanged; the flag only flips when we actually - // served a cache snapshot. - var snapshot = await _cache.ReadCurrentAsync(clusterId, cancellationToken).ConfigureAwait(false); - _staleFlag.MarkStale(); - return fromSnapshot(snapshot); - } - } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/StaleConfigFlag.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/StaleConfigFlag.cs deleted file mode 100644 index 35e7e237..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/StaleConfigFlag.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -/// -/// Thread-safe UsingStaleConfig signal per Phase 6.1 Stream D.3. Flips true whenever -/// a read falls back to a sealed cache snapshot; flips false on the next successful central-DB -/// round-trip. Surfaced on /healthz body and on the Admin /hosts page. -/// -public sealed class StaleConfigFlag -{ - private int _stale; - - /// True when the last config read was served from the sealed cache, not the central DB. - public bool IsStale => Volatile.Read(ref _stale) != 0; - - /// Mark the current config as stale (a read fell back to the cache). - public void MarkStale() => Volatile.Write(ref _stale, 1); - - /// Mark the current config as fresh (a central-DB read succeeded). - public void MarkFresh() => Volatile.Write(ref _stale, 0); -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs index 5f0193dd..1a21d2a5 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs @@ -10,10 +10,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services; /// Phase 6.2 compliance check on control/data-plane separation). /// /// -/// Per Phase 6.2 Stream A.2 this service is expected to run behind the Phase 6.1 -/// ResilientConfigReader pipeline (timeout → retry → fallback-to-cache) so a -/// transient DB outage during sign-in falls back to the sealed snapshot rather than -/// denying every login. +/// +/// This service has no local-cache fallback: a DB outage during sign-in denies logins. +/// +/// +/// The Phase 6.1 ResilientConfigReader pipeline (timeout → retry → +/// fallback-to-sealed-snapshot) this was once expected to run behind was never wired to +/// anything and was deleted along with the rest of the dormant LiteDB local cache, which +/// ZB.MOM.WW.LocalDb supersedes. LocalDb caches the deployed-configuration artifact +/// for driver-role nodes; it does not currently cover admin-plane reads like this one. +/// Reviving the fallback means adding an admin-side cache on LocalDb, not restoring the +/// old pipeline. +/// /// public interface ILdapGroupRoleMappingService { diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj index 4c8b187f..ce45b41c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj @@ -26,7 +26,6 @@ - diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationSealedCacheTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationSealedCacheTests.cs deleted file mode 100644 index d7ed3121..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationSealedCacheTests.cs +++ /dev/null @@ -1,168 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; - -[Trait("Category", "Unit")] -public sealed class GenerationSealedCacheTests : IDisposable -{ - private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-{Guid.NewGuid():N}"); - - /// Cleans up temporary directory after test execution. - public void Dispose() - { - try - { - if (!Directory.Exists(_root)) return; - // Remove ReadOnly attribute first so Directory.Delete can clean sealed files. - foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) - File.SetAttributes(f, FileAttributes.Normal); - Directory.Delete(_root, recursive: true); - } - catch { /* best-effort cleanup */ } - } - - private GenerationSnapshot MakeSnapshot(string clusterId, long generationId, string payload = "{\"sample\":true}") => - new() - { - ClusterId = clusterId, - GenerationId = generationId, - CachedAt = DateTime.UtcNow, - PayloadJson = payload, - }; - - /// Verifies that reading a snapshot on first boot with no existing snapshot throws. - [Fact] - public async Task FirstBoot_NoSnapshot_ReadThrows() - { - var cache = new GenerationSealedCache(_root); - - await Should.ThrowAsync( - () => cache.ReadCurrentAsync("cluster-a")); - } - - /// Verifies that sealed snapshots can be read back correctly. - [Fact] - public async Task SealThenRead_RoundTrips() - { - var cache = new GenerationSealedCache(_root); - var snapshot = MakeSnapshot("cluster-a", 42, "{\"hello\":\"world\"}"); - - await cache.SealAsync(snapshot); - - var read = await cache.ReadCurrentAsync("cluster-a"); - read.GenerationId.ShouldBe(42); - read.ClusterId.ShouldBe("cluster-a"); - read.PayloadJson.ShouldBe("{\"hello\":\"world\"}"); - } - - /// Verifies that sealed files are marked read-only on disk. - [Fact] - public async Task SealedFile_IsReadOnly_OnDisk() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 5)); - - var sealedPath = Path.Combine(_root, "cluster-a", "5.db"); - File.Exists(sealedPath).ShouldBeTrue(); - var attrs = File.GetAttributes(sealedPath); - attrs.HasFlag(FileAttributes.ReadOnly).ShouldBeTrue("sealed file must be read-only"); - } - - /// Verifies that the current generation pointer advances when a new generation is sealed. - [Fact] - public async Task SealingTwoGenerations_PointerAdvances_ToLatest() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 1)); - await cache.SealAsync(MakeSnapshot("cluster-a", 2)); - - cache.TryGetCurrentGenerationId("cluster-a").ShouldBe(2); - var read = await cache.ReadCurrentAsync("cluster-a"); - read.GenerationId.ShouldBe(2); - } - - /// Verifies that prior generation files are preserved after a new seal. - [Fact] - public async Task PriorGenerationFile_Survives_AfterNewSeal() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 1)); - await cache.SealAsync(MakeSnapshot("cluster-a", 2)); - - File.Exists(Path.Combine(_root, "cluster-a", "1.db")).ShouldBeTrue( - "prior generations preserved for audit; pruning is separate"); - File.Exists(Path.Combine(_root, "cluster-a", "2.db")).ShouldBeTrue(); - } - - /// Verifies that reading a corrupt sealed file fails safely. - [Fact] - public async Task CorruptSealedFile_ReadFailsClosed() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 7)); - - // Corrupt the sealed file: clear read-only, truncate, leave pointer intact. - var sealedPath = Path.Combine(_root, "cluster-a", "7.db"); - File.SetAttributes(sealedPath, FileAttributes.Normal); - File.WriteAllBytes(sealedPath, [0x00, 0x01, 0x02]); - - await Should.ThrowAsync( - () => cache.ReadCurrentAsync("cluster-a")); - } - - /// Verifies that reading with a missing sealed file fails safely. - [Fact] - public async Task MissingSealedFile_ReadFailsClosed() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 3)); - - // Delete the sealed file but leave the pointer — corruption scenario. - var sealedPath = Path.Combine(_root, "cluster-a", "3.db"); - File.SetAttributes(sealedPath, FileAttributes.Normal); - File.Delete(sealedPath); - - await Should.ThrowAsync( - () => cache.ReadCurrentAsync("cluster-a")); - } - - /// Verifies that reading with a corrupt pointer file fails safely. - [Fact] - public async Task CorruptPointerFile_ReadFailsClosed() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 9)); - - var pointerPath = Path.Combine(_root, "cluster-a", "CURRENT"); - File.WriteAllText(pointerPath, "not-a-number"); - - await Should.ThrowAsync( - () => cache.ReadCurrentAsync("cluster-a")); - } - - /// Verifies that sealing the same generation twice is idempotent. - [Fact] - public async Task SealSameGenerationTwice_IsIdempotent() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 11)); - await cache.SealAsync(MakeSnapshot("cluster-a", 11, "{\"v\":2}")); - - var read = await cache.ReadCurrentAsync("cluster-a"); - read.PayloadJson.ShouldBe("{\"sample\":true}", "sealed file is immutable; second seal no-ops"); - } - - /// Verifies that independent clusters do not interfere with each other. - [Fact] - public async Task IndependentClusters_DoNotInterfere() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(MakeSnapshot("cluster-a", 1)); - await cache.SealAsync(MakeSnapshot("cluster-b", 10)); - - (await cache.ReadCurrentAsync("cluster-a")).GenerationId.ShouldBe(1); - (await cache.ReadCurrentAsync("cluster-b")).GenerationId.ShouldBe(10); - } -} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs deleted file mode 100644 index 0da9d589..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; - -[Trait("Category", "Unit")] -public sealed class LiteDbConfigCacheTests : IDisposable -{ - private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db"); - - /// Cleans up the temporary database file. - public void Dispose() - { - if (File.Exists(_dbPath)) File.Delete(_dbPath); - } - - private GenerationSnapshot Snapshot(string cluster, long gen) => new() - { - ClusterId = cluster, - GenerationId = gen, - CachedAt = DateTime.UtcNow, - PayloadJson = $"{{\"g\":{gen}}}", - }; - - /// Verifies that payload is preserved through a write-then-read cycle. - [Fact] - public async Task Roundtrip_preserves_payload() - { - using var cache = new LiteDbConfigCache(_dbPath); - var put = Snapshot("c-1", 42); - await cache.PutAsync(put); - - var got = await cache.GetMostRecentAsync("c-1"); - got.ShouldNotBeNull(); - got!.GenerationId.ShouldBe(42); - got.PayloadJson.ShouldBe(put.PayloadJson); - } - - /// Verifies that GetMostRecentAsync returns the latest generation when multiple exist. - [Fact] - public async Task GetMostRecent_returns_latest_when_multiple_generations_present() - { - using var cache = new LiteDbConfigCache(_dbPath); - foreach (var g in new long[] { 10, 20, 15 }) - await cache.PutAsync(Snapshot("c-1", g)); - - var got = await cache.GetMostRecentAsync("c-1"); - got!.GenerationId.ShouldBe(20); - } - - /// Verifies that GetMostRecentAsync returns null for an unknown cluster. - [Fact] - public async Task GetMostRecent_returns_null_for_unknown_cluster() - { - using var cache = new LiteDbConfigCache(_dbPath); - (await cache.GetMostRecentAsync("ghost")).ShouldBeNull(); - } - - /// Verifies that Prune keeps the latest N generations and drops older ones. - [Fact] - public async Task Prune_keeps_latest_N_and_drops_older() - { - using var cache = new LiteDbConfigCache(_dbPath); - for (long g = 1; g <= 15; g++) - await cache.PutAsync(Snapshot("c-1", g)); - - await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10); - - (await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15); - - // Drop them one by one and count — should be exactly 10 remaining - var count = 0; - while (await cache.GetMostRecentAsync("c-1") is not null) - { - count++; - await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count)); - if (count > 20) break; // safety - } - count.ShouldBe(10); - } - - /// Verifies that writing the same cluster/generation twice replaces rather than duplicates. - [Fact] - public async Task Put_same_cluster_generation_twice_replaces_not_duplicates() - { - using var cache = new LiteDbConfigCache(_dbPath); - var first = Snapshot("c-1", 1); - first.PayloadJson = "{\"v\":1}"; - await cache.PutAsync(first); - - var second = Snapshot("c-1", 1); - second.PayloadJson = "{\"v\":2}"; - await cache.PutAsync(second); - - (await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}"); - } - - // ------------------------------------------------------------------------------------ - // Configuration-005 — concurrent PutAsync for the same (ClusterId, GenerationId) must - // not produce duplicate rows. The original find-then-insert was non-atomic so two racing - // callers could both observe `existing is null` and both Insert. - // ------------------------------------------------------------------------------------ - /// Verifies that concurrent PutAsync calls for the same cluster and generation do not create duplicates. - [Fact] - public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate() - { - using var cache = new LiteDbConfigCache(_dbPath); - // Pre-seed gen=99 so prune keepLatest:1 has a sentinel that survives independent of - // any potential duplicate (gen=42) row count. - await cache.PutAsync(Snapshot("c-1", 99)); - - // Many parallel writes for the same key. Without serialization, racing find-then-insert - // would Insert multiple rows for the same (ClusterId, GenerationId=42). - var tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(async () => - { - var s = Snapshot("c-1", 42); - await cache.PutAsync(s); - })).ToArray(); - - await Task.WhenAll(tasks); - - // Count rows for gen=42 directly by inspecting the LiteDB file via a fresh handle. - cache.Dispose(); - using var verify = new LiteDB.LiteDatabase(_dbPath); - var col = verify.GetCollection("generations"); - var gen42Count = col.Find(s => s.ClusterId == "c-1" && s.GenerationId == 42).Count(); - gen42Count.ShouldBe(1, - $"PutAsync must upsert atomically — found {gen42Count} rows for (c-1, gen=42) after 64 concurrent puts"); - } - - // ------------------------------------------------------------------------------------ - // Configuration-012 — the per-instance _writeGate (Configuration-005) does not protect - // against LiteDB's process-wide BsonMapper.Global lazy-init race. Many cache INSTANCES - // constructed + driven concurrently corrupt the shared global mapper, surfacing as - // "Member ClusterId not found on BsonMapper" or a bogus "duplicate key _id = 0". A private - // per-database mapper with the entity pre-registered fixes it. - // ------------------------------------------------------------------------------------ - /// Verifies that many cache instances constructed and driven concurrently do not - /// corrupt LiteDB's shared global BsonMapper — each Put/Get round-trips its own payload and - /// no insert throws a member-not-found or duplicate-_id exception. - [Fact] - public async Task Concurrent_cache_instances_do_not_race_the_shared_bson_mapper() - { - var paths = new List(); - try - { - var outer = Enumerable.Range(0, 24).Select(i => Task.Run(async () => - { - var path = Path.Combine(Path.GetTempPath(), $"otopcua-cache-mapperrace-{Guid.NewGuid():N}.db"); - lock (paths) paths.Add(path); - - using var cache = new LiteDbConfigCache(path); - // Pre-seed a sentinel, then hammer one (cluster, gen) from many threads. - await cache.PutAsync(Snapshot($"c-{i}", 99)); - var inner = Enumerable.Range(0, 16) - .Select(_ => Task.Run(() => cache.PutAsync(Snapshot($"c-{i}", 42)))) - .ToArray(); - await Task.WhenAll(inner); - - var got = await cache.GetMostRecentAsync($"c-{i}"); - got.ShouldNotBeNull(); - got!.GenerationId.ShouldBe(99); // 99 > 42, latest by GenerationId - })).ToArray(); - - // The unfixed code throws LiteException / NotSupportedException out of these tasks under - // the global-mapper race; the fixed code completes cleanly. - await Task.WhenAll(outer); - } - finally - { - foreach (var p in paths) - if (File.Exists(p)) File.Delete(p); - } - } - - /// Verifies that a corrupted cache file surfaces as LocalConfigCacheCorruptException. - [Fact] - public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException() - { - // Write a file large enough to look like a LiteDB page but with garbage contents so page - // deserialization fails on the first read probe. - File.WriteAllBytes(_dbPath, new byte[8192]); - Array.Fill(File.ReadAllBytes(_dbPath), 0xAB); - using (var fs = File.OpenWrite(_dbPath)) - { - fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray()); - } - - Should.Throw(() => new LiteDbConfigCache(_dbPath)); - } -} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ResilientConfigReaderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ResilientConfigReaderTests.cs deleted file mode 100644 index fd24bdbd..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ResilientConfigReaderTests.cs +++ /dev/null @@ -1,355 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Polly.Timeout; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; - -[Trait("Category", "Unit")] -public sealed class ResilientConfigReaderTests : IDisposable -{ - private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}"); - - /// Disposes temporary test files. - public void Dispose() - { - try - { - if (!Directory.Exists(_root)) return; - foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories)) - File.SetAttributes(f, FileAttributes.Normal); - Directory.Delete(_root, recursive: true); - } - catch { /* best-effort */ } - } - - /// Verifies that successful central DB reads return value and mark fresh. - [Fact] - public async Task CentralDbSucceeds_ReturnsValue_MarksFresh() - { - var cache = new GenerationSealedCache(_root); - var flag = new StaleConfigFlag { }; - flag.MarkStale(); // pre-existing stale state - var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance); - - var result = await reader.ReadAsync( - "cluster-a", - _ => ValueTask.FromResult("fresh-from-db"), - _ => "from-cache", - CancellationToken.None); - - result.ShouldBe("fresh-from-db"); - flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag"); - } - - /// Verifies that exhausted retries fall back to cache and mark stale. - [Fact] - public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(new GenerationSnapshot - { - ClusterId = "cluster-a", GenerationId = 99, CachedAt = DateTime.UtcNow, - PayloadJson = "{\"cached\":true}", - }); - var flag = new StaleConfigFlag(); - var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, - timeout: TimeSpan.FromSeconds(10), retryCount: 2); - var attempts = 0; - - var result = await reader.ReadAsync( - "cluster-a", - _ => - { - attempts++; - throw new InvalidOperationException("SQL dead"); -#pragma warning disable CS0162 - return ValueTask.FromResult("never"); -#pragma warning restore CS0162 - }, - snap => snap.PayloadJson, - CancellationToken.None); - - attempts.ShouldBe(3, "1 initial + 2 retries = 3 attempts"); - result.ShouldBe("{\"cached\":true}"); - flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true"); - } - - /// Verifies that DB failure with unavailable cache throws. - [Fact] - public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws() - { - var cache = new GenerationSealedCache(_root); - var flag = new StaleConfigFlag(); - var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, - timeout: TimeSpan.FromSeconds(10), retryCount: 0); - - await Should.ThrowAsync(async () => - { - await reader.ReadAsync( - "cluster-a", - _ => throw new InvalidOperationException("SQL dead"), - _ => "never", - CancellationToken.None); - }); - - flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was"); - } - - /// Verifies that cancellation is not retried. - [Fact] - public async Task Cancellation_NotRetried() - { - var cache = new GenerationSealedCache(_root); - var flag = new StaleConfigFlag(); - var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, - timeout: TimeSpan.FromSeconds(10), retryCount: 5); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - var attempts = 0; - - await Should.ThrowAsync(async () => - { - await reader.ReadAsync( - "cluster-a", - ct => - { - attempts++; - ct.ThrowIfCancellationRequested(); - return ValueTask.FromResult("ok"); - }, - _ => "cache", - cts.Token); - }); - - attempts.ShouldBeLessThanOrEqualTo(1); - } - - // ------------------------------------------------------------------------------------ - // Configuration-006 — command-timeout TaskCanceledException and TimeoutRejectedException - // must fall back to the sealed cache, not propagate as caller cancellation. - // ------------------------------------------------------------------------------------ - - /// Verifies that command timeout TaskCanceledException falls back to cache. - [Fact] - public async Task CommandTimeout_TaskCanceledException_FallsBackToCache() - { - // A SQL command-level timeout surfaces as a TaskCanceledException thrown by the - // delegate itself (not triggered by the caller's CancellationToken). It must be - // treated as a transient failure and trigger the cache fallback, not be mistaken - // for genuine caller cancellation and propagated. - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(new GenerationSnapshot - { - ClusterId = "cluster-b", GenerationId = 7, CachedAt = DateTime.UtcNow, - PayloadJson = "{\"from\":\"cache\"}", - }); - var flag = new StaleConfigFlag(); - var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, - timeout: TimeSpan.FromSeconds(10), retryCount: 0); - - // Simulate a command-level timeout: TaskCanceledException with no linked token. - var result = await reader.ReadAsync( - "cluster-b", - _ => throw new TaskCanceledException("SQL command timeout (no caller token)"), - snap => snap.PayloadJson, - CancellationToken.None); // caller token is NOT cancelled - - result.ShouldBe("{\"from\":\"cache\"}", - "command-timeout TaskCanceledException must fall back to sealed cache"); - flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag"); - } - - /// Verifies that Polly timeout rejection falls back to cache. - [Fact] - public async Task PollyTimeout_TimeoutRejectedException_FallsBackToCache() - { - // When Polly's own timeout strategy fires it throws TimeoutRejectedException. - // That should trigger the cache fallback just like any other transient error. - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(new GenerationSnapshot - { - ClusterId = "cluster-c", GenerationId = 8, CachedAt = DateTime.UtcNow, - PayloadJson = "{\"from\":\"polly-timeout-cache\"}", - }); - var flag = new StaleConfigFlag(); - // Set an extremely short Polly timeout so the async delay triggers it. - var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, - timeout: TimeSpan.FromMilliseconds(10), retryCount: 0); - - var result = await reader.ReadAsync( - "cluster-c", - async ct => - { - await Task.Delay(TimeSpan.FromSeconds(5), ct); // far exceeds 10 ms timeout - return "never"; - }, - snap => snap.PayloadJson, - CancellationToken.None); - - result.ShouldBe("{\"from\":\"polly-timeout-cache\"}", - "Polly TimeoutRejectedException must fall back to sealed cache"); - flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag"); - } - - // ------------------------------------------------------------------------------------ - // Configuration-010 — fallback warning log must scrub connection-string fragments and - // must not include the full exception object (which carries the stack and any inner- - // exception chain). Project rule: no credential or connection-string fragment in logs. - // ------------------------------------------------------------------------------------ - - /// Verifies that fallback warnings do not log exceptions or password fragments. - [Fact] - public async Task FallbackWarning_does_not_log_full_exception_object_or_password_fragment() - { - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(new GenerationSnapshot - { - ClusterId = "cluster-e", GenerationId = 1, CachedAt = DateTime.UtcNow, - PayloadJson = "{\"ok\":true}", - }); - var flag = new StaleConfigFlag(); - var capturing = new CapturingLogger(); - var reader = new ResilientConfigReader(cache, flag, capturing, - timeout: TimeSpan.FromSeconds(10), retryCount: 0); - - // Simulated SqlException-style message carrying a connection-string fragment, the - // kind of thing a poorly-wrapped delegate could surface. - const string secretBearingMessage = - "Login failed for user 'sa'. (Server=sql.example.com,1433;User Id=sa;Password=SuperSecret123!)"; - - await reader.ReadAsync( - "cluster-e", - _ => throw new InvalidOperationException(secretBearingMessage), - snap => snap.PayloadJson, - CancellationToken.None); - - var warning = capturing.Records.ShouldHaveSingleItem(); - warning.LogLevel.ShouldBe(LogLevel.Warning); - - // The exception object passed as the first arg to LogWarning(ex, ...) drives the - // formatter's stack-trace dump; capturing it lets us assert the scrubbing surface. - warning.Exception.ShouldBeNull( - "the warning must not attach the raw exception — it can carry connection-string fragments"); - - // The rendered message must not echo password / user-id strings even if the caller - // embedded them in the exception message. - warning.RenderedMessage.ShouldNotContain("Password=", Case.Insensitive); - warning.RenderedMessage.ShouldNotContain("SuperSecret123!"); - warning.RenderedMessage.ShouldNotContain("User Id=", Case.Insensitive); - } - - /// Verifies that caller cancellation propagates rather than falling back. - [Fact] - public async Task CallerCancellation_Propagates_NotFallback() - { - // Explicit caller cancellation must NOT fall back to the sealed cache — the - // caller said stop, so we must stop. - var cache = new GenerationSealedCache(_root); - await cache.SealAsync(new GenerationSnapshot - { - ClusterId = "cluster-d", GenerationId = 9, CachedAt = DateTime.UtcNow, - PayloadJson = "{\"should\":\"not be returned\"}", - }); - var flag = new StaleConfigFlag(); - var reader = new ResilientConfigReader(cache, flag, NullLogger.Instance, - timeout: TimeSpan.FromSeconds(10), retryCount: 0); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - await Should.ThrowAsync(async () => - { - await reader.ReadAsync( - "cluster-d", - ct => - { - ct.ThrowIfCancellationRequested(); - return ValueTask.FromResult("ok"); - }, - _ => "cache-should-not-be-used", - cts.Token); - }); - - flag.IsStale.ShouldBeFalse("no cache snapshot served on genuine cancellation"); - } -} - -/// Represents a captured log record for testing. -internal sealed record LogRecord(LogLevel LogLevel, string RenderedMessage, Exception? Exception); - -/// Captures log records for assertion in tests. -internal sealed class CapturingLogger : ILogger -{ - /// Gets the list of captured log records. - public List Records { get; } = new(); - - /// Begins a scope (no-op for testing). - /// The type of the scope state. - /// The scope state. - /// A disposable scope handle. - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; - - /// Returns true to enable all log levels. - /// The log level to check. - /// True to indicate the log level is enabled. - public bool IsEnabled(LogLevel logLevel) => true; - - /// Logs a message by capturing it. - /// The type of the log state. - /// The log level. - /// The event identifier. - /// The log state. - /// The exception, if any. - /// Function to format the log message. - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - Records.Add(new LogRecord(logLevel, formatter(state, exception), exception)); - } - - /// No-op scope for testing. - private sealed class NullScope : IDisposable - { - /// Gets the singleton instance. - public static readonly NullScope Instance = new(); - - /// Disposes the scope (no-op). - public void Dispose() { } - } -} - -[Trait("Category", "Unit")] -public sealed class StaleConfigFlagTests -{ - /// Verifies that default state is fresh. - [Fact] - public void Default_IsFresh() - { - new StaleConfigFlag().IsStale.ShouldBeFalse(); - } - - /// Verifies that stale and fresh states toggle correctly. - [Fact] - public void MarkStale_ThenFresh_Toggles() - { - var flag = new StaleConfigFlag(); - flag.MarkStale(); - flag.IsStale.ShouldBeTrue(); - flag.MarkFresh(); - flag.IsStale.ShouldBeFalse(); - } - - /// Verifies that concurrent writes converge to the final state. - [Fact] - public void ConcurrentWrites_Converge() - { - var flag = new StaleConfigFlag(); - Parallel.For(0, 1000, i => - { - if (i % 2 == 0) flag.MarkStale(); else flag.MarkFresh(); - }); - flag.MarkFresh(); - flag.IsStale.ShouldBeFalse(); - } -}