feat: HistorianSession browse + metadata on the reused session (+ env-gated round-trip)

Browse mirrors ReadRawAsync (collect-then-yield); metadata mirrors ReadAtTimeAsync
(unary). Delegates to the HistorianGrpcTagClient …OnSession seams so a leased session
browses + reads metadata without re-handshaking (pending.md A1 broadening).

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-25 10:28:44 -04:00
parent 2f689cbe71
commit 81aff03748
2 changed files with 38 additions and 2 deletions
+23 -1
View File
@@ -7,7 +7,7 @@ namespace AVEVA.Historian.Client;
/// <summary>A live, reusable authenticated Historian session: holds one gRPC connection + one
/// OpenConnection handle and runs ops on them WITHOUT re-handshaking. Reuse across ops amortizes the
/// auth handshake. Idle-expires server-side in ~20-25s — callers keep it warm (PingAsync) or re-open.
/// Reads/historical-write/tag-write/status only; events are NOT exposed (separate channel+auth).</summary>
/// Reads, browse/metadata, historical-write, tag-write and status; events are NOT exposed (separate channel+auth).</summary>
public sealed class HistorianSession : IAsyncDisposable
{
private readonly HistorianGrpcConnection _connection;
@@ -89,6 +89,28 @@ public sealed class HistorianSession : IAsyncDisposable
() => orch.RunAtTimeOnSession(_connection, _session.ClientHandle, tag, timestampsUtc, ct), ct);
}
// --- browse / metadata (call the …OnSession seams, which take the full Session for the string handle) ---
/// <summary>Browses tag names matching <paramref name="filter"/> on the held session.</summary>
public async IAsyncEnumerable<string> BrowseTagNamesAsync(
string filter = "*",
[EnumeratorCancellation] CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
List<string> names = await Task.Run(
() => HistorianGrpcTagClient.BrowseTagNamesOnSession(_connection, _session, filter, _options, ct), ct)
.ConfigureAwait(false);
foreach (string name in names)
{
ct.ThrowIfCancellationRequested();
yield return name;
}
}
/// <summary>Reads metadata for <paramref name="tag"/> on the held session (null if unknown).</summary>
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken ct = default)
=> Task.Run(() => HistorianGrpcTagClient.GetTagMetadataOnSession(_connection, _session, tag, _options, ct), ct);
// --- writes (the …OnSession seams take the full Session, since the historical write keys on the
// string handle + tag GUID and the tag-config ops mix string/uint handles) ---
@@ -53,7 +53,21 @@ public sealed class HistorianSessionRoundTripTests
await session.PingAsync(CancellationToken.None); // must not throw
_output.WriteLine("4) ping -> ok");
_output.WriteLine("session round-trip OK (write+read+status+ping on one session)");
// 5) metadata + browse on the SAME session (no re-handshake)
HistorianTagMetadata? meta = await session.GetTagMetadataAsync(sandboxTag, CancellationToken.None);
Assert.NotNull(meta);
_output.WriteLine("5) metadata -> ok");
List<string> browsed = [];
await foreach (string n in session.BrowseTagNamesAsync("*", CancellationToken.None))
{
browsed.Add(n);
if (browsed.Count >= 5) break;
}
Assert.NotEmpty(browsed);
_output.WriteLine($"6) browse rows={browsed.Count}");
_output.WriteLine("session round-trip OK (write+read+status+ping+metadata+browse on one session)");
}
// verbatim copy of BuildOptions from HandshakeReuseSpikeTests