R4.3: measured idle-state GetStoreForwardStatusAsync over gRPC

Route GetStoreForwardStatusAsync to a gRPC path that actually contacts the
server (StatusService.GetHistorianConsoleStatus) instead of synthesizing an
all-false result blind. On a reachable/normal server it returns the
not-storing baseline but MEASURED; when the server is unreachable or the
console-status call fails it reports ErrorOccurred with the underlying error
(the old synthesis never contacted the server). The active-SF buffer
magnitude (Storing/Pending/DataStored) stays false because it lives behind
the D2 storage-engine console wall.

Non-gRPC transports keep the synthesized fallback. Live-verified against the
2023 R2 server; gated integration test
GetStoreForwardStatusAsync_OverGrpc_ReturnsMeasuredIdleState added. README
operation table updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-21 23:14:17 -04:00
parent c2d8fb9bc8
commit 53a9c87114
4 changed files with 107 additions and 2 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ The supported surface (per [`CLAUDE.md`](CLAUDE.md)):
| `BrowseTagNamesAsync` | live-verified |
| `GetTagMetadataAsync` | live-verified for 17 distinct native data-type codes |
| `GetConnectionStatusAsync` | synthesized from authenticated probe (matches native semantic) |
| `GetStoreForwardStatusAsync` | synthesized defaults (no SF sidecar to probe) |
| `GetStoreForwardStatusAsync` | gRPC: measured idle-state (live-verified — contacts server, reports `ErrorOccurred` when unreachable; active-SF magnitude is D2-gated). Non-gRPC: synthesized defaults |
| `GetSystemParameterAsync` | live-verified via `Stat/GetSystemParameter` |
| `EnsureTagAsync` | live-verified for analog Float/Double/Int2/Int4/UInt4; `ApplyScaling=true` persists distinct MinRaw/MaxRaw |
| `DeleteTagAsync` | live-verified |
@@ -1,4 +1,5 @@
using Grpc.Core;
using AVEVA.Historian.Client.Models;
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
namespace AVEVA.Historian.Client.Grpc;
@@ -36,6 +37,78 @@ internal static class HistorianGrpcStatusClient
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
}
/// <summary>
/// Returns a <em>measured</em> store-forward status over the 2023 R2 gRPC transport (R4.3).
/// <para>
/// Unlike the non-gRPC <see cref="Wcf.HistorianWcfStatusClient"/> path — which synthesizes an
/// all-false result <em>without contacting the server</em> — this opens an authenticated session
/// and calls <c>StatusService.GetHistorianConsoleStatus</c>, the only SF-adjacent signal reachable
/// from a pure managed client. (The direct <c>StorageService.GetSFParameter</c> /
/// <c>GetRemainingSnapshotsSize</c> RPCs that carry the SF buffer magnitude require the
/// <c>OpenStorageConnection</c> storage-engine console handle, which is gated behind the D2
/// storage-engine-pipe wall and is unobtainable here — see
/// <c>docs/plans/store-forward-cache-reverse-engineering.md</c> §9.7.)
/// </para>
/// <para>
/// Semantics: a successful console-status read means the server is reachable and its storage
/// console is reporting normally ⇒ the not-storing baseline (all flags false), but now
/// <em>measured</em> rather than blindly assumed. If the server cannot be reached/authenticated,
/// or the console-status call itself fails, <see cref="HistorianStoreForwardStatus.ErrorOccurred"/>
/// is set with the underlying error. The active-SF state (<see cref="HistorianStoreForwardStatus.Storing"/>
/// / <see cref="HistorianStoreForwardStatus.Pending"/> / <see cref="HistorianStoreForwardStatus.DataStored"/>
/// magnitude) is NOT observable from this signal and remains false; populating it requires the
/// D2-gated storage-console path.
/// </para>
/// </summary>
public static Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(
HistorianClientOptions options,
CancellationToken cancellationToken)
=> Task.Run(() => GetStoreForwardStatus(options, cancellationToken), cancellationToken);
private static HistorianStoreForwardStatus GetStoreForwardStatus(HistorianClientOptions options, CancellationToken cancellationToken)
{
HistorianStoreForwardStatus NotStoring(bool errorOccurred, string? error) => new(
ServerName: options.Host,
Pending: false,
ErrorOccurred: errorOccurred,
Error: error,
DataStored: false,
Storing: false,
ConnectionKind: HistorianConnectionKind.Process);
try
{
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, options, cancellationToken);
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
GrpcStatus.GetHistorianConsoleStatusResponse response = statusClient.GetHistorianConsoleStatus(
new GrpcStatus.GetHistorianConsoleStatusRequest { StrHandle = session.StringHandle },
connection.Metadata,
DateTime.UtcNow.Add(options.RequestTimeout),
cancellationToken);
if (response.Status?.BSuccess ?? false)
{
// Measured: server reachable, storage console reporting normally → not-storing baseline.
return NotStoring(errorOccurred: false, error: null);
}
byte[] err = response.Status?.BtError?.ToByteArray() ?? [];
string detail = err.Length == 0 ? "GetHistorianConsoleStatus returned failure." : Convert.ToHexString(err);
return NotStoring(errorOccurred: true, error: $"GetHistorianConsoleStatus failed: {detail}");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Server unreachable / auth failed — genuinely measured: report it instead of a silent all-false.
return NotStoring(errorOccurred: true, error: $"{ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Reads the Historian server's system time-zone name (roadmap item R1.3,
/// <c>StatusService.GetSystemTimeZoneName</c>). Unlike the 2020 WCF surface — where the native
@@ -57,7 +57,14 @@ internal sealed class Historian2020ProtocolDialect
public Task<HistorianStoreForwardStatus> GetStoreForwardStatusAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
// Over gRPC (2023 R2) we return a MEASURED idle-state: the client actually contacts the server
// (GetHistorianConsoleStatus) and reports ErrorOccurred when unreachable. The active-SF buffer
// magnitude lives behind the D2 storage-engine console wall and stays false. Non-gRPC transports
// keep the synthesized all-false (no SF sidecar to probe). See R4.3 §9.7.
return UseGrpc
? HistorianGrpcStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken)
: Wcf.HistorianWcfStatusClient.GetStoreForwardStatusAsync(_options, cancellationToken);
}
public Task<string?> GetSystemParameterAsync(string name, CancellationToken cancellationToken)
@@ -83,6 +83,31 @@ public sealed class HistorianGrpcIntegrationTests
Assert.False(string.IsNullOrWhiteSpace(zone));
}
[Fact]
public async Task GetStoreForwardStatusAsync_OverGrpc_ReturnsMeasuredIdleState()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// R4.3 measured idle-state: over gRPC, GetStoreForwardStatusAsync actually contacts the server
// (StatusService.GetHistorianConsoleStatus) rather than synthesizing. On an idle/normal server
// it reports the not-storing baseline WITHOUT ErrorOccurred. The active-SF buffer magnitude
// lives behind the D2 storage-engine console wall and is intentionally not surfaced (stays
// false). See docs/plans/store-forward-cache-reverse-engineering.md §9.7.
HistorianClient client = new(BuildOptions(host));
HistorianStoreForwardStatus status = await client.GetStoreForwardStatusAsync(CancellationToken.None);
Assert.Equal(host, status.ServerName);
Assert.False(status.ErrorOccurred, $"reachable server should not report an error: {status.Error}");
Assert.Null(status.Error);
Assert.False(status.Storing);
Assert.False(status.Pending);
Assert.False(status.DataStored);
}
[Fact]
public async Task GetTagMetadataAsync_OverGrpc_ReturnsRequestedTag()
{