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(); - } -}