v2 mxgw migration — Phase 1+2+3.1 wiring (7 PRs)
Foundational PRs from lmx_mxgw_impl.md, all green. Bodies only — DI/wiring deferred to PR 1+2.W (combined wire-up) and PR 3.W. PR 1.1 — IHistorianDataSource lifted to Core.Abstractions/Historian/ Reuses existing DataValueSnapshot + HistoricalEvent shapes; sidecar (PR 3.4) translates byte-quality → uint StatusCode internally. PR 1.2 — IHistoryRouter + HistoryRouter on the server Longest-prefix-match resolution, case-insensitive, ObjectDisposed-guarded, swallow-on-shutdown disposal of misbehaving sources. PR 1.3 — DriverNodeManager.HistoryRead* dispatch through IHistoryRouter Per-tag resolution with LegacyDriverHistoryAdapter wrapping `_driver as IHistoryProvider` so existing tests + drivers keep working until PR 7.2 retires the fallback. PR 2.1 — AlarmConditionInfo extended with five sub-attribute refs InAlarmRef / PriorityRef / DescAttrNameRef / AckedRef / AckMsgWriteRef. Optional defaulted parameters preserve all existing 3-arg call sites. PR 2.2 — AlarmConditionService state machine in Server/Alarms/ Driver-agnostic port of GalaxyAlarmTracker. Sub-attribute refs come from AlarmConditionInfo, values arrive as DataValueSnapshot, ack writes route through IAlarmAcknowledger. State machine preserves Active/Acknowledged/ Inactive transitions, Acked-on-active reset, post-disposal silence. PR 2.3 — DriverNodeManager wires AlarmConditionService MarkAsAlarmCondition registers each alarm-bearing variable with the service; DriverWritableAcknowledger routes ack-message writes through the driver's IWritable + CapabilityInvoker. Service-raised transitions route via OnAlarmServiceTransition → matching ConditionSink. Legacy IAlarmSource path unchanged for null service. PR 3.1 — Driver.Historian.Wonderware shell project (net48 x86) Console host shell + smoke test; SDK references + code lift come in PR 3.2. Tests: 9 (PR 1.1) + 5 (PR 2.1) + 10 (PR 1.2) + 19 (PR 2.2) + 1 (PR 3.1) all pass. Existing AlarmSubscribeIntegrationTests + HistoryReadIntegrationTests unchanged. Plan + audit docs (lmx_backend.md, lmx_mxgw.md, lmx_mxgw_impl.md) included so parallel subagent worktrees can read them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
|
||||
@@ -85,10 +87,31 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
private readonly IReadable? _virtualReadable;
|
||||
private readonly IReadable? _scriptedAlarmReadable;
|
||||
|
||||
// PR 1.3 — server-level history routing. When non-null + a source is registered for
|
||||
// the requested full reference, the four HistoryRead* overrides dispatch through the
|
||||
// router. Otherwise we fall back to the legacy `_driver as IHistoryProvider` path
|
||||
// wrapped in a thin adapter, so existing tests and drivers that still implement
|
||||
// IHistoryProvider directly keep working until PR 1.W flips DI to register the
|
||||
// legacy path inside the router.
|
||||
private readonly IHistoryRouter? _historyRouter;
|
||||
private LegacyDriverHistoryAdapter? _legacyHistoryAdapter;
|
||||
|
||||
// PR 2.3 — server-level alarm-condition state machine. When non-null, every
|
||||
// MarkAsAlarmCondition call also registers the condition with the service so the
|
||||
// server runs the Active/Acknowledged/Inactive transitions itself instead of
|
||||
// relying on the driver's own tracker. _conditionSinks maps conditionId →
|
||||
// ConditionSink so service-raised transitions reach the right OPC UA AlarmCondition
|
||||
// sibling. Legacy IAlarmSource path keeps working in parallel until PR 7.2.
|
||||
private readonly AlarmConditionService? _alarmService;
|
||||
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
|
||||
private EventHandler<AlarmConditionTransition>? _alarmTransitionHandler;
|
||||
|
||||
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
||||
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
||||
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
|
||||
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null)
|
||||
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
|
||||
IHistoryRouter? historyRouter = null,
|
||||
AlarmConditionService? alarmService = null)
|
||||
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
||||
{
|
||||
_driver = driver;
|
||||
@@ -100,7 +123,117 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
_scopeResolver = scopeResolver;
|
||||
_virtualReadable = virtualReadable;
|
||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||
_historyRouter = historyRouter;
|
||||
_alarmService = alarmService;
|
||||
_logger = logger;
|
||||
|
||||
if (_alarmService is not null)
|
||||
{
|
||||
_alarmTransitionHandler = OnAlarmServiceTransition;
|
||||
_alarmService.TransitionRaised += _alarmTransitionHandler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes <see cref="AlarmConditionService.TransitionRaised"/> to the matching
|
||||
/// <see cref="ConditionSink"/> registered during <c>MarkAsAlarmCondition</c>. Translates
|
||||
/// <see cref="AlarmConditionTransition"/> into the legacy <see cref="AlarmEventArgs"/>
|
||||
/// shape the existing sink consumes — the sink's switch on <c>AlarmType</c> string
|
||||
/// ("Active" / "Acknowledged" / "Inactive") is preserved so PR 2.3 doesn't perturb the
|
||||
/// OPC UA Part 9 state mapping. Stale transitions for an untracked condition are
|
||||
/// silently dropped.
|
||||
/// </summary>
|
||||
private void OnAlarmServiceTransition(object? sender, AlarmConditionTransition t)
|
||||
{
|
||||
ConditionSink? sink;
|
||||
lock (Lock)
|
||||
{
|
||||
_conditionSinks.TryGetValue(t.ConditionId, out sink);
|
||||
}
|
||||
if (sink is null) return;
|
||||
|
||||
var transitionName = t.Transition switch
|
||||
{
|
||||
AlarmStateTransition.Active => "Active",
|
||||
AlarmStateTransition.Acknowledged => "Acknowledged",
|
||||
AlarmStateTransition.Inactive => "Inactive",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
sink.OnTransition(new AlarmEventArgs(
|
||||
SubscriptionHandle: null!,
|
||||
SourceNodeId: t.ConditionId,
|
||||
ConditionId: t.ConditionId,
|
||||
AlarmType: transitionName,
|
||||
Message: t.Description ?? t.ConditionId,
|
||||
Severity: MapPriorityToSeverity(t.Priority),
|
||||
SourceTimestampUtc: t.AtUtc));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the integer priority Galaxy carries on <c>.Priority</c> (typically 1-1000) to
|
||||
/// the four-bucket <see cref="AlarmSeverity"/> the OPC UA condition sibling consumes.
|
||||
/// Mirrors the legacy <c>GalaxyProxyDriver.MapSeverity</c> bucketing.
|
||||
/// </summary>
|
||||
private static AlarmSeverity MapPriorityToSeverity(int priority) => priority switch
|
||||
{
|
||||
<= 250 => AlarmSeverity.Low,
|
||||
<= 500 => AlarmSeverity.Medium,
|
||||
<= 800 => AlarmSeverity.High,
|
||||
_ => AlarmSeverity.Critical,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAlarmAcknowledger"/> bound to a driver's <see cref="IWritable"/>.
|
||||
/// Writes the operator comment to the alarm's <c>.AckMsg</c> sub-attribute via the same
|
||||
/// dispatcher OnWriteValue uses so the resilience pipeline gates the call. Returns
|
||||
/// false when the driver doesn't implement <see cref="IWritable"/> — alarms whose
|
||||
/// drivers can't write are tracked but cannot be acknowledged through this path.
|
||||
/// </summary>
|
||||
private sealed class DriverWritableAcknowledger(
|
||||
IWritable? writable, CapabilityInvoker invoker, string driverInstanceId) : IAlarmAcknowledger
|
||||
{
|
||||
public async Task<bool> WriteAckMessageAsync(
|
||||
string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
|
||||
{
|
||||
if (writable is null || string.IsNullOrEmpty(ackMsgWriteRef)) return false;
|
||||
|
||||
var request = new DriverWriteRequest(
|
||||
FullReference: ackMsgWriteRef,
|
||||
Value: comment ?? string.Empty);
|
||||
|
||||
try
|
||||
{
|
||||
// Ack writes are not idempotent — repeating an ack would re-trigger the
|
||||
// driver-side acknowledgement state change. False matches the OnWriteValue
|
||||
// default path for non-Idempotent attributes.
|
||||
var results = await invoker.ExecuteWriteAsync(
|
||||
driverInstanceId,
|
||||
isIdempotent: false,
|
||||
async ct => await writable.WriteAsync(new[] { request }, ct).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return results.Count > 0 && results[0].StatusCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detach from the alarm service before the base disposes. The service is shared across
|
||||
/// drivers, so leaking the handler keeps a dead DriverNodeManager pinned in memory and
|
||||
/// dispatches transitions to a sink that's no longer wired to any OPC UA node.
|
||||
/// </summary>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && _alarmService is not null && _alarmTransitionHandler is not null)
|
||||
{
|
||||
_alarmService.TransitionRaised -= _alarmTransitionHandler;
|
||||
_alarmTransitionHandler = null;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
|
||||
@@ -644,7 +777,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
// Without this the Report fires but has no subscribers to deliver to.
|
||||
_owner.AddRootNotifier(alarm);
|
||||
|
||||
return new ConditionSink(_owner, alarm);
|
||||
var sink = new ConditionSink(_owner, alarm);
|
||||
|
||||
// PR 2.3 — when the server-level alarm-condition service is wired, register
|
||||
// this condition with it so the state machine runs server-side. The sink-map
|
||||
// entry routes future TransitionRaised events back to this OPC UA node.
|
||||
// Conditions whose info lacks an InAlarmRef can't be observed without driver
|
||||
// help — those still rely on the legacy IAlarmSource path until PR 7.2.
|
||||
if (_owner._alarmService is not null && !string.IsNullOrEmpty(info.InAlarmRef))
|
||||
{
|
||||
_owner._conditionSinks[FullReference] = sink;
|
||||
var acker = new DriverWritableAcknowledger(
|
||||
_owner._writable, _owner._invoker, _owner._driver.DriverInstanceId);
|
||||
_owner._alarmService.Track(FullReference, info, acker);
|
||||
}
|
||||
|
||||
return sink;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,29 +956,97 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
|
||||
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
|
||||
|
||||
// ===================== HistoryRead service handlers (LMX #1, PR 38) =====================
|
||||
// ===================== HistoryRead service handlers (LMX #1, PR 38; PR 1.3 routing) =====================
|
||||
//
|
||||
// Wires the driver's IHistoryProvider capability (PR 35 added ReadAtTimeAsync / ReadEventsAsync
|
||||
// alongside the PR 19 ReadRawAsync / ReadProcessedAsync) to the OPC UA HistoryRead service.
|
||||
// CustomNodeManager2 has four protected per-kind hooks; the base dispatches to the right one
|
||||
// based on the concrete HistoryReadDetails subtype. Each hook is sync-returning-void — the
|
||||
// per-driver async calls are bridged via GetAwaiter().GetResult(), matching the pattern
|
||||
// OnReadValue / OnWriteValue already use in this class so HistoryRead doesn't introduce a
|
||||
// different sync-over-async convention.
|
||||
// Wires HistoryRead to the server-level IHistoryRouter (PR 1.2). For each tag:
|
||||
// (1) the router resolves the longest-matching IHistorianDataSource registration —
|
||||
// when a server-registered source covers the namespace it wins; (2) when the router
|
||||
// doesn't match (or no router is configured), we fall back to the driver's own
|
||||
// IHistoryProvider capability via a thin adapter, preserving the legacy behavior tests
|
||||
// rely on. PR 1.W will register the legacy adapter inside the router as well, at
|
||||
// which point this fallback can be deleted.
|
||||
//
|
||||
// Per-node routing: every HistoryReadValueId in nodesToRead has a NodeHandle in
|
||||
// nodesToProcess; the NodeHandle's NodeId.Identifier is the driver-side full reference
|
||||
// (set during Variable() registration) so we can dispatch straight to IHistoryProvider
|
||||
// without a second lookup. Nodes without IHistoryProvider backing (drivers that don't
|
||||
// implement the capability) surface BadHistoryOperationUnsupported per slot and the
|
||||
// rest of the batch continues — same failure-isolation pattern as OnWriteValue.
|
||||
//
|
||||
// Continuation-point handling is pass-through only in this PR: the driver returns null
|
||||
// from its ContinuationPoint field today so the outer result's ContinuationPoint stays
|
||||
// empty. Full Session.SaveHistoryContinuationPoint plumbing is a follow-up when a driver
|
||||
// actually needs paging — the dispatch shape doesn't change, only the result-population.
|
||||
// Continuation-point handling is pass-through only: the source returns null from its
|
||||
// ContinuationPoint today so the outer result's ContinuationPoint stays empty. Proper
|
||||
// Session.SaveHistoryContinuationPoint plumbing is a follow-up when a source actually
|
||||
// needs paging — the dispatch shape doesn't change, only the result-population.
|
||||
|
||||
private IHistoryProvider? History => _driver as IHistoryProvider;
|
||||
/// <summary>
|
||||
/// Resolves the historian data source for a given driver full reference. Returns
|
||||
/// null when neither the router nor the legacy IHistoryProvider path can serve it.
|
||||
/// </summary>
|
||||
/// <param name="fullRef">
|
||||
/// Full reference, or null for driver-root event-history queries (event reads can
|
||||
/// target a notifier rather than a specific variable). Null fullRef skips router
|
||||
/// lookup and goes straight to the legacy fallback so today's "all events in the
|
||||
/// driver namespace" path keeps working.
|
||||
/// </param>
|
||||
private IHistorianDataSource? ResolveHistory(string? fullRef)
|
||||
{
|
||||
if (fullRef is not null
|
||||
&& _historyRouter?.Resolve(fullRef) is { } routed)
|
||||
{
|
||||
return routed;
|
||||
}
|
||||
|
||||
if (_driver is IHistoryProvider legacy)
|
||||
{
|
||||
return _legacyHistoryAdapter ??= new LegacyDriverHistoryAdapter(legacy);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a driver's <see cref="IHistoryProvider"/> as an
|
||||
/// <see cref="IHistorianDataSource"/> so the four HistoryRead* methods can dispatch
|
||||
/// through one interface regardless of resolution path. PR 1.W's legacy
|
||||
/// auto-registration uses the same adapter; PR 7.2 deletes both once
|
||||
/// IHistoryProvider stops being a driver capability.
|
||||
/// </summary>
|
||||
// OTOPCUA0001 (UnwrappedCapabilityCallAnalyzer) flags every direct IHistoryProvider call
|
||||
// that isn't lexically inside a CapabilityInvoker.ExecuteAsync lambda. The adapter's
|
||||
// pass-throughs are direct calls — but the four HistoryRead* call sites that own the
|
||||
// adapter ARE inside ExecuteAsync lambdas, so the wrapping is preserved at runtime.
|
||||
// Suppress here rather than at every call site.
|
||||
#pragma warning disable OTOPCUA0001
|
||||
private sealed class LegacyDriverHistoryAdapter(IHistoryProvider provider) : IHistorianDataSource
|
||||
{
|
||||
// HistoryReadResult is unqualified-ambiguous in this file (Core.Abstractions vs.
|
||||
// Opc.Ua); fully qualify on the adapter signatures so the file's existing var-based
|
||||
// dispatch sites stay readable.
|
||||
public Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
=> provider.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
|
||||
|
||||
public Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
=> provider.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
|
||||
|
||||
public Task<Core.Abstractions.HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
=> provider.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
=> provider.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
|
||||
|
||||
// Legacy IHistoryProvider has no health surface. Return an "unknown but reachable"
|
||||
// snapshot so dashboards don't show the data source as broken.
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
=> new(0, 0, 0, 0, null, null, null,
|
||||
ProcessConnectionOpen: true, EventConnectionOpen: true,
|
||||
ActiveProcessNode: null, ActiveEventNode: null,
|
||||
Nodes: []);
|
||||
|
||||
// Legacy lifecycle is the driver's responsibility — disposing the adapter must
|
||||
// not dispose the driver out from under DriverNodeManager.
|
||||
public void Dispose() { }
|
||||
}
|
||||
#pragma warning restore OTOPCUA0001
|
||||
|
||||
protected override void HistoryReadRawModified(
|
||||
ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps,
|
||||
@@ -838,12 +1054,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// IsReadModified=true requests a "modifications" history (who changed the data, when
|
||||
// it was re-written). The driver side has no modifications store — surface that
|
||||
// explicitly rather than silently returning raw data, which would mislead the client.
|
||||
@@ -868,6 +1078,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = ResolveHistory(fullRef);
|
||||
if (source is null)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||
@@ -883,7 +1100,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
DriverCapability.HistoryRead,
|
||||
ResolveHostFor(fullRef),
|
||||
async ct => await History.ReadRawAsync(
|
||||
async ct => await source.ReadRawAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
@@ -912,12 +1129,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// AggregateType is one NodeId shared across every item in the batch — map once.
|
||||
var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault());
|
||||
if (aggregate is null)
|
||||
@@ -930,10 +1141,6 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
@@ -942,6 +1149,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = ResolveHistory(fullRef);
|
||||
if (source is null)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||
@@ -957,7 +1171,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
DriverCapability.HistoryRead,
|
||||
ResolveHostFor(fullRef),
|
||||
async ct => await History.ReadProcessedAsync(
|
||||
async ct => await source.ReadProcessedAsync(
|
||||
fullRef,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
@@ -987,20 +1201,10 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var requestedTimes = (IReadOnlyList<DateTime>)(details.ReqTimes?.ToArray() ?? Array.Empty<DateTime>());
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
if (fullRef is null)
|
||||
@@ -1009,6 +1213,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = ResolveHistory(fullRef);
|
||||
if (source is null)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_authzGate is not null && _scopeResolver is not null)
|
||||
{
|
||||
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||
@@ -1024,7 +1235,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
DriverCapability.HistoryRead,
|
||||
ResolveHostFor(fullRef),
|
||||
async ct => await History.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
|
||||
async ct => await source.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
|
||||
CancellationToken.None).AsTask().GetAwaiter().GetResult();
|
||||
|
||||
WriteResult(results, errors, i, StatusCodes.Good,
|
||||
@@ -1048,34 +1259,30 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
if (History is null)
|
||||
{
|
||||
MarkAllUnsupported(nodesToProcess, results, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause
|
||||
// handling is a dedicated concern (proper per-select-clause Variant population + where
|
||||
// filter evaluation). This PR treats the event query as "all events in range for the
|
||||
// node's source" and populates only the standard BaseEventType fields. Richer filter
|
||||
// handling is a follow-up; clients issuing empty/default filters get the right answer
|
||||
// today which covers the common alarm-history browse case.
|
||||
// handling is a dedicated concern. This PR treats the event query as "all events in
|
||||
// range for the node's source" and populates only the standard BaseEventType fields.
|
||||
var maxEvents = (int)details.NumValuesPerNode;
|
||||
if (maxEvents <= 0) maxEvents = 1000;
|
||||
|
||||
for (var n = 0; n < nodesToProcess.Count; n++)
|
||||
{
|
||||
var handle = nodesToProcess[n];
|
||||
// NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
|
||||
// arrays. nodesToProcess is the filtered subset (just the nodes this manager
|
||||
// claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
|
||||
// are interleaved across multiple node managers.
|
||||
var i = handle.Index;
|
||||
// Event history queries may target a notifier object (e.g. the driver-root folder)
|
||||
// rather than a specific variable — in that case we pass sourceName=null to mean
|
||||
// "all sources in the driver's namespace" per the IHistoryProvider contract.
|
||||
// rather than a specific variable — in that case fullRef is null and we pass
|
||||
// sourceName=null to the source meaning "all sources in this source's namespace."
|
||||
var fullRef = ResolveFullRef(handle);
|
||||
|
||||
// ResolveHistory tolerates null fullRef — for notifier queries the router is
|
||||
// skipped and the legacy fallback handles "all sources" reads.
|
||||
var source = ResolveHistory(fullRef);
|
||||
if (source is null)
|
||||
{
|
||||
WriteUnsupported(results, errors, i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// fullRef is null for event-history queries that target a notifier (driver root).
|
||||
// Those are cluster-wide reads + need a different scope shape; skip the gate here
|
||||
// and let the driver-level authz handle them. Non-null path gets per-node gating.
|
||||
@@ -1094,7 +1301,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
DriverCapability.HistoryRead,
|
||||
fullRef is null ? _driver.DriverInstanceId : ResolveHostFor(fullRef),
|
||||
async ct => await History.ReadEventsAsync(
|
||||
async ct => await source.ReadEventsAsync(
|
||||
sourceName: fullRef,
|
||||
startUtc: details.StartTime,
|
||||
endUtc: details.EndTime,
|
||||
|
||||
Reference in New Issue
Block a user