fix(opcua): replay kept scripted conditions after a rebuild; fit window values onto re-shaped nodes (Lane B fix round 3)
N3 (critical): the node manager carries only NATIVE conditions' state over a full rebuild; scripted conditions come back fresh and rely on the engine's re-assert, which since the off-thread plan is handled in the window and lands on the discarded instance. On a rebuild the condition replay scope is now AddedConditions plus every enabled scripted condition in the new composition (native kept conditions stay carried-over, not replayed). The test sink again models the real rebuild: it clears non-native condition state only. N4: a surgical in-place shape edit (DataType / IsArray / ArrayLength) resets the raw node and its UNS mirror to BadWaitingForInitialData without ensuring them, so the window's value was not replayed and the two NodeIds disagreed. The applier records re-shaped nodes (MaterialisationRecording.Reshaped); their deferred value replays only once fitted to the new shape (VariableShape.TryFit: as is, or converted invariantly; never an old-typed value - WriteValues assigns the value verbatim with no type check), otherwise it is dropped at Debug and the node keeps waiting. Status-only entries are not replayed onto a re-shaped node. Minors: m1/m2 documented on _replayScope (degraded-path only); m3 corrected the false carry-over premise in the scope doc (the ScriptedAlarmHostActor ordering remark is outside this lane - F8); m4 the replay Debug log reports alarm-state and alarm-quality counts again. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -219,6 +219,9 @@ public sealed class AddressSpaceApplier
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId,
|
||||
d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength,
|
||||
d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength);
|
||||
}
|
||||
// v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's
|
||||
// IDENTITY (2026-08-15 review I2).
|
||||
@@ -270,6 +273,9 @@ public sealed class AddressSpaceApplier
|
||||
updated = false;
|
||||
}
|
||||
if (!updated) { allApplied = false; break; }
|
||||
RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId,
|
||||
d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength,
|
||||
d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1156,30 +1162,49 @@ public sealed class AddressSpaceApplier
|
||||
try
|
||||
{
|
||||
_sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
|
||||
_ensuredVariables?.Add((realm, nodeId));
|
||||
_recording?.Ensured.Add((realm, nodeId));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>While non-null, every variable this applier ensures (realm, NodeId). See <see cref="BeginRecordingEnsuredVariables"/>.</summary>
|
||||
private HashSet<(AddressSpaceRealm, string)>? _ensuredVariables;
|
||||
|
||||
/// <summary>
|
||||
/// Starts recording every variable the following materialise passes ensure, until
|
||||
/// <see cref="EndRecordingEnsuredVariables"/>. The publish actor scopes the replay of writes it deferred while a
|
||||
/// plan was in flight to these (a surgical apply ensures only its delta), so a variable the apply kept is not
|
||||
/// rewritten. Single-caller (the publish actor's thread), like the rest of the apply.
|
||||
/// </summary>
|
||||
/// <returns>The live set the recording fills.</returns>
|
||||
public IReadOnlySet<(AddressSpaceRealm, string)> BeginRecordingEnsuredVariables()
|
||||
/// <summary>Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness /
|
||||
/// array length): the node manager drops such a node's value to BadWaitingForInitialData.</summary>
|
||||
private void RecordReshape(
|
||||
AddressSpaceRealm realm, string nodeId,
|
||||
string previousDataType, bool previousIsArray, uint? previousArrayLength,
|
||||
string dataType, bool isArray, uint? arrayLength)
|
||||
{
|
||||
_ensuredVariables = new HashSet<(AddressSpaceRealm, string)>();
|
||||
return _ensuredVariables;
|
||||
if (_recording is null) return;
|
||||
if (string.Equals(previousDataType, dataType, StringComparison.Ordinal)
|
||||
&& previousIsArray == isArray
|
||||
&& (!isArray || previousArrayLength == arrayLength))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength);
|
||||
}
|
||||
|
||||
/// <summary>Stops the recording <see cref="BeginRecordingEnsuredVariables"/> started (the returned set is kept).</summary>
|
||||
public void EndRecordingEnsuredVariables() => _ensuredVariables = null;
|
||||
/// <summary>The recording in progress, or null. See <see cref="BeginRecording"/>.</summary>
|
||||
private MaterialisationRecording? _recording;
|
||||
|
||||
/// <summary>
|
||||
/// Starts recording what the following apply and materialise passes do to VARIABLES, until
|
||||
/// <see cref="EndRecording"/>: every variable ensured, and every variable an in-place attribute update
|
||||
/// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan
|
||||
/// was in flight to these — a variable the apply kept took the live write already, and rewriting it would
|
||||
/// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply.
|
||||
/// </summary>
|
||||
/// <returns>The live recording.</returns>
|
||||
public MaterialisationRecording BeginRecording()
|
||||
{
|
||||
_recording = new MaterialisationRecording();
|
||||
return _recording;
|
||||
}
|
||||
|
||||
/// <summary>Stops the recording <see cref="BeginRecording"/> started (the returned recording is kept).</summary>
|
||||
public void EndRecording() => _recording = null;
|
||||
|
||||
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the
|
||||
// whole delta classification is one pure, table-testable unit; the applier references them there (and the
|
||||
@@ -1293,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome(
|
||||
bool RebuildCalled,
|
||||
bool RebuildFailed = false,
|
||||
int FailedNodes = 0);
|
||||
|
||||
/// <summary>What one apply did to variables (see <see cref="AddressSpaceApplier.BeginRecording"/>).</summary>
|
||||
public sealed class MaterialisationRecording
|
||||
{
|
||||
/// <summary>Every variable an <c>EnsureVariable</c> was issued for, as (realm, NodeId).</summary>
|
||||
public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new();
|
||||
|
||||
/// <summary>Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape.</summary>
|
||||
public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length.</summary>
|
||||
/// <param name="DataType">The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits).</param>
|
||||
/// <param name="IsArray">Whether the node is a one-dimensional array.</param>
|
||||
/// <param name="ArrayLength">The fixed array length, when known (null / 0 ⇒ any length).</param>
|
||||
public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength)
|
||||
{
|
||||
/// <summary>
|
||||
/// Fits <paramref name="value"/> to this shape for a write onto a re-shaped node: unchanged when it already
|
||||
/// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an
|
||||
/// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that).
|
||||
/// A null value always fits (a status annotation carries none).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to fit.</param>
|
||||
/// <param name="fitted">The fitted value, when this returns true.</param>
|
||||
/// <returns>True when the value fits (as is, or converted).</returns>
|
||||
public bool TryFit(object? value, out object? fitted)
|
||||
{
|
||||
fitted = null;
|
||||
if (value is null) return true;
|
||||
var element = ElementType(DataType);
|
||||
if (!IsArray)
|
||||
{
|
||||
if (value is Array) return false;
|
||||
return TryConvert(value, element, out fitted);
|
||||
}
|
||||
|
||||
if (value is not Array array || value is string) return false;
|
||||
if (ArrayLength is > 0 && array.Length != ArrayLength) return false;
|
||||
if (element is null) { fitted = value; return true; }
|
||||
var result = Array.CreateInstance(element, array.Length);
|
||||
for (var i = 0; i < array.Length; i++)
|
||||
{
|
||||
if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false;
|
||||
result.SetValue(item, i);
|
||||
}
|
||||
|
||||
fitted = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryConvert(object? value, Type? target, out object? converted)
|
||||
{
|
||||
converted = value;
|
||||
if (value is null || target is null || target.IsInstanceOfType(value)) return true;
|
||||
if (value is not IConvertible) return false;
|
||||
try
|
||||
{
|
||||
converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
converted = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set);
|
||||
/// null ⇒ BaseDataType, where any value fits.</summary>
|
||||
private static Type? ElementType(string dataType) => dataType switch
|
||||
{
|
||||
"Boolean" => typeof(bool),
|
||||
"SByte" => typeof(sbyte),
|
||||
"Byte" => typeof(byte),
|
||||
"Int16" => typeof(short),
|
||||
"UInt16" => typeof(ushort),
|
||||
"Int32" => typeof(int),
|
||||
"UInt32" => typeof(uint),
|
||||
"Int64" => typeof(long),
|
||||
"UInt64" => typeof(ulong),
|
||||
"Float" => typeof(float),
|
||||
"Double" => typeof(double),
|
||||
"String" => typeof(string),
|
||||
"DateTime" => typeof(DateTime),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,8 +187,21 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// <list type="bullet">
|
||||
/// <item>Variables: after a FULL rebuild every one (each was recreated, its live write landed on the
|
||||
/// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them).</item>
|
||||
/// <item>Conditions: those in the applied composition but not the previous one — on a rebuild too, because the
|
||||
/// node manager carries a kept condition's state over to its re-materialised instance.</item>
|
||||
/// <item>Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to
|
||||
/// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape
|
||||
/// (<see cref="VariableShape.TryFit"/>); a value that does not fit is dropped and the node left waiting.
|
||||
/// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment.</item>
|
||||
/// <item>Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO
|
||||
/// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over
|
||||
/// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition
|
||||
/// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not
|
||||
/// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is
|
||||
/// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it.</item>
|
||||
/// <item>Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance
|
||||
/// <c>_lastApplied</c>, so the next apply counts its conditions as added again and may replay onto them;
|
||||
/// and the empty-plan repair of a degraded composition scopes no condition (the composition is
|
||||
/// unchanged), so a condition the repair newly places gets no window state — writes to it between the
|
||||
/// two applies were already dropped before this replay existed.</item>
|
||||
/// </list>
|
||||
/// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through
|
||||
/// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.)
|
||||
@@ -198,10 +211,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
private sealed record ReplayScope(
|
||||
bool AllVariables,
|
||||
IReadOnlySet<(AddressSpaceRealm, string)> Variables,
|
||||
IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped,
|
||||
IReadOnlySet<(AddressSpaceRealm, string)> Conditions)
|
||||
{
|
||||
private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>();
|
||||
public static readonly ReplayScope Nothing = new(false, None, None);
|
||||
public static readonly ReplayScope Nothing =
|
||||
new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None);
|
||||
}
|
||||
|
||||
/// <summary>True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in
|
||||
@@ -851,8 +866,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
private void ReplayDeferredWrites(ReplayScope scope)
|
||||
{
|
||||
if (_deferred.IsEmpty) return;
|
||||
var (values, statuses) = _deferred.VariableWrites(
|
||||
key => scope.AllVariables || scope.Variables.Contains(key));
|
||||
var (allValues, allStatuses) = _deferred.VariableWrites(
|
||||
key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key));
|
||||
var values = new List<ValueWrite>(allValues.Count);
|
||||
var statuses = new List<StatusWrite>(allStatuses.Count);
|
||||
var unfit = 0;
|
||||
foreach (var v in allValues)
|
||||
{
|
||||
if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; }
|
||||
if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; }
|
||||
unfit++;
|
||||
_log.Debug(
|
||||
"OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value",
|
||||
v.Realm, v.NodeId, shape.DataType, shape.IsArray);
|
||||
}
|
||||
|
||||
foreach (var st in allStatuses)
|
||||
{
|
||||
// A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the
|
||||
// node's honest state is BadWaitingForInitialData until a value of the new shape arrives.
|
||||
if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; }
|
||||
statuses.Add(st);
|
||||
}
|
||||
if (values.Count > 0)
|
||||
{
|
||||
try { _sink.WriteValues(values); }
|
||||
@@ -865,22 +900,27 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); }
|
||||
}
|
||||
|
||||
var alarms = 0;
|
||||
foreach (var alarm in _deferred.Alarms)
|
||||
{
|
||||
if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue;
|
||||
alarms++;
|
||||
try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); }
|
||||
}
|
||||
|
||||
var qualities = 0;
|
||||
foreach (var quality in _deferred.AlarmQualities)
|
||||
{
|
||||
if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue;
|
||||
qualities++;
|
||||
try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); }
|
||||
}
|
||||
|
||||
_log.Debug("OpcUaPublish: replayed {Values} value and {Statuses} status writes (of {Held} held) deferred during a rebuild plan",
|
||||
values.Count, statuses.Count, _deferred.Count);
|
||||
_log.Debug(
|
||||
"OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan",
|
||||
values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1019,17 +1059,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready)
|
||||
{
|
||||
var previous = _lastApplied;
|
||||
var ensured = _applier!.BeginRecordingEnsuredVariables();
|
||||
var recording = _applier!.BeginRecording();
|
||||
try
|
||||
{
|
||||
var result = ApplyPlanCore(ready, out var rebuilt);
|
||||
// What this apply created (see _replayScope). A throwing pass still scopes to what it did create.
|
||||
_replayScope = new ReplayScope(rebuilt, ensured, AddedConditions(previous, ready.Composition));
|
||||
// What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did.
|
||||
var conditions = AddedConditions(previous, ready.Composition);
|
||||
if (rebuilt)
|
||||
{
|
||||
// Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only
|
||||
// NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert
|
||||
// (or any transition) handled in the window landed on the discarded instance, so every kept scripted
|
||||
// condition must be replayed onto its new one. Native kept conditions stay "added only".
|
||||
foreach (var a in ready.Composition.EquipmentScriptedAlarms)
|
||||
if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
|
||||
}
|
||||
|
||||
_replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_applier.EndRecordingEnsuredVariables();
|
||||
_applier.EndRecording();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user