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);
+ }
+}