What AMQP compatibility means for a local Azure emulator (.NET / MassTransit) | Topaz
Skip to main content<br>I wanted to see whether Topaz could run a real PeekLock consumer, not just accept AMQP frames and pass a basic SDK smoke test. The first MassTransit run failed in two different ways. CompleteAsync waited 60 seconds for a management response that never arrived, and after fixing that, the consumer still stalled after a single message.
That was the point where "supports AMQP" stopped being a useful statement. This post explains what MassTransit was actually doing on the wire, which parts of the protocol Topaz was still missing, and which traces made the root causes obvious.
The concrete examples use MassTransit and the Azure Service Bus SDK for .NET. The AMQP behaviour described applies to any framework driving PeekLock, but the code is C#. If you are not working in .NET, the protocol sections may still be useful context for evaluating any AMQP emulator.
The two layers of Service Bus compatibility
Most discussions of Azure Service Bus compatibility focus on the control plane: can you create namespaces, queues, and topics through ARM or the Azure CLI? That layer is important, it is what makes az servicebus queue create and azurerm_servicebus_queue work locally, but it is not the interesting layer for message-processing code.
The interesting layer is the AMQP data plane, and it breaks down into two sub-layers:
SDK compatibility : does the Azure Service Bus SDK connect, authenticate, send, and receive? This is the easier bar. The SDK connects through CBS (Claims-Based Security), opens a sender link for sending and a receiver link for receiving, and uses basic settled transfers for most operations. Topaz already handled this layer before the MassTransit work, which is why straightforward SDK send and receive scenarios were working.
Framework compatibility : does a message-processing framework like MassTransit, NServiceBus, or Rebus actually work on top of it? Frameworks drive a more complete subset of the AMQP specification. They open management links alongside receive links, use $management request-response to perform operations the SDK does not surface directly, expect unsettled transfers with explicit client-side settlement, and rely on correct credit replenishment to maintain throughput. For a framework-driven consumer, these behaviors are the normal operating path.
That distinction mattered immediately. Passing the SDK path had not told me anything about whether MassTransit would keep consuming messages.
What MassTransit actually does over AMQP
MassTransit's Azure Service Bus transport (MassTransit.Azure.ServiceBus.Core) uses the Azure SDK as its underlying client but adds a layer of messaging conventions on top. When a ReceiveEndpoint starts, it:
Opens an AMQP session and a receiver link to the queue.
Immediately opens a second link to /$management, a request-response link used for management operations like com.microsoft:update-disposition and com.microsoft:renew-lock.
Sends an AMQP FLOW frame on the receiver link with initial link credit, indicating how many messages it is prepared to accept.
For every message it processes, sends a DISPOSITION frame to complete or abandon it, then expects the broker to update the session's delivery state and replenish credit.
Step 2 is where most partial AMQP implementations break down. The Azure Service Bus SDK does not surface queue-level $management directly to callers; it is an internal transport detail. The root $management link is handled by IRequestProcessor in most AMQP server implementations, but queue-scoped $management links are distinct. They attach to the queue's link processor, not the root processor. An emulator that routes all $management traffic to one handler will complete the CBS authentication but silently drop every queue management request, causing MassTransit's CompleteAsync to wait 60 seconds for a response that never arrives.
Step 4 is where the second class of failures appears. If the broker sends transfers as sender-settled (the settled bit set in the TRANSFER frame), the receiver never adds the delivery to its unsettled map. When MassTransit calls CompleteAsync, the SDK sees no pending delivery with that lock token, settlement happens locally without waiting for broker confirmation, and no DISPOSITION frame is sent. The broker never gets the acknowledgement it expects. Credit is consumed but never replenished. After the first message, the consumer stops receiving.
The bugs we found
Running MassTransit against an early version of Topaz exposed exactly these failure modes. This summary is cleaner than the debugging felt at the time. I first assumed the missing management response was the whole problem. It was not.
Bug 1: Missing queue $management handler. Topaz already handled the root $management link for CBS token validation. Queue-scoped management links were being attached to the link processor without a handler....