One running system, one RabbitMQ broker, and every core message-queuing concept demonstrated by a different piece of it. Use this alongside the class plan — point at a different service depending on which concept you're teaching.
# 1. Start RabbitMQ (broker used by every service)
docker-compose up -d
# 2. Confirm it's up - management UI at:
# http://localhost:15672 (user: guest / pass: guest)
# 3. Install Python deps
uv syncjump/
├── docker-compose.yml # single RabbitMQ broker, used by all services
├── common/
│ └── rabbitmq_config.py # shared connection + topology settings
├── order-service/ # PRODUCER (persistent msgs + publisher confirms)
├── email-service/ # CONSUMER - pub/sub + idempotency (at-least-once)
├── inventory-service/ # CONSUMER - pub/sub + retries + app-level DLQ
├── payment-service/ # CONSUMER - native DLX + basic_nack(requeue=False)
├── analytics-service/ # CONSUMER - pub/sub (simplest subscriber)
├── at-most-once-consumer/ # CONSUMER - auto_ack (message loss on crash)
├── image-worker/ # CONSUMER pool - work queue / point-to-point
├── routing-demo/ # topic + direct exchange routing
├── load_generator.py # floods the work queue - backpressure demo
├── worker_autoscaler.py # optional - scale image-workers from queue depth
└── message-queuing-explainer.ipynb
| Concept | Run this | What to point out |
|---|---|---|
| Producer/consumer basics | order-service + any one consumer |
One publishes, one receives — decoupled, don't block each other |
| Pub/sub | email-service, inventory-service, analytics-service together |
Each has its OWN queue bound to the same fanout exchange — all three get every event |
| Point-to-point / work queue | Two or more image-worker instances |
Jobs get round-robined — each job goes to exactly ONE worker, not all of them |
| At-least-once + idempotency | email-service |
Ctrl+C mid-"send", restart — same order redelivered; idempotency skips duplicates |
| At-most-once / message loss | at-most-once-consumer |
auto_ack=True — Ctrl+C mid-processing and the message is gone forever |
| App-level DLQ | inventory-service |
After retries, service republishes to inventory_dlq itself |
| Native DLX + nack | payment-service |
Queue has x-dead-letter-exchange; basic_nack(requeue=False) lets the broker route to payment_dlq |
| Persistence + confirms | order-service |
delivery_mode=2 + confirm_delivery() — durable queue alone is not enough |
| Topic / direct routing | routing-demo |
Same events, different binding keys — who receives what |
| Backpressure | One image-worker + load_generator.py |
Queue depth climbs in the management UI |
| Autoscaling | worker_autoscaler.py + load_generator.py |
Starts/stops image-worker processes from ready depth |
Open several terminals:
# Terminal 1 - analytics subscriber
cd analytics-service && python analytics_service.py
# Terminal 2 - email subscriber (idempotency demo target)
cd email-service && python email_service.py
# Terminal 3 - inventory subscriber (app-level DLQ)
cd inventory-service && python inventory_service.py
# Terminal 4 - payment subscriber (native DLX + nack)
cd payment-service && python payment_service.py
# Terminal 5 - one image worker
cd image-worker && python image_worker.py worker-1
# Terminal 6 - place some orders
cd order-service && python order_service.py 5Watch the pub/sub services print the same order events independently.
Watch image-worker pick up thumbnail jobs one at a time.
Watch some inventory/payment failures retry; exhausted ones land in
inventory_dlq (manual) vs payment_dlq (broker DLX).
Idempotency (at-least-once): while email-service is mid-send
(2s sleep), Ctrl+C, restart — unacked message is redelivered.
At-most-once contrast:
cd at-most-once-consumer && python at_most_once_consumer.py
# place an order, Ctrl+C during the 3s sleep, restart — that order never comes backTopic / direct routing:
# three terminals
cd routing-demo && python routing_consumer.py topic-all
cd routing-demo && python routing_consumer.py topic-us-ship
cd routing-demo && python routing_consumer.py direct-shipped-us
# then publish
cd routing-demo && python routing_publisher.pytopic-all (order.#) gets everything. topic-us-ship and
direct-shipped-us only get order.shipped.us.
Backpressure:
# with only worker-1 running
python load_generator.py 50Then start more workers, or use the autoscaler:
python worker_autoscaler.py
# other terminal:
python load_generator.py 50With manual ack (most services here):
- Broker delivers the message → it moves from Ready to Unacked.
- It stays reserved for that consumer until one of:
basic_ack→ deleted (done)basic_nack/rejectwithrequeue=True→ back to Readybasic_nack/rejectwithrequeue=False→ discarded or dead-lettered- consumer channel/connection closes (crash, Ctrl+C, network drop) → broker automatically requeues remaining unacked messages
- There is no “are you done yet?” poll. Redelivery is driven by ack/nack
or channel death. (RabbitMQ also has a long
consumer_timeout; if you never ack for a very long time the connection can be closed, which then requeues — still not a gentle retry timer.)
With auto_ack=True (at-most-once-consumer): the broker forgets the
message at delivery time. Crash mid-processing = permanent loss.
- Everything uses one RabbitMQ instance — patterns differ by how you set up exchanges/queues/consumers, not by spinning up extra brokers.
email-service's idempotency store is an in-memory set for demo only; production needs Redis / a DB unique constraint so it survives restarts.- Durable queue ≠ durable message. Persistence needs
delivery_mode=2(and usually publisher confirms) as well. inventory-servicevspayment-serviceis intentional: same problem (poison messages), two approaches (app republish vs broker DLX).- Kafka consumer-group comparison remains a natural stretch goal.