Skip to content

Avoid serializing inline Azure Storage queue messages twice #1378

Description

@berndverst

What is the issue

MessageManager.SerializeMessageDataAsync serializes every MessageData instance into rawContent, uses that string to calculate its UTF-8 size and select inline versus blob storage, and then serializes the same object a second time in the common InlineJson branch:

public async Task<string> SerializeMessageDataAsync(MessageData messageData, CancellationToken cancellationToken = default)
{
string rawContent = Utils.SerializeToJson(serializer, messageData);
messageData.TotalMessageSizeBytes = Encoding.UTF8.GetByteCount(rawContent);
MessageFormatFlags messageFormat = this.GetMessageFormatFlags(messageData);
if (messageFormat != MessageFormatFlags.InlineJson)
{
// Get Compressed bytes and upload the full message to blob storage.
byte[] messageBytes = Encoding.UTF8.GetBytes(rawContent);
string blobName = this.GetNewLargeMessageBlobName(messageData);
messageData.CompressedBlobName = blobName;
await this.CompressAndUploadAsBytesAsync(messageBytes, blobName, cancellationToken);
// Create a "wrapper" message which has the blob name but not a task message.
var wrapperMessageData = new MessageData { CompressedBlobName = blobName };
return Utils.SerializeToJson(serializer, wrapperMessageData);
}
return Utils.SerializeToJson(serializer, messageData);
}

The only mutation between those two serializations is TotalMessageSizeBytes. That property is internal and is not a [DataMember], while MessageData is a [DataContract] whose wire properties are explicitly marked:

/// Protocol class for all Azure Queue messages.
/// </summary>
[DataContract]
public class MessageData

/// <summary>
/// TraceContext for correlation.
/// </summary>
[DataMember]
public string SerializableTraceContext { get; set; }
internal string Id => this.OriginalQueueMessage?.MessageId;
internal string QueueName { get; set; }
internal QueueMessage OriginalQueueMessage { get; set; }
internal long TotalMessageSizeBytes { get; set; }
internal MessageFormatFlags MessageFormat { get; set; }

Each serialization also creates a fresh StringBuilder, StringWriter, and result string:

/// <summary>
/// Serialize some object payload to a JSON-string representation.
/// This utility is resilient to end-user changes in the DefaultSettings of Newtonsoft.
/// </summary>
/// <param name="serializer">The serializer to use.</param>
/// <param name="payload">The object to serialize.</param>
/// <returns>The JSON-string representation of the payload</returns>
public static string SerializeToJson(JsonSerializer serializer, object payload)
{
StringBuilder stringBuilder = new StringBuilder();
using (var stringWriter = new StringWriter(stringBuilder))
{
serializer.Serialize(stringWriter, payload);
}
var jsonStr = stringBuilder.ToString();
return jsonStr;
}

This method is called for every outbound Azure Storage task-hub queue message:

async Task<MessageData> AddMessageAsync(TaskMessage taskMessage, OrchestrationInstance sourceInstance, SessionBase? session)
{
MessageData data;
try
{
// We transfer to a new trace activity ID every time a new outbound queue message is created.
Guid outboundTraceActivityId = Guid.NewGuid();
data = new MessageData(
taskMessage,
outboundTraceActivityId,
this.storageQueue.Name,
session?.GetCurrentEpisode(),
sourceInstance);
data.SequenceNumber = Interlocked.Increment(ref messageSequenceNumber);
// Inject Correlation TraceContext on a queue.
CorrelationTraceClient.Propagate(
() => { data.SerializableTraceContext = GetSerializableTraceContext(taskMessage); });
string rawContent = await this.messageManager.SerializeMessageDataAsync(data);

The blob-offload branch legitimately serializes a small wrapper after uploading rawContent; only the inline branch repeats the original serialization.

Performance impact

Inline messages are the normal path for payloads below the 45 KiB threshold. Every such orchestration, activity, timer, sub-orchestration, and external-event message currently incurs two complete Newtonsoft JSON traversals and two sets of temporary buffers/strings instead of one.

The duplicate CPU and allocation cost scales linearly with message throughput and payload size. At high task-hub throughput this increases serialization CPU, memory bandwidth, Gen-0 pressure, and queue-send latency without changing the resulting wire payload.

Proposed backward-compatible solution

Return the already-produced rawContent in the InlineJson branch:

if (messageFormat != MessageFormatFlags.InlineJson)
{
    // Existing blob upload and wrapper serialization remain unchanged.
}

return rawContent;

This preserves the exact JSON that was already used for the byte-count/format decision. It changes no public API, queue schema, serializer settings, or blob behavior.

Validation

  • Add regression coverage for both UseDataContractSerialization modes and representative custom type-binder settings, asserting that the optimized inline result exactly matches the current second serialization.
  • Cover payloads immediately below and above the inline threshold to ensure the blob-wrapper path is unchanged.
  • Benchmark representative small, medium, and near-threshold messages with allocation diagnostics; the inline path should perform one MessageData serialization instead of two.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions