Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs
T
Joseph Doherty 9a67ed918a feat(mtconnect): ISubscribable /sample pump + ring-buffer re-baseline (Task 11)
One shared /sample long poll behind every subscription handle. Subscribe fires
initial data per reference from the primed /current (Part 4 convention) and
returns without waiting on the stream; the pump runs on its own task under its
own token — never the caller's Subscribe token, which is disposed the moment
that call returns.

The pump owns every way the sequence can break:

- sequence gap, measured against the RUNNING cursor (the previous chunk's
  nextSequence). Comparing against the opening `from` reports a gap on every
  chunk once the buffer rolls — a /current re-baseline storm.
- OUT_OF_RANGE under HTTP 200 (InvalidDataException out of SampleAsync), which
  IsSequenceGap cannot see at all — without it a driver recovers from falling a
  little behind but not from falling a lot.
- agent restart, checked BEFORE the gap because a restart resets sequences and
  so usually trips the gap check too; it clears the index (the held values
  describe a device model that no longer exists) and tells the subscribers.
- every chunk advances the cursor, heartbeats included.

Re-baseline calls CurrentAsync directly on the client the pump already holds:
routing it through ReinitializeAsync would take the non-reentrant lifecycle
semaphore from inside a pump a lifecycle method may already be awaiting, and
hang the driver with no exception and no log. StopSampleStreamAsync is filled
in (same call sites) and InitializeAsync now stops the stream before teardown
too, so a re-Initialize on a live instance cannot leave a pump enumerating a
disposed client.

MTConnectStreamEnded/TimeoutException reconnect under a geometric backoff with
a 100 ms growth floor (MinBackoffMs defaults to 0, and 0 x multiplier is still
0). MTConnectStreamNotSupportedException does NOT retry — it latches, reports
loudly, and is cleared only by a re-initialize.

Health precedence is explicit: /current and /sample fail independently, so each
path owns a flag, clears only its own, and Healthy requires both clear. Neither
can paper over the other's failure.

29 new tests, all deterministic — no sleeps, no wall-clock assertions. The fake
grew per-generation sample streams (so a reconnect has somewhere to land),
scripted /current answers, scripted sample failures, and a chunk-consumed
signal that also fires when the consumer abandons the stream. 375/375 green.
2026-07-27 13:57:46 -04:00

30 lines
1.4 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Driver-internal identity of one <c>/sample</c> subscription, matching the sibling drivers'
/// shape (Galaxy's <c>GalaxySubscriptionHandle</c>, the shared <c>PollGroupEngine</c>'s
/// <c>PollSubscriptionHandle</c>): a monotonic per-driver id plus a diagnostic string that
/// carries it into logs.
/// </summary>
/// <remarks>
/// <para>
/// <b>Every handle shares ONE Agent stream.</b> Unlike a polled driver, where each
/// subscription owns a loop, MTConnect's <c>/sample</c> long poll is per-Agent: the driver
/// opens exactly one and fans each chunk out to whichever handles subscribe the reporting
/// DataItem. The handle is therefore purely an identity — it owns no connection, no task,
/// and no cursor — and dropping one only stops the stream when it was the last.
/// </para>
/// <para>
/// A <see langword="record"/> for value equality on the id, so a handle that has round-tripped
/// through the caller still resolves. The id is never reused within a driver instance.
/// </para>
/// </remarks>
/// <param name="SubscriptionId">The monotonic per-driver subscription id.</param>
internal sealed record MTConnectSampleHandle(long SubscriptionId) : ISubscriptionHandle
{
/// <inheritdoc/>
public string DiagnosticId => $"mtconnect-sub-{SubscriptionId}";
}