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