perf(historian): exact tag lookup and a non-thrashing presence cache (lmxopcua#746)
Every Gateway HistoryRead was preceded by a BrowseTagNames catalog stream keyed by a glob filter (a name with '*' or '?' streamed every match), and the 4096-entry presence cache evicted an arbitrary entry (Keys.First()) when full, so a sweep over more tags thrashed it. The presence check is now one exact unary GetTagMetadata (null = absent). Only the deployed gateway's known misparse of a missing tag (Internal / Unknown / InvalidDataException) falls back to the exact-name catalog check; any other failure propagates and never becomes absence. The positive TTL is 5 minutes (was 60 s); a full cache drops expired entries first, else the one closest to expiry. Wire tests cover the working, the misparsing and the refusing gateway. Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22 in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -2,10 +2,17 @@ using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||
|
||||
/// <summary>Bounded metadata cache. Missing tags expire quickly; failures never become missing tags.</summary>
|
||||
/// <summary>
|
||||
/// Bounded presence cache in front of the gateway's exact tag lookup. A present tag is trusted for
|
||||
/// <see cref="PresentTtl"/>, a missing one re-checked after <see cref="MissingTtl"/> (so a freshly provisioned tag is
|
||||
/// visible quickly); failures never become missing tags. When full, expired entries are dropped first, else the entry
|
||||
/// closest to expiry — never an arbitrary one, which thrashed a sweep over more than <see cref="Capacity"/> tags.
|
||||
/// </summary>
|
||||
internal sealed class GatewayTagPresenceCache(IHistorianGatewayClient client, TimeProvider time)
|
||||
{
|
||||
internal const int Capacity = 4096;
|
||||
internal static readonly TimeSpan PresentTtl = TimeSpan.FromMinutes(5);
|
||||
internal static readonly TimeSpan MissingTtl = TimeSpan.FromSeconds(5);
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, (bool Exists, DateTimeOffset Expires)> _entries = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -25,12 +32,34 @@ internal sealed class GatewayTagPresenceCache(IHistorianGatewayClient client, Ti
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_entries.ContainsKey(tag) && _entries.Count >= Capacity) _entries.Remove(_entries.Keys.First());
|
||||
_entries[tag] = (exists, time.GetUtcNow().AddSeconds(exists ? 60 : 5));
|
||||
var now = time.GetUtcNow();
|
||||
if (!_entries.ContainsKey(tag) && _entries.Count >= Capacity) MakeRoom(now);
|
||||
_entries[tag] = (exists, now + (exists ? PresentTtl : MissingTtl));
|
||||
}
|
||||
return exists;
|
||||
}
|
||||
|
||||
/// <summary>Drops every expired entry; if none had expired, the one closest to expiry. Called under the lock, when full.</summary>
|
||||
private void MakeRoom(DateTimeOffset now)
|
||||
{
|
||||
string? soonest = null;
|
||||
var soonestExpiry = DateTimeOffset.MaxValue;
|
||||
List<string>? expired = null;
|
||||
foreach (var (key, entry) in _entries)
|
||||
{
|
||||
if (entry.Expires <= now) (expired ??= []).Add(key);
|
||||
else if (entry.Expires < soonestExpiry) { soonest = key; soonestExpiry = entry.Expires; }
|
||||
}
|
||||
if (expired is not null) foreach (var key in expired) _entries.Remove(key);
|
||||
else if (soonest is not null) _entries.Remove(soonest);
|
||||
}
|
||||
|
||||
/// <summary>Entries currently held — test/diagnostic.</summary>
|
||||
internal int Count { get { lock (_sync) return _entries.Count; } }
|
||||
|
||||
/// <summary>Whether <paramref name="tag"/> has an entry (fresh or not) — test/diagnostic.</summary>
|
||||
internal bool Holds(string tag) { lock (_sync) return _entries.ContainsKey(tag); }
|
||||
|
||||
private static bool IsNotFound(Exception ex)
|
||||
{
|
||||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||||
|
||||
+25
-2
@@ -82,16 +82,39 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// One exact unary lookup (GetTagMetadata: null = absent). The deployed gateway's SDK misparses the reply for a MISSING
|
||||
// tag and surfaces it as Internal ("GetTagInfo response ended unexpectedly"); only that answer falls back to the tag
|
||||
// catalog, which proves presence by exact name (BrowseTagNames takes a glob, so a name holding '*' or '?' would stream
|
||||
// every match — the exact compare keeps it correct). Any other failure propagates: it is never turned into absence.
|
||||
public async Task<bool> TagExistsAsync(string tag, CancellationToken ct)
|
||||
{
|
||||
// older gateway SDKs misparse a missing GetTagInfo response as Internal. A catalog query proves
|
||||
// absence without guessing from an error. Even a glob-like tag is checked by exact name, never by row count.
|
||||
try
|
||||
{
|
||||
return await _inner.GetTagMetadataAsync(tag, ct).ConfigureAwait(false) is not null;
|
||||
}
|
||||
catch (Exception ex) when (!ct.IsCancellationRequested && IsMetadataMisparse(ex))
|
||||
{
|
||||
// Fall through to the catalog.
|
||||
}
|
||||
|
||||
var found = false;
|
||||
await foreach (var name in _inner.BrowseTagNamesAsync(tag, ct).ConfigureAwait(false))
|
||||
found |= string.Equals(name, tag, StringComparison.OrdinalIgnoreCase);
|
||||
return found; // drain first: a trailing stream fault must propagate and must not become a cache entry
|
||||
}
|
||||
|
||||
/// <summary>The known metadata misparse: an Internal/Unknown status, or the parser's own InvalidDataException.</summary>
|
||||
private static bool IsMetadataMisparse(Exception ex)
|
||||
{
|
||||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||||
{
|
||||
if (e is System.IO.InvalidDataException) return true;
|
||||
if (e is Grpc.Core.RpcException rpc)
|
||||
return rpc.StatusCode is Grpc.Core.StatusCode.Internal or Grpc.Core.StatusCode.Unknown;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
||||
string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) =>
|
||||
|
||||
Reference in New Issue
Block a user