Skip to content

Commit 2310623

Browse files
niemyjskiCopilot
andauthored
perf: change IMessage.Data from byte[] to ReadOnlyMemory<byte> (#530)
* perf: change IMessage.Data from byte[] to ReadOnlyMemory<byte> * Fix: Correct VitePress markdown syntax in messaging.md docs - 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 * Fix: Remove incorrect v5.0 version reference from breaking change notice This is a current breaking change, not from v5.0 * Address PR review: preserve byte[] no-copy path and strengthen no-copy 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. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: restore Common Patterns heading; soften IMessage.Data buffer-lifetime 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. * style: add blank lines after guards; rename tests to 3-part convention with AAA blocks --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 29b52cf commit 2310623

9 files changed

Lines changed: 324 additions & 8 deletions

File tree

benchmarks/Program.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using BenchmarkDotNet.Configs;
12
using BenchmarkDotNet.Running;
23
using RhoMicro.BdnLogging;
34

@@ -14,6 +15,11 @@ public static void Main(string[] args)
1415
// dotnet run -c Release -- --filter *Resilience* # Run only resilience benchmarks
1516
// dotnet run -c Release -- --filter *DeepClone* # Run only deep clone benchmarks
1617
// dotnet run -c Release -- --list tree # List all benchmarks
17-
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, SpotlightConfig.Instance);
18+
19+
// The spotlight logger renders a live, cursor-positioned console. When output is
20+
// redirected (CI, agents, log files) there is no interactive console and its cursor
21+
// calls throw, so fall back to the default config in that case.
22+
var config = System.Console.IsOutputRedirected ? DefaultConfig.Instance : SpotlightConfig.Instance;
23+
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);
1824
}
1925
}

docs/guide/messaging.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,16 @@ await messageBus.SubscribeAsync(async (IMessage message, CancellationToken ct) =
273273
});
274274
```
275275

276+
#### Breaking change: `IMessage.Data` is now `ReadOnlyMemory<byte>`
277+
278+
`IMessage.Data` exposes the raw payload as `ReadOnlyMemory<byte>` instead of `byte[]`. This lets memory-backed transports such as Azure Service Bus avoid copying the payload into a new array. Since it is a struct, follow these patterns:
279+
280+
- Check for an empty payload with `message.Data.IsEmpty` (not `== null`)
281+
- Read the bytes directly via `message.Data.Span`
282+
- Call `message.Data.ToArray()` only when you need a `byte[]`
283+
284+
Most code that uses `GetBody()` / `Body` is unaffected. When constructing a `Message`, you can still pass a `byte[]`; it converts implicitly to `ReadOnlyMemory<byte>`.
285+
276286
## Common Patterns
277287

278288
### Event-Driven Architecture

src/Foundatio.TestHarness/Serializer/SerializerTestsBase.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public virtual void Deserialize_WithInvalidInput_ThrowsArgumentException()
3131
Assert.Throws<ArgumentNullException>(() => serializer.Deserialize<SerializeModel>((Stream)null!));
3232
Assert.Throws<ArgumentNullException>(() => serializer.Deserialize<SerializeModel>((byte[])null!));
3333
Assert.Throws<ArgumentException>(() => serializer.Deserialize<SerializeModel>([]));
34+
Assert.Throws<ArgumentException>(() => serializer.Deserialize<SerializeModel>(ReadOnlyMemory<byte>.Empty));
3435
Assert.Throws<ArgumentNullException>(() => serializer.Deserialize<SerializeModel>((string)null!));
3536
Assert.Throws<ArgumentException>(() => serializer.Deserialize<SerializeModel>(String.Empty));
3637
Assert.Throws<ArgumentException>(() => serializer.Deserialize<SerializeModel>(" "));
@@ -116,6 +117,24 @@ public virtual void Deserialize_WithValidBytes_ReturnsDeserializedObject()
116117
Assert.Equal(model.StringProperty, actual.StringProperty);
117118
Assert.Equal(model.ListProperty, actual.ListProperty);
118119

120+
// Act - ReadOnlyMemory<byte> over a managed array (MemoryMarshal.TryGetArray fast path)
121+
var fromArrayMemory = serializer.Deserialize<SerializeModel>(new ReadOnlyMemory<byte>(bytes));
122+
Assert.NotNull(fromArrayMemory);
123+
124+
// Assert
125+
Assert.Equal(model.IntProperty, fromArrayMemory.IntProperty);
126+
Assert.Equal(model.StringProperty, fromArrayMemory.StringProperty);
127+
Assert.Equal(model.ListProperty, fromArrayMemory.ListProperty);
128+
129+
// Act - ReadOnlyMemory<byte> NOT backed by a managed array (ReadOnlyMemoryStream fallback)
130+
var fromNativeMemory = serializer.Deserialize<SerializeModel>(CreateNativeBackedMemory(bytes));
131+
Assert.NotNull(fromNativeMemory);
132+
133+
// Assert
134+
Assert.Equal(model.IntProperty, fromNativeMemory.IntProperty);
135+
Assert.Equal(model.StringProperty, fromNativeMemory.StringProperty);
136+
Assert.Equal(model.ListProperty, fromNativeMemory.ListProperty);
137+
119138
// Act
120139
string text = serializer.SerializeToString(model);
121140
actual = serializer.Deserialize<SerializeModel>(text);
@@ -430,6 +449,32 @@ private static bool IsNumericType(object value)
430449
return value is byte or sbyte or short or ushort or int or uint or long or ulong
431450
or float or double or decimal;
432451
}
452+
453+
/// <summary>
454+
/// Creates a <see cref="ReadOnlyMemory{T}"/> that is NOT backed by a managed array (it is backed by a
455+
/// custom <see cref="System.Buffers.MemoryManager{T}"/>), so the
456+
/// <see cref="System.Runtime.InteropServices.MemoryMarshal.TryGetArray"/> fast path fails and the
457+
/// <c>ReadOnlyMemoryStream</c> fallback is exercised.
458+
/// </summary>
459+
private static ReadOnlyMemory<byte> CreateNativeBackedMemory(byte[] source)
460+
{
461+
return new ManagerBackedMemory(source).Memory;
462+
}
463+
464+
private sealed class ManagerBackedMemory : System.Buffers.MemoryManager<byte>
465+
{
466+
private readonly byte[] _buffer;
467+
468+
public ManagerBackedMemory(byte[] source) => _buffer = source;
469+
470+
public override Span<byte> GetSpan() => _buffer;
471+
472+
public override System.Buffers.MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException();
473+
474+
public override void Unpin() { }
475+
476+
protected override void Dispose(bool disposing) { }
477+
}
433478
}
434479

435480
[MemoryDiagnoser]

src/Foundatio/Messaging/Message.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ public interface IMessage
3535
/// <summary>
3636
/// Gets the raw serialized message payload.
3737
/// </summary>
38-
byte[] Data { get; }
38+
/// <remarks>
39+
/// Returned as a <see cref="ReadOnlyMemory{T}"/> to avoid forcing an array copy on providers
40+
/// whose transport buffers are already memory-backed (e.g. Azure Service Bus). Implementations
41+
/// are expected to keep the underlying buffer valid for the lifetime of the message; consumers
42+
/// that need to retain the payload beyond the current operation should copy it via <c>ToArray()</c>.
43+
/// </remarks>
44+
ReadOnlyMemory<byte> Data { get; }
3945

4046
/// <summary>
4147
/// Deserializes and returns the message payload.
@@ -65,7 +71,7 @@ public class Message : IMessage
6571
{
6672
private readonly Func<IMessage, object?> _getBody;
6773

68-
public Message(byte[] data, Func<IMessage, object?> getBody)
74+
public Message(ReadOnlyMemory<byte> data, Func<IMessage, object?> getBody)
6975
{
7076
Data = data;
7177
_getBody = getBody;
@@ -77,7 +83,7 @@ public Message(byte[] data, Func<IMessage, object?> getBody)
7783
public Type? ClrType { get; set; }
7884
[DisallowNull]
7985
public IDictionary<string, string> Properties { get => field; set => field = value ?? new Dictionary<string, string>(); } = new Dictionary<string, string>();
80-
public byte[] Data { get; set; }
86+
public ReadOnlyMemory<byte> Data { get; set; }
8187
public object? GetBody() => _getBody(this);
8288
}
8389

@@ -90,7 +96,7 @@ public Message(IMessage message)
9096
_message = message;
9197
}
9298

93-
public byte[] Data => _message.Data;
99+
public ReadOnlyMemory<byte> Data => _message.Data;
94100

95101
public T Body => GetBody() as T ?? throw new MessageBusException("Message body is null or not of expected type");
96102

src/Foundatio/Messaging/MessageBusBase.cs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Diagnostics;
55
using System.Linq;
66
using System.Reflection;
7+
using System.Runtime.InteropServices;
78
using System.Threading;
89
using System.Threading.Tasks;
910
using Foundatio.Resilience;
@@ -319,14 +320,14 @@ protected virtual byte[] SerializeMessageBody(string messageType, object body)
319320

320321
protected virtual object? DeserializeMessageBody(IMessage message)
321322
{
322-
if (message.Data is null || message.Data.Length == 0)
323+
if (message.Data.IsEmpty)
323324
return null;
324325

325326
object? body;
326327
try
327328
{
328329
var clrType = message.ClrType ?? GetMappedMessageType(message.Type);
329-
body = clrType != null ? _serializer.Deserialize(message.Data, clrType) : message.Data;
330+
body = clrType != null ? _serializer.Deserialize(message.Data, clrType) : GetRawBody(message.Data);
330331
}
331332
catch (Exception ex)
332333
{
@@ -337,6 +338,27 @@ protected virtual byte[] SerializeMessageBody(string messageType, object body)
337338
return body;
338339
}
339340

341+
/// <summary>
342+
/// Returns the raw payload as a <see cref="byte"/> array for subscribers that consume the body
343+
/// without a mapped CLR type (e.g. <c>Subscribe&lt;byte[]&gt;()</c>).
344+
/// </summary>
345+
/// <remarks>
346+
/// When the payload already wraps a full-length managed array (offset 0, count equal to the array
347+
/// length) the underlying array is returned directly, preserving the no-copy behavior that existed
348+
/// before <see cref="IMessage.Data"/> became a <see cref="ReadOnlyMemory{T}"/>. Otherwise the memory
349+
/// is copied to honor the <c>byte[]</c> contract.
350+
/// </remarks>
351+
private static byte[] GetRawBody(ReadOnlyMemory<byte> data)
352+
{
353+
if (MemoryMarshal.TryGetArray(data, out ArraySegment<byte> segment) && segment.Array is not null
354+
&& segment.Offset == 0 && segment.Count == segment.Array.Length)
355+
{
356+
return segment.Array;
357+
}
358+
359+
return data.ToArray();
360+
}
361+
340362
protected async Task SendMessageToSubscribersAsync(IMessage message)
341363
{
342364
if (IsDisposed)

src/Foundatio/Serializer/ISerializer.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Diagnostics.CodeAnalysis;
33
using System.IO;
4+
using System.Runtime.InteropServices;
45
using System.Text;
56

67
namespace Foundatio.Serializer;
@@ -107,6 +108,51 @@ public static T Deserialize<T>(this ISerializer serializer, byte[] data)
107108
return serializer.Deserialize(stream, objectType);
108109
}
109110

111+
/// <summary>
112+
/// Deserializes an object of type <typeparamref name="T"/> from <paramref name="data"/>.
113+
/// </summary>
114+
/// <returns>The deserialized value, or <c>default</c> if the underlying serializer returns <c>null</c>.</returns>
115+
/// <remarks>
116+
/// The return type is <c>T</c> annotated with <c>[return: MaybeNull]</c> rather than <c>T?</c>
117+
/// because <c>T?</c> on an unconstrained generic would double-wrap <c>Nullable&lt;T&gt;</c> value types.
118+
/// Callers that expect <c>null</c> should use a nullable type argument, e.g. <c>Deserialize&lt;MyType?&gt;</c>.
119+
/// </remarks>
120+
[return: MaybeNull]
121+
public static T Deserialize<T>(this ISerializer serializer, ReadOnlyMemory<byte> data)
122+
{
123+
ArgumentNullException.ThrowIfNull(serializer);
124+
if (data.IsEmpty)
125+
throw new ArgumentException("Data cannot be empty.", nameof(data));
126+
127+
object? result = serializer.Deserialize(data, typeof(T));
128+
if (result is T typed)
129+
return typed;
130+
131+
if (result is not null)
132+
throw new SerializerException($"Deserialized object is of type '{result.GetType().FullName}', expected '{typeof(T).FullName}'.");
133+
134+
return default!;
135+
}
136+
137+
public static object? Deserialize(this ISerializer serializer, ReadOnlyMemory<byte> data, Type objectType)
138+
{
139+
ArgumentNullException.ThrowIfNull(serializer);
140+
ArgumentNullException.ThrowIfNull(objectType);
141+
if (data.IsEmpty)
142+
throw new ArgumentException("Data cannot be empty.", nameof(data));
143+
144+
// Fast path: if the memory is backed by a managed array we can hand it straight to a
145+
// MemoryStream without copying. Otherwise fall back to a stream over the memory.
146+
if (MemoryMarshal.TryGetArray(data, out ArraySegment<byte> segment) && segment.Array is not null)
147+
{
148+
using var arrayStream = new MemoryStream(segment.Array, segment.Offset, segment.Count, writable: false);
149+
return serializer.Deserialize(arrayStream, objectType);
150+
}
151+
152+
using var stream = new ReadOnlyMemoryStream(data);
153+
return serializer.Deserialize(stream, objectType);
154+
}
155+
110156
/// <summary>
111157
/// Deserializes an object of type <typeparamref name="T"/> from <paramref name="data"/>.
112158
/// </summary>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System;
2+
using System.IO;
3+
4+
namespace Foundatio.Serializer;
5+
6+
/// <summary>
7+
/// A read-only, seekable <see cref="Stream"/> over a <see cref="ReadOnlyMemory{T}"/> of bytes.
8+
/// </summary>
9+
/// <remarks>
10+
/// Used as a fallback for deserializing a <see cref="ReadOnlyMemory{T}"/> whose backing store is not
11+
/// a managed array (so the no-copy <see cref="System.Runtime.InteropServices.MemoryMarshal.TryGetArray"/>
12+
/// fast path is unavailable). This avoids allocating an intermediate <c>byte[]</c> copy just to wrap
13+
/// the payload in a <see cref="MemoryStream"/>.
14+
/// </remarks>
15+
internal sealed class ReadOnlyMemoryStream : Stream
16+
{
17+
private readonly ReadOnlyMemory<byte> _memory;
18+
private int _position;
19+
20+
public ReadOnlyMemoryStream(ReadOnlyMemory<byte> memory)
21+
{
22+
_memory = memory;
23+
}
24+
25+
public override bool CanRead => true;
26+
public override bool CanSeek => true;
27+
public override bool CanWrite => false;
28+
public override long Length => _memory.Length;
29+
30+
public override long Position
31+
{
32+
get => _position;
33+
set
34+
{
35+
ArgumentOutOfRangeException.ThrowIfNegative(value);
36+
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, _memory.Length);
37+
38+
_position = (int)value;
39+
}
40+
}
41+
42+
public override int Read(byte[] buffer, int offset, int count)
43+
{
44+
ArgumentNullException.ThrowIfNull(buffer);
45+
return Read(buffer.AsSpan(offset, count));
46+
}
47+
48+
public override int Read(Span<byte> buffer)
49+
{
50+
int remaining = _memory.Length - _position;
51+
if (remaining <= 0)
52+
return 0;
53+
54+
int toCopy = Math.Min(remaining, buffer.Length);
55+
_memory.Span.Slice(_position, toCopy).CopyTo(buffer);
56+
_position += toCopy;
57+
return toCopy;
58+
}
59+
60+
public override int ReadByte()
61+
{
62+
if (_position >= _memory.Length)
63+
return -1;
64+
65+
return _memory.Span[_position++];
66+
}
67+
68+
public override long Seek(long offset, SeekOrigin origin)
69+
{
70+
long target = origin switch
71+
{
72+
SeekOrigin.Begin => offset,
73+
SeekOrigin.Current => _position + offset,
74+
SeekOrigin.End => _memory.Length + offset,
75+
_ => throw new ArgumentOutOfRangeException(nameof(origin))
76+
};
77+
78+
ArgumentOutOfRangeException.ThrowIfNegative(target);
79+
ArgumentOutOfRangeException.ThrowIfGreaterThan(target, _memory.Length);
80+
81+
_position = (int)target;
82+
return _position;
83+
}
84+
85+
public override void Flush() { }
86+
87+
public override void SetLength(long value) => throw new NotSupportedException();
88+
89+
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
90+
}

tests/Foundatio.Tests/Messaging/InMemoryMessageBusTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ await messageBus.SubscribeAsync(msg =>
265265
{
266266
Assert.Null(msg.Type);
267267
Assert.Null(msg.ClrType);
268-
Assert.NotEmpty(msg.Data);
268+
Assert.False(msg.Data.IsEmpty);
269269
rawReceived.Signal();
270270
}, TestCancellationToken);
271271

0 commit comments

Comments
 (0)