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.AddedEquipmentTags.Count +
|
||||||
plan.AddedEquipmentVirtualTags.Count;
|
plan.AddedEquipmentVirtualTags.Count;
|
||||||
|
|
||||||
// Any add/remove of Equipment, ScriptedAlarm, Equipment tag, or Equipment VirtualTag topology
|
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
|
||||||
// requires a real address-space rebuild. Driver-instance changes don't touch the address-space
|
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
|
||||||
// topology directly — they go through DriverHostActor's spawn-plan in Runtime.
|
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
|
||||||
// TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
|
// 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 =
|
var needsRebuild =
|
||||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
|
||||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
|
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
|
||||||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
|
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.ChangedEquipmentTags.Count > 0 ||
|
||||||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;
|
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 || plan.ChangedEquipmentVirtualTags.Count > 0;
|
||||||
|
|
||||||
if (needsRebuild)
|
if (needsRebuild)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -498,6 +498,153 @@ public sealed class Phase7ApplierTests
|
|||||||
sink.RebuildCalls.ShouldBe(1);
|
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(
|
private static Phase7Plan EmptyPlan => new(
|
||||||
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
|
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
|
||||||
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
|
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
|
||||||
|
|||||||
Reference in New Issue
Block a user