Add the rabbitmq module
This commit is contained in:
143
rabbitmq/src/test/java/com/ankurm/rabbit/DeadLetterTest.java
Normal file
143
rabbitmq/src/test/java/com/ankurm/rabbit/DeadLetterTest.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
73
rabbitmq/src/test/java/com/ankurm/rabbit/MaxLengthTest.java
Normal file
73
rabbitmq/src/test/java/com/ankurm/rabbit/MaxLengthTest.java
Normal 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");
|
||||
}
|
||||
|
||||
}
|
||||
152
rabbitmq/src/test/java/com/ankurm/rabbit/RoutingTest.java
Normal file
152
rabbitmq/src/test/java/com/ankurm/rabbit/RoutingTest.java
Normal 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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 — 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"));
|
||||
}
|
||||
|
||||
}
|
||||
100
rabbitmq/src/test/java/com/ankurm/rabbit/TopologyTrapsTest.java
Normal file
100
rabbitmq/src/test/java/com/ankurm/rabbit/TopologyTrapsTest.java
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user