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
@@ -359,10 +359,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
// Two modes: when dbFactory + applier are wired, do a real diff-and-apply pass against // Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the
// the latest deployment artifact. Without them, fall back to a raw sink rebuild — the // artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose
// F10b/dev path before the integration completes. // ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in
if (_dbFactory is null || _applier is null) // msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the
// Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev
// seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space
// without re-materialising, so it must NEVER be reached by a real driver node that has an
// applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below).
if (_applier is null)
{ {
try try
{ {
@@ -379,15 +384,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
try try
{ {
// An in-hand artifact (boot-from-cache) wins: the host already has the cached bytes, and // An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the
// the ConfigDb read the DeploymentId path would do is exactly what is unreachable during // cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what
// the outage this exists to survive. Otherwise prefer the artifact of the deployment the // is unreachable during the outage this exists to survive — and is simply absent on a
// host just applied — at apply time it is not yet Sealed, so LoadLatestArtifact would // driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode),
// return the PREVIOUS revision and materialise a stale composition (variables that don't // prefer the artifact of the deployment the host just applied — at apply time it is not yet
// match the SubscribeBulk refs). Fall back to latest-sealed only for legacy callers that // Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale
// carry neither. // composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed
// only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing
// to load, so yield Array.Empty<byte>() — the #485 guard below reads that as "no answer" and
// abandons the rebuild, holding last-known-good rather than wiping the address space.
var artifact = msg.Artifact var artifact = msg.Artifact
?? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact()); ?? (_dbFactory is not null
? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact())
: Array.Empty<byte>());
// Issue #485 — an artifact we could not obtain is NOT an empty configuration. Parsing zero // Issue #485 — an artifact we could not obtain is NOT an empty configuration. Parsing zero
// bytes yields an empty composition, which the planner diffs against the live one as a // bytes yields an empty composition, which the planner diffs against the live one as a
@@ -110,6 +110,97 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); 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> /// <summary>
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must /// 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 /// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
@@ -358,14 +449,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
: inner.CreateDbContext(); : inner.CreateDbContext();
} }
/// <summary>Seal a deployment carrying the given (equipmentId, name) rows under a shared line, returning /// <summary>Build the artifact bytes for the given (equipmentId, name) rows under a shared line
/// the new DeploymentId so a test can target THAT artifact. Renaming an equipment across two such /// WITHOUT sealing them into a deployment. This is what a driver-only / FetchAndCache node carries
/// deployments produces a ChangedEquipment delta (Rebuild), while adding a row is a PureAdd.</summary> /// in <c>RebuildAddressSpace.Artifact</c> (no ConfigDb read), so tests can drive the in-hand-bytes
private static Guid SeedNamedEquipmentDeployment( /// path directly.</summary>
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, private static byte[] BuildNamedEquipmentArtifact(params (string Id, string Name)[] equipment) =>
params (string Id, string Name)[] equipment) JsonSerializer.SerializeToUtf8Bytes(new
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{ {
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
@@ -383,6 +472,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
ScriptedAlarms = Array.Empty<object>(), 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(); var id = Guid.NewGuid();
using var ctx = dbFactory.CreateDbContext(); using var ctx = dbFactory.CreateDbContext();
ctx.Deployments.Add(new Deployment ctx.Deployments.Add(new Deployment