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;
|
var delivered = 0L;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
|
var part = await reader.ReadNextPartBytesAsync(live).ConfigureAwait(false);
|
||||||
if (part is null)
|
if (part is null)
|
||||||
{
|
{
|
||||||
// The caller cancelling is the ONE contracted way out; anything else is reported.
|
// 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,
|
// 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
|
// and that one IS yielded — it advances the sequence, so swallowing it would strand the
|
||||||
// caller's `from` behind the Agent's buffer.
|
// caller's `from` behind the Agent's buffer.
|
||||||
if (string.IsNullOrWhiteSpace(part))
|
if (IsFramingOnly(part))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -562,6 +562,24 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
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>
|
/// <summary>
|
||||||
/// Frames a <c>multipart/x-mixed-replace</c> body into individual part payloads, reading
|
/// 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.
|
/// 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;
|
public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
|
/// Reads the next part's payload as its raw bytes, or <c>null</c> once the closing
|
||||||
/// the Agent closes the connection — see <see cref="EndReason"/> for which.
|
/// delimiter is seen or the Agent closes the connection — see <see cref="EndReason"/>
|
||||||
|
/// for which.
|
||||||
/// </summary>
|
/// </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)
|
if (_finished)
|
||||||
{
|
{
|
||||||
@@ -652,7 +676,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return body is null ? null : Encoding.UTF8.GetString(body);
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WarnOnceAboutMissingContentLength()
|
private void WarnOnceAboutMissingContentLength()
|
||||||
@@ -669,7 +693,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
uri);
|
uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? Finish(MTConnectStreamEndReason reason)
|
private byte[]? Finish(MTConnectStreamEndReason reason)
|
||||||
{
|
{
|
||||||
_finished = true;
|
_finished = true;
|
||||||
EndReason = reason;
|
EndReason = reason;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
|
|
||||||
@@ -121,6 +122,33 @@ internal static class MTConnectStreamsParser
|
|||||||
return Build(document);
|
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>
|
/// <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>
|
/// <param name="stream">A stream positioned at the start of the XML document.</param>
|
||||||
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
|
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
|
||||||
@@ -141,6 +169,55 @@ internal static class MTConnectStreamsParser
|
|||||||
return Build(document);
|
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)
|
private static MTConnectStreamsResult Build(XDocument document)
|
||||||
{
|
{
|
||||||
var root = document.Root
|
var root = document.Root
|
||||||
|
|||||||
@@ -294,6 +294,113 @@ public sealed class MTConnectStreamsParseTests
|
|||||||
fromStream.Observations.ShouldBe(fromString.Observations);
|
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
|
// ------------------------------------------------------------- fails loudly, never silently empty
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
Reference in New Issue
Block a user