2e64af26ef
Staleness is not a liveness proof: DriverInstanceActor publishes on a 30 s tick and dedups on a health fingerprint, so a running Faulted, idle or tagless driver deleted in /raw (undeployed) went stale and was evicted. DeployedDriverSet reads, per node, the latest and latest-Applied NodeDeploymentState deployments plus the latest Sealed one, parses each artifact's DriverInstances once (cached by deployment id, pruned to the live set, only the blob column of an uncached row is read), and /hosts unions it with the authored set. The staleness clause stays as a second guard. An unreadable artifact or a failed read means 'unknown' and skips eviction. The Hosts.razor comment claiming '~12 health publishes' is corrected. N-new-2: the CSV download's Content-Disposition carries an RFC 5987 filename* via ContentDispositionHeaderValue.SetHttpFileName. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
158 lines
7.4 KiB
C#
158 lines
7.4 KiB
C#
using System.Text;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts;
|
||
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hosts;
|
||
|
||
/// <summary>
|
||
/// Review D-b r1, I1: <c>/hosts</c> keys eviction on authored ∪ DEPLOYED drivers, because a running driver
|
||
/// whose health is unchanged never re-publishes (fingerprint dedup) — staleness is not a liveness proof.
|
||
/// </summary>
|
||
[Trait("Category", "Unit")]
|
||
public sealed class DeployedDriverSetTests
|
||
{
|
||
private static readonly DateTime Now = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
||
|
||
// Test artifacts are a plain comma list; the real parser is DeploymentArtifact.ParseDriverInstances.
|
||
private static IEnumerable<string> Parse(byte[] blob) =>
|
||
Encoding.UTF8.GetString(blob).Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||
|
||
private static byte[] Artifact(params string[] driverIds) => Encoding.UTF8.GetBytes(string.Join(",", driverIds));
|
||
|
||
private sealed class Factory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||
{
|
||
public int Created;
|
||
public OtOpcUaConfigDbContext CreateDbContext()
|
||
{
|
||
Created++;
|
||
return new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
||
}
|
||
|
||
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) => Task.FromResult(CreateDbContext());
|
||
}
|
||
|
||
private static Deployment Dep(Guid id, DeploymentStatus status, DateTime created, byte[] blob) =>
|
||
new() { DeploymentId = id, RevisionHash = id.ToString("N"), CreatedBy = "t", Status = status, CreatedAtUtc = created, ArtifactBlob = blob };
|
||
|
||
private static NodeDeploymentState Node(string node, Guid dep, NodeDeploymentStatus status, DateTime started) =>
|
||
new() { NodeId = node, DeploymentId = dep, Status = status, StartedAtUtc = started };
|
||
|
||
[Fact]
|
||
public async Task Quiet_running_driver_deleted_in_raw_survives_the_sweep()
|
||
{
|
||
// drv-quiet is running (in the deployed artifact), deleted in /raw (not authored), and published
|
||
// once 5 minutes ago — a Faulted driver with a steady error never re-publishes.
|
||
var factory = new Factory(Guid.NewGuid().ToString("N"));
|
||
var d1 = Guid.NewGuid();
|
||
await using (var db = factory.CreateDbContext())
|
||
{
|
||
db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now.AddHours(-1), Artifact("drv-quiet", "drv-kept")));
|
||
db.NodeDeploymentStates.Add(Node("n1", d1, NodeDeploymentStatus.Applied, Now.AddHours(-1)));
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync();
|
||
deployed.ShouldNotBeNull();
|
||
|
||
var store = new InMemoryDriverStatusSnapshotStore();
|
||
store.Upsert(new DriverHealthChanged("MAIN", "drv-quiet", "Faulted", null, "refused", 3, Now.AddMinutes(-5)));
|
||
store.Upsert(new DriverHealthChanged("MAIN", "drv-gone", "Faulted", null, "refused", 3, Now.AddMinutes(-5)));
|
||
|
||
var configured = new HashSet<string>(["drv-kept"], StringComparer.OrdinalIgnoreCase); // authored
|
||
configured.UnionWith(deployed!);
|
||
var removed = store.EvictUnconfiguredStale(configured, Now.AddSeconds(-60));
|
||
|
||
store.TryGet("drv-quiet", out _).ShouldBeTrue();
|
||
// Positive control: an undeployed, unconfigured, stale driver is still evicted.
|
||
removed.ShouldBe(1);
|
||
store.TryGet("drv-gone", out _).ShouldBeFalse();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Union_covers_each_nodes_latest_and_latest_applied_and_the_latest_sealed()
|
||
{
|
||
var factory = new Factory(Guid.NewGuid().ToString("N"));
|
||
Guid old = Guid.NewGuid(), failed = Guid.NewGuid(), sealedOther = Guid.NewGuid(), stale = Guid.NewGuid();
|
||
await using (var db = factory.CreateDbContext())
|
||
{
|
||
db.Deployments.Add(Dep(stale, DeploymentStatus.Sealed, Now.AddDays(-9), Artifact("drv-ancient")));
|
||
db.Deployments.Add(Dep(old, DeploymentStatus.Sealed, Now.AddHours(-3), Artifact("drv-old")));
|
||
db.Deployments.Add(Dep(failed, DeploymentStatus.PartiallyFailed, Now.AddHours(-1), Artifact("drv-new")));
|
||
db.Deployments.Add(Dep(sealedOther, DeploymentStatus.Sealed, Now.AddHours(-2), Artifact("drv-sealed")));
|
||
db.NodeDeploymentStates.Add(Node("n1", stale, NodeDeploymentStatus.Applied, Now.AddDays(-9)));
|
||
db.NodeDeploymentStates.Add(Node("n1", old, NodeDeploymentStatus.Applied, Now.AddHours(-3)));
|
||
db.NodeDeploymentStates.Add(Node("n1", failed, NodeDeploymentStatus.Failed, Now.AddHours(-1)));
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
var deployed = await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync();
|
||
|
||
// n1's latest (failed apply, may be partly running), its latest Applied (what it ran before), and the
|
||
// latest Sealed deployment — but not a superseded one nobody runs.
|
||
deployed!.ShouldBe(["drv-new", "drv-old", "drv-sealed"], ignoreOrder: true);
|
||
deployed!.ShouldNotContain("drv-ancient");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Artifacts_are_parsed_once_per_deployment_and_pruned()
|
||
{
|
||
var factory = new Factory(Guid.NewGuid().ToString("N"));
|
||
var d1 = Guid.NewGuid();
|
||
await using (var db = factory.CreateDbContext())
|
||
{
|
||
db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now, Artifact("drv-a")));
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
var parses = 0;
|
||
var set = new DeployedDriverSet(factory, parseDriverIds: b => { parses++; return Parse(b); });
|
||
await set.LoadAsync();
|
||
await set.LoadAsync();
|
||
parses.ShouldBe(1);
|
||
set.CachedDeployments.ShouldBe(1);
|
||
|
||
var d2 = Guid.NewGuid();
|
||
await using (var db = factory.CreateDbContext())
|
||
{
|
||
db.Deployments.Add(Dep(d2, DeploymentStatus.Sealed, Now.AddMinutes(1), Artifact("drv-b")));
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
(await set.LoadAsync())!.ShouldBe(["drv-b"]);
|
||
set.CachedDeployments.ShouldBe(1); // d1 is no longer live, so it is pruned
|
||
parses.ShouldBe(2);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task An_unreadable_artifact_means_unknown_so_nothing_is_evicted()
|
||
{
|
||
var factory = new Factory(Guid.NewGuid().ToString("N"));
|
||
var d1 = Guid.NewGuid();
|
||
await using (var db = factory.CreateDbContext())
|
||
{
|
||
db.Deployments.Add(Dep(d1, DeploymentStatus.Sealed, Now, Array.Empty<byte>())); // retention-emptied
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
(await new DeployedDriverSet(factory, parseDriverIds: Parse).LoadAsync()).ShouldBeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void Hosts_skips_eviction_when_the_deployed_set_is_unknown()
|
||
{
|
||
var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor");
|
||
src.ShouldContain("var deployed = await Deployed.LoadAsync(_cts.Token);");
|
||
src.ShouldContain("configured.UnionWith(deployed);");
|
||
src.IndexOf("if (deployed is not null)", StringComparison.Ordinal)
|
||
.ShouldBeLessThan(src.IndexOf("DriverStore.EvictUnconfiguredStale(", StringComparison.Ordinal));
|
||
src.ShouldNotContain("≈ 12 health publishes");
|
||
}
|
||
}
|