fix(historian): events arm sets results on bad paths + Variant.Null SourceName + test hardening

- HistoryReadEvents miss path + catch path now both set results[handle.Index] explicitly
  (new SdkHistoryReadResult { StatusCode = BadHistoryOperationUnsupported }) — don't rely on
  base pre-seeding results[i] so every path sets BOTH errors and results coherently (#1)
- ProjectEventField: SourceName null now emits Variant.Null instead of a String-typed null
  variant (evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName)) (#3)
- Comment near the HistoryRead dispatcher block updated: all four arms (Raw/Processed/AtTime
  + Events/Task 4) are now overridden — "left to the base" wording was stale (#5)
- Happy-path test adds ReceiveTime to select clauses and asserts it projects ReceivedTimeUtc
  as a DateTime Variant at the correct select-order position (#4)
- Backend-throw test hardened: asserts errors[0] via ServiceResult.IsBad + explicit code,
  asserts results[0] is non-null with the Bad code (no longer relies on base seeding),
  and asserts EventsEntered to prove the override reached the bridge before the throw (#1)
- RecordingHistorianDataSource gains EventsEntered flag (set before ThrowOnRead check) (#1)
- Events_non_source_node test gains clarifying doc comment explaining the SDK base rejects
  variable nodes (EventNotifier=None) for event reads before our override runs; the
  override's source-guard is exercised by the promoted-without-source test instead (#2)
This commit is contained in:
Joseph Doherty
2026-06-14 20:10:16 -04:00
parent e3c0ef7b41
commit e6ec0ad8be
2 changed files with 53 additions and 20 deletions
@@ -1055,11 +1055,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// it validates handles under Lock, builds `nodesToProcess` (a NodeHandle list for nodes WE own
// that carry the HistoryRead access bit), validates the timestamp args, handles
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
// protected virtuals below. We override the three variable-history virtuals; HistoryReadEvents is
// left to the base (Task 4 adds it). Each override receives the pre-filtered handles and fills
// results[handle.Index] / errors[handle.Index] — handle.Index is the original index into the
// service-level results/errors lists, seeded by the base. The base pre-seeds every handle's error
// to BadHistoryOperationUnsupported, so a handle we don't recognise stays "unsupported" by default.
// protected virtuals below. We override all four arms: the three variable-history virtuals
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents, Task 4). Each override
// receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
// handle.Index is the original index into the service-level results/errors lists, seeded by the
// base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
// we don't recognise stays "unsupported" by default.
//
// NOTE: unlike OnWriteValue, the SDK does NOT hold the node-manager Lock while invoking these, so
// block-bridging the async data source (GetAwaiter().GetResult()) is safe — it can't freeze the
@@ -1196,8 +1197,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (idString is null || !_eventNotifierSources.TryGetValue(idString, out var sourceName))
{
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
// (The base pre-seeds this same status; set it explicitly so the contract is local + obvious.)
// Set both errors and results explicitly on every bad path — don't rely on the SDK base
// pre-seeding results[i], so every path is self-contained and the contract is obvious.
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
continue;
}
@@ -1231,6 +1234,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
}
}
}
@@ -1289,7 +1293,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
"SourceName" => new Variant(evt.SourceName), // string; null ⇒ Variant.Null
"SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
"Time" => new Variant(evt.EventTimeUtc),
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),