perf(mtconnect): sample chunks parse from bytes, no per-chunk string copies
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user