Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs
T
Joseph Doherty b815446940 refactor(drivers): delete GetMemoryFootprint / FlushOptionalCachesAsync (#525)
Both `IDriver` members had no production call site. Their only consumer was
`MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and
`GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which
had not been true since v3, even before #522 removed the poller.

Owner decision on #525 was (a) delete rather than (b) build the process-wide
watchdog that would consume them. The driver-side implementations are in git
history and were mostly reusable; what was always missing is the trigger, and a
member nobody calls is how the misleading doc-comment survived this long.

Removed from `IDriver`, all 12 driver implementations, and every test stub —
plus the support code that existed only to feed them, which is the part a
member-only deletion would have left behind as dead weight:

- MTConnect's three per-entry byte-weight constants, its cached
  `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run
  on every cache fill to populate a number with no remaining reader.
- OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it.
- MQTT's `ApproxBytesPerAuthoredTag`.

Two behavioural notes, neither a change in what the software does:

- `AbCipTemplateCache` now has no production reset path. Its only caller was
  AB CIP's flush, which itself had no caller — so nothing changes except how
  visible the fact is. Recorded on the type, along with the correction that its
  comment's claim about a device `ReinitializeAsync` also flushing it was never
  true. A UDT redefined on a live controller keeps its stale shape until the
  driver instance is rebuilt.
- MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an
  Agent restart. That is now `internal` rather than `private` so the tests which
  used the flush purely to empty the cache drive the real path instead — they
  now cover a case that actually occurs in production. Several unrelated doc
  comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are
  repointed to it.

Tests: the ones that existed only to assert on the removed members are deleted
(3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was
vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a
footprint of 0 before the flush and 0 after with no discovery in between, so it
would have passed against a flush that did nothing at all.

Full suite green except 7 failures verified identical on master before and
after (4 AbLegacy integration with the compose profile down, 3
Host.IntegrationTests), checked by name rather than assumed.

Docs corrected where they described this as live: `DriverLifecycle.md` (which
carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and
`v2/driver-stability.md` — whose "polls this every 30 s" + per-driver
`DriverConfig` memory budget were never built as written and are now banner-ed
as design intent rather than description.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 09:07:11 -04:00

184 lines
9.2 KiB
C#

using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Shared <see cref="IDriver"/> stub harness used by <c>DriverInstanceActorTests</c> and
/// <c>DriverInstanceActorWriteAndSubscribeTests</c>. Promoted from the superset copy in
/// <c>DriverInstanceActorTests</c> so both suites compile against a single definition.
/// </summary>
internal class StubDriver : IDriver
{
/// <summary>Gets or sets a value indicating whether initialization should throw.</summary>
public bool InitializeShouldThrow { get; set; }
/// <summary>Gets the number of times initialization was called.</summary>
public int InitializeCount;
/// <summary>Gets the number of times reinitialization was called.</summary>
public int ReinitializeCount;
private readonly object _initConfigsLock = new();
/// <summary>Every config string passed to <see cref="InitializeAsync"/>, in call order.</summary>
public List<string> InitConfigs { get; } = new();
/// <summary>Optional per-config init behaviour. When set, it fully owns the init outcome for that
/// config (await/throw); <see cref="InitializeShouldThrow"/> is ignored. Null ⇒ legacy behaviour.</summary>
public Func<string, Task>? InitBehavior { get; set; }
/// <summary>Gets the driver instance ID.</summary>
public string DriverInstanceId => "stub-driver-1";
/// <summary>Gets the driver type.</summary>
public string DriverType => "Stub";
/// <summary>Initializes the driver with the specified configuration JSON.</summary>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref InitializeCount);
lock (_initConfigsLock) InitConfigs.Add(driverConfigJson);
if (InitBehavior is not null) { await InitBehavior(driverConfigJson); return; }
if (InitializeShouldThrow) throw new InvalidOperationException("stub-init-fail");
}
/// <summary>Reinitializes the driver with the specified configuration JSON.</summary>
/// <param name="driverConfigJson">The driver configuration JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReinitializeCount);
return Task.CompletedTask;
}
/// <summary>Number of <see cref="ShutdownAsync"/> calls — a child actor stopping raises it, so a test
/// can observe a driver child being torn down (e.g. by the #516 config-change respawn).</summary>
public int ShutdownCount;
/// <summary>Shuts down the driver.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task ShutdownAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ShutdownCount);
return Task.CompletedTask;
}
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
}
/// <summary>
/// A <see cref="StubDriver"/> that also implements <see cref="IWritable"/>, recording every
/// write call and returning a configurable status code.
/// </summary>
internal sealed class WritableStubDriver : StubDriver, IWritable
{
/// <summary>Gets or sets the next status code to return from write operations.</summary>
public uint NextStatusCode { get; set; } = 0u;
/// <summary>Gets the list of write requests received.</summary>
public List<WriteRequest> Writes { get; } = new();
/// <summary>Writes the specified requests.</summary>
/// <param name="writes">The write requests.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
Writes.AddRange(writes);
IReadOnlyList<WriteResult> results = writes.Select(_ => new WriteResult(NextStatusCode)).ToList();
return Task.FromResult(results);
}
}
/// <summary>
/// A <see cref="StubDriver"/> that also implements <see cref="ISubscribable"/>, firing
/// <see cref="OnDataChange"/> on demand and counting subscribe calls.
/// </summary>
internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
{
/// <summary>Occurs when data changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
private readonly StubHandle _handle = new();
/// <summary>Gets the number of subscribers to OnDataChange.</summary>
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
/// <summary>Number of times <see cref="SubscribeAsync"/> was called (re-subscribe asserts).</summary>
public int SubscribeCount;
/// <summary>The reference set passed to the most recent <see cref="SubscribeAsync"/> call.</summary>
public IReadOnlyList<string>? LastSubscribedRefs;
/// <summary>Number of times <see cref="UnsubscribeAsync"/> was called — the observable for a live
/// subscription being torn down (an EMPTY desired set drops the handle rather than re-subscribing).</summary>
public int UnsubscribeCount;
/// <summary>When true, <see cref="UnsubscribeAsync"/> genuinely yields (<c>await Task.Yield()</c>)
/// before completing, so a <c>ConfigureAwait(false)</c> continuation in the actor resumes off the
/// Akka ActorContext on a thread-pool thread — reproducing the no-ActorContext race that a
/// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary>
public bool UnsubscribeYields { get; set; }
/// <summary>
/// How many of the first <see cref="SubscribeAsync"/> calls throw before one succeeds. Default 0
/// (always succeed) leaves existing behaviour untouched.
/// </summary>
/// <remarks>
/// This is how a test reaches the state the #488 reconcile exists for: the actor is Connected and
/// holds a desired ref set, but the subscribe that should have established the handle failed, so
/// <c>_subscriptionHandle</c> is null and nothing else will ever re-assert it — there is no
/// connect transition coming, and no deploy.
/// </remarks>
public int SubscribeFailuresBeforeSuccess { get; set; }
/// <summary>Subscribes to the specified full references.</summary>
/// <param name="fullReferences">The full references to subscribe to.</param>
/// <param name="publishingInterval">The publishing interval.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var attempt = Interlocked.Increment(ref SubscribeCount);
LastSubscribedRefs = fullReferences;
if (attempt <= SubscribeFailuresBeforeSuccess)
{
throw new InvalidOperationException($"stub subscribe failure #{attempt}");
}
return Task.FromResult<ISubscriptionHandle>(_handle);
}
/// <summary>Unsubscribes from the specified subscription handle.</summary>
/// <param name="handle">The subscription handle.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
Interlocked.Increment(ref UnsubscribeCount);
if (UnsubscribeYields)
{
// Complete the awaited task from a fresh background thread that has NO Akka actor
// cell on it, so the caller's `ConfigureAwait(false)` continuation resumes on a
// clean thread-pool thread where InternalCurrentActorCellKeeper.Current is null —
// a deterministic repro of the real async-backend no-ActorContext race.
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(() => tcs.SetResult());
await tcs.Task.ConfigureAwait(false);
}
}
/// <summary>Fires a data change event with the specified parameters.</summary>
/// <param name="fullRef">The full reference of the data that changed.</param>
/// <param name="value">The new value.</param>
/// <param name="statusCode">The OPC UA status code.</param>
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
}
}
/// <summary>Minimal <see cref="ISubscriptionHandle"/> for use by <see cref="SubscribableStubDriver"/>.</summary>
internal sealed class StubHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the subscription.</summary>
public string DiagnosticId => "stub-sub";
}