Repository navigation
perf: change IMessage.Data from byte[] to ReadOnlyMemory<byte> - #530
Conversation
- Replace unsupported ::: warning syntax with plain markdown bold text - Fixes CI build error: 'Element is missing end tag' at line 207:135 - Build now passes successfully
This is a current breaking change, not from v5.0
There was a problem hiding this comment.
Pull request overview
This PR updates Foundatio’s messaging payload surface area to reduce allocations by switching IMessage.Data from byte[] to ReadOnlyMemory<byte>, adding serializer support for ReadOnlyMemory<byte> (including a non-array-backed fallback stream), and updating call sites, docs, and tests accordingly.
Changes:
- Breaks
IMessage.Datafrombyte[]→ReadOnlyMemory<byte>and updatesMessage/Message<T>accordingly. - Adds
ReadOnlyMemoryStreamplus newISerializer.Deserialize(ReadOnlyMemory<byte>, Type)extension to enable zero-copy (array-backed) deserialization with a safe fallback. - Updates
MessageBusBasedeserialization logic, messaging docs, tests, and benchmark runner config behavior under redirected output.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Foundatio.Tests/Messaging/MessageTests.cs | Adds tests for the new ReadOnlyMemory<byte>-based Message.Data contract. |
| tests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs | Updates assertions for ReadOnlyMemory<byte> emptiness checks. |
| src/Foundatio/Serializer/ReadOnlyMemoryStream.cs | Introduces a read-only, seekable stream wrapper over ReadOnlyMemory<byte> for deserialization fallback. |
| src/Foundatio/Serializer/ISerializer.cs | Adds ReadOnlyMemory<byte> deserialization overloads and fast-path array-backed handling. |
| src/Foundatio/Messaging/MessageBusBase.cs | Updates message-body deserialization to use ReadOnlyMemory<byte> APIs and serializer overloads. |
| src/Foundatio/Messaging/Message.cs | Changes IMessage.Data and Message/Message<T> implementations to ReadOnlyMemory<byte>. |
| src/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs | Extends serializer tests to cover both array-backed and non-array-backed ReadOnlyMemory<byte> paths. |
| docs/guide/messaging.md | Documents the breaking change and recommended usage patterns for ReadOnlyMemory<byte>. |
| benchmarks/Program.cs | Avoids Spotlight console cursor usage when output is redirected by falling back to DefaultConfig. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…y test - MessageBusBase: when no CLR type is mapped (Subscribe<byte[]>()), return the underlying managed array directly when the payload wraps a full-length array (offset 0, full count) instead of always allocating via ToArray(). Falls back to a copy only when the memory is sliced or not array-backed, preserving the pre-ReadOnlyMemory byte[] no-copy behavior. - MessageTests: assert the stored ReadOnlyMemory is backed by the same array instance and segment as the source payload to actually verify no-copy.
|
Addressed both Copilot review comments in 50caa03: 1. 2. All 5 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/Foundatio/Messaging/Message.cs:77
MessagestoresgetBodyand invokes it later; if a caller accidentally passesnull,GetBody()will throw aNullReferenceException. Since this is a public type, it’s safer to fail fast with an argument null guard in the constructor.
public Message(ReadOnlyMemory<byte> data, Func<IMessage, object?> getBody)
{
Data = data;
_getBody = getBody;
}
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…fetime wording Address review feedback: - Restore the '## Common Patterns' section header that was accidentally replaced by the breaking-change note, and promote the note to a proper '#### Breaking change' subsection so 'Event-Driven Architecture' is no longer mis-nested under 'Message Types'. - Reword the IMessage.Data remarks to frame buffer validity as an implementation expectation (with consumer guidance to copy via ToArray()) rather than a hard guarantee that external implementers may not uphold.
…n with AAA blocks
Summary
Changes
IMessage.Datafrombyte[]toReadOnlyMemory<byte>. This is a breaking change for any code that implementsIMessagedirectly or readsDataas abyte[], but is source-compatible for all existing provider code becausebyte[]implicitly converts toReadOnlyMemory<byte>.The change eliminates a forced heap allocation on providers whose transport already hands us memory-backed buffers (Azure Service Bus, etc.), and sets up a cleaner zero-copy deserialization path.
Changes
IMessage.Data:byte[]→ReadOnlyMemory<byte>Message(byte[] data, ...)constructor updated to acceptReadOnlyMemory<byte>— existing callers passingbyte[]still compile via implicit conversion.Message.DataandMessage<T>.Dataproperties updated accordingly.New:
ReadOnlyMemoryStream(internal).NET's BCL does not provide a
Streamwrapper overReadOnlyMemory<byte>. Addedsrc/Foundatio/Serializer/ReadOnlyMemoryStream.cs— a fully seekable, read-onlyStreamimplementation used during deserialization.New:
ISerializer.Deserialize(ReadOnlyMemory<byte>, Type)extensionUses
MemoryMarshal.TryGetArray()as a zero-copy fast path (succeeds whenever theReadOnlyMemory<byte>is backed by a managed array — which covers all current providers). Falls back toReadOnlyMemoryStreamwhen the backing is non-array memory (e.g. native memory,MemoryMappedFile).MessageBusBase.DeserializeMessageBodyupdatedmessage.Data is null || message.Data.Length == 0→message.Data.IsEmpty(correct forReadOnlyMemory<byte>, which cannot be null)clrType == nullfallback (forSubscribe<byte[]>()) still calls.ToArray()— intentional, sincebyte[]subscribers need a real arrayDocs & tests updated
docs/guide/messaging.mdupdated to document the type changetests/Foundatio.Tests/Messaging/MessageTests.cs— new tests covering ROM contracttests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs— updated for ROMsrc/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs— coversReadOnlyMemoryStreamfallback pathDesign decisions
Why stay on the copy-lazy path?
MessageBusBasecurrently copies the raw bytes intoMessage.Dataeagerly inSendMessageToSubscribersAsync. We benchmarked four strategies:MemoryStreamre-wrapReadOnlyMemoryStreamMemoryStreamre-wrap allocationDatapast handler lifetimeWe keep the lazy copy and swap
MemoryStreamforReadOnlyMemoryStreamto eliminate one allocation per deserialization without changing the copy semantics.Why not go fully zero-copy?
Providers like RabbitMQ reclaim pooled transport buffers after the receive callback returns. Any subscriber that stores
message.Databeyond the handler (e.g. for async logging) would silently read garbage. The copy at receive time gives a stable, lifetime-safeReadOnlyMemory<byte>.Breaking changes
IMessage.Databyte[] Data { get; }ReadOnlyMemory<byte> Data { get; }Message(data, getBody)ctorbyte[] dataReadOnlyMemory<byte> databyte[]→ReadOnlyMemory<byte>is an implicit conversion, so existing provider code that passes abyte[]to theMessageconstructor compiles without changes. Code that readsmessage.Dataas abyte[]will need to call.ToArray()or.Span.Companion PRs
Body.ToMemory()instead ofBody.ToArray()on receive; fixes critical message-loss bug where swallowed exceptions causedAutoCompleteMessagesto permanently ack failed messagesReadOnlyMemory<byte>signature on republish path; updated receive-path comment to accurately explain the copy rationale