Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs
T
Joseph Doherty c3d148e396 review(Configuration): fix LiteDB global BsonMapper cross-instance race (High)
Re-review at 7286d320. Configuration-012 (High): LiteDbConfigCache/GenerationSealedCache built
LiteDatabase on the process-wide BsonMapper.Global whose lazy member resolution races across
concurrently-constructed DBs (NotSupportedException/duplicate-key under contention; also caused
intermittent suite flakiness). Fix: per-cache fresh BsonMapper + pre-registered entity + TDD.
-013 (dead ValidateClusterTopology, ControlPlane) / -014 (collation case-sensitivity, needs
migration) deferred. No migration touched.
2026-06-19 11:06:56 -04:00

193 lines
8.2 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 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));
}
}