feat: Publish ReadOnlySequence - #1983
Conversation
|
Question for reviewers: Should a no-owner |
|
@lukebakken finally got time to contribute this change, but I saw you are putting the work to release 7.3.0 and I don't want to add to the burden, so if you think it's better we can leave this for 7.4.0 cc @danielmarbach I think you might be interested in this |
danielmarbach
left a comment
There was a problem hiding this comment.
I need to look into it with a fresh mind. Did a quick squim only for now
I guess having a dual path in there would also add overhead right? And if so do we know how much? So should this be seen as the cost of supporting this additional machinery or how do you see it? |
f1befcf to
bbf0e18
Compare
|
@PauloHMattos hello! I added this to |
The So, it's better to have a |
bbf0e18 to
5f9e25f
Compare
lukebakken
left a comment
There was a problem hiding this comment.
Note
Written by Claude (Anthropic's Claude Code), directed and approved by @lukebakken. The findings were verified against 5f9e25f by build, test runs, and mutation testing.
Nice work. I could not find a correctness bug, and I tried reasonably hard to: beyond reading the diff I ran two mutation tests to check whether the new tests actually have teeth.
What I verified locally at 5f9e25f: client builds 0 warnings / 0 errors with -warnaserror, with both net8.0 and netstandard2.0 assemblies actually produced. Unit 186/186, TestBasicPublishAsync 14/14. I could not build net472 here (no targeting pack), so CI's integration-win32 and sequential-integration-win32 are what cover that TFM - both green.
The dispose contract is genuinely pinned. I replaced the guarded if (!bodyOwnerTransferred) in the finally with an unconditional bodyOwner?.Dispose() and 9 of 14 tests failed. DisposalTracker is the reason: Interlocked.Increment plus a 250ms window after the first disposal to catch a second one. That is a real improvement over the Assert.True(body.Disposed) on a plain bool that main had, which could not detect a double-dispose at all. The ownership handoff reads correctly too - the flag is set immediately before ModelSendAsync, and TransmitAsync discriminates on bytes.Size == 0 for the never-captured case.
The one thing I would like fixed before merge is an untested invariant in the multi-segment writer - inline below. Everything else is minor.
Also confirmed: the empty-body fix is real (on main, GetBodyFrameCount(int.MaxValue, 0) returns 1 because the maxPayloadBytes == int.MaxValue early-return precedes any length check, and Connection.Commands.cs:180 sets MaxPayloadSize = int.MaxValue whenever the broker negotiates frame_max = 0), and it is pinned by EmptyBodyWithUnlimitedFrameSizeProducesNoBodyFrames. Your "Incidental fixes" write-up matches what I found in the code.
dc6c086 to
49b0416
Compare
|
Thank you all for the thorough review! It's genuinely a pleasure working with this level of collaboration. Looking forward to the next round of reviews |
9da8668 to
3c3de55
Compare
…ict ExactSizeBufferWriter
…wrapping cost of the memory into a sequence
The zero-copy publish path (multi-segment ReadOnlySequence with a bodyOwner) rented a framing-size buffer and then took Span with buffer.AsSpan(framingSize), the single-argument overload that starts the span at framingSize instead of bounding it to framingSize bytes. Method and Header were written past the intended region, and OutgoingFrame transmitted the uninitialized head of the buffer as the method+header frame, corrupting the frame on the wire. Bound the span with buffer.AsSpan(0, framingSize). Also implement the new ISession.TransmitAsync(in ReadOnlySequence<byte>, ...) overload on the Unit test's TestSession so the project compiles again and the TestSerializeToFramesWithSequence guard tests actually run.
The five IChannelExtensions convenience overloads reference the IChannel.BasicPublishAsync ReadOnlySequence overload via <see cref>, but the crefs omitted the `in` modifier that the real parameter carries (`in ReadOnlySequence<byte> body`). The crefs could not be resolved, so a documentation build emitted CS1574 five times per target framework. Add `in` to each cref so they resolve to the actual overload.
00e617d changed the BenchmarkDotNet job runtime from CoreRuntime.Core31 to Core80, which is unrelated to the ReadOnlySequence publish feature this branch implements. Restore Core31 to keep the PR scoped; the runtime update is tracked separately for its own change.
BodySegmentWriteToWithExactSizeBufferWriter passed even with the +1 removed from GetSpan(StartPayload + 1): the 8-byte header store wrote a zero byte one past a bare 7-byte array, which was harmless, so the test did not guard the invariant it was meant to. Back each rented span with a sentinel guard region and verify it on Advance/flush, so an over-request throws. The test now fails if the +1 is dropped and passes with it in place.
Review flagged BasicPublishCoreAsync's ReadOnlyMemory and ReadOnlySequence overloads as near-duplicate. They are deliberately parallel: a dedicated ReadOnlyMemory path keeps the memory publish hot path from wrapping the body in a ReadOnlySequence, which is the cost the split exists to avoid. Add a comment so the overload is not unified away later.
The body-length check duplicates the stricter GetTotalFrameSetSize guard downstream, but it is kept on purpose: it rejects an oversize body synchronously, before a publisher confirmation sequence number is consumed, and disposes the owner itself. Add a line to its summary explaining the intent.
The five IChannelExtensions.BasicPublishAsync ReadOnlySequence overloads still took body by value, while the IChannel interface and the PublicAPI.Unshipped files declare them with `in`. The mismatch tripped RS0016/RS0017, failing the CI `dotnet format --verify-no-changes` step and skipping the integration jobs. Add `in` to the extension overloads so they match the interface and the declared public API, finishing what 3c3de55 started.
0a2d88d to
2b60d01
Compare
Note
This PR was mostly written by Claude Opus 5 with occasional input from me.
The PR description with the changes summary were written just by Claude.
Why
Message bodies are frequently produced in pieces:
PipeReader.ReadAsynchands back aReadOnlySequence<byte>, serializers write into chained buffers, and pooled allocators hand out fixed-size blocks. To publish such a body today the caller must gather it into one contiguousbyte[]/IMemoryOwner<byte>first, which costs an extra full-body copy and an allocation on every publish.That copy is not required by the protocol. AMQP 0-9-1 already frames a body as a series of body frames, so the wire format never needed contiguity - only the client API did.
This adds
ReadOnlySequence<byte>publish overloads that write the segments straight to the wire, following the shape of theReadOnlyMemory<byte>+IDisposable bodyOwneroverloads added in #1922 and reusing their memory-ownership semantics.What changed
Public API (additive only, 7 new members)
Two
IChanneloverloads, mirroring the existing memory +bodyOwnerpair:Plus the five matching
IChannelExtensionsconveniences (PublicationAddress, andstring/CachedStringwith and withoutmandatory). Entries were added to bothPublicAPI.Unshipped.net8.0.txtandPublicAPI.Unshipped.netstandard2.0.txt. No existing signature changed.Ownership contract (documented on every new overload, identical to the memory overloads): ownership of
bodyOwnertransfers to the client, which disposes it exactly once - after the message reaches the wire, or when publication fails. Every segment must stay valid and unmodified until then, and the caller must not reuse, mutate, or release that memory itself. Passingnullis valid and selects the copy path.Internals
OutgoingFrame._bodychanged fromReadOnlyMemory<byte>toReadOnlySequence<byte>, and the publish path (ISession.TransmitAsync→Channel.ModelSendAsync→BasicPublishCoreAsync) was unified on that one representation rather than duplicated. All of it isinternal.The existing ReadOnlyMemory path retains a dedicated TransmitAsync/SerializeToFrames overload and incurs no wrapping cost.
For the ReadOnlySequence path, every hot spot short-circuits on
IsSingleSegment:Framing.SerializeToFramesdelegates single-segment sequences to the existingReadOnlyMemoryimplementation, andOutgoingFrame.WriteTokeeps its original span loop. Multi-segment bodies use a newBodySegment.WriteTo(IBufferWriter<byte>, …)that writes the 7-byte frame header, each segment, then the end marker independently - so it never asks thePipeWriterfor a span covering more than one segment. Body frame boundaries are sliced out of the sequence independently of segment boundaries.Both branches are preserved for sequences: with no
bodyOwnerthe body is copied into a single pooled buffer (the caller still owns it); with abodyOwneronly the method + header are pooled and the segments go straight out, then the owner is disposed.Struct size
OutgoingFrame._bodychanged fromReadOnlyMemory<byte>(16 bytes) toReadOnlySequence<byte>(24 bytes), widening the struct from 48 to 56 bytes. This also widens the bounded 128-entryChannel<OutgoingFrame>queue atSocketFrameHandler.csby 1 KB total.Validation
body.Lengthis along, while AMQP content headers, the tracing APIs, and the pooled frame buffer are allint-based. Publishing now rejects bodies that cannot be framed, disposing the owner first:ArgumentOutOfRangeExceptionwhenbody.Length > int.MaxValue, thrown at the public entry point before any publisher-confirm sequence number is consumed.ArgumentOutOfRangeExceptionwhen the total frame set (method + header + body + per-frame overhead) would exceedint.MaxValue, checked against the negotiatedMaxPayloadSize.SessionBase.TransmitAsync's existingbytes.Size == 0branch already disposes the owner on this path.Incidental fixes
Both are in
Framing, both were reachable before this PR, and both are covered by new tests:intoverflow in the frame-count math.(length + maxPayloadBytes - 1) / maxPayloadBytesoverflows for a body nearint.MaxValue, producing a negative count and a bogus buffer size. Rewritten as((length - 1) / maxPayloadBytes) + 1, which cannot overflow.GetBodyFrameCountreturned1for a zero-length body whenmaxPayloadBytes == int.MaxValue(i.e. a negotiatedframe_maxof 0). The frame set was sized 8 bytes too large while the write loop emitted no body frame, so the client sent 8 bytes of uninitialized pooled memory and tripped theoffset == sizeassertion in debug builds. It now returns0.Benchmarks
projects/Benchmarks/WireFormatting/MethodFraming.csgained single-segment and multi-segmentReadOnlySequencevariants (BasicPublishWriteSingleSegmentSequence,BasicPublishWriteMultiSegmentSequence, and theirWithOwnercounterparts).What the code paths guarantee, independent of measured numbers:
ReadOnlyMemoryoverloads keep their ownSerializeToFrames/TransmitAsyncpath and are never routed through aReadOnlySequence, so the pre-existing publish paths take on no wrapping cost from this change.ReadOnlySequenceshort-circuits onIsSingleSegmentand delegates to that sameReadOnlyMemoryimplementation, so it runs the memory path rather than the multi-segment one.bodyOwnerwrites its segments straight to the wire instead of gathering them into one contiguous buffer, trading the gather-copy for independent per-segment frame writes; with nobodyOwnerit falls back to a single gather-copy that the caller still owns.Numeric results are intentionally omitted for now: the earlier table predates the split of the
ReadOnlyMemoryandReadOnlySequencepaths, and I would rather add fresh figures from a clean benchmark run than quote stale ones.Testing
New unit tests (no broker required):
TestSegmentedBodyFraming- a multi-segment body produces byte-identical output to its contiguous equivalent, acrossmaxBodyPayloadBytessmaller than, equal to, and larger than the segment size, plus ragged lengths andint.MaxValue; correct body-frame count, payload limits, and end markers;Sizeequals bytes actually written; empty sequences, all-empty-segment sequences, and empty segments interleaved with data.TestSerializeToFramesWithSequence- copy path and zero-copy path emit identical bytes; single-segment sequences match the memory overload; owner disposed exactly once byframe.Dispose(); both oversize guards reject without leaking the owner (using aMemoryManager<byte>-backed sequence that reports a huge length without allocating).New integration tests in
TestBasicPublishAsync- round trip via theIChannel, extension, andCachedStringoverloads; a body spanning multiple body frames and multiple segments; confirms disabled; channel already closed; pre-cancelled token;mandatory: trueproducing abasic.return; oversize rejection. Each asserts the owner was disposed exactly once (waiting for the write loop rather than racing it), and the three pre-existing memory tests were upgraded to the same assertion.