fix(site-runtime): seed republished overrides into Script Actors without firing scripts; alarms still evaluate (review 2026-10-21 01/SR-U1, decision M1)
HandleOverridesLoaded now sends children a site-local AttributeValueSeed (stream still gets the AttributeValueChanged). ScriptActor updates its snapshot, Conditional truth value and Expression last result (seed-only eval, never fires); AlarmActor evaluates it as a normal change.
This commit is contained in:
@@ -246,6 +246,9 @@ public class AlarmActor : ReceiveActor
|
||||
|
||||
// Handle attribute value changes
|
||||
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
|
||||
// Review 2026-10-21 01/SR-U1: an override seeded at instance start is evaluated like any
|
||||
// change — an alarm whose condition already holds must raise.
|
||||
Receive<Messages.AttributeValueSeed>(seed => HandleAttributeValueChanged(seed.Change));
|
||||
|
||||
// Handle alarm execution completion (also releases the in-flight slot)
|
||||
Receive<AlarmExecutionCompleted>(HandleAlarmExecutionCompleted);
|
||||
|
||||
@@ -1831,24 +1831,32 @@ public class InstanceActor : ReceiveActor
|
||||
/// otherwise an "any-change" waiter (or a waiter whose target equals the unchanged
|
||||
/// value) would spuriously fire even though nothing actually changed.
|
||||
/// </param>
|
||||
private void PublishAndNotifyChildren(AttributeValueChanged changed, bool evaluateWaiters = true)
|
||||
/// <param name="seedOnly">
|
||||
/// Review 2026-10-21 01/SR-U1: children receive an <see cref="AttributeValueSeed"/> instead of
|
||||
/// the change — Script Actors adopt the value without launching a run, Alarm Actors evaluate
|
||||
/// it normally. The site stream still gets the ordinary change.
|
||||
/// </param>
|
||||
private void PublishAndNotifyChildren(
|
||||
AttributeValueChanged changed, bool evaluateWaiters = true, bool seedOnly = false)
|
||||
{
|
||||
// Publish to site-wide stream
|
||||
_streamManager?.PublishAttributeValueChanged(changed);
|
||||
|
||||
object toChildren = seedOnly ? new AttributeValueSeed(changed) : changed;
|
||||
|
||||
// Route the change to exactly the children interested in it (P2): the all-changes
|
||||
// bucket (Expression scripts/alarms + fail-open children) plus the children monitoring
|
||||
// this specific attribute. Children still gate on their own trigger config — routing
|
||||
// is a fan-out optimization, not the correctness check.
|
||||
foreach (var child in _allChangeChildren)
|
||||
{
|
||||
child.Tell(changed);
|
||||
child.Tell(toChildren);
|
||||
}
|
||||
if (_childrenByMonitoredAttribute.TryGetValue(changed.AttributeName, out var interested))
|
||||
{
|
||||
foreach (var child in interested)
|
||||
{
|
||||
child.Tell(changed);
|
||||
child.Tell(toChildren);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1942,9 +1950,10 @@ public class InstanceActor : ReceiveActor
|
||||
/// evaluated against the default for the actor's whole lifetime while <c>GetAttribute</c>
|
||||
/// answered with the override. Routing each changed value through
|
||||
/// <see cref="PublishAndNotifyChildren"/> re-seeds every child snapshot and the site stream in
|
||||
/// one place. Consequence, deliberately accepted: a ValueChange/Conditional script keyed on an
|
||||
/// overridden static attribute fires once as the actor starts — exactly as one keyed on a
|
||||
/// data-sourced attribute already fires on that attribute's first DCL value.
|
||||
/// one place. The children get it as an <see cref="AttributeValueSeed"/> (user decision M1):
|
||||
/// Script Actors adopt the value and their trigger state WITHOUT running — a restart must not
|
||||
/// re-fire ValueChange/Conditional/Expression scripts — while Alarm Actors evaluate it, so an
|
||||
/// alarm whose condition already holds raises.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Waiters are NOT evaluated: none can be waiting on a value the instance already held, and a
|
||||
@@ -2035,7 +2044,8 @@ public class InstanceActor : ReceiveActor
|
||||
new AttributeValueChanged(
|
||||
_instanceUniqueName, name, name, _attributes[name],
|
||||
_attributeQualities.GetValueOrDefault(name) ?? "Good", DateTimeOffset.UtcNow),
|
||||
evaluateWaiters: false);
|
||||
evaluateWaiters: false,
|
||||
seedOnly: true);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
|
||||
@@ -117,6 +117,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
// against the latest snapshot. Both flags are touched only on the actor thread.
|
||||
private bool _evalInFlight;
|
||||
private bool _evalPending;
|
||||
// 01/SR-U1: true when the pending evaluation was requested only by seeds (never fires).
|
||||
private bool _evalPendingSeedOnly;
|
||||
|
||||
// WhileTrue trigger state: the most recent truth value of a Conditional
|
||||
// trigger's comparison, used to detect false->true / true->false edges.
|
||||
@@ -205,6 +207,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
|
||||
// Handle attribute value changes for value-change and conditional triggers
|
||||
Receive<AttributeValueChanged>(HandleAttributeValueChanged);
|
||||
// Review 2026-10-21 01/SR-U1: a value seeded at instance start updates trigger state only.
|
||||
Receive<Messages.AttributeValueSeed>(HandleAttributeValueSeed);
|
||||
|
||||
// Handle interval tick
|
||||
Receive<IntervalTick>(_ => TrySpawnExecution(null));
|
||||
@@ -338,6 +342,31 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 01/SR-U1 (user decision M1): adopts a value the Instance Actor seeded at
|
||||
/// start (a persisted static override loaded after this actor was created with the deploy
|
||||
/// default) WITHOUT launching a run. The snapshot and the trigger's edge state are brought up
|
||||
/// to date — a Conditional's truth value, an Expression's last result (evaluated, never fired) —
|
||||
/// so the NEXT real change is judged against the real value: no spurious edge from the replay,
|
||||
/// and no missed edge afterwards. A WhileTrue trigger whose condition already holds is seeded
|
||||
/// true without starting its re-fire timer; it fires on its next false→true edge.
|
||||
/// </summary>
|
||||
private void HandleAttributeValueSeed(Messages.AttributeValueSeed seed)
|
||||
{
|
||||
var changed = seed.Change;
|
||||
_attributeSnapshot[changed.AttributeName] = changed.Value;
|
||||
|
||||
if (_triggerConfig is ConditionalTriggerConfig conditional &&
|
||||
conditional.AttributeName == changed.AttributeName)
|
||||
{
|
||||
_conditionState = EvaluateCondition(conditional, changed.Value);
|
||||
}
|
||||
else if (_triggerConfig is ExpressionTriggerConfig)
|
||||
{
|
||||
StartExpressionEvaluation(seedOnly: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts an off-dispatcher evaluation of the compiled trigger expression (P1).
|
||||
/// The expression previously ran synchronously on the actor's dispatcher thread
|
||||
@@ -359,10 +388,20 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
/// the work is queued — so gate-wait time burns the same budget and a saturated gate
|
||||
/// yields a timely false instead of an unbounded stall.</para>
|
||||
/// </summary>
|
||||
private void StartExpressionEvaluation()
|
||||
/// <param name="seedOnly">
|
||||
/// 01/SR-U1: the result only seeds <see cref="_lastExpressionResult"/> and never fires. A
|
||||
/// coalesced pending evaluation stays seed-only unless a real change also asked for it.
|
||||
/// </param>
|
||||
private void StartExpressionEvaluation(bool seedOnly = false)
|
||||
{
|
||||
if (_compiledTriggerExpression == null || _triggerConfig is not ExpressionTriggerConfig) return;
|
||||
if (_evalInFlight) { _evalPending = true; return; } // coalesce bursts: one in flight, one pending
|
||||
if (_evalInFlight)
|
||||
{
|
||||
// coalesce bursts: one in flight, one pending (seed-only only if every request was)
|
||||
_evalPendingSeedOnly = _evalPending ? _evalPendingSeedOnly && seedOnly : seedOnly;
|
||||
_evalPending = true;
|
||||
return;
|
||||
}
|
||||
_evalInFlight = true;
|
||||
|
||||
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
|
||||
@@ -407,7 +446,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
cts.Dispose();
|
||||
}
|
||||
}).PipeTo(self,
|
||||
success: r => new ExpressionEvalResult(r),
|
||||
success: r => new ExpressionEvalResult(r, seedOnly),
|
||||
failure: ex => new ExpressionEvalFailed(ex));
|
||||
}
|
||||
|
||||
@@ -422,13 +461,21 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
_evalInFlight = false;
|
||||
if (_triggerConfig is ExpressionTriggerConfig exprConfig)
|
||||
{
|
||||
if (exprConfig.Mode == TriggerMode.WhileTrue)
|
||||
if (msg.SeedOnly)
|
||||
{
|
||||
// 01/SR-U1: a seed adopts the truth value and never fires.
|
||||
}
|
||||
else if (exprConfig.Mode == TriggerMode.WhileTrue)
|
||||
HandleWhileTrueTransition(msg.Result, _lastExpressionResult);
|
||||
else if (msg.Result && !_lastExpressionResult)
|
||||
TrySpawnExecution(null);
|
||||
_lastExpressionResult = msg.Result;
|
||||
}
|
||||
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
|
||||
if (_evalPending)
|
||||
{
|
||||
_evalPending = false;
|
||||
StartExpressionEvaluation(_evalPendingSeedOnly);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -873,7 +920,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
/// carries the boolean truth value so the OnTrue/WhileTrue edge logic runs on
|
||||
/// the actor thread.
|
||||
/// </summary>
|
||||
private sealed record ExpressionEvalResult(bool Result);
|
||||
private sealed record ExpressionEvalResult(bool Result, bool SeedOnly = false);
|
||||
|
||||
/// <summary>
|
||||
/// Piped back to self when the off-dispatcher evaluation TASK itself faults
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Site-local: an attribute value an Instance Actor's children must ADOPT without treating it
|
||||
/// as a change that fires scripts (review 2026-10-21 01/SR-U1, user decision M1). Sent in place of
|
||||
/// a plain <see cref="AttributeValueChanged"/> when the persisted static overrides load at actor
|
||||
/// start — the children were created before that load and seeded with the deploy defaults.
|
||||
///
|
||||
/// <para>
|
||||
/// Script Actors update their attribute snapshot and trigger state (Conditional truth value,
|
||||
/// Expression last result) but do NOT launch a run; Alarm Actors evaluate it exactly as a normal
|
||||
/// change, so an alarm whose condition already holds raises. The site stream / Debug View still
|
||||
/// receive the ordinary <see cref="Change"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Change">The value being seeded, in the ordinary change shape.</param>
|
||||
public sealed record AttributeValueSeed(AttributeValueChanged Change);
|
||||
Reference in New Issue
Block a user