From 46f559f5f9e89e628a5bf3d906687636e1036358 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 14 Jun 2026 00:20:57 -0400 Subject: [PATCH] perf(focas): cache equipment-tag parsed addresses (no per-call reparse) Equipment tags resolved at runtime via FocasEquipmentTagParser were not seeded in _parsedAddressesByTagName so both ReadAsync and WriteAsync re-parsed the raw TagConfig JSON address string on every hot-path call. Promoted the field to ConcurrentDictionary (read + write thread safety) and introduced ResolveParsedAddress(GetOrAdd) so the first call stores the parse result and all subsequent calls are a cache hit. Authored tags seeded at InitializeAsync compile and work unchanged. --- .../FocasDriver.cs | 40 +++++++++++-------- .../FocasEquipmentTagTests.cs | 31 ++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index f20f187d..22a58824 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -33,8 +34,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly EquipmentTagRefResolver _resolver; // Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync / // WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot - // call — resolves Driver.FOCAS-008. - private readonly Dictionary _parsedAddressesByTagName = + // call — resolves Driver.FOCAS-008. ConcurrentDictionary is required because the poll loop + // (ReadAsync) and the host write thread (WriteAsync) both mutate this cache when resolver- + // produced equipment tags are encountered for the first time. + private readonly ConcurrentDictionary _parsedAddressesByTagName = new(StringComparer.OrdinalIgnoreCase); private FocasAlarmProjection? _alarmProjection; // _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync). @@ -230,6 +233,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// Test seam — the resolved options the factory built this driver from. internal FocasDriverOptions Options => _options; + /// Test seam — returns true when a parsed for the given + /// reference is present in the address cache (both authored and equipment-tag paths). + internal bool IsParsedAddressCached(string reference) => + _parsedAddressesByTagName.ContainsKey(reference); + + // Resolves a tag definition to its parsed FocasAddress, caching the result so that + // equipment tags (resolver-produced, not seeded at InitializeAsync) don't re-parse the + // address string on every ReadAsync / WriteAsync hot-path call (Driver.FOCAS-008). + // Throwing inside the GetOrAdd factory propagates the exception to the caller and does + // NOT store anything in the dictionary — consistent with the existing "fail fast on a + // malformed address" behaviour from the init-time validation of authored tags. + private FocasAddress ResolveParsedAddress(FocasTagDefinition def) => + _parsedAddressesByTagName.GetOrAdd(def.Name, _ => + FocasAddress.TryParse(def.Address) + ?? throw new InvalidOperationException( + $"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.")); + // ---- IReadable ---- /// Reads values from one or more tags asynchronously. @@ -270,14 +290,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, try { var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); - // Parsed at InitializeAsync — defensive fallback re-parse only if the tag was - // somehow not seeded (shouldn't happen, but keeps the call total). - if (!_parsedAddressesByTagName.TryGetValue(def.Name, out var parsed)) - { - parsed = FocasAddress.TryParse(def.Address) - ?? throw new InvalidOperationException( - $"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."); - } + var parsed = ResolveParsedAddress(def); var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); results[i] = new DataValueSnapshot(value, status, now, now); @@ -334,12 +347,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, try { var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); - if (!_parsedAddressesByTagName.TryGetValue(def.Name, out var parsed)) - { - parsed = FocasAddress.TryParse(def.Address) - ?? throw new InvalidOperationException( - $"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."); - } + var parsed = ResolveParsedAddress(def); var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false); results[i] = new WriteResult(status); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs index f539b5e7..70fa441a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagTests.cs @@ -64,4 +64,35 @@ public class FocasEquipmentTagTests r[0].StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown); r[0].Value.ShouldBe((sbyte)42); } + + /// + /// Regression guard for Driver.FOCAS-008 equipment-tag path: the parsed + /// for a resolver-produced (equipment) tag must be + /// cached after the first read so subsequent reads and writes skip re-parsing + /// the raw TagConfig JSON on every call. + /// + [Fact] + public async Task Equipment_tag_parsed_address_is_cached_after_first_read() + { + var equipRef = """{"deviceHostAddress":"focas://10.0.0.5:8193","address":"R100","dataType":"Byte"}"""; + var factory = new FakeFocasClientFactory + { + Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } }, + }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + Tags = [], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-eq-cache", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Before any read the parsed address must NOT be in the cache. + drv.IsParsedAddressCached(equipRef).ShouldBeFalse(); + + // After a successful read the parsed address MUST be cached. + var r = await drv.ReadAsync([equipRef], CancellationToken.None); + r[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + drv.IsParsedAddressCached(equipRef).ShouldBeTrue(); + } }