fix(driver-historian-wonderware-client): resolve Low code-review findings (Driver.Historian.Wonderware.Client-003,004,006,008,010)
- Driver.Historian.Wonderware.Client-003: replaced the mixed Interlocked + healthLock counters with RecordOutcome that touches _totalQueries and exactly one of _totalSuccesses / _totalFailures under one acquisition. - Driver.Historian.Wonderware.Client-004: InvokeAndClassifyAsync routes transport + sidecar classification through a single RecordOutcome call; the legacy ReclassifySuccessAsFailure two-step is gone. - Driver.Historian.Wonderware.Client-006: removed the dead ReconnectInitialBackoff / ReconnectMaxBackoff options and added a doc <remarks> stating the channel performs a single in-place reconnect; retry/backoff stays with the caller. - Driver.Historian.Wonderware.Client-008: the audit-suppression comment block now records advisory titles, why neither applies, and the revisit trigger. - Driver.Historian.Wonderware.Client-010: reworded Dispose() to claim deadlock-safety and added a GetHealthSnapshot summary documenting the single-channel collapse + counter invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -491,4 +491,95 @@ public sealed class WonderwareHistorianClientTests
|
||||
await Should.ThrowAsync<InvalidDataException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 100, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ===== Finding-003 / Finding-004: health counter consistency =====
|
||||
|
||||
/// <summary>
|
||||
/// (Finding 003 + 004) A sidecar-level failure must be classified once: TotalSuccesses
|
||||
/// must stay at 0, TotalFailures must become 1, and TotalQueries / TotalSuccesses /
|
||||
/// TotalFailures must all be updated under the same lock so a concurrent snapshot can
|
||||
/// never observe inflated successes or out-of-band TotalQueries. This pins behaviour so
|
||||
/// a future regression to the "RecordSuccess then undo via ReclassifySuccessAsFailure"
|
||||
/// dance is caught.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetHealthSnapshot_SidecarFailure_NeverInflatesSuccessCounter()
|
||||
{
|
||||
var pipe = UniquePipeName();
|
||||
await using var server = new FakeSidecarServer(pipe, Secret)
|
||||
{
|
||||
OnReadRaw = _ => new ReadRawReply { Success = false, Error = "boom" },
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, CancellationToken.None));
|
||||
|
||||
var snap = client.GetHealthSnapshot();
|
||||
snap.TotalQueries.ShouldBe(1);
|
||||
snap.TotalSuccesses.ShouldBe(0);
|
||||
snap.TotalFailures.ShouldBe(1);
|
||||
snap.ConsecutiveFailures.ShouldBe(1);
|
||||
snap.LastError.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Finding 003) Concurrent calls + concurrent <see cref="WonderwareHistorianClient.GetHealthSnapshot"/>
|
||||
/// reads must observe consistent counters. Specifically, TotalSuccesses + TotalFailures
|
||||
/// must equal TotalQueries at every observed snapshot (no torn read between an
|
||||
/// Interlocked-incremented TotalQueries and a lock-protected outcome counter). The
|
||||
/// channel serializes calls, so the test is observable: each completed query strictly
|
||||
/// increments either successes or failures by one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetHealthSnapshot_ConcurrentCallsAndReads_CountersAreInternallyConsistent()
|
||||
{
|
||||
var pipe = UniquePipeName();
|
||||
await using var server = new FakeSidecarServer(pipe, Secret)
|
||||
{
|
||||
OnReadRaw = _ => new ReadRawReply { Success = true },
|
||||
};
|
||||
await server.StartAsync();
|
||||
|
||||
await using var client = new WonderwareHistorianClient(OptsFor(pipe));
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
var readerSawInconsistent = false;
|
||||
|
||||
#pragma warning disable xUnit1051 // Internal Task.Run loop drives a polling stress test; cancellation flows via stop.IsCancellationRequested below.
|
||||
var reader = Task.Run(() =>
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
var snap = client.GetHealthSnapshot();
|
||||
// Every completed call increments TotalQueries AND exactly one of
|
||||
// TotalSuccesses or TotalFailures under the same lock; an in-flight call
|
||||
// has not yet incremented any of them. So TotalQueries should always equal
|
||||
// the sum of TotalSuccesses + TotalFailures (no in-between state visible).
|
||||
if (snap.TotalSuccesses + snap.TotalFailures != snap.TotalQueries)
|
||||
{
|
||||
readerSawInconsistent = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
#pragma warning restore xUnit1051
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
await client.ReadRawAsync("Tag", DateTime.UtcNow, DateTime.UtcNow, 1, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
stop.Cancel();
|
||||
await reader;
|
||||
|
||||
readerSawInconsistent.ShouldBeFalse(
|
||||
"GetHealthSnapshot exposed TotalQueries that disagreed with the sum of TotalSuccesses + TotalFailures — counters are not updated under a single lock.");
|
||||
|
||||
var final = client.GetHealthSnapshot();
|
||||
final.TotalQueries.ShouldBe(50);
|
||||
final.TotalSuccesses.ShouldBe(50);
|
||||
final.TotalFailures.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user