fix(mtconnect): deterministic concurrency test + DATA_SET -> BadNotSupported
The concurrency test asserted a post-Apply post-condition from inside the race window: dev1_pos is authored, so a reader beating the first Apply correctly observes BadWaitingForInitialData, which the test's two-member "legal" set omitted. Consistently red (10/10) rather than intermittent. Rewritten to assert only properties that hold at every instant — the whole (status, value, source timestamp) triple against the three legal snapshots, which is the real torn-snapshot check — plus a read counter guarding a vacuous pass and one deterministic closing Apply for an exact end state. The index is unchanged here; BadWaitingForInitialData is a designed state. Also codes structured (DATA_SET / TABLE) observations BadNotSupported now that the parser supplies MTConnectObservation.IsStructured, so a two-entry data set no longer surfaces as the Good string "12".
This commit is contained in:
@@ -48,20 +48,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|||||||
/// would serialize the pump against every read for no semantic gain.
|
/// would serialize the pump against every read for no semantic gain.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>KNOWN LIMITATION — <c>DATA_SET</c> / <c>TABLE</c> observations.</b> Their real content
|
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
|
||||||
/// is <c><Entry key=…></c> child elements, but the parser reads an observation's value
|
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
|
||||||
/// with <c>XElement.Value</c>, which concatenates all descendant text. Such an observation
|
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
|
||||||
/// therefore reaches this index as a run-together string with the keys discarded, and —
|
/// real content in <c><Entry key=…></c> child elements, which reach a text-valued
|
||||||
/// because the type inference correctly demotes <c>DATA_SET</c>/<c>TABLE</c> to
|
/// observation concatenated with the keys discarded — a two-entry set reads as the single
|
||||||
/// <see cref="DriverDataType.String"/> — it coerces "successfully" into a <b>Good-coded
|
/// token <c>"12"</c> — and because the type inference correctly demotes those
|
||||||
/// snapshot carrying noise</b>. This index <b>cannot</b> detect the shape: neither
|
/// representations to <see cref="DriverDataType.String"/>, coercion would otherwise
|
||||||
/// <see cref="MTConnectObservation"/> nor <see cref="MTConnectTagDefinition"/> carries the
|
/// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
|
||||||
/// DataItem's <c>representation</c>, and no value-shape heuristic can separate a
|
/// itself (no value heuristic can separate concatenated entries from a legitimate
|
||||||
/// concatenated entry list from a legitimate space-bearing EVENT (a <c>Message</c> reads
|
/// space-bearing EVENT such as a <c>Message</c> reading "Coolant level low"), so it relies on
|
||||||
/// "Coolant level low"). The discriminating signal — the observation element having child
|
/// <see cref="MTConnectObservation.IsStructured"/>, which
|
||||||
/// elements — exists only in <see cref="MTConnectStreamsParser"/>, so the fix belongs there
|
/// <see cref="MTConnectStreamsParser"/> sets while the XML is still XML.
|
||||||
/// (flag structured observations at parse time; this index then codes them
|
|
||||||
/// <c>BadNotSupported</c> alongside the TIME_SERIES deferral below).
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class MTConnectObservationIndex
|
internal sealed class MTConnectObservationIndex
|
||||||
@@ -257,6 +255,17 @@ internal sealed class MTConnectObservationIndex
|
|||||||
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
|
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DATA_SET / TABLE: the Agent carried the content in <Entry key="…"> child elements, so
|
||||||
|
// Value is their concatenated text with the keys discarded (a two-entry set reads "12").
|
||||||
|
// Publishing that as a Good string would be noise an operator would trust. Checked before
|
||||||
|
// the tag lookup because it is a fact about the wire shape, true whether or not the data
|
||||||
|
// item is authored — and after the sentinel, so an unavailable data set still reports the
|
||||||
|
// truthful no-comms.
|
||||||
|
if (observation.IsStructured)
|
||||||
|
{
|
||||||
|
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
_tags.TryGetValue(observation.DataItemId, out var tag);
|
_tags.TryGetValue(observation.DataItemId, out var tag);
|
||||||
|
|
||||||
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
|
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
|
||||||
|
|||||||
+133
-16
@@ -47,7 +47,8 @@ public sealed class MTConnectObservationIndexTests
|
|||||||
MTConnectStreamsParser.Parse(File.ReadAllText(path));
|
MTConnectStreamsParser.Parse(File.ReadAllText(path));
|
||||||
|
|
||||||
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
|
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
|
||||||
private static MTConnectStreamsResult Synthetic(string dataItemId, string value, DateTime? timestampUtc = null) =>
|
private static MTConnectStreamsResult Synthetic(
|
||||||
|
string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false) =>
|
||||||
new(
|
new(
|
||||||
InstanceId: 1L,
|
InstanceId: 1L,
|
||||||
NextSequence: 2L,
|
NextSequence: 2L,
|
||||||
@@ -55,7 +56,8 @@ public sealed class MTConnectObservationIndexTests
|
|||||||
Observations: [new MTConnectObservation(
|
Observations: [new MTConnectObservation(
|
||||||
dataItemId,
|
dataItemId,
|
||||||
value,
|
value,
|
||||||
timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc))]);
|
timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
isStructured)]);
|
||||||
|
|
||||||
private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
|
private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
|
||||||
|
|
||||||
@@ -475,6 +477,92 @@ public sealed class MTConnectObservationIndexTests
|
|||||||
snap.Value.ShouldBeOfType<string>().ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
|
snap.Value.ShouldBeOfType<string>().ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- DATA_SET / TABLE
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A structured (DATA_SET / TABLE) observation's real content is its <c><Entry></c>
|
||||||
|
/// children, which reach the index concatenated with the keys discarded. It is coded
|
||||||
|
/// BadNotSupported rather than published as that noise — regardless of whether the data item
|
||||||
|
/// is authored, because the shape is a fact about the wire, not about the tag.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Structured_observation_reports_BadNotSupported_when_unauthored()
|
||||||
|
{
|
||||||
|
var idx = new MTConnectObservationIndex();
|
||||||
|
idx.Apply(Synthetic("ds1", "12", isStructured: true));
|
||||||
|
|
||||||
|
var snap = idx.Get("ds1");
|
||||||
|
|
||||||
|
snap.StatusCode.ShouldBe(BadNotSupported);
|
||||||
|
snap.Value.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The String authoring the type inference gives a DATA_SET must not smuggle the noise through.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Structured_observation_is_never_published_as_concatenated_text()
|
||||||
|
{
|
||||||
|
var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
|
||||||
|
idx.Apply(Synthetic("ds1", "12", isStructured: true));
|
||||||
|
|
||||||
|
var snap = idx.Get("ds1");
|
||||||
|
|
||||||
|
snap.Value.ShouldNotBe("12");
|
||||||
|
snap.Value.ShouldBeNull();
|
||||||
|
snap.StatusCode.ShouldBe(BadNotSupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No-comms outranks the unsupported shape, exactly as it does for a TIME_SERIES array.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Unavailable_wins_over_the_structured_shape()
|
||||||
|
{
|
||||||
|
var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
|
||||||
|
idx.Apply(Synthetic("ds1", "UNAVAILABLE", isStructured: true));
|
||||||
|
|
||||||
|
idx.Get("ds1").StatusCode.ShouldBe(BadNoCommunication);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end through the real parser: the entry list that concatenates to the single token
|
||||||
|
/// "12" is caught by the parser's structured flag and never surfaces as a Good value. This is
|
||||||
|
/// the leg that would regress silently if the parser stopped setting the flag.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_parsed_data_set_observation_never_surfaces_as_a_good_value()
|
||||||
|
{
|
||||||
|
const string xml = """
|
||||||
|
<MTConnectStreams>
|
||||||
|
<Header instanceId="1" nextSequence="2" firstSequence="1"/>
|
||||||
|
<Streams><DeviceStream><ComponentStream><Events>
|
||||||
|
<VariableDataSet dataItemId="ds1" timestamp="2026-07-24T12:00:00Z" count="2">
|
||||||
|
<Entry key="V1">1</Entry>
|
||||||
|
<Entry key="V2">2</Entry>
|
||||||
|
</VariableDataSet>
|
||||||
|
</Events></ComponentStream></DeviceStream></Streams>
|
||||||
|
</MTConnectStreams>
|
||||||
|
""";
|
||||||
|
|
||||||
|
var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
|
||||||
|
idx.Apply(MTConnectStreamsParser.Parse(xml));
|
||||||
|
|
||||||
|
var snap = idx.Get("ds1");
|
||||||
|
|
||||||
|
snap.StatusCode.ShouldBe(BadNotSupported);
|
||||||
|
snap.Value.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An ordinary text observation is not structured — the flag must not be over-applied.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_ordinary_observation_is_not_treated_as_structured()
|
||||||
|
{
|
||||||
|
var idx = new MTConnectObservationIndex([Tag("dev1_program", DriverDataType.String)]);
|
||||||
|
idx.Apply(ParseFixture(CurrentFixture));
|
||||||
|
|
||||||
|
var snap = idx.Get("dev1_program");
|
||||||
|
|
||||||
|
snap.StatusCode.ShouldBe(Good);
|
||||||
|
snap.Value.ShouldBe("O1234");
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------- ordering
|
// -------------------------------------------------------------------------------- ordering
|
||||||
|
|
||||||
/// <summary>Within one Apply, the later observation for a dataItemId wins (Agent order is ascending sequence).</summary>
|
/// <summary>Within one Apply, the later observation for a dataItemId wins (Agent order is ascending sequence).</summary>
|
||||||
@@ -652,8 +740,23 @@ public sealed class MTConnectObservationIndexTests
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// /current (Task 10) and the /sample pump (Task 11) write concurrently while OPC UA reads
|
/// /current (Task 10) and the /sample pump (Task 11) write concurrently while OPC UA reads
|
||||||
/// snapshot. Per-key atomicity is the contract: no throw, no torn snapshot (a Good value
|
/// snapshot. Three properties, each stated so it holds at <b>every instant</b> rather than
|
||||||
/// paired with a Bad code), and every read observes one whole snapshot.
|
/// only once the race settles: no call throws; no read observes a torn snapshot; and the
|
||||||
|
/// state after a deterministic closing Apply is exact.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Torn-snapshot check.</b> The whole <c>(status, value, source timestamp)</c> triple
|
||||||
|
/// is matched against the closed set of snapshots <c>dev1_pos</c> may legally present —
|
||||||
|
/// never a field in isolation. A Good code beside the unavailable document's timestamp,
|
||||||
|
/// or beside a null value, matches no member and fails.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The legal set has THREE members, not two.</b> <c>dev1_pos</c> is authored, so a
|
||||||
|
/// reader that beats the first Apply correctly observes <c>BadWaitingForInitialData</c> —
|
||||||
|
/// the designed "authored, not yet reported" state. An earlier version of this test
|
||||||
|
/// asserted a post-Apply post-condition from inside the race window and failed whenever
|
||||||
|
/// a reader got there first: it was asserting something the index never promised, and it
|
||||||
|
/// verified nothing its name claimed.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Concurrent_applies_and_reads_stay_consistent_and_never_throw()
|
public async Task Concurrent_applies_and_reads_stay_consistent_and_never_throw()
|
||||||
@@ -662,14 +765,24 @@ public sealed class MTConnectObservationIndexTests
|
|||||||
var current = ParseFixture(CurrentFixture);
|
var current = ParseFixture(CurrentFixture);
|
||||||
var unavailable = ParseFixture(UnavailableFixture);
|
var unavailable = ParseFixture(UnavailableFixture);
|
||||||
|
|
||||||
|
var goodAt = new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc);
|
||||||
|
(uint Status, object? Value, DateTime? Source)[] legalSnapshots =
|
||||||
|
[
|
||||||
|
(BadWaitingForInitialData, null, null),
|
||||||
|
(Good, 123.4567, goodAt),
|
||||||
|
(BadNoCommunication, null, new DateTime(2026, 7, 24, 12, 10, 0, 100, DateTimeKind.Utc)),
|
||||||
|
];
|
||||||
|
|
||||||
|
var reads = 0L;
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
var writers = Enumerable.Range(0, 4).Select(i => Task.Run(() =>
|
var writers = Enumerable.Range(0, 4).Select(i => Task.Run(() =>
|
||||||
{
|
{
|
||||||
while (!cts.IsCancellationRequested)
|
while (!cts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
idx.Apply(i % 2 == 0 ? current : unavailable);
|
idx.Apply(i % 2 == 0 ? current : unavailable);
|
||||||
}
|
}
|
||||||
}));
|
})).ToArray();
|
||||||
|
|
||||||
var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
|
var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
|
||||||
{
|
{
|
||||||
@@ -677,21 +790,25 @@ public sealed class MTConnectObservationIndexTests
|
|||||||
{
|
{
|
||||||
var snap = idx.Get("dev1_pos");
|
var snap = idx.Get("dev1_pos");
|
||||||
|
|
||||||
// Exactly two snapshots are legal for this tag; anything else is a tear.
|
legalSnapshots.ShouldContain((snap.StatusCode, snap.Value, snap.SourceTimestampUtc));
|
||||||
if (snap.StatusCode == 0u)
|
Interlocked.Increment(ref reads);
|
||||||
{
|
|
||||||
snap.Value.ShouldBe(123.4567);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
snap.StatusCode.ShouldBe(BadNoCommunication);
|
|
||||||
snap.Value.ShouldBeNull();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}));
|
})).ToArray();
|
||||||
|
|
||||||
await Task.WhenAll(writers.Concat(readers));
|
await Task.WhenAll(writers.Concat(readers));
|
||||||
|
|
||||||
|
// Guards a vacuous pass: a reader loop that never ran would have asserted nothing.
|
||||||
|
Interlocked.Read(ref reads).ShouldBeGreaterThan(0L);
|
||||||
|
|
||||||
|
// The racing writers alternate documents, so the state DURING the race is unknowable by
|
||||||
|
// construction — pinning a specific one there is exactly the defect this test used to have.
|
||||||
|
// One deterministic apply, after every writer has stopped, gives an exact end state.
|
||||||
|
idx.Apply(current);
|
||||||
|
|
||||||
|
var final = idx.Get("dev1_pos");
|
||||||
|
final.StatusCode.ShouldBe(Good);
|
||||||
|
final.Value.ShouldBe(123.4567);
|
||||||
|
final.SourceTimestampUtc.ShouldBe(goodAt);
|
||||||
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
|
idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user