Closes Stream D per docs/v2/implementation/phase-6-1-resilience-and-observability.md. New Configuration.LocalCache types (alongside the existing single-file LiteDbConfigCache): - GenerationSealedCache — file-per-generation sealed snapshots per decision #148. Each SealAsync writes <cache-root>/<clusterId>/<generationId>.db as a read-only LiteDB file, then atomically publishes the CURRENT pointer via temp-file + File.Replace. Prior-generation files stay on disk for audit. Mixed-generation reads are structurally impossible: ReadCurrentAsync opens the single file named by CURRENT. Corruption of the pointer or the sealed file raises GenerationCacheUnavailableException — fails closed, never falls back silently to an older generation. TryGetCurrentGenerationId returns the pointer value or null for diagnostics. - StaleConfigFlag — thread-safe (Volatile.Read/Write) bool. MarkStale when a read fell back to the cache; MarkFresh when a central-DB read succeeded. Surfaced on /healthz body and Admin /hosts (Stream C wiring already in place). - ResilientConfigReader — wraps a central-DB fetch function with the Stream D.2 pipeline: timeout 2 s → retry N× jittered (skipped when retryCount=0) → fallback to the sealed cache. Toggles StaleConfigFlag per outcome. Read path only — the write path is expected to bypass this wrapper and fail hard on DB outage so inconsistent writes never land. Cancellation passes through and is NOT retried. Configuration.csproj: - Polly.Core 8.6.6 + Microsoft.Extensions.Logging.Abstractions added. Tests (17 new, all pass): - GenerationSealedCacheTests (10): first-boot-no-snapshot throws GenerationCacheUnavailableException (D.4 scenario C), seal-then-read round trip, sealed file is ReadOnly on disk, pointer advances to latest, prior generation file preserved, corrupt sealed file fails closed, missing sealed file fails closed, corrupt pointer fails closed (D.4 scenario B), same generation sealed twice is idempotent, independent clusters don't interfere. - ResilientConfigReaderTests (4): central-DB success returns value + marks fresh; central-DB failure exhausts retries + falls back to cache + marks stale (D.4 scenario A); central-DB + cache both unavailable throws; cancellation not retried. - StaleConfigFlagTests (3): default is fresh; toggles; concurrent writes converge. Full solution dotnet test: 1033 passing (baseline 906, +127 net across Phase 6.1 Streams A/B/C/D). Pre-existing Client.CLI Subscribe flake unchanged. Integration into Configuration read paths (DriverInstance enumeration, LdapGroupRoleMapping fetches, etc.) + the sp_PublishGeneration hook that writes sealed files lands in the Phase 6.1 Stream E / Admin-refresh PR where the DB integration surfaces are already touched. Existing LiteDbConfigCache continues serving its single-file role for the NodeBootstrap path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.6 KiB
C#
158 lines
5.6 KiB
C#
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}");
|
|
|
|
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,
|
|
};
|
|
|
|
[Fact]
|
|
public async Task FirstBoot_NoSnapshot_ReadThrows()
|
|
{
|
|
var cache = new GenerationSealedCache(_root);
|
|
|
|
await Should.ThrowAsync<GenerationCacheUnavailableException>(
|
|
() => cache.ReadCurrentAsync("cluster-a"));
|
|
}
|
|
|
|
[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\"}");
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|