From 2e3f528afc93a0ec29bfe6324733e9749fa1e4ff Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 04:18:35 -0400 Subject: [PATCH] feat(galaxy): writer borrows live subscription item handles (skip redundant AddItem) GatewayGalaxyDataWriter now accepts an optional subscribedHandleSource delegate; TryResolveCachedOrBorrowed checks _itemHandles first then the source, so the first write to an already-subscribed tag skips the AddItem round-trip. Borrowed handles are not cached (subscription registry owns lifecycle). AddItemCallCount seam confirms gateway calls. --- .../Runtime/GatewayGalaxyDataWriter.cs | 4 +- .../Runtime/GatewayGalaxyDataWriterTests.cs | 93 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs index 9e930a76..bedb1a7e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GatewayGalaxyDataWriter.cs @@ -195,10 +195,10 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter private async Task EnsureItemHandleAsync( MxGatewaySession session, int serverHandle, string fullRef, CancellationToken ct) { - if (_itemHandles.TryGetValue(fullRef, out var existing)) return existing; + if (TryResolveCachedOrBorrowed(fullRef) is int resolved) return resolved; var handle = await session.AddItemAsync(serverHandle, fullRef, ct).ConfigureAwait(false); - _itemHandles[fullRef] = handle; Interlocked.Increment(ref _addItemCallCount); + _itemHandles[fullRef] = handle; return handle; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterTests.cs index 5d159665..7c3f00fe 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterTests.cs @@ -88,4 +88,97 @@ public sealed class GatewayGalaxyDataWriterTests writer.CachedItemHandleCount.ShouldBe(0); writer.CachedSupervisedHandleCount.ShouldBe(0); } + + // ===== subscribedHandleSource / TryResolveCachedOrBorrowed tests ===== + + /// + /// When a subscribedHandleSource delegate returns a positive handle for the + /// requested ref, must + /// return that handle. Critically, the borrowed handle must NOT be stored in the writer's + /// own item-handle cache (the subscription registry owns its lifecycle), and + /// must remain zero (no real + /// gateway round-trip issued). + /// + [Fact] + public void TryResolveCachedOrBorrowed_returns_borrowed_handle_without_caching_it() + { + var writer = new GatewayGalaxyDataWriter( + MinimalSession(), writeUserId: 0, logger: null, + subscribedHandleSource: fr => fr == "Tag.X" ? 7 : (int?)null); + + var result = writer.TryResolveCachedOrBorrowed("Tag.X"); + + result.ShouldBe(7); + writer.CachedItemHandleCount.ShouldBe(0); // borrow NOT stored in _itemHandles + writer.AddItemCallCount.ShouldBe(0); // no real AddItem issued + } + + /// + /// When a handle is present in the writer's own _itemHandles cache (seeded via + /// ), that cached handle must + /// win over the subscribedHandleSource delegate — the writer's own earlier AddItem + /// takes priority. + /// + [Fact] + public void TryResolveCachedOrBorrowed_cached_handle_wins_over_source() + { + // Source would return 99 for "Tag.Y", but the writer already cached handle 42 for it. + var writer = new GatewayGalaxyDataWriter( + MinimalSession(), writeUserId: 0, logger: null, + subscribedHandleSource: fr => fr == "Tag.Y" ? 99 : (int?)null); + + writer.SeedHandleCachesForTest("Tag.Y", itemHandle: 42, supervised: false); + + var result = writer.TryResolveCachedOrBorrowed("Tag.Y"); + + result.ShouldBe(42); // cached wins, not 99 from the source + } + + /// + /// When no subscribedHandleSource is provided (null), the method must + /// return null for a ref that has never been cached — preserving today's + /// AddItem-required behavior. + /// + [Fact] + public void TryResolveCachedOrBorrowed_null_source_returns_null_when_not_cached() + { + var writer = new GatewayGalaxyDataWriter(MinimalSession(), writeUserId: 0); + + var result = writer.TryResolveCachedOrBorrowed("Tag.Z"); + + result.ShouldBeNull(); + } + + /// + /// A subscribedHandleSource that returns 0 (a sentinel meaning "subscribe + /// pending / failed") must be treated as no borrow — the method returns null + /// so the caller issues a fresh AddItem. + /// + [Fact] + public void TryResolveCachedOrBorrowed_source_returning_zero_treated_as_no_borrow() + { + var writer = new GatewayGalaxyDataWriter( + MinimalSession(), writeUserId: 0, logger: null, + subscribedHandleSource: _ => 0); + + var result = writer.TryResolveCachedOrBorrowed("Tag.W"); + + result.ShouldBeNull(); + } + + /// + /// A subscribedHandleSource that returns a negative value (another failure + /// sentinel) must also be treated as no borrow — the method returns null. + /// + [Fact] + public void TryResolveCachedOrBorrowed_source_returning_negative_treated_as_no_borrow() + { + var writer = new GatewayGalaxyDataWriter( + MinimalSession(), writeUserId: 0, logger: null, + subscribedHandleSource: _ => -5); + + var result = writer.TryResolveCachedOrBorrowed("Tag.V"); + + result.ShouldBeNull(); + } }