fix(deploy): rebuild address space on changed-only deploys (H1a, stillpending §1)
This commit is contained in:
@@ -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>(),
|
||||
|
||||
Reference in New Issue
Block a user