When sending a batch of messages, Service Bus and Event Hubs libraries have the following logic -
- Allocate a byte array for the Uber batch message.
- Iterate through the list of messages, encode each message, and append it to the above byte array.
The encode and append operations are handled by the 'encode' API1 in the QPid library.
int Message::encode(byte[] byteArray, int offset, int length)
Because the client doesn't know the final size of the encoded Uber message, today, it allocates the byte array upfront with a size equal to the maximum size link support, which is the MaxMessageSize configured for the entity, new byte[maxMessageSize]. For a Service Bus Queue, the MaxMessageSize can range from 1024KB (1MB) to 102400KB (100MB).
This is an allocation overhead that customers recently started to report. For example, even if the total encoded size of the batch is 2 KB, the library allocates 50MB if the Queue's configured MaxMessageSize is 50MB. The allocation becomes prominent in concurrent send cases.
The above allocation pattern in both Track1 and Track2 SDK are the same.
Looking into how to improve the experience, we came across an overload of QPid 'encode' API2 -
int encode(WritableBuffer buffer);
The currently used 'encode' API1 underneath uses this API2 by wrapping the fixed-size input byte array into java.nio.ByteBuffer, then a 'WritableBuffer' internal impl in QPid abstract this ByteBuffer.
It means the messaging libraries can provide 'WritableBuffer' implementation with the following properties -
- Starts with an internal buffer (byte array) of an initial size.
- Grow the internal buffer as needed, with MaxMessageSize as the upper limit.
It is very much like JDK's ByteArrayOutputStream, and using it with Jackson serialization (encoding) API, that we use in azure-core.
The second gist shows a referance implementation of 'WritableBuffer', 'ByteArrayWritableBuffer' with the above properties.
Further exploration shows that it's not just Azure Messaging libraries that run into allocation over ahead. The Redhat Vertex project using QPid has to roll out it's own 'WritableBuffer' reference. It uses Netty's "Heap" based "Unpooled" ByteBuf underneath as the buffer.
It may not be wise to add the Netty dependency to azure-core-amqp to use ByteBuf -
- First, it's an extra dependency we're bringing in.
- Second, the internal code of the Netty ByteBuf API subset that Vertex uses for their 'WritableBuffer' is the same as 'ByteArrayWritableBuffer'.
- Third, The Netty ByteBuf type has additional complexity for other APIs it has to support but is not needed to back 'WritableBuffer'.
Finally, the buffer reallocation algorithm in 'ByteArrayWritableBuffer' is exactly the same as JDK's ByteArrayOutputStream.
We can also use MethodHandle or Reflection to runtime detect Netty in the classpath and access its APIs. But, it is not worth at least now. It's worthwhile if we ever want to use Netty's "Pooled" ByeBuf, which comes with the great responsability for the libraries to managing reference counted buffers. That's totally a different territory to explore.