perf(runtime): O(1) attribute resolution, precomputed types, coalesced static writes, shared JSON options

This commit is contained in:
Joseph Doherty
2026-08-14 19:57:26 -04:00
parent ee193cd2bb
commit 125055d998
7 changed files with 350 additions and 83 deletions
@@ -177,6 +177,15 @@ public class ScriptAnalysisService
private const int SandboxMaxConsoleChars = 32_000;
private const int SandboxMaxReturnJsonChars = 32_000;
/// <summary>
/// Shared options for the per-Test-Run return-value JSON serialize in
/// <see cref="SerializeReturn"/> (perf remediation, arch-review WP1.5) — avoids
/// allocating a new <see cref="JsonSerializerOptions"/> per run. Settings preserved
/// exactly.
/// </summary>
private static readonly JsonSerializerOptions SandboxReturnJsonOptions =
new() { WriteIndented = true };
private const int SandboxMaxCallSharedDepth = 16;
/// <summary>
@@ -520,7 +529,7 @@ public class ScriptAnalysisService
var typeName = value.GetType().Name;
try
{
var json = JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
var json = JsonSerializer.Serialize(value, SandboxReturnJsonOptions);
if (json.Length > SandboxMaxReturnJsonChars)
json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
return (json, typeName);
@@ -27,6 +27,15 @@ public class ExternalSystemClient : IExternalSystemClient
private readonly ILogger<ExternalSystemClient> _logger;
private readonly ExternalSystemGatewayOptions _options;
/// <summary>
/// Shared options for the per-call AuthConfiguration JSON parse in
/// <see cref="TryParseJsonAuth"/> (perf remediation, arch-review WP1.5) — avoids
/// allocating a new <see cref="JsonSerializerOptions"/> (and its internal cached
/// metadata) on every outbound call. Settings preserved exactly.
/// </summary>
private static readonly JsonSerializerOptions AuthJsonOptions =
new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Initializes a new instance of the ExternalSystemClient.
/// </summary>
@@ -755,7 +764,7 @@ public class ExternalSystemClient : IExternalSystemClient
{
fields = JsonSerializer.Deserialize<Dictionary<string, string?>>(
config,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
AuthJsonOptions)
?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
// Normalize to case-insensitive so "Header"/"header" both resolve.
@@ -17,6 +17,15 @@ public static class ManagementEndpoints
{
private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Shared options for the per-request command payload JSON parse in
/// <see cref="ParseCommand"/> (perf remediation, arch-review WP1.5) — avoids
/// allocating a new <see cref="JsonSerializerOptions"/> per inbound management
/// request. Settings preserved exactly.
/// </summary>
private static readonly JsonSerializerOptions CommandPayloadJsonOptions =
new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Resolves the ManagementActor Ask timeout from configuration.
/// Falls back to <see cref="DefaultAskTimeout"/>
@@ -197,7 +206,7 @@ public static class ManagementEndpoints
? p.GetRawText()
: "{}";
var command = JsonSerializer.Deserialize(payloadJson, commandType,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
CommandPayloadJsonOptions)!;
return CommandParseResult.Ok(command);
}
catch (Exception ex)
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
@@ -92,8 +93,51 @@ public class InstanceActor : ReceiveActor
// attribute lookup must be O(1) rather than a linear scan of
// _configuration.Attributes. Built once in the constructor from the
// deserialized configuration (last-wins on duplicate canonical names,
// mirroring the rest of the actor's by-name dictionaries).
private readonly Dictionary<string, ResolvedAttribute> _resolvedAttributeByName = new();
// mirroring the rest of the actor's by-name dictionaries). Each entry
// additionally carries its DataType/ElementDataType parsed ONCE at
// config-apply time (perf remediation, arch-review WP1.5) so the hot
// TagValueUpdate path never re-Enum.TryParse's a string per update.
private readonly Dictionary<string, AttributeIndexEntry> _resolvedAttributeByName = new();
// Static-override write coalescing (perf remediation, arch-review WP1.5):
// mirrors NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape.
// A burst of rapid SetAttribute calls on the same or different attributes
// collapses into one batched SQLite transaction on a short single-shot
// timer instead of a write per call. Keyed by attribute name — only the
// LATEST value per attribute survives a coalescing window, which matches
// the "in-memory state is authoritative" fire-and-forget persistence model
// already documented on the single-write path this replaces.
private readonly Dictionary<string, string> _dirtyStaticOverrides = new();
private ICancelable? _staticOverrideFlushTimer;
private readonly TimeSpan _staticOverrideFlushInterval;
/// <summary>
/// Cached string forms of <see cref="QualityCode"/> (perf remediation, arch-review
/// WP1.5) — the TagValueUpdate ingest path calls this once or twice per update, so a
/// static lookup replaces repeated <see cref="Enum.ToString()"/> reflection.
/// </summary>
private static readonly Dictionary<QualityCode, string> QualityStrings = new()
{
[QualityCode.Good] = "Good",
[QualityCode.Bad] = "Bad",
[QualityCode.Uncertain] = "Uncertain",
};
private static string QualityToString(QualityCode quality) =>
QualityStrings.TryGetValue(quality, out var s) ? s : quality.ToString();
/// <summary>
/// O(1) attribute-resolution index entry (perf remediation, arch-review WP1.5): wraps a
/// <see cref="ResolvedAttribute"/> together with its <see cref="DataType"/> /
/// element <see cref="DataType"/> / IsList classification, parsed ONCE when the entry
/// is built (constructor) instead of re-parsed via <see cref="Enum.TryParse{DataType}"/>
/// on every access.
/// </summary>
private sealed record AttributeIndexEntry(
ResolvedAttribute Attribute,
DataType DataType,
DataType? ElementDataType,
bool IsList);
// WaitForAttribute (spec §4.2): one-shot waiter registry keyed by the
// request CorrelationId. Each entry holds the watched attribute name, the
@@ -149,7 +193,8 @@ public class InstanceActor : ReceiveActor
ILogger logger,
IActorRef? dclManager = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
IServiceProvider? serviceProvider = null,
TimeSpan? staticOverrideFlushInterval = null)
{
_instanceUniqueName = instanceUniqueName;
_storage = storage;
@@ -161,6 +206,8 @@ public class InstanceActor : ReceiveActor
_dclManager = dclManager;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
// Defaults to 100 ms, mirroring NativeAlarmActor's persistFlushInterval (P4).
_staticOverrideFlushInterval = staticOverrideFlushInterval ?? TimeSpan.FromMilliseconds(100);
// Deserialize the flattened configuration
_configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
@@ -172,18 +219,23 @@ public class InstanceActor : ReceiveActor
{
foreach (var attr in _configuration.Attributes)
{
// Index resolved attributes for O(1) lookup on the hot
// TagValueUpdate ingest path (last-wins on duplicate names).
_resolvedAttributeByName[attr.CanonicalName] = attr;
// Parse DataType/ElementDataType ONCE here (config-apply time) and index
// the wrapping entry for O(1) lookup on the hot TagValueUpdate ingest path
// (last-wins on duplicate names) — perf remediation, arch-review WP1.5.
var dataType = ParseDataType(attr.DataType);
var elementDataType = ParseElementDataType(attr.ElementDataType);
var isList = dataType == DataType.List;
_resolvedAttributeByName[attr.CanonicalName] =
new AttributeIndexEntry(attr, dataType, elementDataType, isList);
// A STATIC List attribute's default is the canonical JSON
// array string. Decode it to a typed List<T> for in-memory reads
// so scripts see a real collection. Scalars store their raw
// string unchanged. A malformed List default decodes to null and
// is marked Bad quality rather than crashing the actor.
if (IsListAttribute(attr))
if (isList)
{
var decoded = DecodeAttributeValue(attr, attr.Value);
var decoded = DecodeAttributeValue(dataType, elementDataType, attr.Value, attr.CanonicalName);
_attributes[attr.CanonicalName] = decoded;
_attributeQualities[attr.CanonicalName] =
decoded is null && !string.IsNullOrEmpty(attr.Value) ? "Bad"
@@ -257,6 +309,10 @@ public class InstanceActor : ReceiveActor
// Handle internal messages
Receive<LoadOverridesResult>(HandleOverridesLoaded);
// Coalesced flush of buffered static-override writes (P4 pattern, see
// NativeAlarmActor.FlushDirtyUpserts).
Receive<FlushStaticOverrides>(_ => FlushDirtyStaticOverrides());
}
/// <inheritdoc />
@@ -300,6 +356,13 @@ public class InstanceActor : ReceiveActor
timer.Cancel();
_tagSubscribeRetryTimers.Clear();
// Best-effort final flush of any coalesced static-override writes so a graceful
// stop does not lose the last <= flush-interval of writes (P4 pattern, mirrors
// NativeAlarmActor.PostStop). Fire-and-forget — the in-memory attribute value
// (already applied) remains authoritative even if this final persist is lost.
_staticOverrideFlushTimer?.Cancel();
FlushDirtyStaticOverrides();
// Operational `instance_lifecycle` event — instance stopped. An
// instance stops on disable, delete, redeployment, and graceful shutdown;
// this single point covers them all.
@@ -369,9 +432,9 @@ public class InstanceActor : ReceiveActor
/// </summary>
private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
{
// Resolve the target attribute's data binding from the flattened config.
var resolved = _configuration?.Attributes
.FirstOrDefault(a => a.CanonicalName == command.AttributeName);
// O(1) lookup via the existing hot-path index instead of a linear scan over
// _configuration.Attributes (perf remediation, arch-review WP1.5).
_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved);
// Reject writes targeting an attribute that does not exist
// on the deployed instance. Without this check, an inbound API
@@ -398,8 +461,8 @@ public class InstanceActor : ReceiveActor
}
var isDataSourced =
!string.IsNullOrEmpty(resolved.DataSourceReference)
&& !string.IsNullOrEmpty(resolved.BoundDataConnectionName);
!string.IsNullOrEmpty(resolved.Attribute.DataSourceReference)
&& !string.IsNullOrEmpty(resolved.Attribute.BoundDataConnectionName);
if (isDataSourced)
{
@@ -423,7 +486,7 @@ public class InstanceActor : ReceiveActor
// store the string verbatim. (HandleSetStaticAttribute already rejected
// unknown attributes, so resolved is non-null here, but guard defensively.)
if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved)
&& IsListAttribute(resolved))
&& resolved.IsList)
{
// The script path pre-encodes valid canonical JSON via ScopeAccessors,
// but the Inbound API / direct-command path can submit an arbitrary
@@ -435,7 +498,8 @@ public class InstanceActor : ReceiveActor
// (valid — clearing) AND a malformed non-empty input (invalid). Only the
// latter is rejected, hence the explicit IsNullOrWhiteSpace guard. An empty
// list "[]" decodes to a non-null empty List, so it passes through.
var decoded = DecodeAttributeValue(resolved, command.Value);
var decoded = DecodeAttributeValue(
resolved.DataType, resolved.ElementDataType, command.Value, command.AttributeName);
if (!string.IsNullOrWhiteSpace(command.Value) && decoded == null)
{
_logger.LogWarning(
@@ -474,32 +538,67 @@ public class InstanceActor : ReceiveActor
PublishAndNotifyChildren(changed);
// Persist asynchronously -- fire and forget since the actor is the source of truth.
var instanceName = _instanceUniqueName;
var attributeName = command.AttributeName;
var logger = _logger;
_storage.SetStaticOverrideAsync(_instanceUniqueName, command.AttributeName, command.Value)
.ContinueWith(t =>
{
logger.LogWarning(
t.Exception?.GetBaseException(),
"Failed to persist static override for {Instance}.{Attribute}; in-memory state is authoritative",
instanceName,
attributeName);
}, TaskContinuationOptions.OnlyOnFaulted);
// Coalesce the persist: record the latest value and arm the short batched-flush
// timer (P4 pattern) instead of firing a SQLite write per call — the actor's
// in-memory state (already updated above) remains the source of truth regardless
// of when the buffered write lands.
MarkDirtyStaticOverride(command.AttributeName, command.Value);
Sender.Tell(new SetStaticAttributeResponse(
command.CorrelationId, _instanceUniqueName, command.AttributeName,
true, null, DateTimeOffset.UtcNow));
}
/// <summary>
/// Records the latest value per attribute name and arms the coalesced flush timer
/// (perf remediation, arch-review WP1.5) — mirrors NativeAlarmActor's
/// MarkDirtyUpsert/FlushDirtyUpserts (P4) shape. A burst of rapid static writes
/// collapses into one batched SQLite transaction instead of a write per call.
/// </summary>
private void MarkDirtyStaticOverride(string attributeName, string value)
{
_dirtyStaticOverrides[attributeName] = value;
if (_staticOverrideFlushTimer == null)
{
_staticOverrideFlushTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
_staticOverrideFlushInterval, Self, FlushStaticOverrides.Instance, Self);
}
}
/// <summary>
/// Persists all coalesced static-override writes in one batched transaction (P4),
/// then disarms the flush timer. Fire-and-forget with OnlyOnFaulted logging — a
/// failed write never blocks the actor; the in-memory attribute value (already
/// applied) remains authoritative.
/// </summary>
private void FlushDirtyStaticOverrides()
{
_staticOverrideFlushTimer = null;
if (_dirtyStaticOverrides.Count == 0)
return;
var batch = _dirtyStaticOverrides
.Select(kv => (AttributeName: kv.Key, Value: kv.Value))
.ToList();
_dirtyStaticOverrides.Clear();
var instanceName = _instanceUniqueName;
var logger = _logger;
_storage.SetStaticOverridesAsync(instanceName, batch)
.ContinueWith(t => logger.LogWarning(
t.Exception?.GetBaseException(),
"Failed to persist {Count} static overrides for {Instance}; in-memory state is authoritative",
batch.Count, instanceName),
TaskContinuationOptions.OnlyOnFaulted);
}
/// <summary>
/// Data-sourced attribute write: forwards a write request to the DCL and pipes
/// the device write result back to the caller. The in-memory value is left
/// untouched (it is refreshed by the subscription when the device confirms);
/// no static override is persisted for a data-sourced attribute.
/// </summary>
private void HandleSetDataAttribute(SetStaticAttributeCommand command, ResolvedAttribute resolved)
private void HandleSetDataAttribute(SetStaticAttributeCommand command, AttributeIndexEntry resolved)
{
var caller = Sender;
var correlationId = command.CorrelationId;
@@ -526,9 +625,10 @@ public class InstanceActor : ReceiveActor
// write rather than forward garbage to the device (mirrors the static-path
// rejection in HandleSetStaticAttributeCore). Scalars are unchanged.
object? writeValue = command.Value;
if (IsListAttribute(resolved) && !string.IsNullOrWhiteSpace(command.Value))
if (resolved.IsList && !string.IsNullOrWhiteSpace(command.Value))
{
var decoded = DecodeAttributeValue(resolved, command.Value);
var decoded = DecodeAttributeValue(
resolved.DataType, resolved.ElementDataType, command.Value, attributeName);
if (decoded == null)
{
_logger.LogWarning(
@@ -545,8 +645,8 @@ public class InstanceActor : ReceiveActor
var writeRequest = new WriteTagRequest(
correlationId,
resolved.BoundDataConnectionName!,
resolved.DataSourceReference!,
resolved.Attribute.BoundDataConnectionName!,
resolved.Attribute.DataSourceReference!,
writeValue,
DateTimeOffset.UtcNow);
@@ -598,8 +698,8 @@ public class InstanceActor : ReceiveActor
foreach (var kv in request.AttributeEncodedValues)
{
if (!_resolvedAttributeByName.TryGetValue(kv.Key, out var resolved)
|| string.IsNullOrEmpty(resolved.DataSourceReference)
|| string.IsNullOrEmpty(resolved.BoundDataConnectionName))
|| string.IsNullOrEmpty(resolved.Attribute.DataSourceReference)
|| string.IsNullOrEmpty(resolved.Attribute.BoundDataConnectionName))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, $"Attribute '{kv.Key}' is not a data-sourced attribute."));
@@ -607,8 +707,8 @@ public class InstanceActor : ReceiveActor
}
if (connName == null)
connName = resolved.BoundDataConnectionName;
else if (!string.Equals(connName, resolved.BoundDataConnectionName, StringComparison.Ordinal))
connName = resolved.Attribute.BoundDataConnectionName;
else if (!string.Equals(connName, resolved.Attribute.BoundDataConnectionName, StringComparison.Ordinal))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, "Batch write spans multiple data connections; not supported."));
@@ -619,9 +719,10 @@ public class InstanceActor : ReceiveActor
// MV: a data-sourced List attribute's encoded value is the canonical JSON
// array string — decode it to a typed List<T> so the DCL/Variant write
// produces a real array (same poison-rejection rule as HandleSetDataAttribute).
if (IsListAttribute(resolved) && !string.IsNullOrWhiteSpace(kv.Value))
if (resolved.IsList && !string.IsNullOrWhiteSpace(kv.Value))
{
var decoded = DecodeAttributeValue(resolved, kv.Value);
var decoded = DecodeAttributeValue(
resolved.DataType, resolved.ElementDataType, kv.Value, kv.Key);
if (decoded == null)
{
caller.Tell(new WriteAttributeBatchResponse(
@@ -631,7 +732,7 @@ public class InstanceActor : ReceiveActor
wv = decoded;
}
values[resolved.DataSourceReference!] = wv;
values[resolved.Attribute.DataSourceReference!] = wv;
}
string? triggerPath = null;
@@ -639,23 +740,23 @@ public class InstanceActor : ReceiveActor
if (!string.IsNullOrEmpty(request.TriggerAttribute))
{
if (!_resolvedAttributeByName.TryGetValue(request.TriggerAttribute, out var tr)
|| string.IsNullOrEmpty(tr.DataSourceReference)
|| string.IsNullOrEmpty(tr.BoundDataConnectionName))
|| string.IsNullOrEmpty(tr.Attribute.DataSourceReference)
|| string.IsNullOrEmpty(tr.Attribute.BoundDataConnectionName))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, $"Trigger attribute '{request.TriggerAttribute}' is not data-sourced."));
return;
}
if (connName != null && !string.Equals(connName, tr.BoundDataConnectionName, StringComparison.Ordinal))
if (connName != null && !string.Equals(connName, tr.Attribute.BoundDataConnectionName, StringComparison.Ordinal))
{
caller.Tell(new WriteAttributeBatchResponse(
cid, false, "Trigger attribute is on a different data connection."));
return;
}
connName ??= tr.BoundDataConnectionName;
triggerPath = tr.DataSourceReference;
connName ??= tr.Attribute.BoundDataConnectionName;
triggerPath = tr.Attribute.DataSourceReference;
triggerVal = request.TriggerEncodedValue;
}
@@ -846,19 +947,19 @@ public class InstanceActor : ReceiveActor
// (a CLR array/IEnumerable from the SDK) into a typed List<T>. On an
// element-type mismatch we set the attribute's quality to Bad, log a
// warning, and skip storing a value rather than crashing the actor.
if (resolved != null && IsListAttribute(resolved))
if (resolved != null && resolved.IsList)
{
if (TryCoerceListValue(resolved, update.Value, out var typedList))
if (TryCoerceListValue(resolved.ElementDataType, update.Value, out var typedList))
{
HandleAttributeValueChanged(new AttributeValueChanged(
_instanceUniqueName, update.TagPath, attrName,
typedList, update.Quality.ToString(), update.Timestamp));
typedList, QualityToString(update.Quality), update.Timestamp));
}
else
{
_logger.LogWarning(
"List attribute {Instance}.{Attribute} received a value that could not be coerced to List<{Element}>; marking quality Bad",
_instanceUniqueName, attrName, resolved.ElementDataType);
_instanceUniqueName, attrName, resolved.Attribute.ElementDataType);
_attributeQualities[attrName] = "Bad";
_attributeTimestamps[attrName] = update.Timestamp;
var currentValue = _attributes.GetValueOrDefault(attrName);
@@ -883,14 +984,21 @@ public class InstanceActor : ReceiveActor
HandleAttributeValueChanged(new AttributeValueChanged(
_instanceUniqueName, update.TagPath, attrName,
value, update.Quality.ToString(), update.Timestamp));
value, QualityToString(update.Quality), update.Timestamp));
}
}
/// <summary>True if the resolved attribute is declared as a <see cref="DataType.List"/>.</summary>
private static bool IsListAttribute(ResolvedAttribute attr) =>
Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
&& dt == DataType.List;
/// <summary>Parses a <see cref="ResolvedAttribute.DataType"/> string, defaulting to
/// <see cref="DataType.String"/> on an unrecognized value (never throws).</summary>
private static DataType ParseDataType(string dataType) =>
Enum.TryParse<DataType>(dataType, ignoreCase: true, out var dt) ? dt : DataType.String;
/// <summary>Parses a <see cref="ResolvedAttribute.ElementDataType"/> string, returning
/// <see langword="null"/> when absent or unrecognized (never throws).</summary>
private static DataType? ParseElementDataType(string? elementDataType) =>
string.IsNullOrEmpty(elementDataType)
? null
: (Enum.TryParse<DataType>(elementDataType, ignoreCase: true, out var et) ? et : (DataType?)null);
/// <summary>
/// Decodes a STATIC (authored / overridden) attribute's canonical value
@@ -903,16 +1011,15 @@ public class InstanceActor : ReceiveActor
/// bad element, missing element type) degrades to <see langword="null"/> + a
/// warning — the caller marks the attribute Bad quality. NEVER throws into the
/// actor.
///
/// Takes the already-parsed <paramref name="dataType"/>/<paramref name="elementType"/>
/// (perf remediation, arch-review WP1.5) rather than re-parsing from the attribute's
/// raw DataType strings — every call site now resolves these once via
/// <see cref="AttributeIndexEntry"/> at config-apply time.
/// </summary>
private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw)
private object? DecodeAttributeValue(
DataType dataType, DataType? elementType, string? raw, string attributeCanonicalName)
{
DataType dataType = Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
? dt
: DataType.String;
DataType? elementType = string.IsNullOrEmpty(attr.ElementDataType)
? null
: (Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var et) ? et : null);
try
{
return AttributeValueCodec.Decode(raw, dataType, elementType);
@@ -921,30 +1028,28 @@ public class InstanceActor : ReceiveActor
{
_logger.LogWarning(ex,
"Attribute '{Attr}' on '{Instance}' has an undecodable List value; marking Bad quality",
attr.CanonicalName, _instanceUniqueName);
attributeCanonicalName, _instanceUniqueName);
return null; // caller marks quality Bad
}
}
/// <summary>
/// Coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
/// into a typed <c>List&lt;elementClrType&gt;</c> matching the attribute's
/// <see cref="ResolvedAttribute.ElementDataType"/>. Each element is converted
/// with invariant culture (round-trip parse for DateTime). Returns
/// <see langword="false"/> on a missing/invalid element type, a non-enumerable
/// value, or any element that cannot be coerced — the caller then marks the
/// attribute quality Bad. Never throws.
/// into a typed <c>List&lt;elementClrType&gt;</c> matching the attribute's element
/// type. Each element is converted with invariant culture (round-trip parse for
/// DateTime). Returns <see langword="false"/> on a missing/invalid element type, a
/// non-enumerable value, or any element that cannot be coerced — the caller then
/// marks the attribute quality Bad. Never throws.
///
/// Takes the already-parsed <paramref name="elementType"/> (perf remediation,
/// arch-review WP1.5) — see <see cref="DecodeAttributeValue"/>.
/// </summary>
private bool TryCoerceListValue(ResolvedAttribute attr, object? incoming, out object? typedList)
private bool TryCoerceListValue(DataType? elementType, object? incoming, out object? typedList)
{
typedList = null;
if (string.IsNullOrEmpty(attr.ElementDataType)
|| !Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var elementType)
|| !AttributeValueCodec.IsValidElementType(elementType))
{
if (elementType is not { } et || !AttributeValueCodec.IsValidElementType(et))
return false;
}
if (incoming is not System.Collections.IEnumerable enumerable || incoming is string)
return false;
@@ -956,7 +1061,7 @@ public class InstanceActor : ReceiveActor
// inside the guarded block means any future change that introduces a
// throw is caught and turned into a Bad-quality result rather than
// escaping into the actor and tripping supervision.
typedList = AttributeValueCodec.CoerceEnumerable(enumerable, elementType);
typedList = AttributeValueCodec.CoerceEnumerable(enumerable, et);
return true;
}
catch (Exception ex)
@@ -964,7 +1069,7 @@ public class InstanceActor : ReceiveActor
// Any coercion / construction failure → Bad quality, never a crash.
_logger.LogWarning(ex,
"Failed to coerce value to List<{Element}> for instance {Instance}; marking quality Bad",
attr.ElementDataType, _instanceUniqueName);
et, _instanceUniqueName);
return false;
}
}
@@ -1417,7 +1522,7 @@ public class InstanceActor : ReceiveActor
// overrides to a typed list (matching the config-default load), set
// Bad quality on a malformed stored value, and never crash the actor.
if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
&& IsListAttribute(resolved))
&& resolved.IsList)
{
// Decode the stored List override (both old array-of-strings
// and native-typed forms decode) and re-persist the native form if
@@ -1425,7 +1530,8 @@ public class InstanceActor : ReceiveActor
// list and comparing to the stored string detects old-form values
// (native → native is byte-identical, so a native value is a no-op).
// The re-persist is fire-and-forget and never throws into the actor.
var decoded = DecodeAttributeValue(resolved, kvp.Value);
var decoded = DecodeAttributeValue(
resolved.DataType, resolved.ElementDataType, kvp.Value, kvp.Key);
_attributes[kvp.Key] = decoded;
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
{
@@ -1742,4 +1848,11 @@ public class InstanceActor : ReceiveActor
/// connection whose previous attempt failed or whose response was lost.
/// </summary>
private sealed record RetryTagSubscribe(string ConnectionName);
/// <summary>Self-tell that fires the coalesced static-override persist flush (P4, arch-review WP1.5).</summary>
private sealed class FlushStaticOverrides
{
public static readonly FlushStaticOverrides Instance = new();
private FlushStaticOverrides() { }
}
}
@@ -345,6 +345,53 @@ public class SiteStorageService
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Batch counterpart of <see cref="SetStaticOverrideAsync"/> (perf remediation,
/// arch-review WP1.5): upserts a set of static attribute overrides for one instance
/// in a SINGLE connection + transaction with one prepared command re-bound per row.
/// Used by the InstanceActor's coalesced flush — mirrors NativeAlarmActor's
/// MarkDirtyUpsert/FlushDirtyUpserts (P4) pattern — so a burst of rapid SetAttribute
/// calls on an instance collapses into one batched write instead of a write per call.
/// </summary>
/// <param name="instanceName">The unique name of the instance.</param>
/// <param name="overrides">The attribute name/value pairs to upsert (latest value per name).</param>
/// <returns>A task that completes when all rows have been committed.</returns>
public async Task SetStaticOverridesAsync(
string instanceName, IReadOnlyList<(string AttributeName, string Value)> overrides)
{
if (overrides.Count == 0)
return;
await using var connection = OpenConnection();
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = @"
INSERT INTO static_attribute_overrides (instance_unique_name, attribute_name, override_value, updated_at)
VALUES (@name, @attr, @val, @updatedAt)
ON CONFLICT(instance_unique_name, attribute_name) DO UPDATE SET
override_value = excluded.override_value,
updated_at = excluded.updated_at";
var pName = command.Parameters.Add("@name", SqliteType.Text);
var pAttr = command.Parameters.Add("@attr", SqliteType.Text);
var pVal = command.Parameters.Add("@val", SqliteType.Text);
var pUpdatedAt = command.Parameters.Add("@updatedAt", SqliteType.Text);
pName.Value = instanceName;
var updatedAt = DateTimeOffset.UtcNow.ToString("O");
foreach (var (attributeName, value) in overrides)
{
pAttr.Value = attributeName;
pVal.Value = value;
pUpdatedAt.Value = updatedAt;
await command.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
/// <summary>
/// Clears all static attribute overrides for an instance.
/// Called on redeployment to reset overrides.
@@ -16,6 +16,15 @@ public class SiteExternalSystemRepository : IExternalSystemRepository
{
private readonly SiteStorageService _storage;
/// <summary>
/// Shared options for the per-row <c>MethodDefinitionDto</c> JSON parse in
/// <see cref="ParseMethodDefinitions"/> (perf remediation, arch-review WP1.5) —
/// avoids allocating a new <see cref="JsonSerializerOptions"/> per call. Settings
/// preserved exactly; the query shape itself is untouched (WP2.6).
/// </summary>
private static readonly JsonSerializerOptions MethodDefinitionJsonOptions =
new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Initializes a new site-side external system repository.
/// </summary>
@@ -279,7 +288,7 @@ public class SiteExternalSystemRepository : IExternalSystemRepository
try
{
var methods = JsonSerializer.Deserialize<List<MethodDefinitionDto>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
MethodDefinitionJsonOptions);
if (methods is null)
return Array.Empty<ExternalSystemMethod>();
@@ -242,6 +242,77 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("100.0", overrides["Temperature"]);
}
/// <summary>
/// Perf remediation (arch-review WP1.5): a burst of rapid static-attribute writes must
/// coalesce into ONE batched SQLite transaction on a short single-shot timer — mirroring
/// NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape — rather than a write
/// per <see cref="SetStaticAttributeCommand"/>. Pinned two ways: (1) nothing is persisted
/// before the flush interval elapses, even though every write already replied
/// synchronously and updated in-memory state; (2) only the LATEST value per attribute
/// survives, across writes to two different attributes coalesced into the same flush.
/// </summary>
[Fact]
public async Task InstanceActor_RapidStaticWrites_CoalesceIntoSingleFlush()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpCoalesce1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Counter", Value = "0", DataType = "Int32" },
new ResolvedAttribute { CanonicalName = "Label", Value = "Idle", DataType = "String" }
]
};
var flushInterval = TimeSpan.FromSeconds(1);
var actor = ActorOf(Props.Create(() => new InstanceActor(
"PumpCoalesce1",
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
null, // dclManager
null, // healthCollector
null, // serviceProvider
flushInterval)));
// Five rapid writes to "Counter" plus one to "Label", all sent back-to-back with no
// delay between them (well inside the 1s coalescing window).
for (var i = 1; i <= 5; i++)
{
actor.Tell(new SetStaticAttributeCommand(
$"corr-counter-{i}", "PumpCoalesce1", "Counter", i.ToString(), DateTimeOffset.UtcNow));
}
actor.Tell(new SetStaticAttributeCommand(
"corr-label", "PumpCoalesce1", "Label", "Running", DateTimeOffset.UtcNow));
// Every write replies synchronously (in-memory state is authoritative regardless of
// when the buffered persist lands) — six writes, six responses.
for (var i = 0; i < 6; i++)
{
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
}
// Well before the flush interval elapses, NOTHING has reached SQLite yet — proves the
// writes did not each fire their own persist (the old per-write fire-and-forget shape
// would already show a row here).
await Task.Delay(200);
var beforeFlush = await _storage.GetStaticOverridesAsync("PumpCoalesce1");
Assert.Empty(beforeFlush);
// After the flush interval elapses, exactly one coalesced write has landed: only the
// LATEST value per attribute survives.
await Task.Delay(1500);
var afterFlush = await _storage.GetStaticOverridesAsync("PumpCoalesce1");
Assert.Equal(2, afterFlush.Count);
Assert.Equal("5", afterFlush["Counter"]);
Assert.Equal("Running", afterFlush["Label"]);
}
[Fact]
public async Task InstanceActor_LoadsStaticOverridesFromSQLite()
{