fix(mtconnect): resetTriggered only resets data sets; empty set is Good {}; re-baseline test (#650 review)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:20:23 -04:00
parent f8f8eb9fb8
commit 9631a40f1f
6 changed files with 123 additions and 13 deletions
@@ -178,6 +178,12 @@ public sealed record MTConnectStreamsResult(
/// <c>true</c> when the observation carried a non-empty <c>resetTriggered</c> attribute — the Agent's statement that
/// the set was reset, so this observation <b>replaces</b> the accumulated set rather than merging into it.
/// </param>
/// <param name="IsDataSet">
/// <c>true</c> when the element is data-set shaped: it carries the <c>count</c> attribute every DATA_SET / TABLE
/// observation has (and no SAMPLE or ordinary EVENT does) or has <c>&lt;Entry&gt;</c> children. This — not
/// <see cref="ResetTriggered"/> — is what lets the index serve an entry-less set as <c>{}</c> rather than as
/// an absent value; <c>resetTriggered</c> also appears on ordinary accumulating SAMPLEs and must not matter there.
/// </param>
/// <param name="Condition">
/// The CONDITION-only detail (#626) — set exactly when the observation came from a
/// <c>&lt;Condition&gt;</c> container, <c>null</c> for every Sample/Event. Its presence is what marks an
@@ -192,7 +198,8 @@ public sealed record MTConnectObservation(
double? SampleRate = null,
MTConnectConditionDetail? Condition = null,
IReadOnlyList<MTConnectDataSetEntry>? Entries = null,
bool ResetTriggered = false);
bool ResetTriggered = false,
bool IsDataSet = false);
/// <summary>One <c>&lt;Cell key="…"&gt;</c> of a <c>TABLE</c> row (#650).</summary>
public sealed record MTConnectDataSetCell(string Key, string Value);
@@ -121,6 +121,7 @@ internal sealed class MTConnectObservationIndex
// Apply, but the lock keeps a concurrent one-shot read-through from corrupting a shared index.
private readonly Dictionary<string, SortedDictionary<string, SetEntry>> _sets = new(StringComparer.Ordinal);
private readonly object _setsLock = new();
private bool _anySets;
private sealed record SetEntry(string? Value, SortedDictionary<string, string>? Cells);
@@ -264,18 +265,37 @@ internal sealed class MTConnectObservationIndex
if (observation.Entries is not { Count: > 0 } entries)
{
if (observation.ResetTriggered && !observation.IsStructured)
var unavailable = string.IsNullOrWhiteSpace(observation.Value) && !observation.IsDataSet ||
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal);
if (unavailable)
{
// resetTriggered with no entries: an empty set, which is a legitimate value rather than an absence.
lock (_setsLock) _sets[id] = new SortedDictionary<string, SetEntry>(StringComparer.Ordinal);
seenSets.Add(id);
return observation with { Value = "{}", ResetTriggered = false };
// Hot path for every ordinary UNAVAILABLE observation: skip the lock until a set exists at all.
if (Volatile.Read(ref _anySets)) lock (_setsLock) _sets.Remove(id);
return observation;
}
if (string.IsNullOrWhiteSpace(observation.Value) ||
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
if (observation.IsDataSet && !observation.IsStructured)
{
lock (_setsLock) _sets.Remove(id);
// An entry-less data-set-shaped observation (count="0", or resetTriggered) is the EMPTY set - a
// legitimate value, Good "{}", not an absence. A reset or a /current baseline discards what
// was accumulated; a bare empty delta leaves the set as it is.
string emptyJson;
lock (_setsLock)
{
var first = seenSets.Add(id);
if (!_sets.TryGetValue(id, out var set) || observation.ResetTriggered || (baseline && first))
{
set = new SortedDictionary<string, SetEntry>(StringComparer.Ordinal);
_sets[id] = set;
Volatile.Write(ref _anySets, true);
}
emptyJson = RenderSet(set);
}
return observation with { Value = emptyJson, ResetTriggered = false, IsDataSet = false };
}
return observation;
@@ -289,6 +309,7 @@ internal sealed class MTConnectObservationIndex
{
set = new SortedDictionary<string, SetEntry>(StringComparer.Ordinal);
_sets[id] = set;
Volatile.Write(ref _anySets, true);
}
foreach (var entry in entries)
@@ -312,7 +333,7 @@ internal sealed class MTConnectObservationIndex
json = RenderSet(set);
}
return observation with { Value = json, IsStructured = false, Entries = null, ResetTriggered = false };
return observation with { Value = json, IsStructured = false, Entries = null, ResetTriggered = false, IsDataSet = false };
}
/// <summary>
@@ -382,6 +382,7 @@ internal static class MTConnectStreamsParser
int? sampleCount = null;
double? sampleRate = null;
var resetTriggered = false;
var hasCount = false;
List<MTConnectDataSetEntry>? entries = null;
(string? NativeCode, string? NativeSeverity, string? Qualifier, string? Type) conditionAttributes = default;
@@ -410,6 +411,7 @@ internal static class MTConnectStreamsParser
if (!isCondition)
{
resetTriggered = !string.IsNullOrWhiteSpace(MTConnectXml.OptionalAttribute(reader, "resetTriggered"));
hasCount = MTConnectXml.OptionalAttribute(reader, "count") is not null;
entries = [];
sampleCount = int.TryParse(
MTConnectXml.OptionalAttribute(reader, "sampleCount"),
@@ -483,7 +485,11 @@ internal static class MTConnectStreamsParser
SampleRate: sampleRate,
Condition: condition,
Entries: entries is { Count: > 0 } ? entries : null,
ResetTriggered: resetTriggered);
// resetTriggered only means "replace the set" on a data-set-shaped observation; on an ordinary
// accumulating SAMPLE/EVENT (PartCount resetTriggered="DAILY") it is not our concern.
ResetTriggered: resetTriggered && (hasCount || entries is { Count: > 0 }),
IsDataSet: !isCondition && (hasCount || entries is { Count: > 0 }));
}
/// <summary>