perf(site-runtime): per-attribute child routing in InstanceActor — attribute changes reach only interested children (P2)

This commit is contained in:
Joseph Doherty
2026-07-09 00:55:26 -04:00
parent 1673268aee
commit d124025cc7
4 changed files with 343 additions and 16 deletions
@@ -53,6 +53,25 @@ public class InstanceActor : ReceiveActor
private readonly Dictionary<string, IActorRef> _scriptActors = new();
private readonly Dictionary<string, IActorRef> _alarmActors = new();
private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();
/// <summary>
/// Per-attribute child routing (P2): monitoredAttributeName → the script/alarm actors
/// that react only to that attribute (value-change / conditional scripts;
/// HiLo/ValueMatch/RangeViolation/RateOfChange alarms). Built once in
/// <see cref="CreateChildActors"/> from each child's trigger config and consulted by
/// <see cref="PublishAndNotifyChildren"/> so an attribute change reaches only the
/// children interested in it instead of every child. The child's own trigger gate
/// remains authoritative — this routing is a fan-out optimization, not the correctness
/// check.
/// </summary>
private readonly Dictionary<string, List<IActorRef>> _childrenByMonitoredAttribute = new();
/// <summary>
/// Children that must hear EVERY attribute change (P2): Expression-triggered scripts and
/// alarms (they keep a full attribute snapshot), plus any child whose trigger config was
/// unparseable (fail-open). Complements <see cref="_childrenByMonitoredAttribute"/>.
/// </summary>
private readonly List<IActorRef> _allChangeChildren = new();
/// <summary>
/// Native alarm kind per source-binding canonical name (the same value handed
/// to each <see cref="NativeAlarmActor"/>'s <c>_nativeKind</c>). Used to stamp
@@ -1253,16 +1272,20 @@ public class InstanceActor : ReceiveActor
// Publish to site-wide stream
_streamManager?.PublishAttributeValueChanged(changed);
// Notify Script Actors (for value-change and conditional triggers)
foreach (var scriptActor in _scriptActors.Values)
// 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)
{
scriptActor.Tell(changed);
child.Tell(changed);
}
// Notify Alarm Actors (for alarm evaluation)
foreach (var alarmActor in _alarmActors.Values)
if (_childrenByMonitoredAttribute.TryGetValue(changed.AttributeName, out var interested))
{
alarmActor.Tell(changed);
foreach (var child in interested)
{
child.Tell(changed);
}
}
// WaitForAttribute (spec §4.2): re-evaluate any waiters on THIS attribute —
@@ -1419,6 +1442,33 @@ public class InstanceActor : ReceiveActor
/// would let a child constructor enumerate it on the child's mailbox thread while
/// this actor mutates it in <c>HandleAttributeValueChanged</c>.
/// </summary>
/// <summary>
/// Files a freshly-created child actor into the attribute-change routing maps (P2)
/// according to its trigger classification. Native alarm actors are NOT routed here —
/// they subscribe to their own native feed through the DCL and never react to computed
/// attribute changes.
/// </summary>
private void RouteChild(IActorRef child, TriggerRoutingDecision decision)
{
switch (decision.Kind)
{
case TriggerRoutingKind.MonitoredAttribute:
if (!_childrenByMonitoredAttribute.TryGetValue(decision.MonitoredAttribute!, out var list))
{
list = new List<IActorRef>();
_childrenByMonitoredAttribute[decision.MonitoredAttribute!] = list;
}
list.Add(child);
break;
case TriggerRoutingKind.AllChanges:
_allChangeChildren.Add(child);
break;
case TriggerRoutingKind.None:
// Interval / call-only triggers never react to attribute changes.
break;
}
}
private void CreateChildActors()
{
if (_configuration == null) return;
@@ -1463,6 +1513,7 @@ public class InstanceActor : ReceiveActor
var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
_scriptActors[script.CanonicalName] = actorRef;
RouteChild(actorRef, TriggerRouting.ForScript(script.TriggerType, script.TriggerConfiguration));
}
// Create Alarm Actors
@@ -1522,6 +1573,7 @@ public class InstanceActor : ReceiveActor
_alarmActors[alarm.CanonicalName] = actorRef;
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
_alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration));
}
// Create Native Alarm Actors — read-only mirror of each bound source's
@@ -1620,6 +1672,16 @@ public class InstanceActor : ReceiveActor
/// </summary>
public int AlarmActorCount => _alarmActors.Count;
/// <summary>
/// Read-only view of the per-attribute child routing map (P2, for testing/diagnostics).
/// </summary>
internal IReadOnlyDictionary<string, List<IActorRef>> ChildrenByMonitoredAttribute => _childrenByMonitoredAttribute;
/// <summary>
/// Read-only view of the all-changes child routing bucket (P2, for testing/diagnostics).
/// </summary>
internal IReadOnlyList<IActorRef> AllChangeChildren => _allChangeChildren;
/// <summary>
/// Internal message for async override loading result.
/// </summary>
@@ -563,23 +563,21 @@ public class ScriptActor : ReceiveActor, IWithTimers
private static ValueChangeTriggerConfig? ParseValueChangeTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
try
{
var doc = JsonDocument.Parse(json);
var attr = doc.RootElement.GetProperty("attributeName").GetString()!;
return new ValueChangeTriggerConfig(attr);
}
catch { return null; }
// Share the monitored-attribute parser with InstanceActor's routing map (P2) so both
// agree on which attribute this trigger reacts to.
return TriggerRouting.TryReadAttributeName(json, out var attr)
? new ValueChangeTriggerConfig(attr)
: null;
}
private static ConditionalTriggerConfig? ParseConditionalTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
// Attribute name comes from the shared parser (P2); operator/threshold/mode stay local.
if (!TriggerRouting.TryReadAttributeName(json, out var attr)) return null;
try
{
var doc = JsonDocument.Parse(json);
var attr = doc.RootElement.GetProperty("attributeName").GetString()!;
var op = doc.RootElement.GetProperty("operator").GetString()!;
var threshold = doc.RootElement.GetProperty("threshold").GetDouble();
return new ConditionalTriggerConfig(