fix(galaxy): seam hardening - advise unmark on throw, dup attributeRef surfaced, bounded subscribe-once read; delete dormant historian connection probe (#605)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+22
@@ -257,4 +257,26 @@ public sealed class GalaxyDriverRawPathKeyingTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Two RawPaths sharing one attributeRef: first wins the reverse map, the loser is recorded in the skipped ledger (06/S-13).</summary>
|
||||
[Fact]
|
||||
public void DuplicateAttributeRef_RecordsLosingRawPath_InSkippedLedger()
|
||||
{
|
||||
var opts = OptsWithRawTags() with
|
||||
{
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry(RawPath, $"{{\"attributeRef\":\"{AttributeRef}\"}}", false),
|
||||
new RawTagEntry("area/line/tank1/level-copy", $"{{\"attributeRef\":\"{AttributeRef}\"}}", false),
|
||||
],
|
||||
};
|
||||
using var driver = new GalaxyDriver("g", opts, hierarchySource: null);
|
||||
|
||||
var report = driver.GetSkippedTags();
|
||||
report.ShouldNotBeNull();
|
||||
report.AuthoredCount.ShouldBe(2);
|
||||
report.SkippedCount.ShouldBe(1);
|
||||
report.Sample[0].RawPath.ShouldBe("area/line/tank1/level-copy");
|
||||
report.Sample[0].Reason.ShouldContain(RawPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,4 +158,25 @@ public sealed class GalaxyDriverReadTests
|
||||
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
|
||||
result[1].StatusCode.ShouldBe(StatusCodeMap.BadNotConnected);
|
||||
}
|
||||
|
||||
/// <summary>A never-publishing tag with CancellationToken.None must complete with BadTimeout at the internal deadline (06/S-3).</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_SubscribeOncePath_NeverPublishes_NoCallerToken_TimesOutWithBadTimeout()
|
||||
{
|
||||
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber)
|
||||
{
|
||||
ReadViaSubscribeDeadline = TimeSpan.FromMilliseconds(200),
|
||||
};
|
||||
|
||||
var readTask = driver.ReadAsync(["Silent.Tag"], CancellationToken.None);
|
||||
var finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
|
||||
finished.ShouldBe(readTask);
|
||||
var result = await readTask;
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].StatusCode.ShouldBe(StatusCodeMap.BadTimeout);
|
||||
subscriber.UnsubscribedHandles.ShouldContain(subscriber.Map["Silent.Tag"]);
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -62,6 +62,37 @@ public sealed class GatewayGalaxyDataWriterFailClosedTests
|
||||
writer.CachedSupervisedHandleCount.ShouldBe(0); // advise failure forgot the handle → next write retries
|
||||
}
|
||||
|
||||
/// <summary>A THROWN advise (transport fault, not a non-OK status) must also forget the handle, or every later
|
||||
/// write on it would skip the advise forever (06/S-12). The next write re-advises and succeeds.</summary>
|
||||
[Fact]
|
||||
public async Task WriteOne_AdviseThrows_FailsClosed_ForgetsHandle_AndNextWriteReAdvises()
|
||||
{
|
||||
var throwNext = true;
|
||||
var ops = new FakeMxWriteOps
|
||||
{
|
||||
InvokeResponder = _ => throwNext
|
||||
? throw new IOException("transport reset")
|
||||
: new MxCommandReply { ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok } },
|
||||
};
|
||||
var writer = NewWriter();
|
||||
|
||||
var first = await writer.WriteOneForTestAsync(
|
||||
ops, new WriteRequest("Tag.T", true), SecurityClassification.FreeAccess, CancellationToken.None);
|
||||
|
||||
first.StatusCode.ShouldBe(StatusCodeMap.BadCommunicationError);
|
||||
ops.RawWriteHandles.ShouldBeEmpty();
|
||||
writer.CachedSupervisedHandleCount.ShouldBe(0);
|
||||
|
||||
throwNext = false;
|
||||
var second = await writer.WriteOneForTestAsync(
|
||||
ops, new WriteRequest("Tag.T", true), SecurityClassification.FreeAccess, CancellationToken.None);
|
||||
|
||||
second.StatusCode.ShouldBe(StatusCodeMap.Good);
|
||||
ops.Invokes.Count(r => r.Command.Kind == MxCommandKind.AdviseSupervisory).ShouldBe(2);
|
||||
ops.RawWriteHandles.ShouldHaveSingleItem();
|
||||
writer.CachedSupervisedHandleCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>When advise succeeds, the raw Write proceeds and a clean reply returns Good (regression).</summary>
|
||||
[Fact]
|
||||
public async Task WriteOne_AdviseSucceeds_RawWriteProceeds()
|
||||
|
||||
+7
-35
@@ -22,20 +22,6 @@ public sealed class GatewayHealthSnapshotTests
|
||||
Assert.Equal(h.TotalQueries, h.TotalSuccesses + h.TotalFailures); // invariant
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connection_state_reflects_GetConnectionStatus_flags()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient
|
||||
{
|
||||
ConnectionStatus = new ConnectionStatus { ConnectedToServer = true, ConnectionKind = 0b11 }, // Process|Event
|
||||
};
|
||||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
await ds.RefreshConnectionStateAsync(TestContext.Current.CancellationToken); // internal probe used by health hosted-service
|
||||
var h = ds.GetHealthSnapshot();
|
||||
Assert.True(h.ProcessConnectionOpen);
|
||||
Assert.True(h.EventConnectionOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHealthSnapshot_does_no_io_and_starts_with_connections_closed()
|
||||
{
|
||||
@@ -49,31 +35,17 @@ public sealed class GatewayHealthSnapshotTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disconnected_status_leaves_both_flags_closed()
|
||||
public async Task Connection_flags_are_derived_from_observed_reads()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient
|
||||
{
|
||||
ConnectionStatus = new ConnectionStatus { ConnectedToServer = false, ConnectionKind = 0b11 },
|
||||
};
|
||||
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
|
||||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
await ds.RefreshConnectionStateAsync(TestContext.Current.CancellationToken);
|
||||
var h = ds.GetHealthSnapshot();
|
||||
Assert.False(h.ProcessConnectionOpen);
|
||||
Assert.False(h.EventConnectionOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_status_query_degrades_flags_without_throwing()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient
|
||||
{
|
||||
ConnectionStatus = new ConnectionStatus { ConnectedToServer = true, ConnectionKind = 0b11 },
|
||||
GetConnectionStatusThrows = new InvalidOperationException("gateway unreachable"),
|
||||
};
|
||||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
await ds.RefreshConnectionStateAsync(TestContext.Current.CancellationToken); // must not throw
|
||||
await ds.ReadRawAsync("T", default, default, 1, TestContext.Current.CancellationToken);
|
||||
Assert.True(ds.GetHealthSnapshot().ProcessConnectionOpen);
|
||||
fake.ThrowOnRead = new InvalidOperationException("boom");
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => ds.ReadRawAsync("T", default, default, 1, TestContext.Current.CancellationToken));
|
||||
var h = ds.GetHealthSnapshot();
|
||||
Assert.False(h.ProcessConnectionOpen);
|
||||
Assert.False(h.EventConnectionOpen);
|
||||
Assert.Equal(0, fake.GetConnectionStatusCallCount); // never probes the gateway
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user