feat(scripted-alarms): DependencyMuxTagUpstreamSource (T7)
Concrete ITagUpstreamSource the scripted-alarm host actor pushes DependencyValueChanged values into and ScriptedAlarmEngine reads/subscribes from. Thread-safe: ConcurrentDictionary value cache + per-path ImmutableList observer lists with atomic add/remove and capture-then-invoke fan-out. ReadTag of an unknown path returns a Bad-quality (0x80000000) snapshot stamped via the injected clock. Adds the Core.ScriptedAlarms project reference Runtime needs to see the interface.
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <see cref="ITagUpstreamSource"/> the scripted-alarm host actor pushes
|
||||
/// tag values INTO and the <see cref="ScriptedAlarmEngine"/> reads / subscribes FROM.
|
||||
/// In the live runtime the host actor translates each Akka
|
||||
/// <c>DependencyValueChanged</c> message into a <see cref="Push(string, DataValueSnapshot)"/>
|
||||
/// call; the engine sees those values synchronously through <see cref="ReadTag"/> and
|
||||
/// reactively through <see cref="SubscribeTag"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <see cref="ConcurrentDictionary{TKey, TValue}"/> caches the latest snapshot
|
||||
/// per path so <see cref="ReadTag"/> can answer synchronously (the engine's
|
||||
/// startup-recovery + read-cache-refill paths both call it). Per-path observer
|
||||
/// lists are held as an immutable list inside a <see cref="ConcurrentDictionary{TKey, TValue}"/>
|
||||
/// so subscribe / unsubscribe mutate via atomic compare-and-swap and
|
||||
/// <see cref="Push(string, DataValueSnapshot)"/> can capture-then-invoke a stable
|
||||
/// snapshot — a concurrent unsubscribe can never corrupt an in-flight fan-out.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
|
||||
{
|
||||
// OPC UA Part 4 StatusCode for an outright Bad value (severity 10, bit 31 set). The
|
||||
// codebase has no shared Core.Abstractions constant — concrete producers (GalaxyDriver,
|
||||
// the Wonderware historian client) inline this same 0x80000000u, and the engine's own
|
||||
// AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too.
|
||||
private const uint StatusBad = 0x80000000u;
|
||||
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ImmutableList<Action<string, DataValueSnapshot>>> _observers
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="DependencyMuxTagUpstreamSource"/>.
|
||||
/// </summary>
|
||||
/// <param name="clock">
|
||||
/// Optional function supplying the current UTC time, used to stamp the Bad-quality
|
||||
/// snapshot returned for an unknown path. Defaults to <see cref="DateTime.UtcNow"/>,
|
||||
/// mirroring how <see cref="ScriptedAlarmEngine"/> takes its clock.
|
||||
/// </param>
|
||||
public DependencyMuxTagUpstreamSource(Func<DateTime>? clock = null)
|
||||
=> _clock = clock ?? (() => DateTime.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Update the cached snapshot for <paramref name="path"/> and then notify every
|
||||
/// observer currently subscribed to that path. NOT part of
|
||||
/// <see cref="ITagUpstreamSource"/> — the host actor calls this from its
|
||||
/// <c>DependencyValueChanged</c> handler.
|
||||
/// </summary>
|
||||
/// <param name="path">The tag path whose value changed.</param>
|
||||
/// <param name="snapshot">The new value snapshot.</param>
|
||||
public void Push(string path, DataValueSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
// Cache first so any observer that re-reads the path inside its callback sees the
|
||||
// value it is being notified about, not the prior one.
|
||||
_cache[path] = snapshot;
|
||||
|
||||
// Capture the immutable observer snapshot, then invoke outside any lock: a
|
||||
// concurrent Subscribe/Dispose swaps the dictionary entry atomically and cannot
|
||||
// mutate the list we are iterating.
|
||||
if (_observers.TryGetValue(path, out var observers))
|
||||
{
|
||||
foreach (var observer in observers)
|
||||
observer(path, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
if (_cache.TryGetValue(path, out var snapshot))
|
||||
return snapshot;
|
||||
|
||||
// No value has been pushed for this path yet — return a Bad-quality placeholder so
|
||||
// the engine's cold-start guard holds the prior condition rather than NRE-ing on a
|
||||
// null value cast.
|
||||
var now = _clock();
|
||||
return new DataValueSnapshot(Value: null, StatusCode: StatusBad, SourceTimestampUtc: null, ServerTimestampUtc: now);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentNullException.ThrowIfNull(observer);
|
||||
|
||||
_observers.AddOrUpdate(
|
||||
path,
|
||||
_ => ImmutableList.Create(observer),
|
||||
(_, existing) => existing.Add(observer));
|
||||
|
||||
return new Subscription(this, path, observer);
|
||||
}
|
||||
|
||||
private void Unsubscribe(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
// Atomically remove exactly this observer. Robust to dispose-after-already-removed
|
||||
// (the path entry may be gone, or the observer may already have been pulled).
|
||||
while (_observers.TryGetValue(path, out var existing))
|
||||
{
|
||||
var updated = existing.Remove(observer);
|
||||
if (ReferenceEquals(updated, existing))
|
||||
return; // observer not present — nothing to do
|
||||
|
||||
if (updated.IsEmpty)
|
||||
{
|
||||
// Drop the empty entry, but only if it hasn't changed under us. The
|
||||
// KeyValuePair overload removes atomically iff both key AND value still match.
|
||||
if (_observers.TryRemove(new KeyValuePair<string, ImmutableList<Action<string, DataValueSnapshot>>>(path, existing)))
|
||||
return;
|
||||
// Lost the race — another mutation swapped the entry; retry.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_observers.TryUpdate(path, updated, existing))
|
||||
return;
|
||||
// Lost the CAS — another subscribe/unsubscribe won; retry with the fresh list.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-observer subscription handle. <see cref="Dispose"/> deregisters exactly the
|
||||
/// observer it was created for and is idempotent — calling it after the observer has
|
||||
/// already been removed is a no-op.
|
||||
/// </summary>
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private DependencyMuxTagUpstreamSource? _owner;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
|
||||
public Subscription(DependencyMuxTagUpstreamSource owner, string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
_owner = owner;
|
||||
_path = path;
|
||||
_observer = observer;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Swap _owner to null first so a double-dispose can't deregister twice (the
|
||||
// second call sees null). Interlocked makes the guard safe under concurrent
|
||||
// Dispose calls on the same handle.
|
||||
var owner = Interlocked.Exchange(ref _owner, null);
|
||||
owner?.Unsubscribe(_path, _observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
-->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<!-- ITagUpstreamSource (the scripted-alarm engine's value-feed seam) lives here;
|
||||
DependencyMuxTagUpstreamSource implements it so the host actor can push
|
||||
DependencyValueChanged values into the engine. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<!-- IScriptLogPublisher lives in Core.Scripting; DpsScriptLogPublisher implements it
|
||||
here so the concrete Akka DPS routing stays out of the Core layer. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
|
||||
Reference in New Issue
Block a user