255 lines
9.6 KiB
C#
255 lines
9.6 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.Deployment;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Deployment;
|
|
|
|
/// <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));
|
|
|
|
[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 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 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)));
|
|
}
|
|
}
|