PR 4.4 — ISubscribable + EventPump
Subscription path online. GalaxyDriver implements ISubscribable; subscribes batches via gw SubscribeBulkAsync, runs a single shared EventPump consumer of StreamEventsAsync, fans out OnDataChange events to every driver subscription that observes the changed gw item handle. Files: - Runtime/GalaxySubscriptionHandle.cs — record implementing ISubscriptionHandle. - Runtime/SubscriptionRegistry.cs — bookkeeping with forward (subscriptionId → bindings) and reverse (itemHandle → list of subscriptionIds) maps. The reverse map is the fan-out index so a single OnDataChange dispatches to every subscription that observes the changed handle. - Runtime/IGalaxySubscriber.cs — driver-side seam: SubscribeBulk + UnsubscribeBulk + StreamEventsAsync. Production wraps GalaxyMxSession; tests substitute a fake driving synthetic MxEvents. - Runtime/GatewayGalaxySubscriber.cs — production. Forwards to MxGatewaySession; bufferedUpdateIntervalMs is captured for now and becomes a SetBufferedUpdateInterval call once gw issue #102 / gw-9 lands (PR 6.3 picks this up). - Runtime/EventPump.cs — long-running background consumer of StreamEventsAsync. Decodes MxValue + maps quality byte/MxStatusProxy via StatusCodeMap. Fan-out per subscriber resolves through the registry; bad handler exceptions are caught + logged, never break the dispatch loop. Filters out non-OnDataChange families (write-complete and operation- complete come back via InvokeAsync's reply path, not the event stream). GalaxyDriver: - Adds ISubscribable. SubscribeAsync allocates a subscription id, SubscribeBulks, builds the binding list (failed gw entries get ItemHandle=0 + a per-tag warn log), registers, and returns the handle. EventPump is started lazily on first subscribe; one pump per driver shared across all subscriptions. - UnsubscribeAsync removes from the registry first (so stale events are filtered immediately) then calls UnsubscribeBulk best-effort. Foreign handles throw ArgumentException. - ReadAsync NotSupportedException message updated: PR 4.4 no longer the pointer (deferred to a small follow-up that wraps the pump as a one-shot reader). - Dispose tears down the pump first, then the repository client, then clears state. - Internal ctor extended with optional subscriber parameter. Tests (15 new, 109 Galaxy total): - SubscriptionRegistryTests: monotonic id allocation, single+multi subscription fan-out, failed-handle exclusion, removal isolation, count invariants. - GalaxyDriverSubscribeTests: handle allocation + value-change dispatch, multi-subscription fan-out, failed-tag silence, unsubscribe drops gw handle and stops dispatch, foreign handle throws, no-subscriber throws, empty-tag-list returns handle without calling gw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
/// <see cref="GalaxyDriverFactoryExtensions"/> registers under driver-type name
|
||||
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
|
||||
/// </remarks>
|
||||
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, IDisposable
|
||||
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IDisposable
|
||||
{
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
@@ -51,21 +51,39 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable,
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification>
|
||||
_securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// PR 4.4 — subscription lifecycle. The pump consumes the gw event stream and fans
|
||||
// out OnDataChange events to every registered driver subscription via the registry's
|
||||
// reverse map. The subscriber is the test seam — production uses
|
||||
// GatewayGalaxySubscriber over a connected GalaxyMxSession.
|
||||
private readonly IGalaxySubscriber? _subscriber;
|
||||
private readonly SubscriptionRegistry _subscriptions = new();
|
||||
private EventPump? _eventPump;
|
||||
private readonly Lock _pumpLock = new();
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed data-change notification. Fires from the
|
||||
/// <see cref="EventPump"/>'s background loop; handlers should be cheap (or queue
|
||||
/// onto another thread) to avoid blocking the gw event stream.
|
||||
/// </summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
public GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
: this(driverInstanceId, options, hierarchySource: null, dataReader: null, dataWriter: null, logger)
|
||||
: this(driverInstanceId, options,
|
||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-visible ctor — inject custom seams so <see cref="DiscoverAsync"/>,
|
||||
/// <see cref="ReadAsync"/>, and <see cref="WriteAsync"/> can be exercised against
|
||||
/// canned data without building real gRPC channels.
|
||||
/// <see cref="ReadAsync"/>, <see cref="WriteAsync"/>, and
|
||||
/// <see cref="SubscribeAsync"/> can be exercised against canned data without
|
||||
/// building real gRPC channels.
|
||||
/// </summary>
|
||||
internal GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
@@ -73,6 +91,7 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable,
|
||||
IGalaxyHierarchySource? hierarchySource,
|
||||
IGalaxyDataReader? dataReader = null,
|
||||
IGalaxyDataWriter? dataWriter = null,
|
||||
IGalaxySubscriber? subscriber = null,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
{
|
||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||
@@ -83,6 +102,7 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable,
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
_subscriber = subscriber;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -206,6 +226,110 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable,
|
||||
return _dataWriter.WriteAsync(writes, ResolveSecurity, cancellationToken);
|
||||
}
|
||||
|
||||
// ===== ISubscribable (PR 4.4) =====
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
|
||||
if (_subscriber is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"GalaxyDriver.SubscribeAsync requires a connected GalaxyMxSession + GatewayGalaxySubscriber. " +
|
||||
"PR 4.W wires the production session; until then route subscriptions through the legacy-host backend.");
|
||||
}
|
||||
|
||||
var pump = EnsureEventPumpStarted();
|
||||
var subscriptionId = _subscriptions.NextSubscriptionId();
|
||||
|
||||
if (fullReferences.Count == 0)
|
||||
{
|
||||
// Empty subscriptions register but never bind anything — keeps Unsubscribe
|
||||
// symmetric for callers that conditionally add tags later.
|
||||
_subscriptions.Register(subscriptionId, []);
|
||||
return new GalaxySubscriptionHandle(subscriptionId);
|
||||
}
|
||||
|
||||
var bufferedIntervalMs = (int)Math.Max(0, publishingInterval.TotalMilliseconds);
|
||||
var results = await _subscriber
|
||||
.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Build the binding list in input order. Failed entries (gw rejected the tag) are
|
||||
// recorded with a non-positive ItemHandle so the caller can detect partial failure
|
||||
// by inspecting the returned handle's diagnostic context — full per-tag error
|
||||
// surface lands in PR 5.3's parity tests.
|
||||
var bindings = new List<TagBinding>(fullReferences.Count);
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var fullRef = fullReferences[i];
|
||||
var match = results.FirstOrDefault(r => string.Equals(r.TagAddress, fullRef, StringComparison.OrdinalIgnoreCase));
|
||||
var itemHandle = match is { WasSuccessful: true } ? match.ItemHandle : 0;
|
||||
bindings.Add(new TagBinding(fullRef, itemHandle));
|
||||
if (match is null || !match.WasSuccessful)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Galaxy subscribe for {FullRef} failed: {Error}",
|
||||
fullRef, match?.ErrorMessage ?? "<no result returned>");
|
||||
}
|
||||
}
|
||||
|
||||
_subscriptions.Register(subscriptionId, bindings);
|
||||
_ = pump; // keep the pump alive for the subscription's lifetime
|
||||
return new GalaxySubscriptionHandle(subscriptionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
if (handle is not GalaxySubscriptionHandle gsh)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Subscription handle was not issued by this driver (expected GalaxySubscriptionHandle, got {handle.GetType().Name}).",
|
||||
nameof(handle));
|
||||
}
|
||||
|
||||
var bindings = _subscriptions.Remove(gsh.SubscriptionId);
|
||||
if (bindings is null) return; // already removed or never registered
|
||||
|
||||
var liveItemHandles = bindings.Where(b => b.ItemHandle > 0).Select(b => b.ItemHandle).ToArray();
|
||||
if (liveItemHandles.Length == 0 || _subscriber is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _subscriber.UnsubscribeBulkAsync(liveItemHandles, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Galaxy UnsubscribeBulk failed for subscription {SubscriptionId} — registry already cleared on driver side.",
|
||||
gsh.SubscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily start the <see cref="EventPump"/> on the first subscribe. The pump is
|
||||
/// shared across every subscription on this driver — fan-out happens through the
|
||||
/// <see cref="SubscriptionRegistry"/> reverse map, not by spinning a pump per
|
||||
/// subscription.
|
||||
/// </summary>
|
||||
private EventPump EnsureEventPumpStarted()
|
||||
{
|
||||
lock (_pumpLock)
|
||||
{
|
||||
if (_eventPump is not null) return _eventPump;
|
||||
_eventPump = new EventPump(_subscriber!, _subscriptions, _logger);
|
||||
_eventPump.OnDataChange += (_, args) => OnDataChange?.Invoke(this, args);
|
||||
_eventPump.Start();
|
||||
return _eventPump;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily builds the default <see cref="IGalaxyHierarchySource"/> from
|
||||
/// <c>_options.Gateway</c>. Owned <see cref="GalaxyRepositoryClient"/> is disposed in
|
||||
@@ -237,6 +361,11 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable,
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
EventPump? pump;
|
||||
lock (_pumpLock) { pump = _eventPump; _eventPump = null; }
|
||||
pump?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
_ownedRepositoryClient?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
_ownedRepositoryClient = null;
|
||||
_hierarchySource = null;
|
||||
|
||||
Reference in New Issue
Block a user