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:
+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