fix(mesh-phase4): OpcUaPublish diff-applies from in-hand bytes with no ConfigDb (no wipe)

A driver-only node has a null dbFactory but a wired applier; the old combined
guard routed it to the raw-sink fallback that wipes the address space and never
re-materialises. Split the guard so in-hand artifact bytes drive the real
diff-and-apply; a missing artifact abandons the rebuild (keep last-known-good,
#485) instead of wiping.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-23 12:53:28 -04:00
parent e791832d7c
commit 73ae8ec5c5
2 changed files with 128 additions and 20 deletions
@@ -110,6 +110,97 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>
/// mesh-phase4 — the wipe-prevention test. A driver-only node has a NULL dbFactory (ConfigDb is
/// admin-only now) but a WIRED applier. A RebuildAddressSpace carrying the artifact bytes in hand
/// (from the fetch/cache) must drive the REAL diff-and-apply (the equipment folder is materialised)
/// and must NOT take the raw-sink <c>RebuildAddressSpace()</c> fallback, which wipes the address
/// space without re-materialising. Before the guard split, a null dbFactory routed straight to that
/// fallback and the applier never ran — a FetchAndCache node deployed green but served EMPTY.
/// </summary>
[Fact]
public void Rebuild_with_null_factory_but_wired_applier_diff_applies_from_in_hand_bytes()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var artifact = BuildNamedEquipmentArtifact(("eq-1", "Pump-1"));
// Driver-only node: null dbFactory, wired applier.
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: null, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), Artifact: artifact));
// The in-hand bytes drove the real diff-and-apply — the equipment folder was materialised…
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2));
// …and the address-space-wiping raw-sink fallback was NOT taken.
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>
/// mesh-phase4 / #485 negative — a driver-only node (null dbFactory, wired applier) that gets a
/// RebuildAddressSpace with NO artifact bytes has no way to obtain a config (there is no ConfigDb to
/// read). The artifact source yields <c>Array.Empty&lt;byte&gt;()</c>, which the #485 zero-length
/// guard treats as "no answer": the rebuild is abandoned WITHOUT touching the served address space —
/// the applier never runs and the raw-sink wipe fallback is never taken. Last-known-good stands.
/// </summary>
[Fact]
public void Rebuild_with_null_factory_and_no_artifact_abandons_without_wiping()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: null, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
Thread.Sleep(200);
sink.RebuildCalls.ShouldBe(0); // the raw-sink wipe fallback was NOT taken
sink.Calls.ShouldBeEmpty(); // the applier's diff-and-apply never ran
}
/// <summary>
/// mesh-phase4 regression (dev seam) — a node with NO applier at all still falls back to the raw-sink
/// rebuild, and now the fallback is keyed on the applier ALONE: even WITH a dbFactory wired, a null
/// applier takes the F10b/dev fallback. Proves the guard split did not change the no-applier seam.
/// </summary>
[Fact]
public void Rebuild_with_null_applier_falls_back_to_raw_sink_even_with_dbFactory()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: db, applier: null));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>
/// mesh-phase4 regression (Direct mode) — a DB-backed node (dbFactory wired, applier wired) with no
/// in-hand bytes but a DeploymentId still loads the artifact from the ConfigDb via <c>LoadArtifact</c>
/// and drives the applier. The guard split must leave the Direct path byte-identical.
/// </summary>
[Fact]
public void Rebuild_direct_mode_with_dbFactory_and_deploymentId_still_loads_and_applies()
{
var db = NewInMemoryDbFactory();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var dep = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink, dbFactory: db, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep)));
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: TimeSpan.FromSeconds(2));
sink.RebuildCalls.ShouldBe(0);
}
/// <summary>
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
@@ -358,14 +449,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
: inner.CreateDbContext();
}
/// <summary>Seal a deployment carrying the given (equipmentId, name) rows under a shared line, returning
/// the new DeploymentId so a test can target THAT artifact. Renaming an equipment across two such
/// deployments produces a ChangedEquipment delta (Rebuild), while adding a row is a PureAdd.</summary>
private static Guid SeedNamedEquipmentDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
params (string Id, string Name)[] equipment)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
/// <summary>Build the artifact bytes for the given (equipmentId, name) rows under a shared line
/// WITHOUT sealing them into a deployment. This is what a driver-only / FetchAndCache node carries
/// in <c>RebuildAddressSpace.Artifact</c> (no ConfigDb read), so tests can drive the in-hand-bytes
/// path directly.</summary>
private static byte[] BuildNamedEquipmentArtifact(params (string Id, string Name)[] equipment) =>
JsonSerializer.SerializeToUtf8Bytes(new
{
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
@@ -383,6 +472,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
ScriptedAlarms = Array.Empty<object>(),
});
/// <summary>Seal a deployment carrying the given (equipmentId, name) rows under a shared line, returning
/// the new DeploymentId so a test can target THAT artifact. Renaming an equipment across two such
/// deployments produces a ChangedEquipment delta (Rebuild), while adding a row is a PureAdd.</summary>
private static Guid SeedNamedEquipmentDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
params (string Id, string Name)[] equipment)
{
var artifact = BuildNamedEquipmentArtifact(equipment);
var id = Guid.NewGuid();
using var ctx = dbFactory.CreateDbContext();
ctx.Deployments.Add(new Deployment