perf(mtconnect): sample chunks parse from bytes, no per-chunk string copies

This commit is contained in:
Joseph Doherty
2026-08-16 04:05:15 -04:00
parent 3795899d10
commit 34f836e03c
3 changed files with 215 additions and 7 deletions
@@ -231,7 +231,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
var delivered = 0L;
while (true)
{
var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
var part = await reader.ReadNextPartBytesAsync(live).ConfigureAwait(false);
if (part is null)
{
// The caller cancelling is the ONE contracted way out; anything else is reported.
@@ -244,7 +244,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// Agent's real keep-alive is a well-formed observation-free MTConnectStreams document,
// and that one IS yielded — it advances the sequence, so swallowing it would strand the
// caller's `from` behind the Agent's buffer.
if (string.IsNullOrWhiteSpace(part))
if (IsFramingOnly(part))
{
continue;
}
@@ -562,6 +562,24 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
}
/// <summary>
/// Whether a multipart part carries nothing but transport filler — the byte-level equivalent
/// of the <c>IsNullOrWhiteSpace</c> check the string-shaped framer used to run, and the same
/// ASCII set the streams parser trims off a chunk before parsing it.
/// </summary>
private static bool IsFramingOnly(ReadOnlySpan<byte> part)
{
foreach (var value in part)
{
if (value is not ((byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n'))
{
return false;
}
}
return true;
}
/// <summary>
/// Frames a <c>multipart/x-mixed-replace</c> body into individual part payloads, reading
/// incrementally and never buffering more than the part currently in flight.
@@ -610,10 +628,16 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
/// <summary>
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
/// the Agent closes the connection — see <see cref="EndReason"/> for which.
/// Reads the next part's payload as its raw bytes, or <c>null</c> once the closing
/// delimiter is seen or the Agent closes the connection — see <see cref="EndReason"/>
/// for which.
/// </summary>
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
/// <remarks>
/// Deliberately <b>not</b> decoded to a string: the one consumer is the streams parser,
/// which reads UTF-8 off a stream itself. Decoding here would materialise every chunk of
/// a never-ending stream twice for a document that is parsed once and dropped.
/// </remarks>
public async Task<byte[]?> ReadNextPartBytesAsync(CancellationToken ct)
{
if (_finished)
{
@@ -652,7 +676,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
}
return body is null ? null : Encoding.UTF8.GetString(body);
return body;
}
private void WarnOnceAboutMissingContentLength()
@@ -669,7 +693,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
uri);
}
private string? Finish(MTConnectStreamEndReason reason)
private byte[]? Finish(MTConnectStreamEndReason reason)
{
_finished = true;
EndReason = reason;
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Xml;
using System.Xml.Linq;
@@ -121,6 +122,33 @@ internal static class MTConnectStreamsParser
return Build(document);
}
/// <summary>
/// Parses one <c>/sample</c> chunk straight from the bytes the multipart framer produced.
/// </summary>
/// <remarks>
/// The <c>/sample</c> hot path used to materialise every chunk twice — once as a decoded
/// string, once again as the trimmed copy handed to the parser — for a document that is read
/// once and thrown away. Parsing from the bytes drops both copies; <see cref="XDocument"/>
/// does its own UTF-8 decoding off the stream. The framing tolerance the string overload's
/// <c>Trim</c> provided is preserved by <see cref="TrimFraming"/>, which slices rather than
/// copies.
/// </remarks>
/// <param name="xml">The chunk's raw bytes; a UTF-8 BOM and surrounding framing whitespace are tolerated.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
public static MTConnectStreamsResult Parse(ReadOnlyMemory<byte> xml)
{
var trimmed = TrimFraming(xml);
if (trimmed.IsEmpty)
{
throw new InvalidDataException(
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
}
using var stream = AsStream(trimmed);
return Parse(stream);
}
/// <summary>Parses a <c>/current</c> response read from a stream.</summary>
/// <param name="stream">A stream positioned at the start of the XML document.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
@@ -141,6 +169,55 @@ internal static class MTConnectStreamsParser
return Build(document);
}
/// <summary>
/// Slices off what the string overload's <c>Trim('', ' ', '\t', '\r', '\n')</c> removed:
/// a UTF-8 byte-order mark and the multipart framing's surrounding line breaks, either of
/// which <see cref="XmlReader"/> rejects ahead of the XML declaration.
/// </summary>
private static ReadOnlyMemory<byte> TrimFraming(ReadOnlyMemory<byte> xml)
{
var span = xml.Span;
var start = 0;
var end = span.Length;
while (start < end)
{
if (end - start >= 3 && span[start] == 0xEF && span[start + 1] == 0xBB && span[start + 2] == 0xBF)
{
start += 3;
continue;
}
if (!IsFramingWhitespace(span[start]))
{
break;
}
start++;
}
while (end > start && IsFramingWhitespace(span[end - 1]))
{
end--;
}
return xml[start..end];
}
private static bool IsFramingWhitespace(byte value) =>
value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n';
/// <summary>
/// Views the (already trimmed) bytes as a stream without copying them where the memory is
/// array-backed — which it always is on the <c>/sample</c> path, whose framer hands over the
/// part's own <c>byte[]</c>.
/// </summary>
private static MemoryStream AsStream(ReadOnlyMemory<byte> xml) =>
MemoryMarshal.TryGetArray(xml, out ArraySegment<byte> segment) && segment.Array is not null
? new MemoryStream(segment.Array, segment.Offset, segment.Count, writable: false)
: new MemoryStream(xml.ToArray(), writable: false);
private static MTConnectStreamsResult Build(XDocument document)
{
var root = document.Root
@@ -294,6 +294,113 @@ public sealed class MTConnectStreamsParseTests
fromStream.Observations.ShouldBe(fromString.Observations);
}
// ---------------------------------------------------- the byte overload the /sample pump feeds on
[Theory]
[InlineData(CurrentFixture)]
[InlineData(SampleFixture)]
[InlineData(GapFixture)]
[InlineData(UnavailableFixture)]
public void Parse_from_bytes_matches_the_string_overload_on_every_fixture(string fixture)
{
// The /sample pump hands the parser the chunk's raw bytes; a chunk parsed from bytes must be
// indistinguishable from the same chunk round-tripped through a string.
var fromBytes = MTConnectStreamsParser.Parse(File.ReadAllBytes(fixture));
var fromString = MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
fromBytes.InstanceId.ShouldBe(fromString.InstanceId);
fromBytes.FirstSequence.ShouldBe(fromString.FirstSequence);
fromBytes.NextSequence.ShouldBe(fromString.NextSequence);
fromBytes.Observations.ShouldBe(fromString.Observations);
}
[Theory]
[InlineData("\r\n")]
[InlineData("\n")]
[InlineData(" \t")]
[InlineData("\r\n\r\n")]
public void Parse_from_bytes_tolerates_framing_whitespace_AHEAD_of_the_xml_declaration(string residue)
{
// THE load-bearing leg of the byte path's trim, and the one that is not falsifiable by
// accident: every fixture opens with an <?xml ...?> declaration, and XmlReader rejects even
// one space ahead of one ("no whitespace characters are allowed to appear before it"). The
// string overload trimmed it; a byte path that fed the frame straight to XDocument.Load
// would turn an Agent that writes a blank line after the part headers into a hard parse
// failure on every chunk. (Trailing whitespace and a leading BOM, by contrast, XmlReader
// handles natively — the cases below pin that rather than the trim.)
var bytes = Encoding.UTF8.GetBytes(residue).Concat(File.ReadAllBytes(SampleFixture)).ToArray();
var withResidue = MTConnectStreamsParser.Parse(bytes);
withResidue.NextSequence.ShouldBe(113);
withResidue.Observations.ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)).Observations);
}
[Theory]
[InlineData(CurrentFixture)]
[InlineData(SampleFixture)]
public void Parse_from_bytes_tolerates_a_utf8_byte_order_mark_ahead_of_the_declaration(string fixture)
{
// A chunk lifted out of a multipart frame can carry a BOM; the string path trimmed ''
// for exactly this reason, so the byte path must handle the EF BB BF that decodes to it.
var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(File.ReadAllBytes(fixture)).ToArray();
var withBom = MTConnectStreamsParser.Parse(bytes);
withBom.Observations.ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(fixture)).Observations);
}
[Theory]
[InlineData("\r\n")]
[InlineData("\r\n\r\n")]
[InlineData("\n")]
[InlineData(" \t\r\n")]
public void Parse_from_bytes_tolerates_the_multipart_frames_trailing_line_break(string residue)
{
// The other half of what the string path's Trim covered: the framing's trailing line break.
var bytes = File.ReadAllBytes(SampleFixture).Concat(Encoding.UTF8.GetBytes(residue)).ToArray();
var withResidue = MTConnectStreamsParser.Parse(bytes);
withResidue.NextSequence.ShouldBe(113);
withResidue.Observations.ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)).Observations);
}
[Fact]
public void Parse_from_bytes_tolerates_a_bom_behind_leading_whitespace()
{
// Whitespace THEN a BOM defeats XmlReader's encoding sniffing outright ("Data at the root
// level is invalid"), so the trim has to interleave the two rather than strip a BOM only at
// offset 0 — which is exactly what the string overload's Trim('', ' ', …) set did.
var bytes = " \r\n"u8.ToArray()
.Concat(new byte[] { 0xEF, 0xBB, 0xBF })
.Concat(File.ReadAllBytes(CurrentFixture))
.Concat("\r\n"u8.ToArray())
.ToArray();
MTConnectStreamsParser.Parse(bytes).Observations
.ShouldBe(MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)).Observations);
}
[Theory]
[InlineData(new byte[0])]
[InlineData(new byte[] { (byte)' ', (byte)'\r', (byte)'\n' })]
[InlineData(new byte[] { 0xEF, 0xBB, 0xBF })]
public void Parse_from_bytes_throws_on_an_empty_payload(byte[] bytes)
{
// Same failure family as the string path: never a silently empty result.
var ex = Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(bytes));
ex.Message.ShouldContain("empty");
}
[Fact]
public void Parse_from_bytes_throws_on_malformed_xml()
{
Should.Throw<InvalidDataException>(
() => MTConnectStreamsParser.Parse("not xml at all"u8.ToArray()));
}
// ------------------------------------------------------------- fails loudly, never silently empty
[Theory]