diff --git a/README.md b/README.md index 84b3eea..fc9c59e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs index d245b88..f96141a 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcStatusClient.cs @@ -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; } + /// + /// Returns a measured store-forward status over the 2023 R2 gRPC transport (R4.3). + /// + /// Unlike the non-gRPC path — which synthesizes an + /// all-false result without contacting the server — this opens an authenticated session + /// and calls StatusService.GetHistorianConsoleStatus, the only SF-adjacent signal reachable + /// from a pure managed client. (The direct StorageService.GetSFParameter / + /// GetRemainingSnapshotsSize RPCs that carry the SF buffer magnitude require the + /// OpenStorageConnection storage-engine console handle, which is gated behind the D2 + /// storage-engine-pipe wall and is unobtainable here — see + /// docs/plans/store-forward-cache-reverse-engineering.md §9.7.) + /// + /// + /// 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 + /// measured rather than blindly assumed. If the server cannot be reached/authenticated, + /// or the console-status call itself fails, + /// is set with the underlying error. The active-SF state ( + /// / / + /// magnitude) is NOT observable from this signal and remains false; populating it requires the + /// D2-gated storage-console path. + /// + /// + public static Task 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}"); + } + } + /// /// Reads the Historian server's system time-zone name (roadmap item R1.3, /// StatusService.GetSystemTimeZoneName). Unlike the 2020 WCF surface — where the native diff --git a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs index 7442edf..82b4290 100644 --- a/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs +++ b/src/AVEVA.Historian.Client/Protocol/Historian2020ProtocolDialect.cs @@ -57,7 +57,14 @@ internal sealed class Historian2020ProtocolDialect public Task 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 GetSystemParameterAsync(string name, CancellationToken cancellationToken) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 6512464..36bbdd6 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -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() {