fix(template-engine): native alarm sources participate in the revision hash — staleness detection restored (deliberate one-time stale flip for affected instances)
TemplateEngine-011: ResolvedNativeAlarmSource bindings were absent from the flattened-config revision hash, so edits to a native alarm source binding (source reference, connection, condition filter, locked state) went undetected by staleness detection. Add a HashableNativeAlarmSource record and fold the sorted, null-when-empty NativeAlarmSources collection into HashableConfiguration in its alphabetical slot. WhenWritingNull keeps every native-source-free config byte-identical to its pre-migration hash (pinned literal test proves it), so the migration is surgical: only native-source-bearing instances flip stale once after upgrade; redeploy clears it — deliberate. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -214,6 +214,8 @@ Each flattened configuration output includes a **revision hash** (computed from
|
|||||||
|
|
||||||
The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected.
|
The override flows into the flattened attribute's `DataSourceReference` and therefore participates in the revision hash — changes to an instance's binding overrides re-deploy as expected.
|
||||||
|
|
||||||
|
The revision hash covers the resolved attributes, alarms, scripts, connections, **and native alarm source bindings** (`ResolvedNativeAlarmSource`: canonical name, connection name, source reference, condition filter, and locked state). Native alarm sources were folded into the hash in migration TemplateEngine-011; the hashable projection is populated null-when-empty and the canonical serializer omits null properties, so every native-source-free configuration keeps its byte-identical pre-migration hash. **Migration note:** native-source-bearing instances will report stale once after upgrade; redeploy clears it — deliberate.
|
||||||
|
|
||||||
### On-Demand Validation
|
### On-Demand Validation
|
||||||
|
|
||||||
The same validation logic is available to Design users in the Central UI without triggering a deployment. This allows template authors to check their work for errors during authoring.
|
The same validation logic is available to Design users in the Central UI without triggering a deployment. This allows template authors to check their work for errors during authoring.
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
|||||||
/// by <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
|
/// by <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
|
||||||
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
|
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// MIGRATION (TemplateEngine-011): native alarm source bindings
|
||||||
|
/// (<see cref="ResolvedNativeAlarmSource"/>) now participate in the hash so
|
||||||
|
/// that binding edits flag the instance stale. The hashable property is
|
||||||
|
/// populated NULL-WHEN-EMPTY and the serializer omits null properties
|
||||||
|
/// (<see cref="JsonIgnoreCondition.WhenWritingNull"/>), so every
|
||||||
|
/// native-source-free configuration keeps its byte-identical pre-migration
|
||||||
|
/// hash — no global staleness storm. Native-source-bearing instances will
|
||||||
|
/// report stale once after upgrade; redeploy clears it — deliberate.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RevisionHashService
|
public class RevisionHashService
|
||||||
{
|
{
|
||||||
@@ -90,6 +100,19 @@ public class RevisionHashService
|
|||||||
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds
|
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
NativeAlarmSources = configuration.NativeAlarmSources is { Count: > 0 }
|
||||||
|
? configuration.NativeAlarmSources
|
||||||
|
.OrderBy(n => n.CanonicalName, StringComparer.Ordinal)
|
||||||
|
.Select(n => new HashableNativeAlarmSource
|
||||||
|
{
|
||||||
|
CanonicalName = n.CanonicalName,
|
||||||
|
ConditionFilter = n.ConditionFilter,
|
||||||
|
ConnectionName = n.ConnectionName,
|
||||||
|
IsLocked = n.IsLocked,
|
||||||
|
SourceReference = n.SourceReference
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
: null,
|
||||||
Connections = configuration.Connections is { Count: > 0 }
|
Connections = configuration.Connections is { Count: > 0 }
|
||||||
? new SortedDictionary<string, HashableConnection>(
|
? new SortedDictionary<string, HashableConnection>(
|
||||||
configuration.Connections.ToDictionary(
|
configuration.Connections.ToDictionary(
|
||||||
@@ -138,6 +161,13 @@ public class RevisionHashService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Collection of native alarm source bindings in the configuration.
|
||||||
|
/// Null when the configuration carries no native alarm sources so that
|
||||||
|
/// native-source-free configs hash identically to before this field was
|
||||||
|
/// added (see the MIGRATION note on the class summary).
|
||||||
|
/// </summary>
|
||||||
|
public List<HashableNativeAlarmSource>? NativeAlarmSources { get; init; }
|
||||||
|
/// <summary>
|
||||||
/// Collection of scripts in the configuration.
|
/// Collection of scripts in the configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<HashableScript> Scripts { get; init; } = [];
|
public List<HashableScript> Scripts { get; init; } = [];
|
||||||
@@ -247,6 +277,30 @@ public class RevisionHashService
|
|||||||
public string Protocol { get; init; } = string.Empty;
|
public string Protocol { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record HashableNativeAlarmSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The path-qualified canonical name of the native alarm source binding.
|
||||||
|
/// </summary>
|
||||||
|
public string CanonicalName { get; init; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Optional condition filter; null = mirror all conditions under the source.
|
||||||
|
/// </summary>
|
||||||
|
public string? ConditionFilter { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// The data connection name that owns the alarm feed.
|
||||||
|
/// </summary>
|
||||||
|
public string ConnectionName { get; init; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the native alarm source binding is locked.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsLocked { get; init; }
|
||||||
|
/// <summary>
|
||||||
|
/// The source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area).
|
||||||
|
/// </summary>
|
||||||
|
public string SourceReference { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record HashableScript
|
private sealed record HashableScript
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+97
@@ -367,6 +367,103 @@ public class RevisionHashServiceTests
|
|||||||
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeHash_NativeAlarmSourceChange_ChangesHash()
|
||||||
|
{
|
||||||
|
// TemplateEngine-011: native alarm source bindings are part of the
|
||||||
|
// deployment package, so an edit to a source's binding (here the
|
||||||
|
// SourceReference) MUST change the revision hash and flag the instance
|
||||||
|
// stale. Before this migration native sources were absent from the hash
|
||||||
|
// and this edit went undetected.
|
||||||
|
var baseSource = new ResolvedNativeAlarmSource
|
||||||
|
{
|
||||||
|
CanonicalName = "Boiler.Alarms",
|
||||||
|
ConnectionName = "plc1",
|
||||||
|
SourceReference = "ns=2;s=Boiler",
|
||||||
|
ConditionFilter = null,
|
||||||
|
IsLocked = false
|
||||||
|
};
|
||||||
|
var editedSource = baseSource with { SourceReference = "ns=2;s=Boiler2" };
|
||||||
|
|
||||||
|
var configBefore = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Instance1",
|
||||||
|
TemplateId = 1,
|
||||||
|
SiteId = 1,
|
||||||
|
NativeAlarmSources = [baseSource]
|
||||||
|
};
|
||||||
|
var configAfter = configBefore with { NativeAlarmSources = [editedSource] };
|
||||||
|
|
||||||
|
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeHash_NativeAlarmSourceIsLockedChange_ChangesHash()
|
||||||
|
{
|
||||||
|
// TemplateEngine-011: IsLocked participates in the hash too.
|
||||||
|
var baseSource = new ResolvedNativeAlarmSource
|
||||||
|
{
|
||||||
|
CanonicalName = "Boiler.Alarms",
|
||||||
|
ConnectionName = "plc1",
|
||||||
|
SourceReference = "ns=2;s=Boiler",
|
||||||
|
IsLocked = false
|
||||||
|
};
|
||||||
|
var editedSource = baseSource with { IsLocked = true };
|
||||||
|
|
||||||
|
var configBefore = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Instance1",
|
||||||
|
TemplateId = 1,
|
||||||
|
SiteId = 1,
|
||||||
|
NativeAlarmSources = [baseSource]
|
||||||
|
};
|
||||||
|
var configAfter = configBefore with { NativeAlarmSources = [editedSource] };
|
||||||
|
|
||||||
|
Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeHash_NativeAlarmSourceOrder_DoesNotAffectHash()
|
||||||
|
{
|
||||||
|
// TemplateEngine-011: sources are sorted by CanonicalName before hashing,
|
||||||
|
// mirroring attributes/alarms/scripts — authoring order is irrelevant.
|
||||||
|
var a = new ResolvedNativeAlarmSource
|
||||||
|
{
|
||||||
|
CanonicalName = "A", ConnectionName = "plc1", SourceReference = "ns=2;s=A"
|
||||||
|
};
|
||||||
|
var b = new ResolvedNativeAlarmSource
|
||||||
|
{
|
||||||
|
CanonicalName = "B", ConnectionName = "plc1", SourceReference = "ns=2;s=B"
|
||||||
|
};
|
||||||
|
|
||||||
|
var config1 = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Instance1",
|
||||||
|
TemplateId = 1,
|
||||||
|
SiteId = 1,
|
||||||
|
NativeAlarmSources = [a, b]
|
||||||
|
};
|
||||||
|
var config2 = config1 with { NativeAlarmSources = [b, a] };
|
||||||
|
|
||||||
|
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline()
|
||||||
|
{
|
||||||
|
// TemplateEngine-011 migration surgicality: adding the (null-when-empty)
|
||||||
|
// NativeAlarmSources property to the hashable projection must NOT change
|
||||||
|
// the hash of any native-source-free configuration. The literal below is
|
||||||
|
// the pre-migration hash of CreateConfig("Instance1", "25.0"); pinning it
|
||||||
|
// proves that only instances that HAVE native sources flip stale — there
|
||||||
|
// is no global staleness storm.
|
||||||
|
var config = CreateConfig("Instance1", "25.0");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"sha256:e937bcfa34a146ca6bdb9bda27bdceb204e440a4a6110ca646f7750f37b166cb",
|
||||||
|
_sut.ComputeHash(config));
|
||||||
|
}
|
||||||
|
|
||||||
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
|
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
|
||||||
{
|
{
|
||||||
return new FlattenedConfiguration
|
return new FlattenedConfiguration
|
||||||
|
|||||||
Reference in New Issue
Block a user