Service Bus Interview Questions and Answers Part 2 - Queues and the Message Lifecycle

Service Bus Interview Q&A · Part 2 of 10

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

Say this first

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.

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"
    }
  }
}
shop.py
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.

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"))
Terminal
$ 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 settingDefault
Lock duration1 minute (max 5)
Max delivery count10, then the DLQ
Real project

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-deletePeek-lock (default)
Removedwhen handed to the consumerwhen the consumer completes it
Guaranteeat most onceat least once
Consumer crashesthe message is lostback after the lock expires
Abandon, defer, dead-letternot possibleyes

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.

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
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)
Terminal
$ 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 1

Both 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.

The catch with peek-lock

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.

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")
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)
Terminal
$ 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:05

A 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.

When messages vanish

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.

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")
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)))
Terminal
$ python send_sale.py 300
sale-orders: 300 orders queued
$ python consumers.py 3
3 consumers | consumers that got work: 3
handled: 300 | unique: 300

consumers 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
The trade-off

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.

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)
Terminal
$ 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 lockedyes
deferredyes
dead-letteredno: peek the DLQ

Peek returns at most 250 messages per call. For dead-lettered messages, peek the dead-letter queue.

Python detail

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.

TierMax message (body + all properties)Max batch
Basic256 KB256 KB
Standard256 KB256 KB
Premiumup 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.

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)
Terminal
$ 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.

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)
Terminal
$ python claim.py
message: 56 bytes
  {"invoice_id": "inv-7001", "blob": "blobs/inv-7001.pdf"}
payload fetched: 300000 bytes

The 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.sh
# 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 102400
My preference

I 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.

send_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)
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))
Terminal
$ 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: 0

The 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
The prefetch trap

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
Full script - writes the files and runs every step against the Service Bus emulator (pip install azure-servicebus), run it with Python
# ========================================================================
# 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')
Next in this series

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.