df53391646
Review round on 3e62f161. Two coverage gaps and three wording/comment nits.
IMPORTANT 1 — deregistration is now proven DATA-PRESERVING. The self-heal test
inserted a deployment_pointer row and rebooted unconfigured but never looked at
the row again, so a library regression that touched base-table data during
cleanup would have gone unnoticed — on the very tables that ARE the
boot-from-cache backstop, and on the default-OFF nodes least likely to notice.
The new DisablingReplication_PrunesBookkeepingButNeverTouchesTheCachedDataItself
asserts the row's CONTENT survives (not just its count — a count passes against
a cleanup that blanks columns) while __localdb_row_version and __localdb_oplog,
the bookkeeping deregistration does own, go to zero.
IMPORTANT 2 — mid-loop partial failure is documented and pinned. Both loops flip
four tables with no try/catch, so a throw on the third leaves two flipped and
propagates out of the AddZbLocalDb singleton factory, failing host start. That
is correct — a node that came up anyway would be silently capturing on an
arbitrary subset — and both library calls are idempotent, so the retried boot
completes the flip. Written into OnReady's remarks and pinned by
AFailureMidwayThroughRegistration_FailsBootLoudly_AndTheRetriedBootConverges,
which drives OnReady through a delegating ILocalDb that throws on the Nth
RegisterReplicated, asserts the throw propagates, asserts the wrapper actually
reached that call, then re-runs OnReady unwrapped and asserts all four converge.
MINOR 3 — the Boot helper now records that OnReady's null-logger fallback lands
on Serilog's static Log.Logger, which this assembly never assigns; assertions
about logging pass an explicit logger.
MINOR 5 — log wording: "are both unset" -> "are both absent — unset or blank",
matching IsNullOrWhiteSpace.
MINOR 4 — LocalDbDeploymentArtifactCacheTests said its setup "mirrors
LocalDbSetup.OnReady"; it reproduces the two tables only and registers them
unconditionally without baselining. Softened to say so and to name the
divergence as intentional and inert there.
Falsified both new pins. Wrapper set to failOnCall: 99 (never throws) => the
partial-flip pin is the SOLE failure, so it genuinely exercises the Nth-call
throw path rather than some incidental exception. Predicate forced constant-true
=> five pins red including the new data-preservation one. Both mutations
reverted.
LocalDbSetupTests 15/15. Host.IntegrationTests 226 passed / 0 failed / 5
skipped. Runtime.Tests LocalDbDeploymentArtifactCacheTests 12/12.
384 lines
16 KiB
C#
384 lines
16 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.LocalDb;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
|
|
|
// NOT "...Tests.Deployment", even though the folder is Deployment/. A child namespace named
|
|
// Deployment under ...Runtime.Tests shadows the Deployment EF entity type for every other file in
|
|
// this assembly — the same CS0118 trap that forced the production namespace's rename.
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.DeploymentCache;
|
|
|
|
/// <summary>
|
|
/// LocalDb Phase 1 (Task 6) — the chunked deployment-artifact cache.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Driven against a real temp-file <see cref="ILocalDb"/> rather than a fake. The behaviours
|
|
/// under test — chunk reassembly order, SHA-256 integrity, retention pruning, upsert
|
|
/// semantics — live entirely in SQL, so a mocked seam would assert nothing about them.
|
|
/// </para>
|
|
/// <para>
|
|
/// This project cannot reference the Host, so the setup here reproduces only the two
|
|
/// cache <b>tables</b> — not <c>LocalDbSetup.OnReady</c>'s behaviour. In particular it
|
|
/// registers them unconditionally and without baselining, whereas the production callback
|
|
/// registers only when the node is configured to replicate and deregisters when it is not.
|
|
/// That divergence is intentional and inert here: these tests need a database whose tables
|
|
/// exist and capture, and nothing below asserts anything about registration. The
|
|
/// load-bearing DDL-then-RegisterReplicated ordering and the conditional-registration rules
|
|
/// are pinned by the Host's own <c>LocalDbSetupTests</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class LocalDbDeploymentArtifactCacheTests : IDisposable
|
|
{
|
|
private const string ClusterA = "SITE-A";
|
|
private const string ClusterB = "SITE-B";
|
|
|
|
private readonly string _dbPath =
|
|
Path.Combine(Path.GetTempPath(), $"otopcua-artifact-cache-{Guid.NewGuid():N}.db");
|
|
|
|
private readonly ServiceProvider _provider;
|
|
private readonly ILocalDb _db;
|
|
|
|
public LocalDbDeploymentArtifactCacheTests()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
|
.Build();
|
|
|
|
_provider = new ServiceCollection()
|
|
.AddZbLocalDb(configuration, db =>
|
|
{
|
|
using (var connection = db.CreateConnection())
|
|
{
|
|
DeploymentCacheSchema.Apply(connection);
|
|
}
|
|
|
|
db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable);
|
|
db.RegisterReplicated(DeploymentCacheSchema.PointerTable);
|
|
})
|
|
.BuildServiceProvider();
|
|
|
|
_db = _provider.GetRequiredService<ILocalDb>();
|
|
}
|
|
|
|
private LocalDbDeploymentArtifactCache CreateCache(
|
|
ILogger<LocalDbDeploymentArtifactCache>? logger = null)
|
|
=> new(_db, logger ?? NullLogger<LocalDbDeploymentArtifactCache>.Instance);
|
|
|
|
private static byte[] RandomArtifact(int length)
|
|
{
|
|
var bytes = new byte[length];
|
|
Random.Shared.NextBytes(bytes);
|
|
return bytes;
|
|
}
|
|
|
|
private async Task<int> CountChunksAsync(string? deploymentId = null)
|
|
{
|
|
var sql = deploymentId is null
|
|
? "SELECT COUNT(*) FROM deployment_artifacts"
|
|
: "SELECT COUNT(*) FROM deployment_artifacts WHERE deployment_id = @DeploymentId";
|
|
|
|
var rows = await _db.QueryAsync(sql, r => r.GetInt32(0),
|
|
deploymentId is null ? null : new { DeploymentId = deploymentId });
|
|
|
|
return rows[0];
|
|
}
|
|
|
|
private async Task<IReadOnlyList<string>> DistinctDeploymentIdsAsync()
|
|
=> await _db.QueryAsync(
|
|
"SELECT DISTINCT deployment_id FROM deployment_artifacts ORDER BY deployment_id",
|
|
r => r.GetString(0));
|
|
|
|
/// <summary>
|
|
/// Total capture-trigger rows in the replication oplog — the direct measure of how much
|
|
/// replication traffic (and, on an unpaired node, how much permanent oplog growth) a store
|
|
/// costs.
|
|
/// </summary>
|
|
private async Task<long> OplogDepthAsync()
|
|
=> (await _db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", r => r.GetInt64(0)))[0];
|
|
|
|
/// <summary>
|
|
/// The LWW version stamps of every replicated row, as a stable ordered projection. Comparing
|
|
/// it across a call proves whether that call touched replicated state at all.
|
|
/// </summary>
|
|
private async Task<IReadOnlyList<string>> RowVersionsAsync()
|
|
=> await _db.QueryAsync(
|
|
"SELECT table_name, pk_json, hlc FROM __localdb_row_version ORDER BY table_name, pk_json",
|
|
r => $"{r.GetString(0)}|{r.GetString(1)}|{r.GetInt64(2)}");
|
|
|
|
[Fact]
|
|
public async Task StoreThenGetCurrent_RoundTripsAMultiChunkArtifactByteForByte()
|
|
{
|
|
// 300 KiB against a 128 KiB chunk forces three chunks, so this covers the reassembly
|
|
// ordering that a single-chunk artifact would never exercise.
|
|
var artifact = RandomArtifact(300 * 1024);
|
|
var cache = CreateCache();
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
|
|
|
var cached = await cache.GetCurrentAsync(ClusterA);
|
|
|
|
cached.ShouldNotBeNull();
|
|
cached.DeploymentId.ShouldBe("dep-1");
|
|
cached.RevisionHash.ShouldBe("rev-1");
|
|
cached.Artifact.ShouldBe(artifact);
|
|
(await CountChunksAsync("dep-1")).ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Store_KeepsOnlyTheTwoNewestDeploymentsPerCluster()
|
|
{
|
|
var cache = CreateCache();
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(64));
|
|
await cache.StoreAsync(ClusterA, "dep-2", "rev-2", RandomArtifact(64));
|
|
await cache.StoreAsync(ClusterA, "dep-3", "rev-3", RandomArtifact(64));
|
|
|
|
// Bounded retention is what stops a node that redeploys daily from filling its disk with
|
|
// address spaces nobody will ever roll back to.
|
|
(await DistinctDeploymentIdsAsync()).ShouldBe(["dep-2", "dep-3"]);
|
|
|
|
var cached = await cache.GetCurrentAsync(ClusterA);
|
|
cached.ShouldNotBeNull();
|
|
cached.DeploymentId.ShouldBe("dep-3");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Store_NeverPrunesTheDeploymentThePointerNames()
|
|
{
|
|
// Regression: the prune ranks survivors by cached_at_utc and falls through to a
|
|
// deployment_id tiebreak. Real deployment ids are GUIDs, so that tiebreak is effectively
|
|
// random — the deployment just written could lose and have its chunks deleted while the
|
|
// pointer still named it. The result is a cache that misses on every boot until the next
|
|
// deploy: silent, permanent, and precisely the outage this cache exists to survive.
|
|
//
|
|
// Rather than trying to race three stores into one clock tick, this forces the general
|
|
// case: the two older deployments are back-dated INTO THE FUTURE so the newest store loses
|
|
// the ranking outright. The invariant must hold regardless of timestamps.
|
|
var cache = CreateCache();
|
|
var newest = RandomArtifact(1024);
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-old-1", "rev-1", RandomArtifact(1024));
|
|
await cache.StoreAsync(ClusterA, "dep-old-2", "rev-2", RandomArtifact(1024));
|
|
|
|
await _db.ExecuteAsync(
|
|
"UPDATE deployment_artifacts SET cached_at_utc = @Future",
|
|
new { Future = "2099-01-01T00:00:00.0000000Z" });
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-new", "rev-3", newest);
|
|
|
|
// The pointer names dep-new, so dep-new's chunks must still be there...
|
|
(await CountChunksAsync("dep-new")).ShouldBeGreaterThan(0);
|
|
|
|
// ...and it must actually read back, which is the behaviour that matters.
|
|
var cached = await cache.GetCurrentAsync(ClusterA);
|
|
cached.ShouldNotBeNull();
|
|
cached.DeploymentId.ShouldBe("dep-new");
|
|
cached.Artifact.ShouldBe(newest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCurrent_ReturnsNullWhenAChunkIsCorrupt()
|
|
{
|
|
var cache = CreateCache();
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));
|
|
|
|
// Valid base64 of the wrong bytes: this passes decoding and the chunk-count check, so only
|
|
// the SHA-256 comparison can catch it. That is the check being pinned.
|
|
await _db.ExecuteAsync(
|
|
"UPDATE deployment_artifacts SET chunk_base64 = @Chunk WHERE deployment_id = @DeploymentId AND chunk_index = 1",
|
|
new { Chunk = Convert.ToBase64String(RandomArtifact(128 * 1024)), DeploymentId = "dep-1" });
|
|
|
|
(await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCurrent_ReturnsNullWhenAChunkIsMissing()
|
|
{
|
|
var cache = CreateCache();
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));
|
|
|
|
// A partially replicated artifact is the realistic version of this: the pointer row arrives
|
|
// before the last chunk does. Reassembling what is present would yield a plausible-looking
|
|
// but silently truncated address space.
|
|
await _db.ExecuteAsync(
|
|
"DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId AND chunk_index = 2",
|
|
new { DeploymentId = "dep-1" });
|
|
|
|
(await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCurrent_ReturnsNullWhenNoPointerExists()
|
|
{
|
|
var cache = CreateCache();
|
|
|
|
(await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Store_IsIdempotentForTheSameDeploymentId()
|
|
{
|
|
var cache = CreateCache();
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));
|
|
|
|
// The re-store is smaller, so a delete-before-insert that did not happen would leave the
|
|
// tail chunks of the first artifact behind — orphans that also break the chunk-count check.
|
|
var second = RandomArtifact(200 * 1024);
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1b", second);
|
|
|
|
(await CountChunksAsync("dep-1")).ShouldBe(2);
|
|
(await CountChunksAsync()).ShouldBe(2);
|
|
|
|
var cached = await cache.GetCurrentAsync(ClusterA);
|
|
cached.ShouldNotBeNull();
|
|
cached.RevisionHash.ShouldBe("rev-1b");
|
|
cached.Artifact.ShouldBe(second);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Store_WritesNothingWhenTheCachedArtifactIsAlreadyIdentical()
|
|
{
|
|
// Phase 1 live gate, check 8: every store rewrote the chunks and the pointer unconditionally,
|
|
// so re-caching an artifact the node already holds — which is what boot-from-cache and
|
|
// RestoreApplied both do on every restart — minted fresh oplog rows for a row nobody changed.
|
|
// On a replicating pair that is pure churn; on a default-OFF node there is no peer to ack
|
|
// them, so they sit in the oplog until the age cap eventually prunes them.
|
|
var cache = CreateCache();
|
|
var artifact = RandomArtifact(300 * 1024);
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
|
|
|
var oplogDepth = await OplogDepthAsync();
|
|
var rowVersions = await RowVersionsAsync();
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
|
|
|
(await OplogDepthAsync()).ShouldBe(oplogDepth);
|
|
(await RowVersionsAsync()).ShouldBe(rowVersions);
|
|
|
|
// Skipping must not cost readability: the artifact still reads back intact.
|
|
var cached = await cache.GetCurrentAsync(ClusterA);
|
|
cached.ShouldNotBeNull();
|
|
cached.DeploymentId.ShouldBe("dep-1");
|
|
cached.Artifact.ShouldBe(artifact);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Store_RewritesWhenTheCachedChunksAreIncompleteDespiteAMatchingPointer()
|
|
{
|
|
// The dangerous half of skip-if-unchanged. A pointer can name a deployment whose chunks are
|
|
// gone or partial — a half-replicated artifact, or a prune that raced the pointer. Trusting
|
|
// the pointer alone would turn a repairable cache into a permanently unreadable one, because
|
|
// the re-store that would have fixed it is exactly the call being skipped.
|
|
var cache = CreateCache();
|
|
var artifact = RandomArtifact(300 * 1024);
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
|
await _db.ExecuteAsync(
|
|
"DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId AND chunk_index = 2",
|
|
new { DeploymentId = "dep-1" });
|
|
(await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
|
|
|
(await CountChunksAsync("dep-1")).ShouldBe(3);
|
|
var cached = await cache.GetCurrentAsync(ClusterA);
|
|
cached.ShouldNotBeNull();
|
|
cached.Artifact.ShouldBe(artifact);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Store_RewritesWhenTheArtifactBytesChangedUnderAnUnchangedRevision()
|
|
{
|
|
// The identity check must be over the bytes, not just the ids. A rebuilt artifact carrying
|
|
// the same deployment id and revision hash is a real case (the same revision re-composed),
|
|
// and silently keeping the stale bytes would serve a boot-from-cache node the wrong address
|
|
// space with every id-shaped signal saying it was correct.
|
|
var cache = CreateCache();
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(1024));
|
|
|
|
var rebuilt = RandomArtifact(1024);
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", rebuilt);
|
|
|
|
var cached = await cache.GetCurrentAsync(ClusterA);
|
|
cached.ShouldNotBeNull();
|
|
cached.Artifact.ShouldBe(rebuilt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCurrentUnkeyed_ReturnsTheOnlyCachedArtifact()
|
|
{
|
|
var artifact = RandomArtifact(300 * 1024);
|
|
var cache = CreateCache();
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
|
|
|
var cached = await cache.GetCurrentUnkeyedAsync();
|
|
|
|
cached.ShouldNotBeNull();
|
|
cached.DeploymentId.ShouldBe("dep-1");
|
|
cached.Artifact.ShouldBe(artifact);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCurrentUnkeyed_TakesTheNewestPointerAndWarnsNamingBothClusters()
|
|
{
|
|
var newest = RandomArtifact(1024);
|
|
var logger = new CapturingLogger();
|
|
var cache = CreateCache(logger);
|
|
|
|
await cache.StoreAsync(ClusterA, "dep-a", "rev-a", RandomArtifact(1024));
|
|
await cache.StoreAsync(ClusterB, "dep-b", "rev-b", newest);
|
|
|
|
var cached = await cache.GetCurrentUnkeyedAsync();
|
|
|
|
cached.ShouldNotBeNull();
|
|
cached.DeploymentId.ShouldBe("dep-b");
|
|
cached.Artifact.ShouldBe(newest);
|
|
|
|
// A re-homed node booting a neighbouring cluster's configuration must be operator-visible.
|
|
// Naming both clusters is what turns "wrong tags appeared" into a one-line diagnosis.
|
|
var warning = logger.Entries.ShouldHaveSingleItem();
|
|
warning.Level.ShouldBe(LogLevel.Warning);
|
|
warning.Message.ShouldContain(ClusterA);
|
|
warning.Message.ShouldContain(ClusterB);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_provider.Dispose();
|
|
|
|
// Real files, and pooled connections outlive the provider — clearing the pools is what
|
|
// makes the delete actually succeed.
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
|
|
{
|
|
if (File.Exists(path))
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
/// <summary>Minimal logger that records formatted messages so a test can assert on them.</summary>
|
|
private sealed class CapturingLogger : ILogger<LocalDbDeploymentArtifactCache>
|
|
{
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
|
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
=> Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
}
|