test(abcip): read-only poll-latency comparison harness for the T21 press gate (proven on ab_server)
This commit is contained in:
@@ -185,6 +185,24 @@ see the same split on the press.
|
||||
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
|
||||
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
|
||||
|
||||
### Latency harness proven on ab_server (T21 Step 1)
|
||||
|
||||
`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
|
||||
(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
|
||||
under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
|
||||
the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
|
||||
in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
|
||||
ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
|
||||
|
||||
| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
|
||||
|---|---|---|---|---|
|
||||
| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
|
||||
| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
|
||||
|
||||
The CompactLogix result is a real finding, not a harness fault: native opens a 504-byte connection there, while libplctag
|
||||
opens a Large one (see the T18 capture above), so native needs 3× the round trips per pass. The T21 press result is
|
||||
recorded under `## Latency (T21)` and is not filled in here.
|
||||
|
||||
## AdminUI /run (T19)
|
||||
|
||||
Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
|
||||
|
||||
+69
-23
@@ -44,6 +44,11 @@ internal sealed class DifferentialResult
|
||||
public required int? ConnectionsBaseline { get; init; }
|
||||
public required int? ConnectionsBeforeNative { get; init; }
|
||||
public required TimeSpan LibplctagDrainWait { get; init; }
|
||||
|
||||
/// <summary>The endpoint's resolved addresses, and this machine's connections to it before the run (null when
|
||||
/// unobservable). Reused by the latency comparison's barriers, which must not start alongside a session.</summary>
|
||||
public required IReadOnlySet<IPAddress> Addresses { get; init; }
|
||||
public required IReadOnlySet<IPEndPoint>? BaselineEndpoints { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -98,7 +103,7 @@ internal static class DifferentialRun
|
||||
|
||||
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
|
||||
var drain = Stopwatch.StartNew();
|
||||
var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, addresses, baseline, ct).ConfigureAwait(false);
|
||||
var beforeNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "libplctag", ct).ConfigureAwait(false);
|
||||
drain.Stop();
|
||||
|
||||
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
|
||||
@@ -114,6 +119,8 @@ internal static class DifferentialRun
|
||||
ConnectionsBaseline = baseline?.Count,
|
||||
ConnectionsBeforeNative = beforeNative,
|
||||
LibplctagDrainWait = drain.Elapsed,
|
||||
Addresses = addresses,
|
||||
BaselineEndpoints = baseline,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,26 +130,42 @@ internal static class DifferentialRun
|
||||
/// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps
|
||||
/// every libplctag operation on ONE session — the mirror of the native anchor lease.
|
||||
/// </summary>
|
||||
internal static async Task<(TransportObservations Observations, IReadOnlyList<SelectedTag> Selection)> RunLibplctagPhaseAsync(
|
||||
internal static Task<(TransportObservations Observations, IReadOnlyList<SelectedTag> Selection)> RunLibplctagPhaseAsync(
|
||||
DifferentialTarget target, CancellationToken ct)
|
||||
{
|
||||
const string phase = "libplctag";
|
||||
var lib = new TransportObservations();
|
||||
var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
|
||||
using var anchor = factories.Tags.Create(target.Params("@tags"));
|
||||
return WithLibplctagAnchorAsync(phase, target, "@tags", async () =>
|
||||
{
|
||||
await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false);
|
||||
await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false);
|
||||
var selection = Select(target, lib);
|
||||
await ReadTagsAsync(phase, target, factories.Tags, selection, lib, afterEach: null, ct).ConfigureAwait(false);
|
||||
return ((TransportObservations)lib, selection);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds one libplctag handle on <paramref name="anchorTag"/> for the whole of <paramref name="body"/>, so every
|
||||
/// libplctag operation in it shares ONE session and ONE Forward Open. A failure to create the anchor aborts
|
||||
/// before <paramref name="body"/> runs; the anchor is disposed when <paramref name="body"/> ends, either way.
|
||||
/// Shared by the differential (anchor <c>@tags</c>) and the latency comparison (anchor = its first tag).
|
||||
/// </summary>
|
||||
internal static async Task<T> WithLibplctagAnchorAsync<T>(
|
||||
string phase, DifferentialTarget target, string anchorTag, Func<Task<T>> body, CancellationToken ct)
|
||||
{
|
||||
var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
|
||||
using var anchor = factories.Tags.Create(target.Params(anchorTag));
|
||||
try
|
||||
{
|
||||
await anchor.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
throw Abort(phase, "anchor session (@tags)", ex);
|
||||
throw Abort(phase, $"anchor session ({anchorTag})", ex);
|
||||
}
|
||||
await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false);
|
||||
await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false);
|
||||
var selection = Select(target, lib);
|
||||
await ReadTagsAsync(phase, target, factories.Tags, selection, lib, afterEach: null, ct).ConfigureAwait(false);
|
||||
return (lib, selection);
|
||||
return await body().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>The native phase alone; internal so the offline abort test can drive it against a fake endpoint.</summary>
|
||||
@@ -152,6 +175,27 @@ internal static class DifferentialRun
|
||||
const string phase = "native";
|
||||
var native = new TransportObservations();
|
||||
var factories = AbCipTransportFactories.For(AbCipTransport.Native);
|
||||
var (_, forwardOpens) = await WithNativeAnchorAsync(phase, target, async checkOpens =>
|
||||
{
|
||||
await BrowseAsync(phase, target, factories.Enumerators, native, checkOpens, ct).ConfigureAwait(false);
|
||||
await ReadTemplatesAsync(phase, target, factories.Templates, native, checkOpens, ct).ConfigureAwait(false);
|
||||
await ReadTagsAsync(phase, target, factories.Tags, selection, native, checkOpens, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}).ConfigureAwait(false);
|
||||
return (native, forwardOpens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="body"/> with every native operation sharing ONE connection, held open by an anchor
|
||||
/// lease: the process-wide Forward Open counter is reset, the open-attempt budget is set to exactly one
|
||||
/// more attempt, and <paramref name="body"/> receives a check to call after each operation that aborts the
|
||||
/// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in
|
||||
/// <c>finally</c>. Shared by the differential and the latency comparison.
|
||||
/// </summary>
|
||||
/// <returns>The body's result and the Forward Opens counted between the reset and the anchor release.</returns>
|
||||
internal static async Task<(T Result, int ForwardOpens)> WithNativeAnchorAsync<T>(
|
||||
string phase, DifferentialTarget target, Func<Action<string>, Task<T>> body)
|
||||
{
|
||||
var anchorParams = target.Params("@tags");
|
||||
|
||||
void CheckOpens(string operation)
|
||||
@@ -165,15 +209,15 @@ internal static class DifferentialRun
|
||||
|
||||
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
|
||||
Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1);
|
||||
T result;
|
||||
int forwardOpens;
|
||||
try
|
||||
{
|
||||
// Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry.
|
||||
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
|
||||
try
|
||||
{
|
||||
await BrowseAsync(phase, target, factories.Enumerators, native, CheckOpens, ct).ConfigureAwait(false);
|
||||
await ReadTemplatesAsync(phase, target, factories.Templates, native, CheckOpens, ct).ConfigureAwait(false);
|
||||
await ReadTagsAsync(phase, target, factories.Tags, selection, native, CheckOpens, ct).ConfigureAwait(false);
|
||||
result = await body(CheckOpens).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -185,7 +229,7 @@ internal static class DifferentialRun
|
||||
{
|
||||
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
|
||||
}
|
||||
return (native, forwardOpens);
|
||||
return (result, forwardOpens);
|
||||
}
|
||||
|
||||
private static async Task BrowseAsync(
|
||||
@@ -422,11 +466,11 @@ internal static class DifferentialRun
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private static DifferentialAbortedException Abort(string phase, string operation, Exception ex) =>
|
||||
internal static DifferentialAbortedException Abort(string phase, string operation, Exception ex) =>
|
||||
new($"Differential ABORTED in the {phase} phase at {operation}: {Describe(ex)}. " +
|
||||
"Stopped on the first connection-level failure; nothing was retried.", ex);
|
||||
|
||||
private static Task PaceAsync(DifferentialTarget target, CancellationToken ct) =>
|
||||
internal static Task PaceAsync(DifferentialTarget target, CancellationToken ct) =>
|
||||
target.PaceMs > 0 ? Task.Delay(target.PaceMs, ct) : Task.CompletedTask;
|
||||
|
||||
/// <summary>Resolves the endpoint once, bounded (an IP literal needs no lookup).</summary>
|
||||
@@ -448,14 +492,16 @@ internal static class DifferentialRun
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// libplctag closes its session on a background thread some time after the last handle is destroyed.
|
||||
/// Wait until no TCP connection to the endpoint remains that was not already there before the run
|
||||
/// (compared by local endpoint, not by count — review r1 M6), so the two transports are never
|
||||
/// A transport closes its session asynchronously (libplctag on a background thread some time after the last
|
||||
/// handle is destroyed). Wait until no TCP connection to the endpoint remains that was not already there
|
||||
/// before the run (compared by local endpoint, not by count — review r1 M6), so two sessions are never
|
||||
/// connected to the controller at the same time.
|
||||
/// </summary>
|
||||
/// <returns>The connection count observed when native work may start (null when unobservable).</returns>
|
||||
private static async Task<int?> WaitForLibplctagSessionToCloseAsync(
|
||||
DifferentialTarget target, IReadOnlySet<IPAddress> addresses, IReadOnlySet<IPEndPoint>? baseline, CancellationToken ct)
|
||||
/// <param name="whose">Names the session waited for, in the abort message.</param>
|
||||
/// <returns>The connection count observed when the next phase may start (null when unobservable).</returns>
|
||||
internal static async Task<int?> WaitForSessionsToCloseAsync(
|
||||
DifferentialTarget target, IReadOnlySet<IPAddress> addresses, IReadOnlySet<IPEndPoint>? baseline, string whose,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (baseline is null)
|
||||
{
|
||||
@@ -471,8 +517,8 @@ internal static class DifferentialRun
|
||||
if (added.Count == 0) return now.Count;
|
||||
if (deadline.Elapsed > LibplctagDrainBudget)
|
||||
throw new DifferentialAbortedException(
|
||||
$"libplctag's session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after its " +
|
||||
$"handles were disposed (local {string.Join(", ", added)}); refusing to start the native phase alongside it.");
|
||||
$"{whose}'s session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after it was " +
|
||||
$"released (local {string.Join(", ", added)}); refusing to start the next phase alongside it.");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -102,7 +102,8 @@ public sealed class DifferentialSafetyTests
|
||||
[Fact]
|
||||
public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping()
|
||||
{
|
||||
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar);
|
||||
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar,
|
||||
DifferentialTarget.LatencyTagsVar, DifferentialTarget.LatencyPassesVar, DifferentialTarget.LatencyPassGapVar);
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.EndpointVar, "127.0.0.1:1");
|
||||
@@ -115,10 +116,29 @@ public sealed class DifferentialSafetyTests
|
||||
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, null);
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.PaceVar, null);
|
||||
var t = DifferentialTarget.FromEnvironment()!;
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, null);
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, null);
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, null);
|
||||
var t =DifferentialTarget.FromEnvironment()!;
|
||||
t.MaxTags.ShouldBe(500);
|
||||
t.MaxTemplates.ShouldBe(DifferentialTarget.DefaultMaxTemplates);
|
||||
t.PaceMs.ShouldBe(DifferentialTarget.DefaultPaceMs);
|
||||
t.LatencyTags.ShouldBe(100);
|
||||
t.LatencyPasses.ShouldBe(30);
|
||||
t.LatencyPassGapMs.ShouldBe(200);
|
||||
|
||||
// Latency knobs (Task 21): the same fail-fast rule, with tighter ceilings.
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "201");
|
||||
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyTagsVar);
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "200");
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "51");
|
||||
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassesVar);
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "50");
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "10001");
|
||||
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassGapVar);
|
||||
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "0");
|
||||
var lat = DifferentialTarget.FromEnvironment()!;
|
||||
(lat.LatencyTags, lat.LatencyPasses, lat.LatencyPassGapMs).ShouldBe((200, 50, 0));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
+23
-2
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
/// <item><c>AB_DIFF_MAX_TEMPLATES</c> — cap on template reads per transport, default 50, at most 500.</item>
|
||||
/// <item><c>AB_DIFF_PACE_MS</c> — delay before every harness-issued request, default 20 ms; 0 for ab_server.</item>
|
||||
/// <item><c>AB_DIFF_COUNT_OPENS</c> — <c>1</c> ⇒ assert exactly one native Forward Open over the run.</item>
|
||||
/// <item><c>AB_LAT_TAGS</c> — latency comparison: tags read per pass, default 100, at most 200 (higher fails fast).</item>
|
||||
/// <item><c>AB_LAT_PASSES</c> — latency comparison: passes per transport, default 30, at most 50.</item>
|
||||
/// <item><c>AB_LAT_PASS_GAP_MS</c> — latency comparison: pause between passes, default 200 ms, at most 10000.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal sealed record DifferentialTarget(
|
||||
@@ -25,7 +28,10 @@ internal sealed record DifferentialTarget(
|
||||
int MaxTags,
|
||||
bool CountOpens,
|
||||
int MaxTemplates = DifferentialTarget.DefaultMaxTemplates,
|
||||
int PaceMs = DifferentialTarget.DefaultPaceMs)
|
||||
int PaceMs = DifferentialTarget.DefaultPaceMs,
|
||||
int LatencyTags = DifferentialTarget.DefaultLatencyTags,
|
||||
int LatencyPasses = DifferentialTarget.DefaultLatencyPasses,
|
||||
int LatencyPassGapMs = DifferentialTarget.DefaultLatencyPassGapMs)
|
||||
{
|
||||
public const string EndpointVar = "AB_DIFF_ENDPOINT";
|
||||
public const string PathVar = "AB_DIFF_PATH";
|
||||
@@ -35,6 +41,16 @@ internal sealed record DifferentialTarget(
|
||||
public const string CountOpensVar = "AB_DIFF_COUNT_OPENS";
|
||||
public const string MaxTemplatesVar = "AB_DIFF_MAX_TEMPLATES";
|
||||
public const string PaceVar = "AB_DIFF_PACE_MS";
|
||||
public const string LatencyTagsVar = "AB_LAT_TAGS";
|
||||
public const string LatencyPassesVar = "AB_LAT_PASSES";
|
||||
public const string LatencyPassGapVar = "AB_LAT_PASS_GAP_MS";
|
||||
|
||||
/// <summary>Latency comparison ceilings: a production press is the target, and each pass is one burst.</summary>
|
||||
public const int LatencyTagsCeiling = 200;
|
||||
public const int LatencyPassesCeiling = 50;
|
||||
public const int DefaultLatencyTags = 100;
|
||||
public const int DefaultLatencyPasses = 30;
|
||||
public const int DefaultLatencyPassGapMs = 200;
|
||||
|
||||
/// <summary>Hard ceiling on <c>AB_DIFF_MAX_TAGS</c> / <c>AB_DIFF_MAX_TEMPLATES</c>: a production press is the target.</summary>
|
||||
public const int Ceiling = 500;
|
||||
@@ -86,8 +102,13 @@ internal sealed record DifferentialTarget(
|
||||
if (tags is { Count: > Ceiling })
|
||||
throw new InvalidOperationException($"{TagsVar} names {tags.Count} tags; the ceiling is {Ceiling}.");
|
||||
|
||||
var latencyTags = BoundedInt(LatencyTagsVar, DefaultLatencyTags, 1, LatencyTagsCeiling);
|
||||
var latencyPasses = BoundedInt(LatencyPassesVar, DefaultLatencyPasses, 1, LatencyPassesCeiling);
|
||||
var latencyGap = BoundedInt(LatencyPassGapVar, DefaultLatencyPassGapMs, 0, 10_000);
|
||||
|
||||
var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1";
|
||||
return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace);
|
||||
return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace,
|
||||
latencyTags, latencyPasses, latencyGap);
|
||||
}
|
||||
|
||||
/// <summary>Reads an integer knob; out of range FAILS rather than clamping, so a typo never silently widens a press run.</summary>
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
using System.Globalization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
|
||||
/// <summary>
|
||||
/// Task 21 latency comparison: a fixed set of atomic tags polled through libplctag, then through the native
|
||||
/// transport, each pass issuing every read at once. Pass criterion: native p95 ≤ libplctag p95 × 1.10.
|
||||
/// <para>Gated by the same variables as the differential (<c>AB_DIFF_ENDPOINT</c> absent ⇒ skip), in the same
|
||||
/// serialized collection, and read-only under the same source/IL guard
|
||||
/// (<see cref="TransportDifferentialTests.The_harness_is_read_only_by_construction"/>). It runs AFTER the
|
||||
/// differential's one run, reusing its browse for tag selection, so the target sees one listing per transport,
|
||||
/// not two. Knobs: <c>AB_LAT_TAGS</c>, <c>AB_LAT_PASSES</c>, <c>AB_LAT_PASS_GAP_MS</c> (see
|
||||
/// <see cref="DifferentialTarget"/>).</para>
|
||||
/// </summary>
|
||||
[Collection(DifferentialCollection.Name)]
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
|
||||
{
|
||||
private const double Tolerance = 1.10;
|
||||
private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(10);
|
||||
|
||||
[Fact]
|
||||
public async Task Native_poll_p95_is_within_ten_percent_of_libplctag()
|
||||
{
|
||||
var target = DifferentialTarget.FromEnvironment();
|
||||
if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
|
||||
var differential = await fixture.GetAsync();
|
||||
|
||||
using var cts = new CancellationTokenSource(RunBudget);
|
||||
var r = await PollLatencyRun.ExecuteAsync(target, differential, cts.Token);
|
||||
|
||||
Log($"## Latency — {r.Target}");
|
||||
Log($"Tags per pass: {r.Tags.Count} ({r.DistinctTags} distinct" +
|
||||
(r.Repeated ? $", REPEATED cyclically to reach {DifferentialTarget.LatencyTagsVar}={r.Target.LatencyTags}" : "") +
|
||||
$"); passes per transport: {r.Target.LatencyPasses}; gap between passes: {r.Target.LatencyPassGapMs} ms.");
|
||||
Log("Types: " + string.Join(", ", r.Tags.GroupBy(t => t.Type).Select(g => $"{g.Key} {g.Count()}")));
|
||||
if (r.Dropped.Count > 0) Log($"Dropped (libplctag refused at setup, removed from both sets): {string.Join(", ", r.Dropped)}");
|
||||
Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}.");
|
||||
Log("");
|
||||
|
||||
var libP50 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 50);
|
||||
var libP95 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 95);
|
||||
var natP50 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 50);
|
||||
var natP95 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 95);
|
||||
var limit = libP95 * Tolerance;
|
||||
|
||||
Log("| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |");
|
||||
Log("|---|---|---|---|---|---|---|---|");
|
||||
Log(Row("libplctag", r.Libplctag, libP50, libP95));
|
||||
Log(Row("native", r.Native, natP50, natP95));
|
||||
Log("");
|
||||
Log($"Criterion: native p95 {Ms(natP95)} ms ≤ libplctag p95 {Ms(libP95)} ms × {Tolerance:0.00} = {Ms(limit)} ms → " +
|
||||
(natP95 <= limit ? "PASS" : "**FAIL**"));
|
||||
Log("");
|
||||
Log("| Pass | libplctag ms | native ms | native requests |");
|
||||
Log("|---|---|---|---|");
|
||||
for (var i = 0; i < Math.Max(r.Libplctag.Count, r.Native.Count); i++)
|
||||
{
|
||||
var lib = i < r.Libplctag.Count ? r.Libplctag[i] : null;
|
||||
var nat = i < r.Native.Count ? r.Native[i] : null;
|
||||
Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | " +
|
||||
$"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} |");
|
||||
}
|
||||
foreach (var p in r.Problems) Log($"- PROBLEM: {p}");
|
||||
|
||||
r.Problems.ShouldBeEmpty();
|
||||
r.Libplctag.Count.ShouldBe(r.Target.LatencyPasses);
|
||||
r.Native.Count.ShouldBe(r.Target.LatencyPasses);
|
||||
r.Libplctag.Sum(p => p.TagFailures).ShouldBe(0, "every timed libplctag read must succeed");
|
||||
r.Native.Sum(p => p.TagFailures).ShouldBe(0, "every timed native read must succeed");
|
||||
if (r.Target.CountOpens) r.NativeForwardOpens.ShouldBe(1);
|
||||
natP95.ShouldBeLessThanOrEqualTo(limit, $"native p95 must be ≤ libplctag p95 × {Tolerance:0.00}");
|
||||
}
|
||||
|
||||
/// <summary>Offline: the tag pick takes atomic scalars only, and repeats a short set deliberately and visibly.</summary>
|
||||
[Fact]
|
||||
public void Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly()
|
||||
{
|
||||
var browsed = new TransportObservations();
|
||||
browsed.Browse.Add(new AbCipDiscoveredTag("D", null, AbCipDataType.DInt, false));
|
||||
browsed.Browse.Add(new AbCipDiscoveredTag("R", null, AbCipDataType.Real, false));
|
||||
browsed.Browse.Add(new AbCipDiscoveredTag("S", null, AbCipDataType.String, false));
|
||||
browsed.Browse.Add(new AbCipDiscoveredTag("T", null, AbCipDataType.Dt, false));
|
||||
browsed.Browse.Add(new AbCipDiscoveredTag("A", null, AbCipDataType.DInt, false, ElementCount: 4, IsArray: true));
|
||||
browsed.Browse.Add(new AbCipDiscoveredTag("Sys", null, AbCipDataType.DInt, false, IsSystemTag: true));
|
||||
var target = new DifferentialTarget("127.0.0.1", 1, "1,0", AbCipPlcFamily.CompactLogix, null, 200, false, LatencyTags: 5);
|
||||
|
||||
var (tags, distinct, repeated) = PollLatencyRun.PickTags(target, browsed);
|
||||
distinct.ShouldBe(2);
|
||||
repeated.ShouldBeTrue();
|
||||
tags.Select(t => t.Name).ShouldBe(["D", "R", "D", "R", "D"]);
|
||||
|
||||
var (one, oneDistinct, oneRepeated) = PollLatencyRun.PickTags(target with { LatencyTags = 1 }, browsed);
|
||||
one.ShouldHaveSingleItem().Name.ShouldBe("D");
|
||||
oneDistinct.ShouldBe(1);
|
||||
oneRepeated.ShouldBeFalse();
|
||||
|
||||
PollLatencyRun.Percentile([TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2)], 50)
|
||||
.ShouldBe(2);
|
||||
PollLatencyRun.Percentile(Enumerable.Range(1, 20).Select(i => TimeSpan.FromMilliseconds(i)), 95).ShouldBe(19);
|
||||
}
|
||||
|
||||
private static string Row(string name, IReadOnlyList<LatencyPass> passes, double p50, double p95)
|
||||
{
|
||||
var walls = passes.Select(p => p.Wall.TotalMilliseconds).ToList();
|
||||
var requests = passes.Where(p => p.Requests is not null).Select(p => p.Requests!.Value).Order().ToList();
|
||||
var medianRequests = requests.Count == 0 ? "n/a" : requests[requests.Count / 2].ToString(CultureInfo.InvariantCulture);
|
||||
return $"| {name} | {passes.Count} | {Ms(p50)} | {Ms(p95)} | {Ms(walls.Min())} | {Ms(walls.Max())} | {medianRequests} | " +
|
||||
$"{passes.Sum(p => p.TagFailures)} |";
|
||||
}
|
||||
|
||||
private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
|
||||
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
|
||||
|
||||
/// <summary>One timed burst: every tag read once, concurrently.</summary>
|
||||
/// <param name="Requests">Native only: <see cref="CipConnection.RequestsSent"/> delta over the pass (keep-alives included).</param>
|
||||
/// <param name="TagFailures">Reads that ended with a tag-level status (a connection-level one aborts instead).</param>
|
||||
internal sealed record LatencyPass(int Index, TimeSpan Wall, int? Requests, int TagFailures);
|
||||
|
||||
/// <summary>The outcome of the latency comparison.</summary>
|
||||
internal sealed class LatencyResult
|
||||
{
|
||||
public required DifferentialTarget Target { get; init; }
|
||||
|
||||
/// <summary>The fixed set read every pass, in order (a name appears more than once only when <see cref="Repeated"/>).</summary>
|
||||
public required IReadOnlyList<SelectedTag> Tags { get; init; }
|
||||
public required int DistinctTags { get; init; }
|
||||
public required bool Repeated { get; init; }
|
||||
|
||||
/// <summary>Names libplctag could not read at setup (tag-level status): dropped from BOTH transports' sets.</summary>
|
||||
public required IReadOnlyList<string> Dropped { get; init; }
|
||||
public required IReadOnlyList<LatencyPass> Libplctag { get; init; }
|
||||
public required IReadOnlyList<LatencyPass> Native { get; init; }
|
||||
public required int NativeForwardOpens { get; init; }
|
||||
|
||||
/// <summary>Native requests spent creating the runtimes (one read each, before any timed pass).</summary>
|
||||
public required int NativeSetupRequests { get; init; }
|
||||
public required List<string> Problems { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 21's poll-latency comparison, read-only and built on the differential's machinery: the same
|
||||
/// endpoint parsing, the same anchors (one libplctag handle, one native lease), the same open-attempt budget,
|
||||
/// the same connection barrier and the same stop-on-first-connection-failure rule.
|
||||
/// <para>Order: the differential's run (its browse supplies the tags — no second listing), a barrier until its
|
||||
/// native session is gone, every libplctag pass with every handle then disposed, a barrier until libplctag's
|
||||
/// session is gone, then every native pass under one anchor lease. The two transports never overlap.</para>
|
||||
/// <para>Per transport: create and read every tag once (untimed, paced by <c>AB_DIFF_PACE_MS</c>), then
|
||||
/// <c>AB_LAT_PASSES</c> timed passes, each issuing all <c>AB_LAT_TAGS</c> reads at once (<c>Task.WhenAll</c>)
|
||||
/// so the native shared scheduler can pack them into Multiple Service Packets as libplctag packs internally,
|
||||
/// with <c>AB_LAT_PASS_GAP_MS</c> of quiet between passes.</para>
|
||||
/// </summary>
|
||||
internal static class PollLatencyRun
|
||||
{
|
||||
private static readonly HashSet<AbCipDataType> Atomic =
|
||||
[
|
||||
AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int, AbCipDataType.DInt, AbCipDataType.LInt,
|
||||
AbCipDataType.USInt, AbCipDataType.UInt, AbCipDataType.UDInt, AbCipDataType.ULInt,
|
||||
AbCipDataType.Real, AbCipDataType.LReal,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The fixed tag set: atomic, non-array scalars, from the differential's libplctag browse — or, when
|
||||
/// <c>AB_DIFF_TAGS</c> is set, those of its names whose browsed type is an atomic scalar. When fewer
|
||||
/// distinct tags exist than <c>AB_LAT_TAGS</c>, the set is REPEATED cyclically up to the count (each
|
||||
/// repeat is its own runtime/handle on the same name) and reported as such — never silently.
|
||||
/// </summary>
|
||||
internal static (IReadOnlyList<SelectedTag> Tags, int Distinct, bool Repeated) PickTags(
|
||||
DifferentialTarget target, TransportObservations browsed)
|
||||
{
|
||||
var candidates = DifferentialRun.Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed)
|
||||
.Where(t => !t.IsArray && t.Type is { } type && Atomic.Contains(type))
|
||||
.DistinctBy(t => t.Name)
|
||||
.Take(target.LatencyTags)
|
||||
.ToList();
|
||||
if (candidates.Count == 0 || candidates.Count >= target.LatencyTags) return (candidates, candidates.Count, false);
|
||||
var repeated = Enumerable.Range(0, target.LatencyTags).Select(i => candidates[i % candidates.Count]).ToList();
|
||||
return (repeated, candidates.Count, true);
|
||||
}
|
||||
|
||||
public static async Task<LatencyResult> ExecuteAsync(DifferentialTarget target, DifferentialResult differential, CancellationToken ct)
|
||||
{
|
||||
var (picked, distinct, repeated) = PickTags(target, differential.Libplctag);
|
||||
if (picked.Count == 0)
|
||||
throw new InvalidOperationException("The browse offered no atomic scalar tags to time; nothing was read.");
|
||||
|
||||
// The differential's native session must be gone before libplctag connects.
|
||||
await DifferentialRun.WaitForSessionsToCloseAsync(
|
||||
target, differential.Addresses, differential.BaselineEndpoints, "the differential's native", ct).ConfigureAwait(false);
|
||||
|
||||
// ---- libplctag: setup, passes, every handle disposed (anchor last). ----
|
||||
var dropped = new List<string>();
|
||||
var libFactory = AbCipTransportFactories.For(AbCipTransport.Libplctag).Tags;
|
||||
// Anchor on a tag the differential already read Ok through libplctag, so the anchor is never the refusal.
|
||||
var anchorTag = picked.FirstOrDefault(t => differential.Libplctag.Reads.TryGetValue(t.Name, out var o) && o.Succeeded)
|
||||
?? picked[0];
|
||||
var (libPasses, tags) = await DifferentialRun.WithLibplctagAnchorAsync("latency libplctag", target, anchorTag.Name, async () =>
|
||||
{
|
||||
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
|
||||
try
|
||||
{
|
||||
foreach (var tag in picked)
|
||||
{
|
||||
var rt = await CreateAsync("latency libplctag", target, libFactory, tag, ct).ConfigureAwait(false);
|
||||
if (rt is null) { dropped.Add(tag.Name); continue; }
|
||||
runtimes.Add((tag, rt));
|
||||
}
|
||||
var passes = await RunPassesAsync("latency libplctag", target, runtimes, countRequests: false, afterEach: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
return (passes, (IReadOnlyList<SelectedTag>)runtimes.Select(r => r.Tag).ToList());
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var (_, rt) in runtimes) rt.Dispose();
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
|
||||
await DifferentialRun.WaitForSessionsToCloseAsync(
|
||||
target, differential.Addresses, differential.BaselineEndpoints, "libplctag", ct).ConfigureAwait(false);
|
||||
|
||||
// ---- native: the same (post-drop) set, under one anchor lease. ----
|
||||
var problems = new List<string>();
|
||||
var nativeFactory = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
|
||||
var ((nativePasses, setupRequests), opens) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens =>
|
||||
{
|
||||
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
|
||||
try
|
||||
{
|
||||
var before = Volatile.Read(ref CipConnection.RequestsSent);
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var rt = await CreateAsync("latency native", target, nativeFactory, tag, ct).ConfigureAwait(false);
|
||||
checkOpens($"setup of '{tag.Name}'");
|
||||
if (rt is null) { problems.Add($"native could not read '{tag.Name}', which libplctag read"); continue; }
|
||||
runtimes.Add((tag, rt));
|
||||
}
|
||||
var setup = Volatile.Read(ref CipConnection.RequestsSent) - before;
|
||||
var passes = await RunPassesAsync("latency native", target, runtimes, countRequests: true, checkOpens, ct)
|
||||
.ConfigureAwait(false);
|
||||
return (passes, setup);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var (_, rt) in runtimes) rt.Dispose();
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return new LatencyResult
|
||||
{
|
||||
Target = target,
|
||||
Tags = tags,
|
||||
DistinctTags = distinct,
|
||||
Repeated = repeated,
|
||||
Dropped = dropped,
|
||||
Libplctag = libPasses,
|
||||
Native = nativePasses,
|
||||
NativeForwardOpens = opens,
|
||||
NativeSetupRequests = setupRequests,
|
||||
Problems = problems,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Create + first read of one tag. Null on a tag-level refusal; a connection-level failure aborts.</summary>
|
||||
private static async Task<IAbCipTagRuntime?> CreateAsync(
|
||||
string phase, DifferentialTarget target, IAbCipTagFactory factory, SelectedTag tag, CancellationToken ct)
|
||||
{
|
||||
var rt = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type));
|
||||
try
|
||||
{
|
||||
await DifferentialRun.PaceAsync(target, ct).ConfigureAwait(false);
|
||||
await rt.InitializeAsync(ct).ConfigureAwait(false);
|
||||
if (rt.GetStatus() == (int)AbStatus.Ok) return rt;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
if (DifferentialRun.IsConnectionLevel(ex))
|
||||
{
|
||||
rt.Dispose();
|
||||
throw DifferentialRun.Abort(phase, $"setup of '{tag.Name}'", ex);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
rt.Dispose();
|
||||
throw;
|
||||
}
|
||||
rt.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<LatencyPass>> RunPassesAsync(
|
||||
string phase, DifferentialTarget target, List<(SelectedTag Tag, IAbCipTagRuntime Runtime)> runtimes,
|
||||
bool countRequests, Action<string>? afterEach, CancellationToken ct)
|
||||
{
|
||||
var passes = new List<LatencyPass>(target.LatencyPasses);
|
||||
for (var p = 1; p <= target.LatencyPasses; p++)
|
||||
{
|
||||
if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false);
|
||||
|
||||
var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent);
|
||||
var clock = Stopwatch.StartNew();
|
||||
var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false);
|
||||
clock.Stop();
|
||||
var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore;
|
||||
|
||||
var failures = 0;
|
||||
for (var i = 0; i < outcomes.Length; i++)
|
||||
{
|
||||
if (outcomes[i] is { } ex)
|
||||
{
|
||||
if (DifferentialRun.IsConnectionLevel(ex))
|
||||
throw DifferentialRun.Abort(phase, $"pass {p}, tag '{runtimes[i].Tag.Name}'", ex);
|
||||
failures++;
|
||||
}
|
||||
else if (runtimes[i].Runtime.GetStatus() != (int)AbStatus.Ok)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
afterEach?.Invoke($"pass {p}");
|
||||
passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures));
|
||||
}
|
||||
return passes;
|
||||
}
|
||||
|
||||
private static async Task<Exception?> ReadOnceAsync(IAbCipTagRuntime runtime, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nearest-rank percentile (p in (0,100]) of the pass wall times, in milliseconds.</summary>
|
||||
internal static double Percentile(IEnumerable<TimeSpan> walls, double p)
|
||||
{
|
||||
var sorted = walls.Select(w => w.TotalMilliseconds).Order().ToList();
|
||||
if (sorted.Count == 0) throw new InvalidOperationException("No passes to take a percentile of.");
|
||||
var rank = (int)Math.Ceiling(p / 100.0 * sorted.Count);
|
||||
return sorted[Math.Clamp(rank, 1, sorted.Count) - 1];
|
||||
}
|
||||
}
|
||||
+6
@@ -169,6 +169,10 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
|
||||
var dir = Path.GetDirectoryName(ThisFile())!;
|
||||
var sources = Directory.GetFiles(dir, "*.cs");
|
||||
sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs");
|
||||
// Task 21's latency comparison and its run helper are part of the harness the press sees: pinned in scope,
|
||||
// so moving either out of this folder (or namespace) fails the guard instead of silently escaping it.
|
||||
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyComparisonTests) + ".cs");
|
||||
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyRun) + ".cs");
|
||||
var textHits = (from f in sources
|
||||
let text = File.ReadAllText(f)
|
||||
from token in forbidden
|
||||
@@ -179,6 +183,8 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
|
||||
var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList();
|
||||
harnessTypes.ShouldContain(typeof(TransportDifferentialTests));
|
||||
harnessTypes.ShouldContain(typeof(PollLatencyComparisonTests));
|
||||
harnessTypes.ShouldContain(typeof(PollLatencyRun));
|
||||
var callHits = (from t in harnessTypes
|
||||
from m in MethodsOf(t)
|
||||
from callee in CalleesOf(m)
|
||||
|
||||
Reference in New Issue
Block a user