using AVEVA.Historian.Client.Grpc;
using AVEVA.Historian.Client.Models;
namespace AVEVA.Historian.Client.Tests;
///
/// Live integration tests for the 2023 R2 RemoteGrpc transport. Gated on a dedicated
/// HISTORIAN_GRPC_HOST env var (plus HISTORIAN_TEST_TAG) so they skip cleanly until
/// a 2023 R2 Historian is available. Optional:
/// HISTORIAN_GRPC_PORT (default 32565), HISTORIAN_GRPC_TLS (true/false),
/// HISTORIAN_USER / HISTORIAN_PASSWORD (explicit creds; otherwise IntegratedSecurity),
/// HISTORIAN_GRPC_DNSID (server certificate name when connecting by IP over TLS).
///
public sealed class HistorianGrpcIntegrationTests
{
[Fact]
public async Task ProbeAsync_OverGrpc_ReturnsTrue()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host))
{
return;
}
// ProbeAsync calls the unauthenticated GetInterfaceVersion RPCs, so it succeeds even when
// credentials are unavailable — no HISTORIAN_USER/PASSWORD required.
HistorianClient client = new(BuildOptions(host));
Assert.True(await client.ProbeAsync(CancellationToken.None));
}
[Fact]
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
HistorianClient client = new(BuildOptions(host));
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
List samples = [];
await foreach (HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
{
samples.Add(sample);
}
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task GetSystemParameterAsync_OverGrpc_ReturnsValue()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
HistorianClient client = new(BuildOptions(host));
string? version = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
Assert.False(string.IsNullOrWhiteSpace(version));
}
[Fact]
public async Task GetServerTimeZoneAsync_OverGrpc_ReturnsZone()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// R1.3: gRPC StatusService.GetSystemTimeZoneName returns the real server zone (the 2020 WCF
// op is a stub). Live-verified value: "Eastern Daylight Time".
HistorianClient client = new(BuildOptions(host));
string? zone = await client.GetServerTimeZoneAsync(CancellationToken.None);
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()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag)
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
HistorianClient client = new(BuildOptions(host));
HistorianTagMetadata? metadata = await client.GetTagMetadataAsync(tag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(tag, metadata!.Name);
// A real metadata record decodes to a known data type (descriptor passed MapDataType).
Assert.True(Enum.IsDefined(metadata.DataType));
}
[Fact]
public async Task BrowseTagNamesAsync_OverGrpc_ReturnsSystemTags()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Full R0.1 browse over gRPC: StartTagQuery(OData) -> paged QueryTag(0x6752) -> EndTagQuery.
HistorianClient client = new(BuildOptions(host));
List names = [];
await foreach (string name in client.BrowseTagNamesAsync("Sys*", CancellationToken.None))
{
names.Add(name);
}
Assert.NotEmpty(names);
Assert.All(names, n => Assert.StartsWith("Sys", n, StringComparison.Ordinal));
}
[Fact]
public async Task NonStreamedWriteTransaction_OverGrpc_BeginsAndDiscards()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// M3 reachability probe: on 2020 WCF this op group is walled (TransactionService relay
// returns UnknownClient(51) — the storage-engine-pipe requirement, see
// docs/plans/revision-write-path.md). On the 2023 R2 gRPC front door the native client
// passes the Open2 storage-session GUID straight to TransactionService and it works.
// This asserts the wall is gone: a write-enabled session opens and AddNonStreamValuesBegin
// returns a transaction id, which we immediately End with bCommit=false (writes nothing).
var probe = new HistorianGrpcRevisionProbe(BuildOptions(host));
HistorianGrpcRevisionProbeResult result = await probe.ProbeBeginAsync(CancellationToken.None);
Assert.True(result.OpenSucceeded);
Assert.True(result.BeginSucceeded, "AddNonStreamValuesBegin should return a transaction id over gRPC.");
Assert.False(string.IsNullOrEmpty(result.BeginTransactionId));
Assert.True(result.EndDiscardSucceeded, "AddNonStreamValuesEnd(bCommit:false) should discard cleanly.");
}
[Fact]
public async Task OpenStorageConnection_OverGrpc_RefusedAsNotRegistered()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// M3 R3.1 follow-up finding (2026-06-21): StorageService.OpenStorageConnection is NOT the
// missing non-streamed-write precondition. It's the storage engine's SF/snapshot channel
// (separate GrpcStorageClient / service identity), and on the Historian front door it is
// refused with native type=4 code=85 ("session not registered") for every parameter combo —
// the same code the event read returns before RegisterTags2. The real precondition is the
// front-door HistoryService.RegisterTags (RTag2-family). See docs/plans/revision-write-path.md
// §"R3.1 follow-up". This test pins the refusal so a future server/behaviour change is noticed.
var probe = new HistorianGrpcStorageConnectionProbe(BuildOptions(host));
HistorianGrpcOpenStorageConnectionResult result = await probe.ProbeAsync(CancellationToken.None);
Assert.True(result.OpenSucceeded, "the write-enabled gRPC session itself should still open.");
Assert.False(result.OpenStorageSucceeded, "OpenStorageConnection is not a front-door client op (error 85).");
Assert.NotEmpty(result.Attempts);
Assert.All(result.Attempts, a => Assert.False(a.Succeeded));
}
[Fact]
public async Task AddHistoricalValuesAsync_OverGrpc_WritesAndReadsBack()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
// Gated additionally on a dedicated sandbox-tag env var so this WRITE test never runs by
// accident — set HISTORIAN_WRITE_SANDBOX_TAG to an existing Float tag you are happy to write
// backfill samples to. M3 R3.2: HistoryService.AddStreamValues ("ON" buffer).
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandboxTag)
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
HistorianClient client = new(BuildOptions(host));
// A backfill sample at a fixed historical second, with a distinctive whole-number value so
// it round-trips for any analog tag type (Float/Double/Int2/Int4/UInt4).
DateTime stamp = new DateTime(DateTime.UtcNow.Year, 1, 2, 3, 4, 5, DateTimeKind.Utc);
const double expected = 7777;
bool wrote = await client.AddHistoricalValuesAsync(
sandboxTag!,
[new HistorianHistoricalValue(stamp, expected)],
CancellationToken.None);
Assert.True(wrote);
// Read the window around the sample back and confirm it landed.
List samples = [];
await foreach (HistorianSample s in client.ReadRawAsync(sandboxTag!, stamp.AddMinutes(-1), stamp.AddMinutes(1), maxValues: 16, CancellationToken.None))
{
samples.Add(s);
}
Assert.Contains(samples, s => s.NumericValue is { } v && Math.Abs(v - expected) < 0.01);
}
[Fact]
public async Task ReadAggregateAsync_OverGrpc_ReturnsTimeWeightedAverageRows()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
HistorianClient client = new(BuildOptions(host));
// Self-calibrate the window from a real raw sample. The 2023 R2 box may be idle (no recent
// collection), so a "last N hours" window can be empty AND make the interpolating modes do a
// slow bounding-value scan. Seeding from where data actually exists makes this robust on any
// server state and keeps the per-bucket scan cheap. See HISTORIAN_GRPC_TIMEOUT for slow links.
(DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!);
if (window is null)
{
return; // tag has no data anywhere in the lookback — nothing to aggregate
}
List samples = [];
await foreach (HistorianAggregateSample sample in client.ReadAggregateAsync(
testTag!, window.Value.startUtc, window.Value.endUtc,
RetrievalMode.TimeWeightedAverage,
TimeSpan.FromMinutes(10),
CancellationToken.None))
{
samples.Add(sample);
}
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
Assert.All(samples, s => Assert.Equal(RetrievalMode.TimeWeightedAverage, s.RetrievalMode));
}
// Exercises the "QueryType byte = native enum ordinal" mapping over the gRPC StartQuery envelope
// for a few non-default retrieval modes — the server must accept each without error. Window is
// seeded from real data (idle-server-safe); rows may legitimately be empty for some modes.
[Theory]
[InlineData(RetrievalMode.MinimumWithTime)]
[InlineData(RetrievalMode.MaximumWithTime)]
[InlineData(RetrievalMode.BestFit)]
public async Task ReadAggregateAsync_OverGrpc_AcceptsRetrievalMode(RetrievalMode mode)
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
HistorianClient client = new(BuildOptions(host));
(DateTime startUtc, DateTime endUtc)? window = await SeedAggregateWindowAsync(client, testTag!);
if (window is null)
{
return;
}
List samples = [];
await foreach (HistorianAggregateSample s in client.ReadAggregateAsync(
testTag!, window.Value.startUtc, window.Value.endUtc, mode, TimeSpan.FromMinutes(10), CancellationToken.None))
{
samples.Add(s);
}
// Absence of an exception proves the QueryType byte was accepted; pin the echoed mode.
Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode));
}
///
/// Finds a 1-hour window that actually contains raw data for by reading a
/// real raw sample over a wide lookback, then anchoring the window at that sample. Returns null
/// when the tag has no data in the lookback. This keeps the aggregate tests independent of whether
/// the live 2023 R2 box is actively collecting.
///
private static async Task<(DateTime startUtc, DateTime endUtc)?> SeedAggregateWindowAsync(HistorianClient client, string tag)
{
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(30);
await foreach (HistorianSample s in client.ReadRawAsync(tag, startUtc, endUtc, maxValues: 1, CancellationToken.None))
{
DateTime anchor = s.TimestampUtc;
return (anchor - TimeSpan.FromMinutes(1), anchor + TimeSpan.FromHours(1));
}
return null;
}
[Fact]
public async Task ReadAtTimeAsync_OverGrpc_ReturnsRequestedTimestamps()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
{
return;
}
HistorianClient client = new(BuildOptions(host));
DateTime nowUtc = DateTime.UtcNow;
DateTime[] timestamps =
[
nowUtc - TimeSpan.FromDays(1),
nowUtc - TimeSpan.FromHours(12),
nowUtc - TimeSpan.FromHours(1)
];
IReadOnlyList samples = await client.ReadAtTimeAsync(testTag, timestamps, CancellationToken.None);
Assert.NotEmpty(samples);
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
}
[Fact]
public async Task GetRuntimeParameterAsync_OverGrpc_ReturnsValue()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Config op tooled over gRPC: StatusService.GetRuntimeParameter carries the proven 2020 GETRP
// request/response buffers unchanged inside the protobuf bytes fields.
HistorianClient client = new(BuildOptions(host));
string? value = await client.GetRuntimeParameterAsync("HistorianVersion", CancellationToken.None);
Assert.False(string.IsNullOrWhiteSpace(value));
}
[Fact]
public async Task GetTagExtendedPropertiesAsync_OverGrpc_DoesNotThrow()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
string? tag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(tag)
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Config op tooled over gRPC: RetrievalService.GetTagExtendedPropertiesFromName carries the
// proven 2020 GetTepByNm buffers. A system tag may have no user-defined properties, so this
// asserts the call completes and returns a well-formed (possibly empty) list.
HistorianClient client = new(BuildOptions(host));
IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(tag!, CancellationToken.None);
Assert.NotNull(props);
}
[Fact]
public async Task ExecuteSqlCommandAsync_OverGrpc_IsServerWalled()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// ExecuteSqlCommand request rides the gRPC front door, but the server-side
// CSrvDbConnection.ExecuteSqlCommand faults (IndexOutOfRange / native error 38) — an unmet
// DB-connection precondition the pure managed gRPC session doesn't establish (captured
// 2026-06-22). The SDK surfaces this as ProtocolEvidenceMissingException. This test pins the
// wall so a future server/registration change that lifts it is noticed.
HistorianClient client = new(BuildOptions(host));
await Assert.ThrowsAsync(() => client.ExecuteSqlCommandAsync(
"SELECT 10 AS Num, 'alpha' AS Word UNION ALL SELECT 20, NULL",
cancellationToken: CancellationToken.None));
}
[Fact]
public async Task TagWriteLifecycle_OverGrpc_CreatesAddsPropRenamesDeletes()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
// DESTRUCTIVE: gated on a dedicated sandbox-tag name so it never mutates a server by accident.
// Set HISTORIAN_GRPC_WRITE_SANDBOX_TAG to a throwaway tag name the test may create/rename/delete.
string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_WRITE_SANDBOX_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandbox)
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Exercises the full gRPC tag-config write surface end-to-end against a write-enabled (0x401)
// session, then cleans up after itself: EnsureTags -> AddTagExtendedProperties ->
// (read-back verify) -> StartJob rename -> DeleteTags.
HistorianClient client = new(BuildOptions(host));
string renamed = sandbox + "_R";
try
{
// Clean slate: a prior run's async rename job may have left either name behind, which would
// collide with this run's create/rename. Best-effort delete both before starting.
try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* ignore */ }
try { await client.DeleteTagAsync(renamed, CancellationToken.None); } catch { /* ignore */ }
bool created = await client.EnsureTagAsync(
new HistorianTagDefinition { TagName = sandbox!, DataType = HistorianDataType.Float, EngineeringUnit = "u", MaxEU = 100 },
CancellationToken.None);
Assert.True(created, "EnsureTags over gRPC should create the sandbox tag.");
bool propAdded = await client.AddTagExtendedPropertyAsync(sandbox!, "GrpcToolingTest", "ok", CancellationToken.None);
Assert.True(propAdded, "AddTagExtendedProperties over gRPC should succeed.");
// Read the written property back: confirms AddTagExtendedProperties round-trips AND that the
// shared GetTepByNm parser handles the multi-group / uint16-flags response shape captured live
// 2026-06-22 (the earlier 0x01-vs-0x09 drift is fixed).
IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None);
Assert.Contains(props, p => string.Equals(p.Name, "GrpcToolingTest", StringComparison.OrdinalIgnoreCase));
// Rename is an async StartJob; the server can transiently reject it right after the create
// commits. Retry a few times before asserting.
HistorianTagRenameResult rename = default!;
for (int attempt = 0; attempt < 4; attempt++)
{
rename = await client.RenameTagsAsync([(sandbox!, renamed)], CancellationToken.None);
if (rename.Accepted)
{
break;
}
await Task.Delay(TimeSpan.FromSeconds(1));
}
Assert.True(rename.Accepted, $"StartJob rename over gRPC should be accepted: {rename.Error}");
}
finally
{
// Cleanup of whichever name survives. Rename is an async server job, so _R may only appear a
// moment after the job runs; delete BOTH names across a generous window so neither the pending
// rename nor metadata-server lag leaves litter on the shared server. Best-effort by design —
// the browse/metadata view is eventually consistent, so a hard absence assert here would be
// racy. The next run's pre-clean is the backstop.
for (int attempt = 0; attempt < 8; attempt++)
{
try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* ignore */ }
try { await client.DeleteTagAsync(renamed, CancellationToken.None); } catch { /* ignore */ }
await Task.Delay(TimeSpan.FromSeconds(1));
}
}
}
[Fact]
public async Task ReadEventsAsync_OverGrpc_StartsQueryButRowRetrievalIsLongPollBlocked()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Plan #2: ReadEvents over gRPC. The chain runs end-to-end and StartEventQuery succeeds
// (no InvalidOperationException), but — confirmed live 2026-06-22 — GetNextEventQueryResultBuffer
// LONG-POLLS when the query has no rows: the gRPC server blocks to the deadline instead of
// returning the synchronous 5-byte code-85 terminal the 2020 WCF op returns. The idle dev box
// holds no events, so the orchestrator reaches its no-data terminal with zero rows and (rather
// than assert a possibly-false "no events" empty) throws ProtocolEvidenceMissingException.
// This pins that current reality and that the chain stays BOUNDED (no multi-minute hang) via
// the short registration + poll deadlines. Flip to asserting parsed rows once an event-bearing
// 2023 R2 server is available. (Set a small HISTORIAN_GRPC_TIMEOUT to keep this snappy.)
HistorianClient client = new(BuildOptions(host));
DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(30);
await Assert.ThrowsAsync(async () =>
{
await foreach (HistorianEvent evt in client.ReadEventsAsync(startUtc, endUtc, CancellationToken.None))
{
// An event-bearing server would yield rows here instead of reaching the no-data throw.
_ = evt;
}
});
}
[Fact]
public async Task GetConnectionStatusAsync_OverGrpc_ReportsConnected()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Plan #5: GetConnectionStatus over gRPC is measured from a real handshake (OpenConnection
// yields a storage-session GUID). Against a reachable server it reports connected with no error.
HistorianClient client = new(BuildOptions(host));
HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None);
Assert.Equal(host, status.ServerName);
Assert.True(status.ConnectedToServer, $"should be connected: {status.Error}");
Assert.True(status.ConnectedToServerStorage);
Assert.False(status.ErrorOccurred);
Assert.Null(status.Error);
Assert.False(status.ConnectedToStoreForward);
}
[Fact]
public async Task DeleteTagExtendedProperties_OverGrpc_ProbeMultiplexedChannel()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
// DESTRUCTIVE: reuses the write sandbox-tag gate so it never mutates a server by accident.
string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_WRITE_SANDBOX_TAG");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sandbox)
|| string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
{
return;
}
// Out-of-scope opportunistic probe (grpc-tooling-completion.md). DelTep is server-blocked on the
// 2020 WCF transport because the server resolves the property from a per-CONNECTION working set
// that WCF's separate per-service channels can't populate (prime + delete land on different
// connections). gRPC builds every service client on ONE shared channel, so this probe runs the
// native GetTgByNm -> GetTepByNm -> DelTep sequence over a single write-enabled session to see
// whether the multiplexed channel satisfies the working-set check.
//
// RESULT — live 2026-06-22 (2023 R2 server, History iface 12): the multiplexed channel does NOT
// lift the wall. BOTH primes succeed on the shared channel (GetTgByNm returns the tag identity,
// GetTepByNm returns the property page), yet DelTep is still rejected with native error code=1
// (the 5-byte buffer's byte0=132 is the universal 0x84 error marker, not a code) and the property
// survives. So DelTep stays server-blocked on BOTH transports — the working set the server
// consults is populated by something the SDK can't reproduce even over one connection (likely the
// native client's in-process registration object, not the wire session). This test PINS that
// negative: it asserts the probe is rejected and the property survives. If a future server or
// registration change lifts the wall, the inverted assertion flips and we notice.
HistorianClientOptions options = BuildOptions(host);
HistorianClient client = new(options);
const string propName = "GrpcDelProbe";
try
{
try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* clean slate */ }
Assert.True(await client.EnsureTagAsync(
new HistorianTagDefinition { TagName = sandbox!, DataType = HistorianDataType.Float, EngineeringUnit = "u", MaxEU = 100 },
CancellationToken.None), "EnsureTags should create the sandbox tag.");
Assert.True(await client.AddTagExtendedPropertyAsync(sandbox!, propName, "todelete", CancellationToken.None),
"AddTagExtendedProperties should seed the property to delete.");
IReadOnlyList before = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None);
Assert.Contains(before, p => string.Equals(p.Name, propName, StringComparison.OrdinalIgnoreCase));
HistorianGrpcTagWriteOrchestrator orchestrator = new(options);
HistorianGrpcTagWriteOrchestrator.DeleteTagExtendedPropertiesProbeResult probe =
await orchestrator.ProbeDeleteTagExtendedPropertiesAsync(sandbox!, [propName], CancellationToken.None);
// The primes must succeed (proving the single channel DID load the working set) — otherwise the
// negative below would be inconclusive (a prime failure, not a delete-path block).
Assert.True(probe.TagInfoPrimeBytes > 0, "GetTgByNm prime should return the tag identity on the shared channel.");
Assert.True(probe.ExtPropPrimePages > 0, "GetTepByNm prime should return the property page on the shared channel.");
IReadOnlyList after = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None);
bool stillPresent = after.Any(p => string.Equals(p.Name, propName, StringComparison.OrdinalIgnoreCase));
// Pinned negative: the multiplexed channel does not lift the working-set wall.
Assert.False(probe.Accepted,
$"Unexpected: DelTep over gRPC was ACCEPTED — the multiplexed channel may now lift the wall. " +
$"Promote DeleteTagExtendedProperties to a public op. Probe={DescribeProbe(probe, stillPresent)}");
Assert.True(stillPresent,
$"Unexpected: property was removed despite DelTep reporting failure. Probe={DescribeProbe(probe, stillPresent)}");
}
finally
{
for (int i = 0; i < 5; i++)
{
try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* best-effort */ }
await Task.Delay(TimeSpan.FromSeconds(1));
}
}
}
private static string DescribeProbe(
HistorianGrpcTagWriteOrchestrator.DeleteTagExtendedPropertiesProbeResult probe, bool stillPresent)
=> $"Accepted={probe.Accepted} StillPresent={stillPresent} TgPrimeBytes={probe.TagInfoPrimeBytes} " +
$"TepPrimePages={probe.ExtPropPrimePages} Err={probe.ErrorDescription}";
private static HistorianClientOptions BuildOptions(string host)
{
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
bool explicitCreds = !string.IsNullOrEmpty(user);
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
? parsed
: HistorianClientOptions.DefaultGrpcPort;
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
// Optional per-call deadline override (seconds) for slow/remote boxes — heavier aggregate
// modes over a tunnelled link can exceed the 30s default. Falls back to the SDK default.
TimeSpan timeout = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TIMEOUT"), out int secs) && secs > 0
? TimeSpan.FromSeconds(secs)
: new HistorianClientOptions { Host = host }.RequestTimeout;
return new HistorianClientOptions
{
Host = host,
Port = port,
Transport = HistorianTransport.RemoteGrpc,
GrpcUseTls = tls,
AllowUntrustedServerCertificate = tls,
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
IntegratedSecurity = !explicitCreds,
UserName = user ?? string.Empty,
Password = password ?? string.Empty,
RequestTimeout = timeout
};
}
}