Skip to content

perf: zero-copy republish path; document receive-path copy rationale#86

Merged
niemyjski merged 5 commits into
mainfrom
perf/readonly-memory-republish
Jun 25, 2026
Merged

perf: zero-copy republish path; document receive-path copy rationale#86
niemyjski merged 5 commits into
mainfrom
perf/readonly-memory-republish

Conversation

@niemyjski

Copy link
Copy Markdown
Member

Summary

Two improvements to RabbitMQMessageBus for the ReadOnlyMemory<byte> transport contract introduced in FoundatioFx/Foundatio#530:

  1. Zero-copy republish pathPublishMessageAsync now accepts ReadOnlyMemory<byte> instead of byte[]; RepublishMessageWithIncrementedDeliveryCountAsync passes envelope.Body directly with no .ToArray() copy
  2. Honest receive-path comment — the .ToArray() copy in ConvertToMessage was previously unexplained; comment now accurately describes why the copy exists

Change 1: Zero-copy republish path

// Before
private async Task PublishMessageAsync(byte[] body, ...)

// After
private async Task PublishMessageAsync(ReadOnlyMemory<byte> body, ...)

RepublishMessageWithIncrementedDeliveryCountAsync previously called envelope.Body.ToArray() to satisfy the byte[] parameter. Now it passes envelope.Body directly — no copy.

This is safe because the publish call is fully awaited within the receive callback, which is itself called before the channel returns the buffer to the pool.


Change 2: Accurate receive-path comment

// Before (misleading — implied the copy was strictly required)
var message = new Message(envelope.Body.ToArray(), DeserializeMessageBody);

// After (explains the rationale)
// Copy the body bytes — RabbitMQ reclaims pooled channel buffers after this callback returns.
// Subscribers may store message.Data beyond the handler lifetime, so a stable copy is required.
var message = new Message(envelope.Body.ToArray(), DeserializeMessageBody);

The copy itself is unchanged and correct. RabbitMQ's BasicDeliverEventArgs.Body is backed by a pooled buffer that is returned to the pool after the receive callback completes. Any subscriber that stores message.Data for use beyond the handler (e.g. async logging, deferred processing) would read garbage from a recycled buffer without this copy.

Comment thread src/Foundatio.RabbitMQ/Messaging/RabbitMQMessageBus.cs Outdated
Comment thread src/Foundatio.RabbitMQ/Messaging/RabbitMQMessageBus.cs Outdated
Comment thread tests/Foundatio.RabbitMQ.Tests/Foundatio.RabbitMQ.Tests.csproj Outdated
Co-authored-by: Blake Niemyjski <bniemyjski@gmail.com>
Per review feedback, removed the inline PERF/rationale comments on both the
republish and receive paths.

Also restores the .ToArray() copy in ConvertToMessage that was inadvertently
dropped alongside the comment. RabbitMQ rents BasicDeliverEventArgs.Body from an
ArrayPool and reclaims it once the async consumer callback returns. Subscribers
run on separate Task.Run continuations and may capture the message and read
message.Data after their handler returns, so the receive path must own a stable
copy. The republish path stays zero-copy because the publish is fully awaited
within the consumer callback while the pooled buffer is still valid.
@niemyjski niemyjski self-assigned this Jun 25, 2026
Reference envelope.Body directly instead of copying via ToArray(). Safe because the
body is deserialized synchronously within the awaited subscriber dispatch before the
handler returns, per the handler-scoped IMessage.Data validity contract.
@niemyjski
niemyjski merged commit 73cd35b into main Jun 25, 2026
4 checks passed
@niemyjski
niemyjski deleted the perf/readonly-memory-republish branch June 25, 2026 19:14
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.

1 participant