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