In this post we are going to cover queues and the message lifecycle in Azure Service Bus. These are 7 real Service Bus interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario: a nightly batch drops fifty thousand invoice messages into a queue at once, and you process them safely and quickly.
The message lifecycle is everything that happens between a send and the moment the message leaves the queue: the lock, complete, abandon, redelivery and expiry. Interviewers ask about it because most production problems live here. Messages that run twice, messages that vanish, consumers that fall behind and payloads that are too big all come back to how a queue hands out and settles messages.
Part 2 of the Real Time Azure Service Bus Interview Questions and Answers series on TechBrothersIT.
- The short answer
- Let's set up the project
- Question 1: A teammate asks what happens to a message from send to completion. How do you walk through the lifecycle?
- Question 2: Someone asks the difference between receive-and-delete and peek-lock. When do you use each?
- Question 3: Messages sit in the queue longer than expected and then vanish. How does time to live work?
- Question 4: A queue grows during a sale and consumers fall behind. How do you scale the consumers?
- Question 5: A teammate wants to look at messages without taking them. How does peek work?
- Question 6: Your manager asks how big a message can be and what to do with large payloads. What do you tell them?
- Scenario 7: A nightly batch drops fifty thousand invoice messages into a queue at once. How do you process them safely and quickly?
- The full script
The short answer
A message is sent (sequence number), locked by a peek-lock receiver, then completed, or abandoned and redelivered with a higher delivery count. Peek-lock is at least once, receive-and-delete at most once; the queue's time to live is the ceiling, peek looks without locking, and big payloads go through a claim check.
Let's set up the project
We run Microsoft's official Service Bus emulator locally, with SQL Server next to it, and small Python scripts that use the azure-servicebus client, so every output is real. Features the emulator doesn't support are shown as code. The emulator reads its queues from Config.json: orders-rad and orders-lock have a 5 second lock, offers-expire and offers-deadletter have a 5 second default time to live, and only offers-deadletter dead-letters on expiration. shop.py has client() and waiting(), which peeks from sequence number 1 without locking anything.
{
"UserConfig": {
"Namespaces": [
{
"Name": "sbemulatorns",
"Queues": [
{
"Name": "invoices",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "orders-rad",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT5S",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "orders-lock",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT5S",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "offers-expire",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT5S",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "offers-deadletter",
"Properties": {
"DeadLetteringOnMessageExpiration": true,
"DefaultMessageTimeToLive": "PT5S",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "sale-orders",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "shipments",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "documents",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "nightly-invoices",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
}
],
"Topics": []
}
],
"Logging": {
"Type": "File"
}
}
}import os
from azure.servicebus import ServiceBusClient
def client():
return ServiceBusClient.from_connection_string(
os.environ["SERVICEBUS_CONNECTION_STRING"])
def waiting(sbc, queue):
"""Peek from sequence number 1: no lock, nothing removed."""
with sbc.get_queue_receiver(queue) as receiver:
msgs = receiver.peek_messages(max_message_count=250,
sequence_number=1)
return [(m.message_id, m.delivery_count) for m in msgs]Question 1: A teammate asks what happens to a message from send to completion. How do you walk through the lifecycle?
Three stages: send, receive in peek-lock, complete. Or the message comes back, when the consumer abandons it or the lock expires.
Let's go through it in order:
- Send - stored, acknowledged, a sequence number assigned
- Receive in peek-lock - locked for this consumer, hidden from the others
- Complete - removed from the queue
- Abandon or lock expiry - back in the queue, delivery count + 1
lifecycle.py sends three invoices, locks all three, completes two, abandons the third and receives it again. We run python lifecycle.py.
import shop
from azure.servicebus import ServiceBusMessage
with shop.client() as sbc:
with sbc.get_queue_sender("invoices") as sender: # 1. send
sender.send_messages([ServiceBusMessage(
f"invoice {i}", message_id=f"inv-{i}") for i in (1, 2, 3)])
with sbc.get_queue_receiver("invoices") as receiver: # peek-lock
msgs = receiver.receive_messages(max_message_count=3,
max_wait_time=5) # 2. lock
for m in msgs:
print("locked:", m.message_id, "| seq", m.sequence_number,
"| delivery_count", m.delivery_count)
print("peek while locked:", shop.waiting(sbc, "invoices"))
receiver.complete_message(msgs[0]) # 3. done
receiver.complete_message(msgs[1])
receiver.abandon_message(msgs[2]) # give back
print("after complete, complete, abandon:",
shop.waiting(sbc, "invoices"))
again = receiver.receive_messages(max_wait_time=5)[0]
print("redelivered:", again.message_id,
"| delivery_count", again.delivery_count)
receiver.complete_message(again)
print("finally:", shop.waiting(sbc, "invoices"))$ python lifecycle.py
locked: inv-1 | seq 1 | delivery_count 0
locked: inv-2 | seq 2 | delivery_count 0
locked: inv-3 | seq 3 | delivery_count 0
peek while locked: [('inv-1', 0), ('inv-2', 0), ('inv-3', 0)]
after complete, complete, abandon: [('inv-3', 1)]
redelivered: inv-3 | delivery_count 1
finally: []The invoices get seq 1, seq 2 and seq 3, each with delivery_count 0 on the first try. The peek while locked still lists all three: locked messages are still in the queue, only hidden from other receivers.
After complete, complete, abandon, only ('inv-3', 1) is left, now with delivery count 1, and the next receive gets it right back. After we complete it, finally: [].
In Azure, these are the defaults.
| Azure setting | Default |
|---|---|
| Lock duration | 1 minute (max 5) |
| Max delivery count | 10, then the DLQ |
Set the lock longer than your normal processing time.
Question 2: Someone asks the difference between receive-and-delete and peek-lock. When do you use each?
Receive-and-delete removes the message the moment it's handed to the consumer. That's at most once: fast, but if the consumer fails, the message is gone. Peek-lock, the default, keeps it locked until the consumer settles it. That's at least once.
| Receive-and-delete | Peek-lock (default) | |
|---|---|---|
| Removed | when handed to the consumer | when the consumer completes it |
| Guarantee | at most once | at least once |
| Consumer crashes | the message is lost | back after the lock expires |
| Abandon, defer, dead-letter | not possible | yes |
Let's crash both. The same order goes to two queues, each with a 5 second lock. crash.py receives it and the process dies before complete. We run python crash.py rad and python crash.py lock, wait with sleep 7, then run python recover.py.
import os, sys, shop
from azure.servicebus import ServiceBusMessage, ServiceBusReceiveMode
mode = sys.argv[1] # "rad" or "lock"
queue = "orders-" + mode # both: LockDuration PT5S
receive_mode = (ServiceBusReceiveMode.RECEIVE_AND_DELETE
if mode == "rad" else ServiceBusReceiveMode.PEEK_LOCK)
sbc = shop.client()
with sbc.get_queue_sender(queue) as sender:
sender.send_messages(ServiceBusMessage("order 3001",
message_id="3001"))
receiver = sbc.get_queue_receiver(queue, receive_mode=receive_mode)
msg = receiver.receive_messages(max_wait_time=5)[0]
print(queue + ": got", msg.message_id, "then crashed before complete")
os._exit(1) # the process dies right hereimport shop
with shop.client() as sbc:
for queue in ["orders-rad", "orders-lock"]:
print(queue, "after the crash:", shop.waiting(sbc, queue))
with sbc.get_queue_receiver("orders-lock") as receiver:
for msg in receiver.receive_messages(max_wait_time=5):
print("orders-lock, new consumer:", msg.message_id,
"| delivery_count", msg.delivery_count)
receiver.complete_message(msg)$ python crash.py rad
orders-rad: got 3001 then crashed before complete
$ python crash.py lock
orders-lock: got 3001 then crashed before complete
$ sleep 7
$ python recover.py
orders-rad after the crash: []
orders-lock after the crash: [('3001', 1)]
orders-lock, new consumer: 3001 | delivery_count 1Both consumers print got 3001 then crashed before complete. Seven seconds later, orders-rad after the crash: []: order 3001 is lost. The peek-lock queue still has it, [('3001', 1)], with delivery count 1, and a new consumer processes it.
So peek-lock is my default for anything with business value. Receive-and-delete fits data you can afford to lose, and it can't abandon, defer or dead-letter a message.
A crash after the work but before complete means the work runs twice. So consumers must be idempotent.
Question 3: Messages sit in the queue longer than expected and then vanish. How does time to live work?
Every message has a time to live, and the queue's default is also the ceiling: a longer value on the message is silently cut down. When it passes, the message is dropped, or moved to the dead-letter queue if dead-lettering on expiration is on.
Let's try it. Both offers queues have a 5 second default. send_offers.py sends three flash offers to each, asking for 10 minutes. Nobody reads them for 8 seconds, then check_offers.py peeks, receives and reads the dead-letter queue. We run python send_offers.py, sleep 8 and python check_offers.py.
import shop
from datetime import timedelta
from azure.servicebus import ServiceBusMessage
with shop.client() as sbc:
for queue in ["offers-expire", "offers-deadletter"]:
with sbc.get_queue_sender(queue) as sender:
sender.send_messages([ServiceBusMessage(
f"flash offer {i}", message_id=f"offer-{i}",
time_to_live=timedelta(minutes=10)) # asks 10 min
for i in (1, 2, 3)])
print("sent 3 offers to", queue, "with time_to_live 10 min")import shop
from azure.servicebus import ServiceBusSubQueue
DLQ = ServiceBusSubQueue.DEAD_LETTER
with shop.client() as sbc:
for queue in ["offers-expire", "offers-deadletter"]:
peeked = shop.waiting(sbc, queue) # lazy expiry
with sbc.get_queue_receiver(queue) as receiver:
got = receiver.receive_messages(max_wait_time=3)
with sbc.get_queue_receiver(queue, sub_queue=DLQ) as dlq:
dead = dlq.receive_messages(max_message_count=10,
max_wait_time=3)
for m in dead: dlq.complete_message(m)
print(queue, "| peek:", len(peeked), "| receive:", len(got),
"| dead-letter queue:", len(dead))
for m in dead[:1]:
print(" ", m.message_id, m.dead_letter_reason,
"| time_to_live", m.time_to_live)$ python send_offers.py
sent 3 offers to offers-expire with time_to_live 10 min
sent 3 offers to offers-deadletter with time_to_live 10 min
$ sleep 8
$ python check_offers.py
offers-expire | peek: 3 | receive: 0 | dead-letter queue: 0
offers-deadletter | peek: 3 | receive: 0 | dead-letter queue: 3
offer-1 TTLExpiredException | time_to_live 0:00:05A peek still lists 3 in each queue. That's documented: expiry is lazy, and expired messages can still show up in a peek. But a receive returns 0.
In offers-expire, they're simply gone: dead-letter queue: 0. In offers-deadletter, all three are in the dead-letter queue, with the reason TTLExpiredException, and the time to live shows 0:00:05, not 10 minutes.
Check the queue default first, then the sender. Turn on DeadLetteringOnMessageExpiration for anything you can't lose silently. And remember Basic caps the time to live at 14 days.
Question 4: A queue grows during a sale and consumers fall behind. How do you scale the consumers?
Add consumers to the same queue: the competing consumers pattern. Peek-lock gives each message to one consumer at a time, so we scale out without any coordination. In production, autoscale on queue length, with Azure Functions or a KEDA scaler.
Let's drop a sale into the sale-orders queue with python send_sale.py 300. Then consumers.py starts three consumers, each with its own client, that pull batches of 10 and complete each order after the work. We run python consumers.py 3.
import sys, shop
from azure.servicebus import ServiceBusMessage
count = int(sys.argv[1])
with shop.client() as sbc, sbc.get_queue_sender("sale-orders") as s:
s.send_messages([ServiceBusMessage(f"order {i}", message_id=str(i))
for i in range(1, count + 1)]) # the sale spike
print("sale-orders:", count, "orders queued")import collections, sys, threading, time, shop
handled = collections.defaultdict(list)
def consumer(name): # its own client and receiver
with shop.client() as sbc, sbc.get_queue_receiver(
"sale-orders") as receiver: # peek-lock
while batch := receiver.receive_messages(max_message_count=10,
max_wait_time=3):
for msg in batch:
time.sleep(0.02) # the work
handled[name].append(msg.message_id)
receiver.complete_message(msg)
n = int(sys.argv[1])
threads = [threading.Thread(target=consumer, args=(f"c{i}",))
for i in range(1, n + 1)]
for t in threads: t.start()
for t in threads: t.join()
ids = [i for got in handled.values() for i in got]
print(n, "consumers | consumers that got work:", len(handled))
print("handled:", len(ids), "| unique:", len(set(ids)))$ python send_sale.py 300
sale-orders: 300 orders queued
$ python consumers.py 3
3 consumers | consumers that got work: 3
handled: 300 | unique: 300consumers that got work: 3, and handled: 300 | unique: 300: no order was processed twice. The split between consumers depends on timing, so we print only totals.
Then tune each consumer:
- More consumers - on the same queue
- Concurrency and batch size - inside each consumer
- Prefetch - carefully: locks start in the buffer
- Limit - 5,000 concurrent receive requests per entity
- Premium - more messaging units when CPU or memory runs hot
Competing consumers don't keep order. If order matters per customer, we need sessions, which come later in this series.
Question 5: A teammate wants to look at messages without taking them. How does peek work?
Peek reads messages without locking or removing them. It returns a snapshot, starting from the lowest sequence number or one we pass in, and a peeked message can't be settled.
Let's look. peek.py puts five shipments in the queue, peeks three from sequence number 1, then three from sequence number 4, tries to complete a peeked message, and finally receives normally. We run python peek.py.
import shop
from azure.servicebus import ServiceBusMessage
with shop.client() as sbc:
with sbc.get_queue_sender("shipments") as sender:
sender.send_messages([ServiceBusMessage(f"shipment {i}")
for i in range(1, 6)])
with sbc.get_queue_receiver("shipments") as receiver:
first = receiver.peek_messages(3, sequence_number=1)
more = receiver.peek_messages(3, sequence_number=4)
for label, msgs in [("from 1", first), ("from 4", more)]:
print("peek 3", label + ":", [(m.sequence_number,
m.delivery_count) for m in msgs])
print("locked_until_utc:", first[0].locked_until_utc)
try:
receiver.complete_message(first[0])
except ValueError as e: # peeked: can't settle
print("ValueError:", str(e).split(".")[0])
got = receiver.receive_messages(max_message_count=5,
max_wait_time=5)
print("receive:", [(m.sequence_number, m.delivery_count)
for m in got])
for m in got: receiver.complete_message(m)$ python peek.py
peek 3 from 1: [(1, 0), (2, 0), (3, 0)]
peek 3 from 4: [(4, 0), (5, 0)]
locked_until_utc: None
ValueError: The operation complete is not supported for peeked messages
receive: [(1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]We get [(1, 0), (2, 0), (3, 0)], then [(4, 0), (5, 0)]. No lock is taken: locked_until_utc: None, and every delivery count stays 0.
Completing a peeked message fails right away: ValueError: The operation complete is not supported for peeked messages. A normal receive then gets all five, still with delivery count 0. The peek changed nothing.
Here is what a peek shows.
| Peek shows | |
|---|---|
| active and locked | yes |
| deferred | yes |
| dead-lettered | no: peek the DLQ |
Peek returns at most 250 messages per call. For dead-lettered messages, peek the dead-letter queue.
In this client, a peek without a sequence number continues after the last message that receiver saw, so pass one.
Question 6: Your manager asks how big a message can be and what to do with large payloads. What do you tell them?
On Basic and Standard, 256 KB for the whole message: body plus system and application properties. Premium allows up to 100 MB over AMQP, at a cost in throughput and latency.
| Tier | Max message (body + all properties) | Max batch |
|---|---|---|
| Basic | 256 KB | 256 KB |
| Standard | 256 KB | 256 KB |
| Premium | up to 100 MB over AMQP (default 1 MB per entity) | 1 MB |
The emulator has the 256 KB limit too. Let's send a 300 KB invoice document with python big.py.
import logging, re, shop
from azure.servicebus import ServiceBusMessage
from azure.servicebus.exceptions import MessageSizeExceededError
logging.getLogger("azure").setLevel(logging.CRITICAL)
document = "x" * 300_000 # a 300 KB invoice document
with shop.client() as sbc, sbc.get_queue_sender("documents") as s:
try:
s.send_messages(ServiceBusMessage(document))
except MessageSizeExceededError as e:
text = str(e).split(" TrackingId")[0] # drop run IDs
text = re.sub(r"delivery-id:\d+, ", "", text)
print(type(e).__name__ + ":")
print(text)$ python big.py
MessageSizeExceededError:
The received message (size:300064 bytes) exceeds the limit (262144 bytes) currently allowed on the link.The service rejects it: MessageSizeExceededError, exceeds the limit (262144 bytes) currently allowed on the link.
The fix is the claim check pattern. Store the payload outside, normally in Blob Storage, here a local folder standing in for it, and send a small message with the reference. The consumer fetches the document. We run python claim.py.
import json, os, shop
from azure.servicebus import ServiceBusMessage
document = "x" * 300_000
os.makedirs("blobs", exist_ok=True) # stands in for Blob Storage
path = "blobs/inv-7001.pdf"
with open(path, "w") as f: f.write(document) # 1. store it
claim = json.dumps({"invoice_id": "inv-7001", "blob": path})
with shop.client() as sbc:
with sbc.get_queue_sender("documents") as sender:
sender.send_messages(ServiceBusMessage(claim)) # 2. reference
with sbc.get_queue_receiver("documents") as receiver:
msg = receiver.receive_messages(max_wait_time=5)[0]
ref = json.loads(str(msg))
with open(ref["blob"]) as f: payload = f.read() # 3. fetch
body = str(msg)
print("message:", len(body.encode()), "bytes")
print(" ", body)
print("payload fetched:", len(payload.encode()), "bytes")
receiver.complete_message(msg)$ python claim.py
message: 56 bytes
{"invoice_id": "inv-7001", "blob": "blobs/inv-7001.pdf"}
payload fetched: 300000 bytesThe message is now 56 bytes, and the consumer fetched 300000 bytes of payload. Decide up front who deletes the payload once it's processed.
On Premium, we can raise a queue's max message size, here to 100 MB, given in kilobytes.
# Premium only: set one queue's max message size (in KB)
az servicebus queue create -g rg-shop --namespace-name sb-shop-prem \
-n documents --max-message-size 102400I still use the claim check. Large messages slow things down and can't be batched.
Scenario 7: A nightly batch drops fifty thousand invoice messages into a queue at once. How do you process them safely and quickly?
Send in batches, receive in batches with peek-lock, complete after the work, and make that work idempotent. For the demo, 2,000 invoices.
- Nightly job - batched sends
- nightly-invoices - queue
- Processors - competing, peek-lock
- Complete - after an idempotent write
First the sender. send_nightly.py fills a message batch and sends it when the next invoice doesn't fit. Every invoice gets a stable message ID, so duplicate detection can drop a resend. I cap each batch at 256,000 bytes, a little headroom under the limit. Then process_nightly.py receives up to 100 at a time and completes each invoice after the work. We run python send_nightly.py 2000 and python process_nightly.py.
import json, sys, shop
from azure.servicebus import ServiceBusMessage
from azure.servicebus.exceptions import MessageSizeExceededError
count = int(sys.argv[1]) # 50,000 in production
lines = [{"sku": f"SKU-{n}", "qty": 1} for n in range(12)]
LIMIT, sizes = 256000, [] # bytes: headroom under 256 KB
with shop.client() as sbc, \
sbc.get_queue_sender("nightly-invoices") as sender:
batch = sender.create_message_batch(max_size_in_bytes=LIMIT)
for i in range(1, count + 1):
msg = ServiceBusMessage(json.dumps(
{"invoice_id": f"inv-{i}", "lines": lines}),
message_id=f"inv-{i}") # a stable ID
try:
batch.add_message(msg)
except MessageSizeExceededError: # batch is full
sender.send_messages(batch); sizes.append(len(batch))
batch = sender.create_message_batch(max_size_in_bytes=LIMIT)
batch.add_message(msg)
sender.send_messages(batch); sizes.append(len(batch))
print("sent", sum(sizes), "invoices in", len(sizes), "batches:", sizes)import json, shop
done, seen = 0, set()
with shop.client() as sbc:
with sbc.get_queue_receiver("nightly-invoices") as receiver:
while batch := receiver.receive_messages(
max_message_count=100, max_wait_time=10):
for msg in batch:
invoice = json.loads(str(msg))
seen.add(invoice["invoice_id"]) # idempotent upsert
receiver.complete_message(msg) # after the work
done += 1
left = shop.waiting(sbc, "nightly-invoices")
print("processed:", done, "| unique invoices:", len(seen))
print("left in the queue:", len(left))$ python send_nightly.py 2000
sent 2000 invoices in 4 batches: [614, 612, 610, 164]
$ python process_nightly.py
processed: 2000 | unique invoices: 2000
left in the queue: 0The sender prints sent 2000 invoices in 4 batches: [614, 612, 610, 164]. The processor prints processed: 2000 | unique invoices: 2000 and left in the queue: 0. For fifty thousand, run several processors as competing consumers.
On Standard, throttling is credit based: 1,000 credits per second per namespace, one per message sent or received. Throttled requests are retried, not lost.
Three things to get right:
- Prefetch - locks expire in the buffer: delivery counts rise
- Long invoices - renew the lock (AutoLockRenewer)
- Idempotent write - keyed by invoice ID
A big prefetch with peek-lock lets locks expire in the buffer, raising delivery counts until good invoices are dead-lettered.
Key points to remember
- Send, lock, complete; abandon or lock expiry = redelivery, delivery count + 1
- Peek-lock = at least once, receive-and-delete = at most once
- The queue's default time to live is the ceiling; dead-letter on expiration to keep expired messages
- Scale with competing consumers; peek looks without locking
- 256 KB on Standard: claim check for big payloads, batches for big volumes
The full script
This is a Python script that writes every file and runs every command above in order against the Service Bus emulator: start the emulator with the Config.json above, run pip install azure-servicebus first, and set SERVICEBUS_CONNECTION_STRING.
Show the full script that writes the files and runs every step
# ========================================================================
# Service Bus Interview Q&A #2 - Queues and the message lifecycle
# TechBrothersIT. Names and sample data are invented for teaching.
# Every step below was run on the official Azure Service Bus emulator (Docker image, version 2.0.1) with
# SQL Server 2022 and azure-servicebus 7.15.0; the outputs in the video and the blog post are the real outputs.
# To run it yourself:
# 1. Start the emulator with Microsoft's installer (github.com/Azure/azure-service-bus-emulator-installer),
# using the Config.json this script writes first (Step 0) as the emulator's config file; it holds every
# queue, topic, subscription, rule and setting (lock duration, max delivery count, duplicate detection...)
# the steps depend on.
# 2. pip install azure-servicebus
# 3. Set SERVICEBUS_CONNECTION_STRING to the emulator connection string (Endpoint=sb://localhost;
# SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;)
# then run python ServiceBus_Interview_QA_02_Code.py.
# Run it in an empty folder and on a freshly started emulator: it creates its files (and any SQLite
# databases) there, and a second run on old files or old messages gives different output.
# Steps that restart the emulator say so in a comment: stop and start it by hand at that point.
# ========================================================================
import os, subprocess, time
def write(path, text):
if text is None:
if os.path.exists(path): os.remove(path)
return
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text.strip("\n") + "\n")
def run(cmd, expect_error=False, allow_error=False, env=None):
print("\n$ " + cmd, flush=True)
rc = subprocess.run(cmd, shell=True, env=dict(os.environ, **(env or {}))).returncode
if rc != 0 and not (expect_error or allow_error):
raise SystemExit("stopped: this command was expected to succeed")
# Step 0: the emulator config (mount it as ServiceBus_Emulator/ConfigFiles/Config.json, then start the emulator)
write('Config.json', """\
{
"UserConfig": {
"Namespaces": [
{
"Name": "sbemulatorns",
"Queues": [
{
"Name": "invoices",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "orders-rad",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT5S",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "orders-lock",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT5S",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "offers-expire",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT5S",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "offers-deadletter",
"Properties": {
"DeadLetteringOnMessageExpiration": true,
"DefaultMessageTimeToLive": "PT5S",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "sale-orders",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "shipments",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "documents",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
},
{
"Name": "nightly-invoices",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
}
],
"Topics": []
}
],
"Logging": {
"Type": "File"
}
}
}
""")
# Step 0: the starting files
write('shop.py', 'import os\nfrom azure.servicebus import ServiceBusClient\n\n\ndef client():\n return ServiceBusClient.from_connection_string(\n os.environ["SERVICEBUS_CONNECTION_STRING"])\n\n\ndef waiting(sbc, queue):\n """Peek from sequence number 1: no lock, nothing removed."""\n with sbc.get_queue_receiver(queue) as receiver:\n msgs = receiver.peek_messages(max_message_count=250,\n sequence_number=1)\n return [(m.message_id, m.delivery_count) for m in msgs]\n')
# Step 1: Send three invoices, lock them, complete two, abandon one and receive it again
write('lifecycle.py', """\
import shop
from azure.servicebus import ServiceBusMessage
with shop.client() as sbc:
with sbc.get_queue_sender("invoices") as sender: # 1. send
sender.send_messages([ServiceBusMessage(
f"invoice {i}", message_id=f"inv-{i}") for i in (1, 2, 3)])
with sbc.get_queue_receiver("invoices") as receiver: # peek-lock
msgs = receiver.receive_messages(max_message_count=3,
max_wait_time=5) # 2. lock
for m in msgs:
print("locked:", m.message_id, "| seq", m.sequence_number,
"| delivery_count", m.delivery_count)
print("peek while locked:", shop.waiting(sbc, "invoices"))
receiver.complete_message(msgs[0]) # 3. done
receiver.complete_message(msgs[1])
receiver.abandon_message(msgs[2]) # give back
print("after complete, complete, abandon:",
shop.waiting(sbc, "invoices"))
again = receiver.receive_messages(max_wait_time=5)[0]
print("redelivered:", again.message_id,
"| delivery_count", again.delivery_count)
receiver.complete_message(again)
print("finally:", shop.waiting(sbc, "invoices"))
""")
run('python lifecycle.py')
# Step 2: Receive-and-delete vs peek-lock: a consumer crashes before complete (LockDuration PT5S)
write('crash.py', """\
import os, sys, shop
from azure.servicebus import ServiceBusMessage, ServiceBusReceiveMode
mode = sys.argv[1] # "rad" or "lock"
queue = "orders-" + mode # both: LockDuration PT5S
receive_mode = (ServiceBusReceiveMode.RECEIVE_AND_DELETE
if mode == "rad" else ServiceBusReceiveMode.PEEK_LOCK)
sbc = shop.client()
with sbc.get_queue_sender(queue) as sender:
sender.send_messages(ServiceBusMessage("order 3001",
message_id="3001"))
receiver = sbc.get_queue_receiver(queue, receive_mode=receive_mode)
msg = receiver.receive_messages(max_wait_time=5)[0]
print(queue + ": got", msg.message_id, "then crashed before complete")
os._exit(1) # the process dies right here
""")
write('recover.py', """\
import shop
with shop.client() as sbc:
for queue in ["orders-rad", "orders-lock"]:
print(queue, "after the crash:", shop.waiting(sbc, queue))
with sbc.get_queue_receiver("orders-lock") as receiver:
for msg in receiver.receive_messages(max_wait_time=5):
print("orders-lock, new consumer:", msg.message_id,
"| delivery_count", msg.delivery_count)
receiver.complete_message(msg)
""")
run('python crash.py rad', allow_error=True)
run('python crash.py lock', allow_error=True)
run('sleep 7', allow_error=True)
run('python recover.py', allow_error=True)
# Step 3: Time to live: a 5 second queue default, with and without dead-lettering on expiration
write('send_offers.py', """\
import shop
from datetime import timedelta
from azure.servicebus import ServiceBusMessage
with shop.client() as sbc:
for queue in ["offers-expire", "offers-deadletter"]:
with sbc.get_queue_sender(queue) as sender:
sender.send_messages([ServiceBusMessage(
f"flash offer {i}", message_id=f"offer-{i}",
time_to_live=timedelta(minutes=10)) # asks 10 min
for i in (1, 2, 3)])
print("sent 3 offers to", queue, "with time_to_live 10 min")
""")
write('check_offers.py', """\
import shop
from azure.servicebus import ServiceBusSubQueue
DLQ = ServiceBusSubQueue.DEAD_LETTER
with shop.client() as sbc:
for queue in ["offers-expire", "offers-deadletter"]:
peeked = shop.waiting(sbc, queue) # lazy expiry
with sbc.get_queue_receiver(queue) as receiver:
got = receiver.receive_messages(max_wait_time=3)
with sbc.get_queue_receiver(queue, sub_queue=DLQ) as dlq:
dead = dlq.receive_messages(max_message_count=10,
max_wait_time=3)
for m in dead: dlq.complete_message(m)
print(queue, "| peek:", len(peeked), "| receive:", len(got),
"| dead-letter queue:", len(dead))
for m in dead[:1]:
print(" ", m.message_id, m.dead_letter_reason,
"| time_to_live", m.time_to_live)
""")
run('python send_offers.py')
run('sleep 8')
run('python check_offers.py')
# Step 4: Competing consumers: three consumers share a queue of 300 sale orders
write('send_sale.py', """\
import sys, shop
from azure.servicebus import ServiceBusMessage
count = int(sys.argv[1])
with shop.client() as sbc, sbc.get_queue_sender("sale-orders") as s:
s.send_messages([ServiceBusMessage(f"order {i}", message_id=str(i))
for i in range(1, count + 1)]) # the sale spike
print("sale-orders:", count, "orders queued")
""")
write('consumers.py', """\
import collections, sys, threading, time, shop
handled = collections.defaultdict(list)
def consumer(name): # its own client and receiver
with shop.client() as sbc, sbc.get_queue_receiver(
"sale-orders") as receiver: # peek-lock
while batch := receiver.receive_messages(max_message_count=10,
max_wait_time=3):
for msg in batch:
time.sleep(0.02) # the work
handled[name].append(msg.message_id)
receiver.complete_message(msg)
n = int(sys.argv[1])
threads = [threading.Thread(target=consumer, args=(f"c{i}",))
for i in range(1, n + 1)]
for t in threads: t.start()
for t in threads: t.join()
ids = [i for got in handled.values() for i in got]
print(n, "consumers | consumers that got work:", len(handled))
print("handled:", len(ids), "| unique:", len(set(ids)))
""")
run('python send_sale.py 300')
run('python consumers.py 3')
# Step 5: Peek from a sequence number: no lock, no delivery count change
write('peek.py', """\
import shop
from azure.servicebus import ServiceBusMessage
with shop.client() as sbc:
with sbc.get_queue_sender("shipments") as sender:
sender.send_messages([ServiceBusMessage(f"shipment {i}")
for i in range(1, 6)])
with sbc.get_queue_receiver("shipments") as receiver:
first = receiver.peek_messages(3, sequence_number=1)
more = receiver.peek_messages(3, sequence_number=4)
for label, msgs in [("from 1", first), ("from 4", more)]:
print("peek 3", label + ":", [(m.sequence_number,
m.delivery_count) for m in msgs])
print("locked_until_utc:", first[0].locked_until_utc)
try:
receiver.complete_message(first[0])
except ValueError as e: # peeked: can't settle
print("ValueError:", str(e).split(".")[0])
got = receiver.receive_messages(max_message_count=5,
max_wait_time=5)
print("receive:", [(m.sequence_number, m.delivery_count)
for m in got])
for m in got: receiver.complete_message(m)
""")
run('python peek.py')
# Step 6: A 300 KB message is rejected
write('big.py', 'import logging, re, shop\nfrom azure.servicebus import ServiceBusMessage\nfrom azure.servicebus.exceptions import MessageSizeExceededError\nlogging.getLogger("azure").setLevel(logging.CRITICAL)\n\ndocument = "x" * 300_000 # a 300 KB invoice document\nwith shop.client() as sbc, sbc.get_queue_sender("documents") as s:\n try:\n s.send_messages(ServiceBusMessage(document))\n except MessageSizeExceededError as e:\n text = str(e).split(" TrackingId")[0] # drop run IDs\n text = re.sub(r"delivery-id:\\d+, ", "", text)\n print(type(e).__name__ + ":")\n print(text)\n')
run('python big.py')
# Step 7: The claim check pattern: the payload in storage, a small reference message
write('claim.py', """\
import json, os, shop
from azure.servicebus import ServiceBusMessage
document = "x" * 300_000
os.makedirs("blobs", exist_ok=True) # stands in for Blob Storage
path = "blobs/inv-7001.pdf"
with open(path, "w") as f: f.write(document) # 1. store it
claim = json.dumps({"invoice_id": "inv-7001", "blob": path})
with shop.client() as sbc:
with sbc.get_queue_sender("documents") as sender:
sender.send_messages(ServiceBusMessage(claim)) # 2. reference
with sbc.get_queue_receiver("documents") as receiver:
msg = receiver.receive_messages(max_wait_time=5)[0]
ref = json.loads(str(msg))
with open(ref["blob"]) as f: payload = f.read() # 3. fetch
body = str(msg)
print("message:", len(body.encode()), "bytes")
print(" ", body)
print("payload fetched:", len(payload.encode()), "bytes")
receiver.complete_message(msg)
""")
run('python claim.py')
# Step 8: A nightly batch scaled down to 2,000 invoices: batched sends, batched peek-lock receives
write('send_nightly.py', 'import json, sys, shop\nfrom azure.servicebus import ServiceBusMessage\nfrom azure.servicebus.exceptions import MessageSizeExceededError\n\ncount = int(sys.argv[1]) # 50,000 in production\nlines = [{"sku": f"SKU-{n}", "qty": 1} for n in range(12)]\nLIMIT, sizes = 256000, [] # bytes: headroom under 256 KB\nwith shop.client() as sbc, \\\n sbc.get_queue_sender("nightly-invoices") as sender:\n batch = sender.create_message_batch(max_size_in_bytes=LIMIT)\n for i in range(1, count + 1):\n msg = ServiceBusMessage(json.dumps(\n {"invoice_id": f"inv-{i}", "lines": lines}),\n message_id=f"inv-{i}") # a stable ID\n try:\n batch.add_message(msg)\n except MessageSizeExceededError: # batch is full\n sender.send_messages(batch); sizes.append(len(batch))\n batch = sender.create_message_batch(max_size_in_bytes=LIMIT)\n batch.add_message(msg)\n sender.send_messages(batch); sizes.append(len(batch))\nprint("sent", sum(sizes), "invoices in", len(sizes), "batches:", sizes)\n')
write('process_nightly.py', """\
import json, shop
done, seen = 0, set()
with shop.client() as sbc:
with sbc.get_queue_receiver("nightly-invoices") as receiver:
while batch := receiver.receive_messages(
max_message_count=100, max_wait_time=10):
for msg in batch:
invoice = json.loads(str(msg))
seen.add(invoice["invoice_id"]) # idempotent upsert
receiver.complete_message(msg) # after the work
done += 1
left = shop.waiting(sbc, "nightly-invoices")
print("processed:", done, "| unique invoices:", len(seen))
print("left in the queue:", len(left))
""")
run('python send_nightly.py 2000')
run('python process_nightly.py')
Part 3: Topics, Subscriptions and Filters
The sample data on this page is invented for teaching. Every terminal output is the real output of the command above it, run on the official Azure Service Bus emulator (2.0.1) with SQL Server and azure-servicebus 7.15.0. Features the emulator does not support are shown as code. Part of the Real Time Azure Service Bus Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of October 2026; check the official Azure Service Bus documentation (learn.microsoft.com/azure/service-bus-messaging) for anything you quote in an interview.



No comments:
Post a Comment
Note: Only a member of this blog may post a comment.