refactor(#655): delete the dead driver-side history capability

HistoryRead is served server-side by IHistorianDataSource; nothing ever dispatched
to a driver's IHistoryProvider. Removes IHistoryProvider, the OpcUaClient history
passthrough, DriverCapability.HistoryRead (+ resilience tier rows and AdminUI row),
HistoryReadStatusException and its node-manager catches, and the tests that only
covered them. Shared result types and HistoryAggregateNotSupportedException stay
(moved to Historian/HistoryReadContracts.cs). A stored ResilienceConfig still
carrying a HistoryRead key parses (diagnostic only; new test).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 23:34:02 -04:00
parent ca17d2cf71
commit f7d497e532
23 changed files with 149 additions and 1608 deletions
@@ -8,13 +8,13 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Regression coverage for the stale-session concurrency race in the history/read wire
/// paths. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// Regression coverage for the stale-session concurrency race in the alarm-acknowledge wire
/// path. A background reconnect handler can swap <see cref="OpcUaClientDriver.Session"/>
/// while a caller is parked on <c>_gate.WaitAsync</c>. If a method captures the session
/// reference <i>before</i> the gate and uses it afterwards, it issues the wire call against
/// the closed/stale session. The correct idiom — already used by ReadAsync/WriteAsync — is
/// a fast-fail guard outside the gate plus an authoritative re-read of <c>Session</c>
/// <i>inside</i> the gate. These tests prove the history/read funnels follow it.
/// <i>inside</i> the gate. This test proves the acknowledge path follows it.
/// </summary>
/// <remarks>
/// The swap is staged so the race is exercised, not raced against:
@@ -41,13 +41,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
{
private static Mock<ISession> NewSessionMock(TaskCompletionSource? prologueReached = null)
{
var resp = new HistoryReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = new HistoryReadResultCollection(),
DiagnosticInfos = new DiagnosticInfoCollection(),
};
var mock = new Mock<ISession>(MockBehavior.Loose);
// NodeId parsing inside the driver resolves the reference against the session's
// MessageContext. This getter is read in the driver's synchronous prologue (before the
@@ -56,14 +49,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
.Returns(new ServiceMessageContext(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.SdkTelemetry.Context))
.Callback(() => prologueReached?.TrySetResult());
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
mock.Setup(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ExtensionObject>(),
It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(),
It.IsAny<HistoryReadValueIdCollection>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resp);
// Acknowledge issues its wire call via CallAsync; stub it so AcknowledgeAsync can run to
// completion against whichever session it ends up binding to.
mock.Setup(s => s.CallAsync(
@@ -79,18 +64,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
return mock;
}
private static void VerifyWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Once);
oldSession.Verify(s => s.HistoryReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<ExtensionObject>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<bool>(), It.IsAny<HistoryReadValueIdCollection>(), It.IsAny<CancellationToken>()),
Times.Never);
}
private static void VerifyAckWireHitNewSessionNotOld(Mock<ISession> newSession, Mock<ISession> oldSession)
{
newSession.Verify(s => s.CallAsync(
@@ -101,82 +74,6 @@ public sealed class OpcUaClientStaleSessionRaceTests
Times.Never);
}
/// <summary>
/// ExecuteHistoryReadAsync (the ReadRaw/ReadProcessed/ReadAtTime funnel) must issue the
/// HistoryRead against the session current <i>after</i> the gate, not a reference
/// captured before WaitAsync.
/// </summary>
[Fact]
public async Task ReadRawAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
// v3: a HistoryRead over a variable is keyed by the tag's RawPath, resolved through the
// authored RawTags table to its upstream node id. Author "ns=2;s=Counter" as both the
// RawPath and the TagConfig.nodeId so the two-stage resolve reaches the wire.
var options = new OpcUaClientDriverOptions
{
RawTags = [new RawTagEntry("ns=2;s=Counter", """{"nodeId":"ns=2;s=Counter"}""", WriteIdempotent: false)],
};
using var drv = new OpcUaClientDriver(options, "opcua-stale-raw");
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), options.RawTags));
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
// Hold the gate so the method parks on WaitAsync after running its synchronous prologue.
await drv.Gate.WaitAsync(ct);
var call = drv.ReadRawAsync(
"ns=2;s=Counter", DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, 1000, ct);
// Wait until the buggy capture-before-gate has grabbed the OLD session (signalled via
// its MessageContext read), then stage the reconnect swap and release the gate. The
// fixed path reads no pre-gate session member, so the signal never fires and the
// bounded fallback elapses — harmless, because the fixed read happens only after the
// gate is released and therefore always sees the swapped session.
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// ReadEventsAsync is a separate HistoryRead site with the same hazard — it must also
/// re-read Session inside the gate rather than use the pre-gate capture.
/// </summary>
[Fact]
public async Task ReadEventsAsync_uses_session_swapped_in_across_the_gate_not_the_captured_one()
{
var ct = TestContext.Current.CancellationToken;
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-stale-events");
var prologueReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// ReadEventsAsync only parses the source NodeId when sourceName is non-empty, so pass a
// concrete source id to force the prologue's MessageContext read (and its signal).
var oldSession = NewSessionMock(prologueReached);
var newSession = NewSessionMock();
drv.SetSessionForTest(oldSession.Object);
await drv.Gate.WaitAsync(ct);
var call = drv.ReadEventsAsync(
sourceName: "ns=2;s=Pump17", startUtc: DateTime.UtcNow.AddMinutes(-5),
endUtc: DateTime.UtcNow, maxEvents: 100, cancellationToken: ct);
await WaitForPreGateCaptureOrFallbackAsync(prologueReached.Task, ct);
drv.SetSessionForTest(newSession.Object);
drv.Gate.Release();
await call;
VerifyWireHitNewSessionNotOld(newSession, oldSession);
}
/// <summary>
/// AcknowledgeAsync issues its Acknowledge method <c>CallAsync</c> against the session
/// current after the gate — and parses the namespace-relative ConditionId against that