fix(deploy): rebuild address space on changed-only deploys (H1a, stillpending §1)

This commit is contained in:
Joseph Doherty
2026-06-15 09:57:40 -04:00
parent 50a2fdf32d
commit 1e95856b00
2 changed files with 159 additions and 8 deletions
@@ -77,15 +77,19 @@ public sealed class Phase7Applier
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
// Any add/remove of Equipment, ScriptedAlarm, Equipment tag, or Equipment VirtualTag topology
// requires a real address-space rebuild. Driver-instance changes don't touch the address-space
// topology directly — they go through DriverHostActor's spawn-plan in Runtime.
// TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
// re-severitied alarm, a flipped tag dataType/Writable, or an edited VirtualTag expression) must
// still rebuild or the running server keeps the stale node.
// ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the
// address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which
// re-spawns the affected driver actor without re-materialising any nodes.
var needsRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.ChangedEquipmentTags.Count > 0 ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Count > 0;
if (needsRebuild)
{
@@ -498,6 +498,153 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a — a deploy that ONLY changes an existing equipment tag (e.g. flips its dataType or
/// Writable bit) must rebuild the address space. The planner diffs the tag into
/// <c>ChangedEquipmentTags</c> with no Added/Removed of anything else; the applier must still drive
/// exactly one rebuild so the running server drops the stale node and re-materialises it.</summary>
[Fact]
public void Changed_equipment_tags_only_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
// Same tag id, but DataType + Writable flipped — the planner classifies this as a change.
var next = CompositionWithTags(
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null));
var plan = Phase7Planner.Compute(previous, next);
// Guard the arrange: ONLY ChangedEquipmentTags is populated.
plan.ChangedEquipmentTags.Count.ShouldBe(1);
plan.AddedEquipmentTags.ShouldBeEmpty();
plan.RemovedEquipmentTags.ShouldBeEmpty();
plan.AddedEquipment.ShouldBeEmpty();
plan.RemovedEquipment.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.ChangedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a — a deploy that ONLY edits an existing VirtualTag's expression must rebuild the
/// address space. The planner diffs it into <c>ChangedEquipmentVirtualTags</c> alone; the applier
/// must drive exactly one rebuild.</summary>
[Fact]
public void Changed_equipment_virtual_tags_only_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));
// Same VirtualTag id, edited expression — the planner classifies this as a change.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));
var plan = Phase7Planner.Compute(previous, next);
// Guard the arrange: ONLY ChangedEquipmentVirtualTags is populated.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.ChangedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a — a deploy that ONLY edits an existing scripted alarm (here its message template)
/// must rebuild the address space. The planner diffs the thin <c>ScriptedAlarmPlan</c> projection
/// into <c>ChangedAlarms</c> alone; the applier must drive exactly one rebuild so the condition node
/// reflects the edit.</summary>
[Fact]
public void Changed_alarms_only_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp high"));
// Same alarm id, edited message template — the planner classifies this as a change.
var next = CompositionWithAlarms(new ScriptedAlarmPlan("alm-1", "eq-1", "scr-1", "Temp critically high"));
var plan = Phase7Planner.Compute(previous, next);
// Guard the arrange: ONLY ChangedAlarms is populated.
plan.ChangedAlarms.Count.ShouldBe(1);
plan.AddedAlarms.ShouldBeEmpty();
plan.RemovedAlarms.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.ChangedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a guard — a deploy that ONLY changes a driver instance's config must NOT rebuild the
/// address space. Driver-instance changes route through DriverHostActor's spawn-plan in Runtime, not
/// the address-space topology, so <c>ChangedDrivers</c> is intentionally excluded from
/// <c>needsRebuild</c>. This pins the exclusion against accidental inclusion. (The pre-existing
/// <see cref="Driver_only_changes_do_not_trigger_address_space_rebuild"/> uses a hand-built plan;
/// this one drives the planner end-to-end.)</summary>
[Fact]
public void Changed_drivers_only_do_not_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"));
var next = CompositionWithDrivers(new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}"));
var plan = Phase7Planner.Compute(previous, next);
// Guard the arrange: ONLY ChangedDrivers is populated.
plan.ChangedDrivers.Count.ShouldBe(1);
plan.AddedDrivers.ShouldBeEmpty();
plan.RemovedDrivers.ShouldBeEmpty();
plan.ChangedEquipment.ShouldBeEmpty();
plan.ChangedAlarms.ShouldBeEmpty();
plan.ChangedEquipmentTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
outcome.ChangedNodes.ShouldBe(1); // driver change is still tallied, just not rebuild-forcing
sink.RebuildCalls.ShouldBe(0);
}
private static Phase7CompositionResult CompositionWithTags(params EquipmentTagPlan[] tags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = tags,
};
private static Phase7CompositionResult CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = vtags,
};
private static Phase7CompositionResult CompositionWithAlarms(params ScriptedAlarmPlan[] alarms) =>
// ScriptedAlarmPlans is the set the planner diffs into Added/Removed/ChangedAlarms.
new(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), alarms);
private static Phase7CompositionResult CompositionWithDrivers(params DriverInstancePlan[] drivers) =>
new(
Array.Empty<EquipmentNode>(), drivers, Array.Empty<ScriptedAlarmPlan>());
private static Phase7Plan EmptyPlan => new(
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),