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
@@ -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) =>