Add the rabbitmq module

This commit is contained in:
2026-08-29 09:58:34 +05:30
parent 3a682e496e
commit de9fc5ce4c
27 changed files with 1523 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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;
});
}
}

View File

@@ -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"));
}
}

View File

@@ -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();
}
}