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.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
@@ -33,8 +34,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly EquipmentTagRefResolver<FocasTagDefinition> _resolver;
|
private readonly EquipmentTagRefResolver<FocasTagDefinition> _resolver;
|
||||||
// Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync /
|
// 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
|
// WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot
|
||||||
// call — resolves Driver.FOCAS-008.
|
// call — resolves Driver.FOCAS-008. ConcurrentDictionary is required because the poll loop
|
||||||
private readonly Dictionary<string, FocasAddress> _parsedAddressesByTagName =
|
// (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<string, FocasAddress> _parsedAddressesByTagName =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private FocasAlarmProjection? _alarmProjection;
|
private FocasAlarmProjection? _alarmProjection;
|
||||||
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
||||||
@@ -230,6 +233,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// <summary>Test seam — the resolved options the factory built this driver from.</summary>
|
/// <summary>Test seam — the resolved options the factory built this driver from.</summary>
|
||||||
internal FocasDriverOptions Options => _options;
|
internal FocasDriverOptions Options => _options;
|
||||||
|
|
||||||
|
/// <summary>Test seam — returns true when a parsed <see cref="FocasAddress"/> for the given
|
||||||
|
/// reference is present in the address cache (both authored and equipment-tag paths).</summary>
|
||||||
|
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 ----
|
// ---- IReadable ----
|
||||||
|
|
||||||
/// <summary>Reads values from one or more tags asynchronously.</summary>
|
/// <summary>Reads values from one or more tags asynchronously.</summary>
|
||||||
@@ -270,14 +290,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
// Parsed at InitializeAsync — defensive fallback re-parse only if the tag was
|
var parsed = ResolveParsedAddress(def);
|
||||||
// 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 (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||||
@@ -334,12 +347,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
if (!_parsedAddressesByTagName.TryGetValue(def.Name, out var parsed))
|
var parsed = ResolveParsedAddress(def);
|
||||||
{
|
|
||||||
parsed = FocasAddress.TryParse(def.Address)
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
$"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
|
||||||
}
|
|
||||||
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||||
results[i] = new WriteResult(status);
|
results[i] = new WriteResult(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,4 +64,35 @@ public class FocasEquipmentTagTests
|
|||||||
r[0].StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
|
r[0].StatusCode.ShouldNotBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||||
r[0].Value.ShouldBe((sbyte)42);
|
r[0].Value.ShouldBe((sbyte)42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression guard for Driver.FOCAS-008 equipment-tag path: the parsed
|
||||||
|
/// <see cref="FocasAddress"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user