Skip to content

perf: change IMessage.Data from byte[] to ReadOnlyMemory<byte> - #530

Merged
niemyjski merged 7 commits into
mainfrom
perf/readonly-memory-message-data
Jun 25, 2026
Merged

niemyjski merged 7 commits into
mainfrom
perf/readonly-memory-message-data

Conversation

@niemyjski

Copy link
Copy Markdown
Member

Summary

Changes IMessage.Data from byte[] to ReadOnlyMemory<byte>. This is a breaking change for any code that implements IMessage directly or reads Data as a byte[], but is source-compatible for all existing provider code because byte[] implicitly converts to ReadOnlyMemory<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>

// Before
byte[] Data { get; }

// After
ReadOnlyMemory<byte> Data { get; }
  • Message(byte[] data, ...) constructor updated to accept ReadOnlyMemory<byte> — existing callers passing byte[] still compile via implicit conversion.
  • Message.Data and Message<T>.Data properties updated accordingly.

New: ReadOnlyMemoryStream (internal)

.NET's BCL does not provide a Stream wrapper over ReadOnlyMemory<byte>. Added src/Foundatio/Serializer/ReadOnlyMemoryStream.cs — a fully seekable, read-only Stream implementation used during deserialization.

New: ISerializer.Deserialize(ReadOnlyMemory<byte>, Type) extension

public static object? Deserialize(this ISerializer serializer, ReadOnlyMemory<byte> data, Type objectType)

Uses MemoryMarshal.TryGetArray() as a zero-copy fast path (succeeds whenever the ReadOnlyMemory<byte> is backed by a managed array — which covers all current providers). Falls back to ReadOnlyMemoryStream when the backing is non-array memory (e.g. native memory, MemoryMappedFile).

MessageBusBase.DeserializeMessageBody updated

  • message.Data is null || message.Data.Length == 0 → message.Data.IsEmpty (correct for ReadOnlyMemory<byte>, which cannot be null)
  • Deserialization now routes through the ROM-aware extension method above
  • The clrType == null fallback (for Subscribe<byte[]>()) still calls .ToArray() — intentional, since byte[] subscribers need a real array

Docs & tests updated

  • docs/guide/messaging.md updated to document the type change
  • tests/Foundatio.Tests/Messaging/MessageTests.cs — new tests covering ROM contract
  • tests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs — updated for ROM
  • src/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs — covers ReadOnlyMemoryStream fallback path

Design decisions

Why stay on the copy-lazy path?
MessageBusBase currently copies the raw bytes into Message.Data eagerly in SendMessageToSubscribersAsync. We benchmarked four strategies:

Strategy Notes
Baseline: copy + MemoryStream re-wrap Previous behavior
Copy + ReadOnlyMemoryStream ✅ Chosen — eliminates the MemoryStream re-wrap allocation
Eager copy (no lazy stream) Allocates byte[] up front on every message
Eager no-copy (pass ROM directly) Lowest allocation — but risky if a subscriber stores Data past handler lifetime

We keep the lazy copy and swap MemoryStream for ReadOnlyMemoryStream to 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.Data beyond the handler (e.g. for async logging) would silently read garbage. The copy at receive time gives a stable, lifetime-safe ReadOnlyMemory<byte>.


Breaking changes

API Before After
IMessage.Data byte[] Data { get; } ReadOnlyMemory<byte> Data { get; }
Message(data, getBody) ctor byte[] data ReadOnlyMemory<byte> data

byte[] → ReadOnlyMemory<byte> is an implicit conversion, so existing provider code that passes a byte[] to the Message constructor compiles without changes. Code that reads message.Data as a byte[] will need to call .ToArray() or .Span.


Companion PRs

  • Foundatio.AzureServiceBus — zero-copy Body.ToMemory() instead of Body.ToArray() on receive; fixes critical message-loss bug where swallowed exceptions caused AutoCompleteMessages to permanently ack failed messages
  • Foundatio.RabbitMQ — ReadOnlyMemory<byte> signature on republish path; updated receive-path comment to accurately explain the copy rationale

- 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.Data from byte[] → ReadOnlyMemory<byte> and updates Message / Message<T> accordingly.
  • Adds ReadOnlyMemoryStream plus new ISerializer.Deserialize(ReadOnlyMemory<byte>, Type) extension to enable zero-copy (array-backed) deserialization with a safe fallback.
  • Updates MessageBusBase deserialization 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.
@niemyjski

Copy link
Copy Markdown
Member Author

Addressed both Copilot review comments in 50caa03:

1. MessageBusBase raw-body allocation (Subscribe<byte[]>())
Root cause: the clrType == null path used message.Data.ToArray(), which unconditionally allocated a copy even when the payload already wrapped a full managed array. Before IMessage.Data became ReadOnlyMemory<byte>, this path returned the existing byte[] with no copy. Added a GetRawBody helper that uses MemoryMarshal.TryGetArray and returns the underlying array directly when it is array-backed at offset 0 with count == array.Length, falling back to a copy only when the memory is sliced or not array-backed. This restores the original no-copy behavior while still honoring the byte[] contract.

2. MessageTests no-copy assertion
Root cause: Constructor_WithReadOnlyMemory_StoresDataWithoutCopy only asserted byte equality, which would still pass if the constructor defensively copied. Strengthened it to assert via MemoryMarshal.TryGetArray that message.Data is backed by the same array instance and segment (Assert.Same(payload, segment.Array), offset 0, full count), so it now actually verifies the no-copy contract.

All 5 MessageTests pass; full suite is green aside from a pre-existing flaky InMemoryQueueTests.CanQueueAndDequeueMultipleWorkItemsAsync timing test unrelated to this change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • Message stores getBody and invokes it later; if a caller accidentally passes null, GetBody() will throw a NullReferenceException. 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;
    }

Comment thread docs/guide/messaging.md Resolved
Comment thread src/Foundatio/Messaging/Message.cs Resolved
niemyjski and others added 3 commits June 24, 2026 21:06
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.
@niemyjski
niemyjski merged commit 2310623 into main Jun 25, 2026
4 checks passed
@niemyjski
niemyjski deleted the perf/readonly-memory-message-data branch June 25, 2026 17:23
@niemyjski
niemyjski restored the perf/readonly-memory-message-data branch June 25, 2026 18:00
@niemyjski
niemyjski deleted the perf/readonly-memory-message-data branch July 6, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants