232bff5df7
1. Wiped-node back-fill (live gate check 4). Fixed upstream in ZB.MOM.WW.LocalDb 0.1.2 and pinned here. The gate's diagnosis was slightly off: the oplog cap was not the gate. Snapshot detection measured the peer's gap against the oldest surviving oplog row and read an empty oplog as "no gap possible" — and an empty oplog is the steady state of a converged pair, since ack-pruning deletes everything the peer confirmed. The healthy state was the one state that could not heal a wiped peer. Now measured against last_acked_seq when the oplog is empty. Pinned at this level too, not just the library's: the pair harness grows a WipePassiveAsync (a NEW database — a rebuilt node comes back with a new node id and a zero watermark), and the new scenario asserts the emptied oplog as its precondition before wiping, then requires the deployment artifact to come back byte-identical with no new deploy. Verified RED against the pinned 0.1.1 (times out waiting for back-fill) and green on 0.1.2. 2. Oplog growth on default-OFF nodes (live gate check 8), fixed at the source: StoreAsync now skips entirely when the pointer already names this deployment/revision, the SHA matches the bytes, and the expected chunk count is present. Re-caching an artifact the node already holds — every restart's boot-from-cache, every RestoreApplied — writes nothing and mints no oplog rows, where before it cost a delete plus an insert per chunk plus a pointer update, all identical to what was already there. Identity is over the bytes and the chunks are counted, both deliberately: a re-composed artifact can carry the same ids with different bytes, and a pointer can name a deployment whose chunks are missing, where skipping would make an unreadable cache permanent. Both guards verified RED against a naive pointer-only skip. The gate's stated bound was also wrong and is corrected in the doc: growth was never headed for the 1M row cap. AddZbLocalDbReplication is registered unconditionally, so MaintenanceBackgroundService runs on default-OFF nodes and the 7-day MaxOplogAge cap prunes. Runtime.Tests 412/0/31, Host.IntegrationTests LocalDb 45/45. Pre-existing and unrelated: AbCip_Green_AgainstSim (needs the AB CIP docker fixture, red on a clean master too) and a flaky Roslyn race test (green 2/2 in isolation). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
380 lines
16 KiB
C#
380 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 schema setup here mirrors
|
|
/// <c>LocalDbSetup.OnReady</c> rather than calling it. The load-bearing
|
|
/// <c>DDL → RegisterReplicated</c> ordering is pinned by the Host's own
|
|
/// <c>LocalDbSetupTests</c>; what matters here is only that the tables exist.
|
|
/// </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)));
|
|
}
|
|
}
|