feat(opcuaserver): node manager batched WriteValues under one lock

This commit is contained in:
Joseph Doherty
2026-08-15 12:13:46 -04:00
parent adebad711f
commit 8d7350ea73
3 changed files with 219 additions and 15 deletions
@@ -513,25 +513,60 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, nodeId);
lock (Lock)
{
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(nodeId, ns);
_variables[key] = variable;
}
WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc);
}
variable.Value = value;
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
/// <summary>Batched <see cref="WriteValue"/>: one <c>Lock</c> acquisition for the whole batch instead of
/// one per value — the publish path's per-value lock take was the steady-state throughput ceiling.
/// Per-item semantics are identical to <see cref="WriteValue"/> (create-on-first-write, #519 verbatim
/// status codes, ClearChangeMasks per node).</summary>
/// <param name="values">The value writes to apply, in order.</param>
public void WriteValues(IReadOnlyList<ValueWrite> values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Count == 0) return;
EnsureAddressSpaceCreated();
lock (Lock)
{
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
WriteValueLocked(v.NodeId, NamespaceIndexForRealm(v.Realm), MapKey(v.Realm, v.NodeId),
v.Value, v.StatusCode, v.SourceTimestampUtc);
}
}
}
/// <summary>The body of a single value write. <b>Caller MUST hold <c>Lock</c></b> — this is factored out
/// solely so <see cref="WriteValues"/> can apply a whole batch under one acquisition; it is byte-identical
/// to what <see cref="WriteValue"/> has always done inside its own <c>lock</c>.</summary>
/// <param name="nodeId">The bare <c>s=</c> identifier of the variable.</param>
/// <param name="ns">The namespace index resolved from the write's realm.</param>
/// <param name="key">The realm-qualified map key for <paramref name="nodeId"/>.</param>
/// <param name="value">The new value to write.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
private void WriteValueLocked(string nodeId, ushort ns, string key, object? value, uint statusCode, DateTime sourceTimestampUtc)
{
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(nodeId, ns);
_variables[key] = variable;
}
variable.Value = value;
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
// Stays INSIDE the lock, per node: this is how the SDK notifies monitored items of the change.
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
/// <summary>
/// Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
/// been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
@@ -24,6 +24,13 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteValue(nodeId, value, statusCode, sourceTimestampUtc, realm);
/// <inheritdoc />
/// <remarks>Overrides the interface's default implementation on purpose: that default loops
/// <see cref="WriteValue"/>, which would re-take the node manager's SDK-wide <c>Lock</c> once per value.
/// Forwarding to the manager's own batched entry point takes it ONCE for the whole batch.</remarks>
public void WriteValues(IReadOnlyList<ValueWrite> values)
=> _nodeManager.WriteValues(values);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);