diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 58412d0f..1bc2cedd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -293,6 +293,9 @@ public sealed class GalaxyDriver if (_ownedMxSession is null) return; var clientOptions = BuildClientOptions(_options.Gateway); await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false); + // The recreated session invalidates every prior gw item handle; drop the writer's handle/advise + // caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session. + _dataWriter?.InvalidateHandleCaches(); } /// 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 2574f1e5..1d74c721 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 @@ -42,6 +42,42 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter _logger = logger ?? NullLogger.Instance; } + /// + public void InvalidateHandleCaches() + { + _itemHandles.Clear(); + _supervisedHandles.Clear(); + } + + // ===== Test seams (internal — not part of the public contract) ===== + + /// + /// Count of item-handle cache entries. Zero on a fresh instance or immediately after + /// . Used by unit tests to verify cache state + /// without running a real gRPC round-trip. + /// + internal int CachedItemHandleCount => _itemHandles.Count; + + /// + /// Count of supervisory-advised handle entries. Zero on a fresh instance or immediately + /// after . Used by unit tests to verify cache state. + /// + internal int CachedSupervisedHandleCount => _supervisedHandles.Count; + + /// + /// Pre-populate both caches as if a write had already occurred. Used by unit tests to + /// simulate the "post-write" state without running a real gRPC gateway session (the SDK + /// session types are sealed + internal-ctor and cannot be faked). + /// + /// The tag full reference to add to the item-handle cache. + /// The item handle to cache for that reference. + /// When true, also records the handle in the supervised-handle cache. + internal void SeedHandleCachesForTest(string fullRef, int itemHandle, bool supervised) + { + _itemHandles[fullRef] = itemHandle; + if (supervised) _supervisedHandles.TryAdd(itemHandle, 0); + } + /// Writes values to Galaxy tags through the gateway. /// The write requests. /// Function to resolve security classification per tag. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs index f91e66e2..5032463c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/IGalaxyDataWriter.cs @@ -30,4 +30,8 @@ public interface IGalaxyDataWriter IReadOnlyList writes, Func securityResolver, CancellationToken cancellationToken); + + /// Drop cached gateway item handles + supervisory-advise state. Call after a session + /// reconnect — the prior handles are dead, so the next write must re-AddItem + re-AdviseSupervisory. + void InvalidateHandleCaches(); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/TracedGalaxyDataWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/TracedGalaxyDataWriter.cs index 4c15b21c..37d745ef 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/TracedGalaxyDataWriter.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/TracedGalaxyDataWriter.cs @@ -10,6 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; /// internal sealed class TracedGalaxyDataWriter(IGalaxyDataWriter inner, string clientName) : IGalaxyDataWriter { + /// + /// No span — this is a local cache-clear operation, not a gateway round-trip. + public void InvalidateHandleCaches() => inner.InvalidateHandleCaches(); + /// Writes data to Galaxy while recording telemetry span. /// The list of write requests to process. /// Function to resolve security classification for tag references. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs index 7de25685..61c86d06 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverWriteTests.cs @@ -94,6 +94,9 @@ public sealed class GalaxyDriverWriteTests } return Task.FromResult>(results); } + + /// + public void InvalidateHandleCaches() { /* no-op — this fake has no handle caches */ } } private static GalaxyAttribute Attr(string name, int sec) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyTelemetryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyTelemetryTests.cs index a1017e1a..bfef6df2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyTelemetryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyTelemetryTests.cs @@ -178,6 +178,9 @@ public sealed class GalaxyTelemetryTests CancellationToken cancellationToken) => Task.FromResult>( writes.Select(_ => new WriteResult(0u)).ToList()); + + /// + public void InvalidateHandleCaches() { /* no-op — this fake has no handle caches */ } } private sealed class FakeHierarchy : IGalaxyHierarchySource 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 new file mode 100644 index 00000000..5d159665 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyDataWriterTests.cs @@ -0,0 +1,91 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; + +/// +/// Tests for . +/// The SDK session types are sealed with internal ctors and cannot be faked, so we +/// drive the cache-seeding path through +/// and verify the +/// handle-count seams — the contract under test is purely that +/// zeroes both dictionaries +/// so the next write is forced to re-AddItem + re-AdviseSupervisory. +/// +public sealed class GatewayGalaxyDataWriterTests +{ + private static GalaxyMxSession MinimalSession() + => new(new GalaxyMxAccessOptions(ClientName: "OtOpcUa-Test")); + + /// + /// Approach (b): seed the item-handle cache directly via the internal test seam, + /// confirm the count is positive, call , + /// and confirm both caches are cleared. + /// The next write (not simulated here — needs a live gw) would therefore be forced + /// to re-AddItem because the cache is empty. + /// + [Fact] + public void InvalidateHandleCaches_clears_item_and_supervised_handle_caches() + { + var session = MinimalSession(); + var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); + + // Pre-seed both caches via the internal test seam so we can assert the + // "after a write" state without spinning up a real gRPC gateway session. + writer.SeedHandleCachesForTest("TestMachine_001.TestAttr", itemHandle: 42, supervised: true); + + writer.CachedItemHandleCount.ShouldBe(1); + writer.CachedSupervisedHandleCount.ShouldBe(1); + + writer.InvalidateHandleCaches(); + + writer.CachedItemHandleCount.ShouldBe(0); + writer.CachedSupervisedHandleCount.ShouldBe(0); + } + + /// + /// A second seed + invalidate cycle proves the method isn't one-shot — a reconnect + /// followed by writes followed by another reconnect must also start fresh. + /// + [Fact] + public void InvalidateHandleCaches_is_repeatable_across_multiple_reconnects() + { + var session = MinimalSession(); + var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); + + // First session cycle + writer.SeedHandleCachesForTest("Tag.A", itemHandle: 1, supervised: false); + writer.SeedHandleCachesForTest("Tag.B", itemHandle: 2, supervised: true); + writer.CachedItemHandleCount.ShouldBe(2); + writer.InvalidateHandleCaches(); + writer.CachedItemHandleCount.ShouldBe(0); + writer.CachedSupervisedHandleCount.ShouldBe(0); + + // Second session cycle — handles re-populated after the reconnect's replay + writer.SeedHandleCachesForTest("Tag.A", itemHandle: 99, supervised: true); + writer.CachedItemHandleCount.ShouldBe(1); + writer.InvalidateHandleCaches(); + writer.CachedItemHandleCount.ShouldBe(0); + } + + /// + /// on a fresh (never-used) + /// writer must be a no-op rather than throwing — the reconnect supervisor may call it + /// before any write has occurred. + /// + [Fact] + public void InvalidateHandleCaches_on_empty_caches_is_a_noop() + { + var session = MinimalSession(); + var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); + + // Caches are empty — must not throw. + writer.CachedItemHandleCount.ShouldBe(0); + writer.CachedSupervisedHandleCount.ShouldBe(0); + writer.InvalidateHandleCaches(); + writer.CachedItemHandleCount.ShouldBe(0); + writer.CachedSupervisedHandleCount.ShouldBe(0); + } +}