35576485b1
v2-ci / unit-tests (push) Has been cancelled
Root cause: DeploymentDriftProbe (a singleton shared by every circuit) cached its verdict until the ConfigEdit audit watermark (MAX(EditedAtUtc), COUNT(*)) moved, assuming every configuration write appends a ConfigEdit row. Only deployments and driver restart/reconnect do; the AdminUI editors (RawTreeService, UnsTreeService, ScriptEdit, cluster/node/ACL pages) write the config tables directly. So after a draft change the page kept showing the cached "In sync" until something unrelated moved the watermark. - DeploymentDriftProbe: the watermark cache is gone; every verdict comes from a composition that started after the caller asked. Compositions stay serialised behind the gate, and callers queued behind one share a verdict only when that composition started after they arrived (start sequence number) for the same sealed hash, so sharing is exact, never stale. - Deployments page: the drift check runs in OnAfterRenderAsync (interactive render only, so a visit costs one composition, not two with prerender); the first render shows a neutral "Checking for changes..." chip, never a verdict; a failed check shows "Drift check failed" and logs a warning instead of crashing the page. A deploy re-runs the check. - Tests: probe tests for an un-audited draft change (real ConfigComposer over InMemory), fresh composition per call, and queued callers not reusing an older composition; bUnit page tests that a second page load after a direct draft change shows "Configuration drift" and that the first render shows "Checking" until the comparison answers. All fail against the previous code. DeploymentDriftProbeSqlServerTests is removed: it pinned the SQL translation of the watermark query, which no longer exists. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
127 lines
5.2 KiB
C#
127 lines
5.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
|
|
|
|
/// <summary>
|
|
/// Pins the <see cref="DeploymentDriftProbe"/> freshness contract (lmxopcua#903): every verdict comes from a
|
|
/// composition that started after the caller asked, so a draft change made by a path that writes no
|
|
/// <c>ConfigEdit</c> audit row (every AdminUI editor) is never hidden behind an earlier "In sync"; concurrent
|
|
/// callers still share one composition when that composition is fresh for all of them.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DeploymentDriftProbeTests
|
|
{
|
|
private const string SealedHash = "sealed-hash-aaaa";
|
|
|
|
[Fact]
|
|
public async Task A_draft_change_without_an_audit_row_is_reported_as_drift()
|
|
{
|
|
// The real composer over a real (InMemory) configuration: seal the current state, then edit the draft the
|
|
// way RawTreeService/UnsTreeService/ClusterEdit do — straight into the table, no ConfigEdit row.
|
|
var name = $"drift-{Guid.NewGuid():N}";
|
|
var factory = UnsTreeTestDb.Factory(name);
|
|
await using (var db = UnsTreeTestDb.CreateNamed(name))
|
|
{
|
|
UnsTreeTestDb.Seed(db);
|
|
}
|
|
|
|
var sealedHash = await CurrentHashAsync(name);
|
|
var probe = new DeploymentDriftProbe(factory);
|
|
|
|
(await probe.GetDriftAsync(sealedHash, TestContext.Current.CancellationToken)).ShouldBe(false);
|
|
|
|
await using (var db = UnsTreeTestDb.CreateNamed(name))
|
|
{
|
|
db.ServerClusters.Add(new ServerCluster
|
|
{
|
|
ClusterId = "NEW-1", Name = "New", Enterprise = "zb", Site = "s9",
|
|
RedundancyMode = RedundancyMode.None, CreatedBy = "test",
|
|
});
|
|
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
|
db.ConfigEdits.Any().ShouldBeFalse(); // the precondition the old watermark cache missed
|
|
}
|
|
|
|
(await probe.GetDriftAsync(sealedHash, TestContext.Current.CancellationToken)).ShouldBe(true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sequential_calls_each_compose()
|
|
{
|
|
var compose = new GatedCompose(SealedHash, gated: false);
|
|
var probe = new DeploymentDriftProbe(UnsTreeTestDb.Factory($"drift-{Guid.NewGuid():N}"), compose.ComposeAsync);
|
|
|
|
(await probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken)).ShouldBe(false);
|
|
compose.Result = "a-different-hash";
|
|
(await probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken)).ShouldBe(true);
|
|
|
|
compose.Count.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Callers_queued_behind_an_older_composition_do_not_reuse_it_but_share_a_fresh_one()
|
|
{
|
|
var compose = new GatedCompose(SealedHash, gated: true);
|
|
var probe = new DeploymentDriftProbe(UnsTreeTestDb.Factory($"drift-{Guid.NewGuid():N}"), compose.ComposeAsync);
|
|
|
|
// A starts composing and blocks inside the composer.
|
|
var a = probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken);
|
|
await compose.WaitForStartAsync(1);
|
|
|
|
// B and C ask while A's composition (which may predate their edits) is in flight.
|
|
compose.Result = "a-different-hash";
|
|
var b = probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken);
|
|
var c = probe.GetDriftAsync(SealedHash, TestContext.Current.CancellationToken);
|
|
|
|
compose.Release();
|
|
(await a).ShouldBe(false);
|
|
(await b).ShouldBe(true); // not A's stale answer
|
|
(await c).ShouldBe(true);
|
|
|
|
compose.Count.ShouldBe(2); // A's, then ONE fresh composition shared by B and C
|
|
}
|
|
|
|
private static async Task<string> CurrentHashAsync(string name)
|
|
{
|
|
await using var db = UnsTreeTestDb.CreateNamed(name);
|
|
return (await ConfigComposer.SnapshotAndFlattenAsync(db)).RevisionHash;
|
|
}
|
|
|
|
/// <summary>Stand-in for the <c>ConfigComposer</c> seam that counts calls and can hold them open.</summary>
|
|
private sealed class GatedCompose(string result, bool gated)
|
|
{
|
|
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
private int _count;
|
|
|
|
public string Result { get; set; } = result;
|
|
|
|
public int Count => Volatile.Read(ref _count);
|
|
|
|
public void Release() => _release.TrySetResult();
|
|
|
|
public async Task WaitForStartAsync(int n)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
|
while (Count < n)
|
|
{
|
|
if (DateTime.UtcNow > deadline) throw new TimeoutException("composition never started");
|
|
await Task.Delay(5);
|
|
}
|
|
}
|
|
|
|
public async Task<string> ComposeAsync(OtOpcUaConfigDbContext db)
|
|
{
|
|
var snapshot = Result; // what the configuration looked like when this composition read it
|
|
Interlocked.Increment(ref _count);
|
|
if (gated) await _release.Task;
|
|
return snapshot;
|
|
}
|
|
}
|
|
}
|