Resolve all 46 low-severity architecture review findings
This commit is contained in:
@@ -279,6 +279,9 @@ public class AlarmActor : ReceiveActor
|
||||
/// </summary>
|
||||
private void HandleAttributeValueChanged(AttributeValueChanged changed)
|
||||
{
|
||||
// Alarm audit rows have no inferred-edge metadata: never assert that an armed
|
||||
// device publish was caused by the candidate writer. Observed static writes retain it.
|
||||
var observedSource = changed.SourceExecutionInferred ? null : changed.SourceExecutionId;
|
||||
// Expression triggers evaluate against a snapshot of every attribute,
|
||||
// not a single monitored attribute. Keep the snapshot current for every
|
||||
// change before the IsMonitoredAttribute gate (which does not apply).
|
||||
@@ -288,7 +291,7 @@ public class AlarmActor : ReceiveActor
|
||||
// (ParentExecutionId tag-cascade): remember which
|
||||
// execution (if any) wrote the newest value folded into the snapshot,
|
||||
// so the off-dispatcher evaluation can attribute its raise to it.
|
||||
_latestSourceExecutionId = changed.SourceExecutionId;
|
||||
_latestSourceExecutionId = observedSource;
|
||||
}
|
||||
else if (!IsMonitoredAttribute(changed.AttributeName))
|
||||
{
|
||||
@@ -300,7 +303,7 @@ public class AlarmActor : ReceiveActor
|
||||
{
|
||||
if (_triggerType == AlarmTriggerType.HiLo)
|
||||
{
|
||||
HandleHiLoTransition(EvaluateHiLo(changed.Value), changed.SourceExecutionId);
|
||||
HandleHiLoTransition(EvaluateHiLo(changed.Value), observedSource);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -322,7 +325,7 @@ public class AlarmActor : ReceiveActor
|
||||
_ => false
|
||||
};
|
||||
|
||||
ApplyTriggeredState(isTriggered, changed.SourceExecutionId);
|
||||
ApplyTriggeredState(isTriggered, observedSource);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -174,7 +174,7 @@ public class InstanceActor : ReceiveActor
|
||||
/// <c>Trace</c>, and the arm is consumed. Bounded by the attribute count (one entry per
|
||||
/// attribute, newest wins); no sweep — an unconsumed arm is overwritten or expires in place.
|
||||
/// Armed at ISSUE, disarmed on a failed/timed-out write (arch-review F6, <see cref="DisarmTrace"/>);
|
||||
/// a successful write's echo consumes it.
|
||||
/// the first subsequent device update consumes it, with explicitly inferred linkage.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (Guid SourceExecutionId, DateTimeOffset ExpiresAt)> _traceArms = new();
|
||||
// Maps each tag path to every attribute canonical name that references it.
|
||||
@@ -337,7 +337,7 @@ public class InstanceActor : ReceiveActor
|
||||
Receive<RetryTagSubscribe>(msg => SendTagSubscribe(msg.ConnectionName));
|
||||
|
||||
// Arch-review F6: a traced write the DCL rejected (or that timed out) drops its own arm,
|
||||
// so an unrelated later update is not stamped as that write's device echo.
|
||||
// so a later update is not associated with that failed write.
|
||||
Receive<DisarmTrace>(msg =>
|
||||
{
|
||||
if (_traceArms.TryGetValue(msg.AttributeName, out var arm)
|
||||
@@ -751,10 +751,10 @@ public class InstanceActor : ReceiveActor
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
// Flow Debugger trace arm (design §5.3): only a TRACED write with a known writer arms;
|
||||
// the device echo that follows is stamped in HandleTagValueUpdate. Untraced writes
|
||||
// the next device update is marked as inferred in HandleTagValueUpdate. Untraced writes
|
||||
// never touch the map, so production behaviour is unchanged. The arm is taken at ISSUE
|
||||
// and dropped again on a failed/timed-out write (arch-review F6, via the DisarmTrace
|
||||
// self-message below); a successful write's echo consumes it.
|
||||
// self-message below); the first subsequent device update consumes it, with explicitly inferred linkage.
|
||||
Guid? armed = null;
|
||||
if (command.Trace && command.SourceExecutionId is { } traceSource)
|
||||
{
|
||||
@@ -1063,9 +1063,9 @@ public class InstanceActor : ReceiveActor
|
||||
// O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
|
||||
_resolvedAttributeByName.TryGetValue(attrName, out var resolved);
|
||||
|
||||
// Flow Debugger trace arm (design §5.3): consume the arm for this attribute — the
|
||||
// echo is stamped whatever its value (a device that clamps or rejects the write
|
||||
// still shows what it did) — unless the arm has already expired.
|
||||
// A device subscription has no write-request identity. Associate only the first
|
||||
// update inside the arm window, explicitly as inferred: a periodic publish may
|
||||
// win this race, and equality cannot establish causality (devices can coerce values).
|
||||
Guid? traceSource = null;
|
||||
var traced = false;
|
||||
if (_traceArms.Remove(attrName, out var arm) && arm.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
@@ -1085,7 +1085,7 @@ public class InstanceActor : ReceiveActor
|
||||
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||
_instanceUniqueName, update.TagPath, attrName,
|
||||
typedList, QualityToString(update.Quality), update.Timestamp,
|
||||
SourceExecutionId: traceSource, Trace: traced));
|
||||
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1103,7 +1103,7 @@ public class InstanceActor : ReceiveActor
|
||||
PublishAndNotifyChildren(new AttributeValueChanged(
|
||||
_instanceUniqueName, update.TagPath, attrName,
|
||||
currentValue, "Bad", update.Timestamp,
|
||||
SourceExecutionId: traceSource, Trace: traced), evaluateWaiters: false);
|
||||
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced), evaluateWaiters: false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1118,7 +1118,7 @@ public class InstanceActor : ReceiveActor
|
||||
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||
_instanceUniqueName, update.TagPath, attrName,
|
||||
value, QualityToString(update.Quality), update.Timestamp,
|
||||
SourceExecutionId: traceSource, Trace: traced));
|
||||
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
TriggerKind = "valuechange",
|
||||
TriggerAttribute = changed.AttributeName,
|
||||
TriggerValue = AttributeValueCodec.Encode(changed.Value),
|
||||
TriggerSource = "value-change",
|
||||
TriggerSource = changed.SourceExecutionInferred ? "inferred-device-update" : "value-change",
|
||||
});
|
||||
}
|
||||
else
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
||||
/// </summary>
|
||||
internal const int InstancesPerScriptThread = 8;
|
||||
|
||||
/// <summary>Thread-name prefix; also the inlining guard in <see cref="TryExecuteTaskInline"/>.</summary>
|
||||
/// <summary>Thread-name prefix for diagnostics; inlining is guarded by scheduler identity.</summary>
|
||||
private const string ThreadNamePrefix = "script-execution-";
|
||||
|
||||
private readonly BlockingCollection<Task> _queue = new();
|
||||
@@ -338,8 +338,12 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[ThreadStatic]
|
||||
private static ScriptExecutionScheduler? CurrentScheduler;
|
||||
|
||||
private void WorkerLoop(int index)
|
||||
{
|
||||
CurrentScheduler = this;
|
||||
CurrentWorkerSlot = index;
|
||||
var slot = _slots[index];
|
||||
try
|
||||
@@ -375,6 +379,7 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
||||
finally
|
||||
{
|
||||
CurrentWorkerSlot = null;
|
||||
CurrentScheduler = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +391,7 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
||||
{
|
||||
// Only inline if we are already on one of this scheduler's worker threads,
|
||||
// so script work never escapes onto a thread-pool thread.
|
||||
if (Thread.CurrentThread.Name?.StartsWith(ThreadNamePrefix, StringComparison.Ordinal) != true)
|
||||
if (!ReferenceEquals(CurrentScheduler, this))
|
||||
return false;
|
||||
|
||||
return TryExecuteTask(task);
|
||||
|
||||
@@ -411,7 +411,7 @@ public class ScriptRuntimeContext
|
||||
{
|
||||
// The collector is a debug-only observer: it must never fail a script call, and it
|
||||
// must never keep the durable audit row from reaching the writer (review, 2026-09-06).
|
||||
_logger.LogWarning(ex, "Flow trace collector rejected {Action} ({EventId})", evt.Action, evt.EventId);
|
||||
_innerLogger.LogWarning(ex, "Flow trace collector rejected {Action} ({EventId})", evt.Action, evt.EventId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ public class ScriptRuntimeContext
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Flow trace event build failed for {Kind}", kind);
|
||||
_innerLogger.LogWarning(ex, "Flow trace event build failed for {Kind}", kind);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -560,7 +560,7 @@ public class ScriptRuntimeContext
|
||||
if (!t.IsCompletedSuccessfully)
|
||||
{
|
||||
_ = t.ContinueWith(
|
||||
x => _logger.LogWarning(x.Exception, "Flow trace write failed for {Kind}", kind),
|
||||
x => _innerLogger.LogWarning(x.Exception, "Flow trace write failed for {Kind}", kind),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
@@ -568,7 +568,7 @@ public class ScriptRuntimeContext
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Flow trace write failed for {Kind}", kind);
|
||||
_innerLogger.LogWarning(ex, "Flow trace write failed for {Kind}", kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,17 +1057,22 @@ public class ScriptRuntimeContext
|
||||
return result.ReturnValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to shared script execution via the Scripts property.
|
||||
/// </summary>
|
||||
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
||||
private ScriptCallHelper? _scripts;
|
||||
private ExternalSystemHelper? _externalSystem;
|
||||
private DatabaseHelper? _database;
|
||||
private NotifyHelper? _notify;
|
||||
private AlarmsAccessor? _alarms;
|
||||
private TrackingHelper? _tracking;
|
||||
|
||||
/// <summary>Provides access to shared script execution via a helper cached for this context.</summary>
|
||||
public ScriptCallHelper Scripts => _scripts ??= new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to external system calls.
|
||||
/// ExternalSystem.Call("systemName", "methodName", params)
|
||||
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
||||
/// </summary>
|
||||
public ExternalSystemHelper ExternalSystem => new(
|
||||
public ExternalSystemHelper ExternalSystem => _externalSystem ??= new(
|
||||
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
|
||||
// Emit CachedSubmit telemetry
|
||||
// on every ExternalSystem.CachedCall enqueue.
|
||||
@@ -1089,7 +1094,7 @@ public class ScriptRuntimeContext
|
||||
/// Database.Connection("name")
|
||||
/// Database.CachedWrite("name", "sql", params)
|
||||
/// </summary>
|
||||
public DatabaseHelper Database => new(
|
||||
public DatabaseHelper Database => _database ??= new(
|
||||
_databaseGateway,
|
||||
_instanceName,
|
||||
_logger,
|
||||
@@ -1127,7 +1132,7 @@ public class ScriptRuntimeContext
|
||||
/// Best-effort per alog.md §7 — a thrown writer never aborts the script's
|
||||
/// <c>Send</c>.
|
||||
/// </remarks>
|
||||
public NotifyHelper Notify => new(
|
||||
public NotifyHelper Notify => _notify ??= new(
|
||||
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
|
||||
_executionId, _auditWriter,
|
||||
// (ParentExecutionId): the spawning execution's id,
|
||||
@@ -1146,7 +1151,7 @@ public class ScriptRuntimeContext
|
||||
/// Gateway) conditions alike — so a <c>Call</c> script can answer "what is in alarm
|
||||
/// right now?" without an on-trigger <c>Alarm</c> context.
|
||||
/// </summary>
|
||||
public AlarmsAccessor Alarms => new(this);
|
||||
public AlarmsAccessor Alarms => _alarms ??= new(this);
|
||||
|
||||
/// <summary>
|
||||
/// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's
|
||||
@@ -1210,7 +1215,7 @@ public class ScriptRuntimeContext
|
||||
/// returns a <see cref="TrackingStatusSnapshot"/>, or <c>null</c> when the
|
||||
/// id is unknown / has already been purged.
|
||||
/// </summary>
|
||||
public TrackingHelper Tracking => new(_operationTrackingStore, _logger);
|
||||
public TrackingHelper Tracking => _tracking ??= new(_operationTrackingStore, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for Scripts.CallShared() syntax.
|
||||
|
||||
@@ -28,6 +28,7 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
public sealed class TriggerEvalGate : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _gate;
|
||||
private bool _processOwned;
|
||||
|
||||
private static volatile TriggerEvalGate? _shared;
|
||||
private static readonly object SharedLock = new();
|
||||
@@ -48,7 +49,8 @@ public sealed class TriggerEvalGate : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// The process-wide gate, used when no gate is injected. Lazily created from
|
||||
/// <see cref="SiteRuntimeOptions.TriggerEvalMaxConcurrency"/>; the first caller wins.
|
||||
/// <see cref="SiteRuntimeOptions.TriggerEvalMaxConcurrency"/>; the first caller wins. The shared gate is process-owned; calling
|
||||
/// <see cref="Dispose"/> on it does not release the shared semaphore.
|
||||
/// </summary>
|
||||
/// <param name="options">Site runtime options supplying the concurrency limit.</param>
|
||||
/// <returns>The shared gate instance.</returns>
|
||||
@@ -59,7 +61,7 @@ public sealed class TriggerEvalGate : IDisposable
|
||||
|
||||
lock (SharedLock)
|
||||
{
|
||||
return _shared ??= new TriggerEvalGate(options.TriggerEvalMaxConcurrency);
|
||||
return _shared ??= new TriggerEvalGate(options.TriggerEvalMaxConcurrency) { _processOwned = true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,10 @@ public sealed class TriggerEvalGate : IDisposable
|
||||
/// <summary>Returns a permit. Must be called exactly once per successful <see cref="WaitAsync"/>.</summary>
|
||||
public void Release() => _gate.Release();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _gate.Dispose();
|
||||
/// <summary>Disposes a caller-created gate. The process-owned <see cref="Shared"/> gate remains available.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_processOwned)
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user