Add the rabbitmq module

This commit is contained in:
2026-08-29 10:30:21 +05:30
parent 3a682e496e
commit de9fc5ce4c
27 changed files with 1523 additions and 0 deletions
@@ -0,0 +1,36 @@
package com.ankurm.rabbit;
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Spring Boot does <b>not</b> auto-configure a JSON message converter for RabbitMQ. The default
* is {@code SimpleMessageConverter}, which handles {@code String}, {@code byte[]} and
* {@code Serializable} and nothing else:
*
* <pre>
* IllegalArgumentException: SimpleMessageConverter only supports String, byte[] and
* Serializable payloads, received: com.ankurm.rabbit.OrderMessage
* </pre>
*
* <p>Declaring one {@code MessageConverter} bean fixes both directions &mdash; the auto-configured
* {@code RabbitTemplate} and the listener container factory both pick it up.
*
* <p>Note the class name. Spring AMQP 4.1 ships {@code Jackson2JsonMessageConverter} (Jackson 2)
* and {@code JacksonJsonMessageConverter} (Jackson 3) side by side, exactly as Spring Kafka
* ships {@code JsonSerializer} and {@code JacksonJsonSerializer}. Boot 4 is a Jackson 3
* application; pick the one without the 2.
*
* @see <a href="../../../../../docs/01-the-on-ramp.md">docs/01-the-on-ramp.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class ConverterConfiguration {
@Bean
MessageConverter messageConverter() {
return new JacksonJsonMessageConverter();
}
}
@@ -0,0 +1,13 @@
package com.ankurm.rabbit;
/**
* The payload. Kept to a String field so the tests are about routing and acknowledgement rather
* than about converters.
*/
public record OrderMessage(String orderId, String detail) {
public static OrderMessage of(String orderId) {
return new OrderMessage(orderId, "detail for " + orderId);
}
}
@@ -0,0 +1,20 @@
package com.ankurm.rabbit;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* All four AMQP 0-9-1 exchange types, a working dead-letter path, and the traps that make
* RabbitMQ feel unpredictable until you know them.
*
* <p>Everything here runs against a real broker. See the module README for how the scripts start
* one without Docker.
*/
@SpringBootApplication
public class RabbitDemoApplication {
public static void main(String[] args) {
SpringApplication.run(RabbitDemoApplication.class, args);
}
}
@@ -0,0 +1,217 @@
package com.ankurm.rabbit;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.FanoutExchange;
import org.springframework.amqp.core.HeadersExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
/**
* The whole topology as beans. {@code RabbitAdmin} declares every {@code Exchange},
* {@code Queue} and {@code Binding} bean when the connection is first opened.
*
* <p>Two things about that are worth knowing before you rely on it:
* <ul>
* <li>Declaration is <b>idempotent only if the arguments match</b>. Redeclaring an existing
* queue with different arguments is a channel-level {@code PRECONDITION_FAILED} &mdash; see
* docs/06-changing-your-mind.md.</li>
* <li>Declarations happen on connection, not on context refresh, so a topology error surfaces
* at the first publish rather than at startup.</li>
* </ul>
*
* @see <a href="../../../../../docs/02-exchanges.md">docs/02-exchanges.md</a>
*/
@Configuration(proxyBeanMethods = false)
public class Topology {
public static final String DIRECT = "orders.direct";
public static final String FANOUT = "orders.fanout";
public static final String TOPIC = "orders.topic";
public static final String HEADERS = "orders.headers";
public static final String DLX = "orders.dlx";
public static final String Q_NEW = "orders.new";
public static final String Q_CANCEL = "orders.cancel";
public static final String Q_AUDIT = "audit.all";
public static final String Q_ANALYTICS = "analytics.all";
public static final String Q_EU = "orders.eu";
public static final String Q_HIGH = "orders.high";
public static final String Q_PRIORITY = "orders.priority";
public static final String Q_ANY = "orders.any";
public static final String Q_WORK = "orders.work";
public static final String Q_TTL = "orders.ttl";
public static final String Q_DLQ = "orders.dlq";
// --- direct: routing key must match the binding key exactly -----------------------------
@Bean
DirectExchange directExchange() {
return new DirectExchange(DIRECT);
}
@Bean
Queue newQueue() {
return QueueBuilder.durable(Q_NEW).build();
}
@Bean
Queue cancelQueue() {
return QueueBuilder.durable(Q_CANCEL).build();
}
@Bean
Binding bindNew(DirectExchange directExchange, Queue newQueue) {
return BindingBuilder.bind(newQueue).to(directExchange).with("new");
}
@Bean
Binding bindCancel(DirectExchange directExchange, Queue cancelQueue) {
return BindingBuilder.bind(cancelQueue).to(directExchange).with("cancel");
}
// --- fanout: routing key ignored entirely -----------------------------------------------
@Bean
FanoutExchange fanoutExchange() {
return new FanoutExchange(FANOUT);
}
@Bean
Queue auditQueue() {
return QueueBuilder.durable(Q_AUDIT).build();
}
@Bean
Queue analyticsQueue() {
return QueueBuilder.durable(Q_ANALYTICS).build();
}
@Bean
Binding bindAudit(FanoutExchange fanoutExchange, Queue auditQueue) {
return BindingBuilder.bind(auditQueue).to(fanoutExchange);
}
@Bean
Binding bindAnalytics(FanoutExchange fanoutExchange, Queue analyticsQueue) {
return BindingBuilder.bind(analyticsQueue).to(fanoutExchange);
}
// --- topic: '*' is exactly one word, '#' is zero or more -------------------------------
@Bean
TopicExchange topicExchange() {
return new TopicExchange(TOPIC);
}
@Bean
Queue euQueue() {
return QueueBuilder.durable(Q_EU).build();
}
@Bean
Queue highQueue() {
return QueueBuilder.durable(Q_HIGH).build();
}
@Bean
Binding bindEu(TopicExchange topicExchange, Queue euQueue) {
return BindingBuilder.bind(euQueue).to(topicExchange).with("order.eu.*");
}
@Bean
Binding bindHigh(TopicExchange topicExchange, Queue highQueue) {
return BindingBuilder.bind(highQueue).to(topicExchange).with("order.#.high");
}
// --- headers: routing key ignored, header map matched ------------------------------------
@Bean
HeadersExchange headersExchange() {
return new HeadersExchange(HEADERS);
}
@Bean
Queue priorityQueue() {
return QueueBuilder.durable(Q_PRIORITY).build();
}
@Bean
Queue anyQueue() {
return QueueBuilder.durable(Q_ANY).build();
}
/** x-match=all: every header must match. */
@Bean
Binding bindPriority(HeadersExchange headersExchange, Queue priorityQueue) {
return BindingBuilder.bind(priorityQueue).to(headersExchange)
.whereAll(Map.of("priority", "high", "region", "eu")).match();
}
/** x-match=any: one is enough. */
@Bean
Binding bindAny(HeadersExchange headersExchange, Queue anyQueue) {
return BindingBuilder.bind(anyQueue).to(headersExchange)
.whereAny(Map.of("priority", "high", "region", "eu")).match();
}
// --- the dead-letter path ----------------------------------------------------------------
@Bean
DirectExchange deadLetterExchange() {
return new DirectExchange(DLX);
}
@Bean
Queue deadLetterQueue() {
// Deliberately NO dead-letter-exchange on the DLQ itself. Pointing a DLQ's DLX back at
// the exchange that feeds it is the classic infinite loop.
return QueueBuilder.durable(Q_DLQ).build();
}
@Bean
Binding bindDlq(DirectExchange deadLetterExchange, Queue deadLetterQueue) {
// The routing key used when dead-lettering is the message's ORIGINAL routing key unless
// deadLetterRoutingKey() overrides it. Both work queues below set it explicitly to
// "failed", so this one binding catches everything.
return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange).with("failed");
}
@Bean
Queue workQueue() {
return QueueBuilder.durable(Q_WORK)
.deadLetterExchange(DLX)
.deadLetterRoutingKey("failed")
.build();
}
@Bean
Queue ttlQueue() {
return QueueBuilder.durable(Q_TTL)
.ttl(1500)
.deadLetterExchange(DLX)
.deadLetterRoutingKey("failed")
.build();
}
}
@@ -0,0 +1,31 @@
spring:
application:
name: rabbitmq-exchanges-and-dlq
main:
banner-mode: off
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
# BOTH of these are required to find out about an unroutable message, and they are set in
# two different places. publisher-returns puts the connection factory into a mode where it
# listens for returns at all; mandatory asks the broker to return this particular publish.
# Setting only mandatory - which is what most examples show - changes nothing at all.
# See docs/03-the-silent-drop.md.
publisher-returns: true
template:
mandatory: true
listener:
simple:
# MANUAL means nothing is removed from the queue until your code says so - and that
# basicNack(requeue=false) is what feeds the dead-letter exchange.
acknowledge-mode: manual
prefetch: 1
# Listener containers are not started automatically here; each test starts the one it
# needs, so that a redelivery loop in one test cannot consume another test's messages.
auto-startup: false
logging:
level:
root: WARN
com.ankurm: INFO
@@ -0,0 +1,143 @@
package com.ankurm.rabbit;
import com.rabbitmq.client.GetResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The dead-letter path, driven with the AMQP primitives directly rather than through a listener
* container. {@code basicGet} + {@code basicNack} is what a container does underneath, and doing
* it by hand removes all the timing from the test.
*
* @see <a href="../../../../../docs/04-dead-lettering.md">docs/04-dead-lettering.md</a>
*/
@SpringBootTest
class DeadLetterTest {
@Autowired
RabbitTemplate template;
@Autowired
RabbitAdmin admin;
@BeforeEach
void drain() {
List.of(Topology.Q_WORK, Topology.Q_TTL, Topology.Q_DLQ)
.forEach((q) -> this.admin.purgeQueue(q, false));
}
private int depth(String queue) {
Properties properties = this.admin.getQueueProperties(queue);
return (properties == null) ? -1
: ((Number) properties.get(RabbitAdmin.QUEUE_MESSAGE_COUNT)).intValue();
}
private int settled(String queue, int expected) throws InterruptedException {
for (int i = 0; i < 150; i++) {
if (depth(queue) == expected) {
return expected;
}
Thread.sleep(20);
}
return depth(queue);
}
@Test
void nackWithoutRequeueDeadLettersAndStampsTheReason() throws Exception {
this.template.convertAndSend(Topology.DIRECT, "ignored", OrderMessage.of("o-1"),
(m) -> m, null);
// Publish straight to the work queue via the default exchange: the empty exchange name
// routes by queue name, which is the one piece of AMQP that behaves like a magic
// constant and is worth knowing.
this.template.convertAndSend("", Topology.Q_WORK, OrderMessage.of("o-1"));
assertThat(settled(Topology.Q_WORK, 1)).isEqualTo(1);
this.template.execute((channel) -> {
GetResponse response = channel.basicGet(Topology.Q_WORK, false);
assertThat(response).isNotNull();
// requeue=false is the entire dead-letter trigger. There is no separate "send to
// DLQ" call in AMQP.
channel.basicNack(response.getEnvelope().getDeliveryTag(), false, false);
return null;
});
assertThat(settled(Topology.Q_DLQ, 1)).isEqualTo(1);
assertThat(depth(Topology.Q_WORK)).isZero();
Map<String, Object> death = firstDeath(Topology.Q_DLQ);
System.out.println("=== x-death after basicNack(requeue=false) ===");
death.forEach((k, v) -> System.out.printf(" %-16s %s%n", k, v));
assertThat(death).containsEntry("reason", "rejected");
assertThat(death).containsEntry("queue", Topology.Q_WORK);
assertThat(((Number) death.get("count")).intValue()).isEqualTo(1);
}
@Test
void messageTtlExpiryAlsoDeadLettersButWithADifferentReason() throws Exception {
this.template.convertAndSend("", Topology.Q_TTL, OrderMessage.of("o-ttl"));
// orders.ttl carries x-message-ttl=1500. Nobody consumes it; the broker expires it.
assertThat(settled(Topology.Q_DLQ, 1)).isEqualTo(1);
Map<String, Object> death = firstDeath(Topology.Q_DLQ);
System.out.println("=== x-death after x-message-ttl expiry ===");
death.forEach((k, v) -> System.out.printf(" %-16s %s%n", k, v));
// Same destination, different reason. This is the field that tells an operator whether
// the consumer rejected the work or never got to it.
assertThat(death).containsEntry("reason", "expired");
assertThat(death).containsEntry("queue", Topology.Q_TTL);
}
@Test
void nackWithRequeueIsAnInfiniteLoopAndNeverDeadLetters() throws Exception {
this.template.convertAndSend("", Topology.Q_WORK, OrderMessage.of("o-loop"));
assertThat(settled(Topology.Q_WORK, 1)).isEqualTo(1);
int redeliveries = this.template.execute((channel) -> {
int count = 0;
for (int i = 0; i < 200; i++) {
GetResponse response = channel.basicGet(Topology.Q_WORK, false);
if (response == null) {
break;
}
if (response.getEnvelope().isRedeliver()) {
count++;
}
// requeue=true puts it back. The dead-letter exchange is never consulted,
// x-death is never written, and the loop has no counter to exhaust.
channel.basicNack(response.getEnvelope().getDeliveryTag(), false, true);
}
return count;
});
System.out.println("=== basicNack(requeue=true), 200 attempts ===");
System.out.println(" redelivered " + redeliveries + " times");
System.out.println(" dead-lettered " + depth(Topology.Q_DLQ));
System.out.println(" still on queue " + depth(Topology.Q_WORK));
assertThat(redeliveries).isGreaterThan(150);
// The message is exactly where it started, having been processed 200 times.
assertThat(depth(Topology.Q_DLQ)).isZero();
assertThat(settled(Topology.Q_WORK, 1)).isEqualTo(1);
}
@SuppressWarnings("unchecked")
private Map<String, Object> firstDeath(String queue) {
var message = this.template.receive(queue, 5000);
assertThat(message).isNotNull();
List<Map<String, Object>> deaths =
(List<Map<String, Object>>) message.getMessageProperties().getHeader("x-death");
assertThat(deaths).isNotNull().isNotEmpty();
return deaths.get(0);
}
}
@@ -0,0 +1,73 @@
package com.ankurm.rabbit;
import com.rabbitmq.client.GetResponse;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The third dead-letter trigger: a queue that is full.
*
* <p>Included because the article states all three triggers in one table, and this was the only
* row not produced by a run. It is now.
*
* @see <a href="../../../../../docs/04-dead-lettering.md">docs/04-dead-lettering.md</a>
*/
@SpringBootTest
class MaxLengthTest {
static final String Q_BOUNDED = "orders.bounded";
@Autowired
RabbitTemplate template;
@Autowired
RabbitAdmin admin;
@Test
@SuppressWarnings("unchecked")
void exceedingMaxLengthDeadLettersTheOldestWithReasonMaxlen() throws Exception {
this.admin.deleteQueue(Q_BOUNDED);
Queue bounded = QueueBuilder.durable(Q_BOUNDED)
.maxLength(2)
.deadLetterExchange(Topology.DLX)
.deadLetterRoutingKey("failed")
.build();
this.admin.declareQueue(bounded);
Binding binding = BindingBuilder.bind(bounded)
.to(new DirectExchange(Topology.DIRECT)).with("bounded");
this.admin.declareBinding(binding);
this.admin.purgeQueue(Topology.Q_DLQ, false);
// Three messages into a queue that holds two. RabbitMQ drops from the HEAD, so the
// FIRST message is the one dead-lettered - the oldest, not the newest.
for (String id : List.of("m-1", "m-2", "m-3")) {
this.template.convertAndSend(Topology.DIRECT, "bounded", OrderMessage.of(id));
}
var message = this.template.receive(Topology.Q_DLQ, 10_000);
assertThat(message).isNotNull();
List<Map<String, Object>> deaths =
(List<Map<String, Object>>) message.getMessageProperties().getHeader("x-death");
System.out.println("=== x-death after x-max-length overflow ===");
deaths.get(0).forEach((k, v) -> System.out.printf(" %-16s %s%n", k, v));
System.out.println(" body " + new String(message.getBody()));
assertThat(deaths.get(0)).containsEntry("reason", "maxlen");
assertThat(deaths.get(0)).containsEntry("queue", Q_BOUNDED);
assertThat(new String(message.getBody())).contains("m-1");
}
}
@@ -0,0 +1,152 @@
package com.ankurm.rabbit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
/**
* All four exchange types, driven with real publishes and counted by draining the queues.
*
* @see <a href="../../../../../docs/02-exchanges.md">docs/02-exchanges.md</a>
*/
@SpringBootTest
class RoutingTest {
@Autowired
RabbitTemplate template;
@Autowired
RabbitAdmin admin;
private static final List<String> QUEUES = List.of(Topology.Q_NEW, Topology.Q_CANCEL,
Topology.Q_AUDIT, Topology.Q_ANALYTICS, Topology.Q_EU, Topology.Q_HIGH,
Topology.Q_PRIORITY, Topology.Q_ANY);
@BeforeEach
void drain() {
QUEUES.forEach((q) -> this.admin.purgeQueue(q, false));
}
/**
* Ready message count, straight from the broker.
*
* <p>Note the {@code Number}: {@code RabbitAdmin.QUEUE_MESSAGE_COUNT} holds a {@code Long}
* in Spring AMQP 4.1. Casting it to {@code Integer}, as every older example does, is a
* {@code ClassCastException} at runtime.
*/
private int depth(String queue) {
Properties properties = this.admin.getQueueProperties(queue);
return (properties == null) ? -1
: ((Number) properties.get(RabbitAdmin.QUEUE_MESSAGE_COUNT)).intValue();
}
/** Publishing is asynchronous; give the broker a moment to route before counting. */
private int settledDepth(String queue) {
int last = -1;
for (int i = 0; i < 50; i++) {
last = depth(queue);
if (last > 0) {
return last;
}
try {
Thread.sleep(20);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
return last;
}
@Test
void directExchangeMatchesTheRoutingKeyExactly() {
this.template.convertAndSend(Topology.DIRECT, "new", OrderMessage.of("o-1"));
this.template.convertAndSend(Topology.DIRECT, "cancel", OrderMessage.of("o-2"));
// No binding for "amend". The broker discards it - see UnroutableTest.
this.template.convertAndSend(Topology.DIRECT, "amend", OrderMessage.of("o-3"));
assertThat(settledDepth(Topology.Q_NEW)).isEqualTo(1);
assertThat(settledDepth(Topology.Q_CANCEL)).isEqualTo(1);
}
@Test
void fanoutIgnoresTheRoutingKeyCompletely() {
this.template.convertAndSend(Topology.FANOUT, "this-is-ignored", OrderMessage.of("o-1"));
assertThat(settledDepth(Topology.Q_AUDIT)).isEqualTo(1);
assertThat(settledDepth(Topology.Q_ANALYTICS)).isEqualTo(1);
}
@Test
void topicWildcardsAreWordsNotCharacters() {
// binding "order.eu.*" - exactly one word after order.eu
// binding "order.#.high" - zero or more words between order. and .high
Map<String, String> routingKeys = new LinkedHashMap<>();
routingKeys.put("order.eu.high", "matches both");
routingKeys.put("order.eu.low", "matches order.eu.* only");
routingKeys.put("order.us.high", "matches order.#.high only");
routingKeys.put("order.eu.west.high", "matches order.#.high only - * is one word");
routingKeys.put("order.high", "matches order.#.high - # can be zero words");
System.out.println("=== topic exchange ===");
System.out.printf("%-24s %-12s %-12s %s%n", "routing key", "orders.eu", "orders.high", "note");
for (Map.Entry<String, String> entry : routingKeys.entrySet()) {
this.admin.purgeQueue(Topology.Q_EU, false);
this.admin.purgeQueue(Topology.Q_HIGH, false);
this.template.convertAndSend(Topology.TOPIC, entry.getKey(), OrderMessage.of("o"));
System.out.printf("%-24s %-12s %-12s %s%n", entry.getKey(), settledDepth(Topology.Q_EU) == 1,
settledDepth(Topology.Q_HIGH) == 1, entry.getValue());
}
this.admin.purgeQueue(Topology.Q_EU, false);
this.admin.purgeQueue(Topology.Q_HIGH, false);
// The one everybody gets wrong: '*' is one WORD, so it does not match two.
this.template.convertAndSend(Topology.TOPIC, "order.eu.west.high", OrderMessage.of("o"));
assertThat(depth(Topology.Q_EU)).isEqualTo(0);
assertThat(settledDepth(Topology.Q_HIGH)).isEqualTo(1);
this.admin.purgeQueue(Topology.Q_HIGH, false);
// And '#' really does match zero words.
this.template.convertAndSend(Topology.TOPIC, "order.high", OrderMessage.of("o"));
assertThat(settledDepth(Topology.Q_HIGH)).isEqualTo(1);
}
@Test
void headersExchangeMatchesTheHeaderMapNotTheRoutingKey() {
send(Map.of("priority", "high", "region", "eu"));
assertThat(settledDepth(Topology.Q_PRIORITY)).isEqualTo(1); // x-match=all: both present
assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1); // x-match=any: either is enough
drain();
send(Map.of("priority", "high"));
assertThat(depth(Topology.Q_PRIORITY)).isEqualTo(0); // all: region missing
assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1);
drain();
send(Map.of("priority", "high", "region", "us"));
// x-match=all needs every header to match by VALUE, not merely to be present.
assertThat(depth(Topology.Q_PRIORITY)).isEqualTo(0);
assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1);
}
private void send(Map<String, Object> headers) {
this.template.convertAndSend(Topology.HEADERS, "routing-key-is-ignored",
OrderMessage.of("o-1"), (message) -> {
MessageProperties properties = message.getMessageProperties();
headers.forEach(properties::setHeader);
return message;
});
}
}
@@ -0,0 +1,33 @@
package com.ankurm.rabbit;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.rabbitmq.RabbitMQContainer;
import org.testcontainers.utility.DockerImageName;
/**
* The Testcontainers route, for machines that have a Docker daemon.
*
* <p>{@code @ServiceConnection} supplies host, port, username and password to the
* auto-configuration, so no {@code spring.rabbitmq.*} property and no
* {@code @DynamicPropertySource} block is needed.
*
* <p>The Maven coordinate is <b>{@code org.testcontainers:testcontainers-rabbitmq}</b>.
* Testcontainers 2.x prefixed every module artifact; the old {@code org.testcontainers:rabbitmq}
* stopped at 1.21.4 and is not managed by the Boot 4.1 BOM.
*
* <p>The committed transcripts under {@code docs/output/} came from a broker started by
* {@code scripts/broker.sh} instead, on a machine with no Docker &mdash; see the module README
* for why that matters and what version it was.
*/
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
RabbitMQContainer rabbitContainer() {
return new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.1-management"));
}
}
@@ -0,0 +1,100 @@
package com.ankurm.rabbit;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.AmqpIOException;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Two ways a RabbitMQ topology loses your work quietly.
*
* @see <a href="../../../../../docs/03-the-silent-drop.md">docs/03-the-silent-drop.md</a>
* @see <a href="../../../../../docs/06-changing-your-mind.md">docs/06-changing-your-mind.md</a>
*/
@SpringBootTest
class TopologyTrapsTest {
@Autowired
RabbitTemplate template;
@Autowired
RabbitAdmin admin;
@Test
void anUnroutableMessageIsDiscardedUnlessYouAskForItBack() throws Exception {
List<ReturnedMessage> returned = new CopyOnWriteArrayList<>();
this.template.setReturnsCallback(returned::add);
// "amend" matches no binding on orders.direct. The broker has nowhere to put it.
this.template.convertAndSend(Topology.DIRECT, "amend", OrderMessage.of("o-1"));
for (int i = 0; i < 100 && returned.isEmpty(); i++) {
Thread.sleep(20);
}
// spring.rabbitmq.template.mandatory=true is what turns a silent discard into a return.
assertThat(this.template.isMandatoryFor(null)).isTrue();
assertThat(returned).hasSize(1);
ReturnedMessage message = returned.get(0);
System.out.println("=== returned message ===");
System.out.println(" replyCode " + message.getReplyCode());
System.out.println(" replyText " + message.getReplyText());
System.out.println(" exchange " + message.getExchange());
System.out.println(" routingKey " + message.getRoutingKey());
assertThat(message.getReplyCode()).isEqualTo(312);
assertThat(message.getReplyText()).isEqualTo("NO_ROUTE");
assertThat(message.getRoutingKey()).isEqualTo("amend");
// Note what did NOT happen: convertAndSend returned normally. Publishing is fire and
// forget at the protocol level, so even with mandatory=true the failure arrives
// asynchronously on another thread. Nothing throws.
}
@Test
void redeclaringAQueueWithDifferentArgumentsIsAPreconditionFailure() {
// orders.ttl already exists with x-message-ttl=1500. Same name, different argument.
Queue conflicting = QueueBuilder.durable(Topology.Q_TTL)
.ttl(9999)
.deadLetterExchange(Topology.DLX)
.deadLetterRoutingKey("failed")
.build();
assertThatExceptionOfType(AmqpIOException.class)
.isThrownBy(() -> this.admin.declareQueue(conflicting))
.satisfies((ex) -> {
String detail = rootMessage(ex);
System.out.println("=== redeclaring orders.ttl with x-message-ttl=9999 ===");
System.out.println(" " + detail);
assertThat(detail).contains("PRECONDITION_FAILED")
.contains("inequivalent arg 'x-message-ttl'");
});
// Queue arguments are immutable. There is no ALTER QUEUE. Changing a TTL, a max-length
// or a dead-letter exchange on an existing queue means: declare a new queue, move the
// consumers, drain the old one, delete it. Plan the rename into the change.
}
private static String rootMessage(Throwable throwable) {
// The useful text is on the cause. AmqpIOException's own message is just
// "java.io.IOException", which is why this failure is so often reported as "IOException"
// with no further detail.
Throwable current = throwable;
StringBuilder all = new StringBuilder();
while (current != null) {
all.append(current.getMessage()).append(" | ");
current = current.getCause();
}
return all.toString();
}
}