feat(siteruntime): decode static List attributes to typed lists in InstanceActor (load/override/set)

This commit is contained in:
Joseph Doherty
2026-06-16 15:52:29 -04:00
parent 6ef6bab26e
commit 7f97780bb3
2 changed files with 259 additions and 6 deletions
@@ -124,13 +124,29 @@ public class InstanceActor : ReceiveActor
{
foreach (var attr in _configuration.Attributes)
{
_attributes[attr.CanonicalName] = attr.Value;
_attributeQualities[attr.CanonicalName] =
string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
// MV-8: index resolved attributes for O(1) lookup on the hot
// TagValueUpdate ingest path (last-wins on duplicate names).
_resolvedAttributeByName[attr.CanonicalName] = attr;
// MV-7: 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))
{
var decoded = DecodeAttributeValue(attr, attr.Value);
_attributes[attr.CanonicalName] = decoded;
_attributeQualities[attr.CanonicalName] =
decoded is null && !string.IsNullOrEmpty(attr.Value) ? "Bad"
: string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
}
else
{
_attributes[attr.CanonicalName] = attr.Value;
_attributeQualities[attr.CanonicalName] =
string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
}
}
}
@@ -319,7 +335,21 @@ public class InstanceActor : ReceiveActor
/// </summary>
private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
{
_attributes[command.AttributeName] = command.Value;
// MV-7: command.Value is the canonical form — a plain string for scalars,
// a JSON array string for List attributes. For a List attribute we store
// the DECODED typed list in memory (so scripts read a real collection) but
// persist + publish the canonical JSON string UNCHANGED below. Scalars
// 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))
{
_attributes[command.AttributeName] = DecodeAttributeValue(resolved, command.Value);
}
else
{
_attributes[command.AttributeName] = command.Value;
}
// Publish attribute change to stream (WP-23) and notify children
var changed = new AttributeValueChanged(
@@ -499,6 +529,40 @@ public class InstanceActor : ReceiveActor
Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
&& dt == DataType.List;
/// <summary>
/// MV-7: decodes a STATIC (authored / overridden) attribute's canonical value
/// for in-memory storage. List attributes carry a canonical JSON array string
/// (config default or persisted override) which is decoded via
/// <see cref="AttributeValueCodec.Decode"/> into a typed <c>List&lt;T&gt;</c>
/// so scripts read a real collection; scalars pass through unchanged. This is
/// the authored counterpart to MV-8's <see cref="TryCoerceListValue"/> (which
/// coerces live OPC UA CLR arrays). An undecodable List value (malformed JSON,
/// bad element, missing element type) degrades to <see langword="null"/> + a
/// warning — the caller marks the attribute Bad quality. NEVER throws into the
/// actor.
/// </summary>
private object? DecodeAttributeValue(ResolvedAttribute attr, string? raw)
{
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);
}
catch (FormatException ex)
{
_logger.LogWarning(ex,
"Attribute '{Attr}' on '{Instance}' has an undecodable List value; marking Bad quality",
attr.CanonicalName, _instanceUniqueName);
return null; // caller marks quality Bad
}
}
/// <summary>
/// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
/// into a typed <c>List&lt;elementClrType&gt;</c> matching the attribute's
@@ -825,7 +889,22 @@ public class InstanceActor : ReceiveActor
foreach (var kvp in result.Overrides)
{
_attributes[kvp.Key] = kvp.Value;
// MV-7: persisted override values are canonical strings — a JSON array
// string for List attributes, a plain string for scalars. Decode List
// 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))
{
var decoded = DecodeAttributeValue(resolved, kvp.Value);
_attributes[kvp.Key] = decoded;
if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
_attributeQualities[kvp.Key] = "Bad";
}
else
{
_attributes[kvp.Key] = kvp.Value;
}
}
_logger.LogDebug(