// Copyright (c) 2007-Present Pivotal Software, Inc. All rights reserved.
//
// This software, the RabbitMQ Java client library, is triple-licensed under the
// Mozilla Public License 1.1 ("MPL"), the GNU General Public License version 2
// ("GPL") and the Apache License version 2 ("ASL"). For the MPL, please see
// LICENSE-MPL-RabbitMQ. For the GPL, please see LICENSE-GPL2. For the ASL,
// please see LICENSE-APACHE2.
//
// This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND,
// either express or implied. See the LICENSE file for specific language governing
// rights and limitations of this software.
//
// If you have any questions regarding licensing, please contact us at
// info@rabbitmq.com.
package com.rabbitmq.client.test.functional;
import com.rabbitmq.client.*;
import com.rabbitmq.client.AMQP.BasicProperties;
import com.rabbitmq.client.test.BrokerTestCase;
import org.junit.Test;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.Assert.*;
public class DeadLetterExchange extends BrokerTestCase {
public static final String DLX = "dead.letter.exchange";
public static final String DLX_ARG = "x-dead-letter-exchange";
public static final String DLX_RK_ARG = "x-dead-letter-routing-key";
public static final String TEST_QUEUE_NAME = "test.queue.dead.letter";
public static final String DLQ = "queue.dlq";
public static final String DLQ2 = "queue.dlq2";
public static final int MSG_COUNT = 10;
public static final int TTL = 1000;
@Override
protected void createResources() throws IOException {
channel.exchangeDelete(DLX);
channel.queueDelete(DLQ);
channel.exchangeDeclare(DLX, "direct");
channel.queueDeclare(DLQ, false, true, false, null);
}
@Override
protected void releaseResources() throws IOException {
channel.exchangeDelete(DLX);
}
@Test public void declareQueueWithExistingDeadLetterExchange()
throws IOException
{
declareQueue(DLX);
}
@Test public void declareQueueWithNonExistingDeadLetterExchange()
throws IOException
{
declareQueue("some.random.exchange.name");
}
@Test public void declareQueueWithEquivalentDeadLetterExchange()
throws IOException
{
declareQueue(DLX);
declareQueue(DLX);
}
@Test public void declareQueueWithEquivalentDeadLetterRoutingKey()
throws IOException
{
declareQueue(TEST_QUEUE_NAME, DLX, "routing_key", null);
declareQueue(TEST_QUEUE_NAME, DLX, "routing_key", null);
}
@Test public void declareQueueWithInvalidDeadLetterExchangeArg()
throws IOException
{
try {
declareQueue(133);
fail("x-dead-letter-exchange must be a valid exchange name");
} catch (IOException ex) {
checkShutdownSignal(AMQP.PRECONDITION_FAILED, ex);
}
}
@Test public void redeclareQueueWithInvalidDeadLetterExchangeArg()
throws IOException
{
declareQueue("inequivalent_dlx_name", "dlx_foo", null, null);
try {
declareQueue("inequivalent_dlx_name", "dlx_bar", null, null);
fail("x-dead-letter-exchange must be a valid exchange name " +
"and must not change in subsequent declarations");
} catch (IOException ex) {
checkShutdownSignal(AMQP.PRECONDITION_FAILED, ex);
}
}
@Test public void declareQueueWithInvalidDeadLetterRoutingKeyArg()
throws IOException
{
try {
declareQueue("foo", "amq.direct", 144, null);
fail("x-dead-letter-routing-key must be a string");
} catch (IOException ex) {
checkShutdownSignal(AMQP.PRECONDITION_FAILED, ex);
}
}
@Test public void redeclareQueueWithInvalidDeadLetterRoutingKeyArg()
throws IOException
{
declareQueue("inequivalent_dlx_rk", "amq.direct", "dlx_rk", null);
try {
declareQueue("inequivalent_dlx_rk", "amq.direct", "dlx_rk2", null);
fail("x-dead-letter-routing-key must be a string and must not " +
"change in subsequent declarations");
} catch (IOException ex) {
checkShutdownSignal(AMQP.PRECONDITION_FAILED, ex);
}
}
@Test public void declareQueueWithRoutingKeyButNoDeadLetterExchange()
throws IOException
{
try {
Map<String, Object> args = new HashMap<String, Object>();
args.put(DLX_RK_ARG, "foo");
channel.queueDeclare(randomQueueName(), false, true, false, args);
fail("dlx must be defined if dl-rk is set");
} catch (IOException ex) {
checkShutdownSignal(AMQP.PRECONDITION_FAILED, ex);
}
}
@Test public void redeclareQueueWithRoutingKeyButNoDeadLetterExchange()
throws IOException, InterruptedException {
try {
String queueName = randomQueueName();
Map<String, Object> args = new HashMap<String, Object>();
channel.queueDeclare(queueName, false, true, false, args);
args.put(DLX_RK_ARG, "foo");
channel.queueDeclare(queueName, false, true, false, args);
fail("x-dead-letter-exchange must be specified if " +
"x-dead-letter-routing-key is set");
} catch (IOException ex) {
checkShutdownSignal(AMQP.PRECONDITION_FAILED, ex);
}
}
@Test public void deadLetterQueueTTLExpiredMessages() throws Exception {
ttlTest(TTL);
}
@Test public void deadLetterQueueZeroTTLExpiredMessages() throws Exception {
ttlTest(0);
}
@Test public void deadLetterQueueTTLPromptExpiry() throws Exception {
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", TTL);
declareQueue(TEST_QUEUE_NAME, DLX, null, args);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
//measure round-trip latency
QueueMessageConsumer c = new QueueMessageConsumer(channel);
String cTag = channel.basicConsume(TEST_QUEUE_NAME, true, c);
long start = System.currentTimeMillis();
publish(null, "test");
byte[] body = c.nextDelivery(TTL);
long stop = System.currentTimeMillis();
assertNotNull(body);
channel.basicCancel(cTag);
long latency = stop-start;
channel.basicConsume(DLQ, true, c);
// publish messages at regular intervals until currentTime +
// 3/4th of TTL
int count = 0;
start = System.currentTimeMillis();
stop = start + TTL * 3 / 4;
long now = start;
while (now < stop) {
publish(null, Long.toString(now));
count++;
Thread.sleep(TTL / 100);
now = System.currentTimeMillis();
}
checkPromptArrival(c, count, latency);
start = System.currentTimeMillis();
// publish message - which kicks off the queue's ttl timer -
// and immediately fetch it in noack mode
publishAt(start);
basicGet(TEST_QUEUE_NAME);
// publish a 2nd message and immediately fetch it in ack mode
publishAt(start + TTL * 1 / 2);
GetResponse r = channel.basicGet(TEST_QUEUE_NAME, false);
// publish a 3rd message
publishAt(start + TTL * 3 / 4);
// reject 2nd message after the initial timer has fired but
// before the message is due to expire
waitUntil(start + TTL * 5 / 4);
channel.basicReject(r.getEnvelope().getDeliveryTag(), true);
checkPromptArrival(c, 2, latency);
}
@Test public void deadLetterDeletedDLX() throws Exception {
declareQueue(TEST_QUEUE_NAME, DLX, null, null, 1);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
channel.exchangeDelete(DLX);
publishN(MSG_COUNT);
sleep(100);
consumeN(DLQ, 0, WithResponse.NULL);
channel.exchangeDeclare(DLX, "direct");
channel.queueBind(DLQ, DLX, "test");
publishN(MSG_COUNT);
sleep(100);
consumeN(DLQ, MSG_COUNT, WithResponse.NULL);
}
@Test public void deadLetterPerMessageTTLRemoved() throws Exception {
declareQueue(TEST_QUEUE_NAME, DLX, null, null, 1);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
final BasicProperties props = MessageProperties.BASIC.builder().expiration("100").build();
publish(props, "test message");
// The message's expiration property should have been removed, thus
// after 100ms of hitting the queue, the message should get routed to
// the DLQ *AND* should remain there, not getting removed after a subsequent
// wait time > 100ms
sleep(500);
consumeN(DLQ, 1, new WithResponse() {
@SuppressWarnings("unchecked")
public void process(GetResponse getResponse) {
assertNull(getResponse.getProps().getExpiration());
Map<String, Object> headers = getResponse.getProps().getHeaders();
assertNotNull(headers);
ArrayList<Object> death = (ArrayList<Object>)headers.get("x-death");
assertNotNull(death);
assertDeathReason(death, 0, TEST_QUEUE_NAME, "expired");
final Map<String, Object> deathHeader =
(Map<String, Object>)death.get(0);
assertEquals("100", deathHeader.get("original-expiration").toString());
}
});
}
@Test public void deadLetterOnReject() throws Exception {
rejectionTest(false);
}
@Test public void deadLetterOnNack() throws Exception {
rejectionTest(true);
}
@Test public void deadLetterNoDeadLetterQueue() throws IOException {
channel.queueDelete(DLQ);
declareQueue(TEST_QUEUE_NAME, DLX, null, null, 1);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
publishN(MSG_COUNT);
}
@Test public void deadLetterMultipleDeadLetterQueues()
throws IOException
{
declareQueue(TEST_QUEUE_NAME, DLX, null, null, 1);
channel.queueDeclare(DLQ2, false, true, false, null);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
channel.queueBind(DLQ2, DLX, "test");
publishN(MSG_COUNT);
}
@Test public void deadLetterTwice() throws Exception {
declareQueue(TEST_QUEUE_NAME, DLX, null, null, 1);
channel.queueDelete(DLQ);
declareQueue(DLQ, DLX, null, null, 1);
channel.queueDeclare(DLQ2, false, true, false, null);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
channel.queueBind(DLQ2, DLX, "test");
publishN(MSG_COUNT);
sleep(100);
// There should now be two copies of each message on DLQ2: one
// with one set of death headers, and another with two sets.
consumeN(DLQ2, MSG_COUNT*2, new WithResponse() {
@SuppressWarnings("unchecked")
public void process(GetResponse getResponse) {
Map<String, Object> headers = getResponse.getProps().getHeaders();
assertNotNull(headers);
ArrayList<Object> death = (ArrayList<Object>)headers.get("x-death");
assertNotNull(death);
if (death.size() == 1) {
assertDeathReason(death, 0, TEST_QUEUE_NAME, "expired");
} else if (death.size() == 2) {
assertDeathReason(death, 0, DLQ, "expired");
assertDeathReason(death, 1, TEST_QUEUE_NAME, "expired");
} else {
fail("message was dead-lettered more times than expected");
}
}
});
}
@Test public void deadLetterSelf() throws Exception {
declareQueue(TEST_QUEUE_NAME, "amq.direct", "test", null, 1);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
publishN(MSG_COUNT);
// This test hangs if the queue doesn't process ALL the
// messages before being deleted, so make sure the next
// sleep is long enough.
sleep(200);
// The messages will NOT be dead-lettered to self.
consumeN(TEST_QUEUE_NAME, 0, WithResponse.NULL);
}
@Test public void deadLetterCycle() throws Exception {
// testDeadLetterTwice and testDeadLetterSelf both test that we drop
// messages in pure-expiry cycles. So we just need to test that
// non-pure-expiry cycles do not drop messages.
declareQueue("queue1", "", "queue2", null, 1);
declareQueue("queue2", "", "queue1", null, 0);
channel.basicPublish("", "queue1", MessageProperties.BASIC, "".getBytes());
final CountDownLatch latch = new CountDownLatch(10);
channel.basicConsume("queue2", false,
new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope,
AMQP.BasicProperties properties, byte[] body) throws IOException {
channel.basicReject(envelope.getDeliveryTag(), false);
latch.countDown();
}
});
assertTrue(latch.await(10, TimeUnit.SECONDS));
}
@Test public void deadLetterNewRK() throws Exception {
declareQueue(TEST_QUEUE_NAME, DLX, "test-other", null, 1);
channel.queueDeclare(DLQ2, false, true, false, null);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
channel.queueBind(DLQ2, DLX, "test-other");
Map<String, Object> headers = new HashMap<String, Object>();
headers.put("CC", Arrays.asList("foo"));
headers.put("BCC", Arrays.asList("bar"));
publishN(MSG_COUNT, (new AMQP.BasicProperties.Builder())
.headers(headers)
.build());
sleep(100);
consumeN(DLQ, 0, WithResponse.NULL);
consumeN(DLQ2, MSG_COUNT, new WithResponse() {
@SuppressWarnings("unchecked")
public void process(GetResponse getResponse) {
Map<String, Object> headers = getResponse.getProps().getHeaders();
assertNotNull(headers);
assertNull(headers.get("CC"));
assertNull(headers.get("BCC"));
ArrayList<Object> death = (ArrayList<Object>)headers.get("x-death");
assertNotNull(death);
assertEquals(1, death.size());
assertDeathReason(death, 0, TEST_QUEUE_NAME,
"expired", "amq.direct",
Arrays.asList("test", "foo"));
}
});
}
@SuppressWarnings("unchecked")
@Test public void republish() throws Exception {
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 100);
declareQueue(TEST_QUEUE_NAME, DLX, null, args);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
publishN(1);
sleep(200);
GetResponse getResponse = channel.basicGet(DLQ, true);
assertNotNull("Message not dead-lettered",
getResponse);
assertEquals("test message", new String(getResponse.getBody()));
BasicProperties props = getResponse.getProps();
Map<String, Object> headers = props.getHeaders();
assertNotNull(headers);
ArrayList<Object> death = (ArrayList<Object>) headers.get("x-death");
assertNotNull(death);
assertEquals(1, death.size());
assertDeathReason(death, 0, TEST_QUEUE_NAME, "expired", "amq.direct",
Arrays.asList("test"));
// Make queue zero length
args = new HashMap<String, Object>();
args.put("x-max-length", 0);
channel.queueDelete(TEST_QUEUE_NAME);
declareQueue(TEST_QUEUE_NAME, DLX, null, args);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
sleep(100);
//Queueing second time with same props
channel.basicPublish("amq.direct", "test",
new AMQP.BasicProperties.Builder()
.headers(headers)
.build(), "test message".getBytes());
sleep(100);
getResponse = channel.basicGet(DLQ, true);
assertNotNull("Message not dead-lettered", getResponse);
assertEquals("test message", new String(getResponse.getBody()));
headers = getResponse.getProps().getHeaders();
assertNotNull(headers);
death = (ArrayList<Object>) headers.get("x-death");
assertNotNull(death);
assertEquals(2, death.size());
assertDeathReason(death, 0, TEST_QUEUE_NAME, "maxlen", "amq.direct",
Arrays.asList("test"));
assertDeathReason(death, 1, TEST_QUEUE_NAME, "expired", "amq.direct",
Arrays.asList("test"));
//Set invalid headers
headers.put("x-death", "[I, am, not, array]");
channel.basicPublish("amq.direct", "test",
new AMQP.BasicProperties.Builder()
.headers(headers)
.build(), "test message".getBytes());
sleep(100);
getResponse = channel.basicGet(DLQ, true);
assertNotNull("Message not dead-lettered", getResponse);
assertEquals("test message", new String(getResponse.getBody()));
headers = getResponse.getProps().getHeaders();
assertNotNull(headers);
death = (ArrayList<Object>) headers.get("x-death");
assertNotNull(death);
assertEquals(1, death.size());
assertDeathReason(death, 0, TEST_QUEUE_NAME, "maxlen", "amq.direct",
Arrays.asList("test"));
}
public void rejectionTest(final boolean useNack) throws Exception {
deadLetterTest(new Callable<Void>() {
public Void call() throws Exception {
for (int x = 0; x < MSG_COUNT; x++) {
GetResponse getResponse =
channel.basicGet(TEST_QUEUE_NAME, false);
long tag = getResponse.getEnvelope().getDeliveryTag();
if (useNack) {
channel.basicNack(tag, false, false);
} else {
channel.basicReject(tag, false);
}
}
return null;
}
}, null, "rejected");
}
private void deadLetterTest(final Runnable deathTrigger,
Map<String, Object> queueDeclareArgs,
String reason)
throws Exception
{
deadLetterTest(new Callable<Object>() {
public Object call() throws Exception {
deathTrigger.run();
return null;
}
}, queueDeclareArgs, reason);
}
private void deadLetterTest(Callable<?> deathTrigger,
Map<String, Object> queueDeclareArgs,
final String reason)
throws Exception
{
declareQueue(TEST_QUEUE_NAME, DLX, null, queueDeclareArgs);
channel.queueBind(TEST_QUEUE_NAME, "amq.direct", "test");
channel.queueBind(DLQ, DLX, "test");
publishN(MSG_COUNT);
deathTrigger.call();
consume(channel, reason);
}
public static void consume(final Channel channel, final String reason) throws IOException {
consumeN(channel, DLQ, MSG_COUNT, new WithResponse() {
@SuppressWarnings("unchecked")
public void process(GetResponse getResponse) {
Map<String, Object> headers = getResponse.getProps().getHeaders();
assertNotNull(headers);
ArrayList<Object> death = (ArrayList<Object>) headers.get("x-death");
assertNotNull(death);
assertEquals(1, death.size());
assertDeathReason(death, 0, TEST_QUEUE_NAME, reason,
"amq.direct",
Arrays.asList("test"));
}
});
}
private void ttlTest(final long ttl) throws Exception {
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", ttl);
deadLetterTest(new Runnable() {
public void run() { sleep(ttl + 1500); }
}, args, "expired");
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException ex) {
// whoosh
}
}
/* check that each message arrives within epsilon of the
publication time + TTL + latency */
private void checkPromptArrival(QueueMessageConsumer c,
int count, long latency) throws Exception {
long epsilon = TTL / 10;
for (int i = 0; i < count; i++) {
byte[] body = c.nextDelivery(TTL + TTL + latency + epsilon);
assertNotNull("message #" + i + " did not expire", body);
long now = System.currentTimeMillis();
long publishTime = Long.valueOf(new String(body));
long targetTime = publishTime + TTL + latency;
assertTrue("expiry outside bounds (+/- " + epsilon + "): " +
(now - targetTime),
(now >= targetTime - epsilon) &&
(now <= targetTime + epsilon));
}
}
private void declareQueue(Object deadLetterExchange) throws IOException {
declareQueue(TEST_QUEUE_NAME, deadLetterExchange, null, null);
}
private void declareQueue(String queue, Object deadLetterExchange,
Object deadLetterRoutingKey,
Map<String, Object> args) throws IOException {
declareQueue(queue, deadLetterExchange, deadLetterRoutingKey, args, 0);
}
private void declareQueue(String queue, Object deadLetterExchange,
Object deadLetterRoutingKey,
Map<String, Object> args, int ttl)
throws IOException
{
if (args == null) {
args = new HashMap<String, Object>();
}
if (ttl > 0){
args.put("x-message-ttl", ttl);
}
args.put(DLX_ARG, deadLetterExchange);
if (deadLetterRoutingKey != null) {
args.put(DLX_RK_ARG, deadLetterRoutingKey);
}
channel.queueDeclare(queue, false, true, false, args);
}
private void publishN(int n) throws IOException {
publishN(n, null);
}
private void publishN(int n, AMQP.BasicProperties props)
throws IOException
{
for(int x = 0; x < n; x++) { publish(props, "test message"); }
}
private void publish(AMQP.BasicProperties props, String body)
throws IOException
{
channel.basicPublish("amq.direct", "test", props, body.getBytes());
}
private void publishAt(long when) throws Exception {
waitUntil(when);
publish(null, Long.toString(System.currentTimeMillis()));
}
private void waitUntil(long when) throws Exception {
long delay = when - System.currentTimeMillis();
Thread.sleep(delay > 0 ? delay : 0);
}
private void consumeN(String queue, int n, WithResponse withResponse)
throws IOException
{
consumeN(channel, queue, n, withResponse);
}
private static void consumeN(Channel channel, String queue, int n, WithResponse withResponse)
throws IOException
{
for(int x = 0; x < n; x++) {
GetResponse getResponse =
channel.basicGet(queue, true);
assertNotNull("Messages not dead-lettered (" + (n-x) + " left)",
getResponse);
assertEquals("test message", new String(getResponse.getBody()));
withResponse.process(getResponse);
}
GetResponse getResponse = channel.basicGet(queue, true);
assertNull("expected empty queue", getResponse);
}
@SuppressWarnings("unchecked")
private static void assertDeathReason(List<Object> death, int num,
String queue, String reason,
String exchange, List<String> routingKeys)
{
Map<String, Object> deathHeader =
(Map<String, Object>)death.get(num);
assertEquals(exchange, deathHeader.get("exchange").toString());
List<String> deathRKs = new ArrayList<String>();
for (Object rk : (ArrayList<?>)deathHeader.get("routing-keys")) {
deathRKs.add(rk.toString());
}
Collections.sort(deathRKs);
Collections.sort(routingKeys);
assertEquals(routingKeys, deathRKs);
assertDeathReason(death, num, queue, reason);
}
@SuppressWarnings("unchecked")
private static void assertDeathReason(List<Object> death, int num,
String queue, String reason) {
Map<String, Object> deathHeader =
(Map<String, Object>)death.get(num);
assertEquals(queue, deathHeader.get("queue").toString());
assertEquals(reason, deathHeader.get("reason").toString());
}
private static interface WithResponse {
static final WithResponse NULL = new WithResponse() {
public void process(GetResponse getResponse) {
}
};
public void process(GetResponse response);
}
private static String randomQueueName() {
return DeadLetterExchange.class.getSimpleName() + "-" + UUID.randomUUID().toString();
}
class QueueMessageConsumer extends DefaultConsumer {
BlockingQueue<byte[]> messages = new LinkedBlockingQueue<byte[]>();
public QueueMessageConsumer(Channel channel) {
super(channel);
}
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
messages.add(body);
}
byte[] nextDelivery() {
return messages.poll();
}
byte[] nextDelivery(long timeoutInMs) throws InterruptedException {
return messages.poll(timeoutInMs, TimeUnit.MILLISECONDS);
}
}
}