refactor(localdb): delete the dormant LiteDB LocalCache (superseded by ZB.MOM.WW.LocalDb)
Never registered in DI and dead since Phase 6.1. Keeping two unwired cache designs invites the next reader to wire the wrong one. Deletes all three test files - LiteDbConfigCacheTests.cs was missing from the plan's list and would have failed to compile (it calls new LiteDB.LiteDatabase directly). StaleConfigFlagTests lives inside ResilientConfigReaderTests.cs, so it goes too. The XML-doc reference in ILdapGroupRoleMappingService is rewritten rather than removed: it now records that no sign-in fallback exists and that reviving one means an admin-side cache on LocalDb.
This commit is contained in:
@@ -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}");
|
||||
|
||||
/// <summary>Cleans up temporary directory after test execution.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Verifies that reading a snapshot on first boot with no existing snapshot throws.</summary>
|
||||
[Fact]
|
||||
public async Task FirstBoot_NoSnapshot_ReadThrows()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sealed snapshots can be read back correctly.</summary>
|
||||
[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\"}");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sealed files are marked read-only on disk.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the current generation pointer advances when a new generation is sealed.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that prior generation files are preserved after a new seal.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading a corrupt sealed file fails safely.</summary>
|
||||
[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<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading with a missing sealed file fails safely.</summary>
|
||||
[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<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reading with a corrupt pointer file fails safely.</summary>
|
||||
[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<GenerationCacheUnavailableException>(
|
||||
() => cache.ReadCurrentAsync("cluster-a"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sealing the same generation twice is idempotent.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that independent clusters do not interfere with each other.</summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
/// <summary>Cleans up the temporary database file.</summary>
|
||||
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}}}",
|
||||
};
|
||||
|
||||
/// <summary>Verifies that payload is preserved through a write-then-read cycle.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetMostRecentAsync returns the latest generation when multiple exist.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that GetMostRecentAsync returns null for an unknown cluster.</summary>
|
||||
[Fact]
|
||||
public async Task GetMostRecent_returns_null_for_unknown_cluster()
|
||||
{
|
||||
using var cache = new LiteDbConfigCache(_dbPath);
|
||||
(await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Prune keeps the latest N generations and drops older ones.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that writing the same cluster/generation twice replaces rather than duplicates.</summary>
|
||||
[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.
|
||||
// ------------------------------------------------------------------------------------
|
||||
/// <summary>Verifies that concurrent PutAsync calls for the same cluster and generation do not create duplicates.</summary>
|
||||
[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<GenerationSnapshot>("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.
|
||||
// ------------------------------------------------------------------------------------
|
||||
/// <summary>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.</summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_cache_instances_do_not_race_the_shared_bson_mapper()
|
||||
{
|
||||
var paths = new List<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a corrupted cache file surfaces as LocalConfigCacheCorruptException.</summary>
|
||||
[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<byte>(File.ReadAllBytes(_dbPath), 0xAB);
|
||||
using (var fs = File.OpenWrite(_dbPath))
|
||||
{
|
||||
fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray());
|
||||
}
|
||||
|
||||
Should.Throw<LocalConfigCacheCorruptException>(() => new LiteDbConfigCache(_dbPath));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
/// <summary>Disposes temporary test files.</summary>
|
||||
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 */ }
|
||||
}
|
||||
|
||||
/// <summary>Verifies that successful central DB reads return value and mark fresh.</summary>
|
||||
[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<ResilientConfigReader>.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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that exhausted retries fall back to cache and mark stale.</summary>
|
||||
[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<ResilientConfigReader>.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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that DB failure with unavailable cache throws.</summary>
|
||||
[Fact]
|
||||
public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"cluster-a",
|
||||
_ => throw new InvalidOperationException("SQL dead"),
|
||||
_ => "never",
|
||||
CancellationToken.None);
|
||||
});
|
||||
|
||||
flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that cancellation is not retried.</summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_NotRetried()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 5);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var attempts = 0;
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"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.
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that command timeout TaskCanceledException falls back to cache.</summary>
|
||||
[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<ResilientConfigReader>.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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Polly timeout rejection falls back to cache.</summary>
|
||||
[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<ResilientConfigReader>.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.
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Verifies that fallback warnings do not log exceptions or password fragments.</summary>
|
||||
[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<ResilientConfigReader>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that caller cancellation propagates rather than falling back.</summary>
|
||||
[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<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Represents a captured log record for testing.</summary>
|
||||
internal sealed record LogRecord(LogLevel LogLevel, string RenderedMessage, Exception? Exception);
|
||||
|
||||
/// <summary>Captures log records for assertion in tests.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
/// <summary>Gets the list of captured log records.</summary>
|
||||
public List<LogRecord> Records { get; } = new();
|
||||
|
||||
/// <summary>Begins a scope (no-op for testing).</summary>
|
||||
/// <typeparam name="TState">The type of the scope state.</typeparam>
|
||||
/// <param name="state">The scope state.</param>
|
||||
/// <returns>A disposable scope handle.</returns>
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
/// <summary>Returns true to enable all log levels.</summary>
|
||||
/// <param name="logLevel">The log level to check.</param>
|
||||
/// <returns>True to indicate the log level is enabled.</returns>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <summary>Logs a message by capturing it.</summary>
|
||||
/// <typeparam name="TState">The type of the log state.</typeparam>
|
||||
/// <param name="logLevel">The log level.</param>
|
||||
/// <param name="eventId">The event identifier.</param>
|
||||
/// <param name="state">The log state.</param>
|
||||
/// <param name="exception">The exception, if any.</param>
|
||||
/// <param name="formatter">Function to format the log message.</param>
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Records.Add(new LogRecord(logLevel, formatter(state, exception), exception));
|
||||
}
|
||||
|
||||
/// <summary>No-op scope for testing.</summary>
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
/// <summary>Gets the singleton instance.</summary>
|
||||
public static readonly NullScope Instance = new();
|
||||
|
||||
/// <summary>Disposes the scope (no-op).</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class StaleConfigFlagTests
|
||||
{
|
||||
/// <summary>Verifies that default state is fresh.</summary>
|
||||
[Fact]
|
||||
public void Default_IsFresh()
|
||||
{
|
||||
new StaleConfigFlag().IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stale and fresh states toggle correctly.</summary>
|
||||
[Fact]
|
||||
public void MarkStale_ThenFresh_Toggles()
|
||||
{
|
||||
var flag = new StaleConfigFlag();
|
||||
flag.MarkStale();
|
||||
flag.IsStale.ShouldBeTrue();
|
||||
flag.MarkFresh();
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that concurrent writes converge to the final state.</summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user