From 6139a65a7bf5215bf34d340e87b629c100cd086f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 04:21:26 -0400 Subject: [PATCH] fix(site-runtime): fan tag updates out to every attribute sharing a tag path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstanceActor._tagPathToAttribute was a Dictionary — one tag path mapped to a single attribute. When two attributes reference the same PLC node (e.g. two composed cooling-tank modules both reading ns=3;s=Tank.Level, or a pump's TempSensor and AlarmSensor both reading ns=3;s=Sensor.Reading), SubscribeToDcl's map assignment overwrote, so only the last-registered attribute ever received values — the rest stayed permanently Uncertain. The map is now Dictionary>; HandleTagValueUpdate fans each update out to every attribute referencing the tag path, and each distinct tag path is still subscribed only once per connection. --- .../Actors/InstanceActor.cs | 44 +++++++++---- .../Actors/InstanceActorTests.cs | 61 +++++++++++++++++++ 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index cad327e..1b3c80b 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -52,8 +52,11 @@ public class InstanceActor : ReceiveActor // DCL manager actor reference for subscribing to tag values private readonly IActorRef? _dclManager; - // Maps tag paths back to attribute canonical names for DCL updates - private readonly Dictionary _tagPathToAttribute = new(); + // Maps each tag path to every attribute canonical name that references it. + // A tag path can back more than one attribute (e.g. two composed modules + // whose members reference the same PLC node), so a tag value update must + // fan out to all of them — not just the last one registered. + private readonly Dictionary> _tagPathToAttributes = new(); public InstanceActor( string instanceUniqueName, @@ -350,13 +353,17 @@ public class InstanceActor : ReceiveActor /// private void HandleTagValueUpdate(TagValueUpdate update) { - if (_tagPathToAttribute.TryGetValue(update.TagPath, out var attrName)) - { - // Normalize array values to JSON strings so they survive Akka serialization - var value = update.Value is Array - ? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType()) - : update.Value; + if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames)) + return; + // Normalize array values to JSON strings so they survive Akka serialization + var value = update.Value is Array + ? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType()) + : update.Value; + + // One tag path may back several attributes — update every one of them. + foreach (var attrName in attrNames) + { var changed = new AttributeValueChanged( _instanceUniqueName, update.TagPath, attrName, value, update.Quality.ToString(), update.Timestamp); @@ -414,11 +421,24 @@ public class InstanceActor : ReceiveActor string.IsNullOrEmpty(attr.BoundDataConnectionName)) continue; - _tagPathToAttribute[attr.DataSourceReference] = attr.CanonicalName; + // Record every attribute that references this tag path so a single + // tag value update fans out to all of them. + if (!_tagPathToAttributes.TryGetValue(attr.DataSourceReference, out var attrs)) + { + attrs = new List(); + _tagPathToAttributes[attr.DataSourceReference] = attrs; + } + attrs.Add(attr.CanonicalName); - if (!byConnection.ContainsKey(attr.BoundDataConnectionName)) - byConnection[attr.BoundDataConnectionName] = new List(); - byConnection[attr.BoundDataConnectionName].Add(attr.DataSourceReference); + if (!byConnection.TryGetValue(attr.BoundDataConnectionName, out var connTags)) + { + connTags = new List(); + byConnection[attr.BoundDataConnectionName] = connTags; + } + // Subscribe each distinct tag path once per connection — a tag shared + // by several attributes still needs only one DCL subscription. + if (!connTags.Contains(attr.DataSourceReference)) + connTags.Add(attr.DataSourceReference); } // Send subscription requests to DCL for each connection diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs index dce9659..7ca1439 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -2,6 +2,8 @@ using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Commons.Interfaces.Protocol; +using ScadaLink.Commons.Messages.DataConnection; using ScadaLink.Commons.Messages.Instance; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Types.Flattening; @@ -306,4 +308,63 @@ public class InstanceActorTests : TestKit, IDisposable Assert.True(response.Found); Assert.Equal("Good", response.Quality); } + + /// + /// A single PLC tag can back more than one attribute — e.g. two composed + /// cooling-tank modules whose members both reference the one simulated + /// ns=3;s=Tank.Level node. A must fan out + /// to every attribute that references that tag path, not just the last one + /// registered: the tag-path → attribute map previously overwrote on a shared + /// tag, leaving all but one attribute permanently Uncertain. + /// + [Fact] + public void InstanceActor_TagUpdate_FansOutToEveryAttributeSharingTheTagPath() + { + const string sharedTag = "ns=3;s=Tank.Level"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Motor-1", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "CoolingTank.Level", Value = "0", DataType = "Int", + DataSourceReference = sharedTag, BoundDataConnectionName = "PLC" + }, + new ResolvedAttribute + { + CanonicalName = "CoolingTank2.Level", Value = "0", DataType = "Int", + DataSourceReference = sharedTag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = ActorOf(Props.Create(() => new InstanceActor( + "Motor-1", + JsonSerializer.Serialize(config), + _storage, + _compilationService, + _sharedScriptLibrary, + null, + _options, + NullLogger.Instance, + dcl.Ref))); + + // On startup the actor subscribes its data-sourced tags through the DCL. + dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + + // One value arrives for the tag that both attributes reference. + actor.Tell(new TagValueUpdate("PLC", sharedTag, 47, QualityCode.Good, DateTimeOffset.UtcNow)); + + // BOTH attributes must reflect it — not just the last-registered one. + foreach (var attrName in new[] { "CoolingTank.Level", "CoolingTank2.Level" }) + { + actor.Tell(new GetAttributeRequest("corr-fanout", "Motor-1", attrName, DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(response.Found); + Assert.Equal("47", response.Value?.ToString()); + Assert.Equal("Good", response.Quality); + } + } }