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:
Joseph Doherty
2026-10-22 16:36:24 -04:00
parent 8dc594d8e4
commit bc7e58ed64
7 changed files with 187 additions and 19 deletions
@@ -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)
@@ -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) =>