Skip to content

Commit a9dc7e9

Browse files
garyrussellartembilan
authored andcommitted
GH-1127: Add MessageBatchRecoverer
Resolves #1127
1 parent e6e659a commit a9dc7e9

File tree

6 files changed

+188
-37
lines changed

6 files changed

+188
-37
lines changed

spring-rabbit/src/main/java/org/springframework/amqp/rabbit/config/StatefulRetryOperationsInterceptorFactoryBean.java

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,21 @@
1616

1717
package org.springframework.amqp.rabbit.config;
1818

19+
import java.util.List;
20+
1921
import org.apache.commons.logging.Log;
2022
import org.apache.commons.logging.LogFactory;
2123

2224
import org.springframework.amqp.ImmediateAcknowledgeAmqpException;
2325
import org.springframework.amqp.core.Message;
26+
import org.springframework.amqp.rabbit.retry.MessageBatchRecoverer;
2427
import org.springframework.amqp.rabbit.retry.MessageKeyGenerator;
2528
import org.springframework.amqp.rabbit.retry.MessageRecoverer;
2629
import org.springframework.amqp.rabbit.retry.NewMessageIdentifier;
2730
import org.springframework.retry.RetryOperations;
31+
import org.springframework.retry.interceptor.MethodArgumentsKeyGenerator;
32+
import org.springframework.retry.interceptor.MethodInvocationRecoverer;
33+
import org.springframework.retry.interceptor.NewMethodArgumentsIdentifier;
2834
import org.springframework.retry.interceptor.StatefulRetryOperationsInterceptor;
2935
import org.springframework.retry.support.RetryTemplate;
3036

@@ -71,34 +77,49 @@ public StatefulRetryOperationsInterceptor getObject() {
7177
retryTemplate = new RetryTemplate();
7278
}
7379
retryInterceptor.setRetryOperations(retryTemplate);
80+
retryInterceptor.setNewItemIdentifier(createNewItemIdentifier());
81+
retryInterceptor.setRecoverer(createRecoverer());
82+
retryInterceptor.setKeyGenerator(createKeyGenerator());
83+
return retryInterceptor;
84+
85+
}
7486

75-
retryInterceptor.setNewItemIdentifier(args -> {
76-
Message message = (Message) args[1];
87+
private NewMethodArgumentsIdentifier createNewItemIdentifier() {
88+
return args -> {
89+
Message message = argToMessage(args);
7790
if (StatefulRetryOperationsInterceptorFactoryBean.this.newMessageIdentifier == null) {
7891
return !message.getMessageProperties().isRedelivered();
7992
}
8093
else {
8194
return StatefulRetryOperationsInterceptorFactoryBean.this.newMessageIdentifier.isNew(message);
8295
}
83-
});
96+
};
97+
}
8498

85-
final MessageRecoverer messageRecoverer = getMessageRecoverer();
86-
retryInterceptor.setRecoverer((args, cause) -> {
87-
Message message = (Message) args[1];
99+
@SuppressWarnings("unchecked")
100+
private MethodInvocationRecoverer<?> createRecoverer() {
101+
return (args, cause) -> {
102+
MessageRecoverer messageRecoverer = getMessageRecoverer();
103+
Object arg = args[1];
88104
if (messageRecoverer == null) {
89-
logger.warn("Message dropped on recovery: " + message, cause);
105+
logger.warn("Message(s) dropped on recovery: " + arg, cause);
90106
}
91-
else {
92-
messageRecoverer.recover(message, cause);
107+
else if (arg instanceof Message) {
108+
messageRecoverer.recover((Message) arg, cause);
109+
}
110+
else if (arg instanceof List && messageRecoverer instanceof MessageBatchRecoverer) {
111+
((MessageBatchRecoverer) messageRecoverer).recover((List<Message>) arg, cause);
93112
}
94113
// This is actually a normal outcome. It means the recovery was successful, but we don't want to consume
95114
// any more messages until the acks and commits are sent for this (problematic) message...
96115
throw new ImmediateAcknowledgeAmqpException("Recovered message forces ack (if ack mode requires it): "
97-
+ message, cause);
98-
});
116+
+ arg, cause);
117+
};
118+
}
99119

100-
retryInterceptor.setKeyGenerator(args -> {
101-
Message message = (Message) args[1];
120+
private MethodArgumentsKeyGenerator createKeyGenerator() {
121+
return args -> {
122+
Message message = argToMessage(args);
102123
if (StatefulRetryOperationsInterceptorFactoryBean.this.messageKeyGenerator == null) {
103124
String messageId = message.getMessageProperties().getMessageId();
104125
if (messageId == null && message.getMessageProperties().isRedelivered()) {
@@ -109,10 +130,20 @@ public StatefulRetryOperationsInterceptor getObject() {
109130
else {
110131
return StatefulRetryOperationsInterceptorFactoryBean.this.messageKeyGenerator.getKey(message);
111132
}
112-
});
113-
114-
return retryInterceptor;
133+
};
134+
}
115135

136+
@SuppressWarnings("unchecked")
137+
private Message argToMessage(Object[] args) {
138+
Object arg = args[1];
139+
Message message = null;
140+
if (arg instanceof Message) {
141+
message = (Message) arg;
142+
}
143+
else if (arg instanceof List) {
144+
message = ((List<Message>) arg).get(0);
145+
}
146+
return message;
116147
}
117148

118149
@Override

spring-rabbit/src/main/java/org/springframework/amqp/rabbit/config/StatelessRetryOperationsInterceptorFactoryBean.java

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616

1717
package org.springframework.amqp.rabbit.config;
1818

19+
import java.util.List;
20+
1921
import org.apache.commons.logging.Log;
2022
import org.apache.commons.logging.LogFactory;
2123

2224
import org.springframework.amqp.core.Message;
25+
import org.springframework.amqp.rabbit.retry.MessageBatchRecoverer;
2326
import org.springframework.amqp.rabbit.retry.MessageRecoverer;
2427
import org.springframework.retry.RetryOperations;
28+
import org.springframework.retry.interceptor.MethodInvocationRecoverer;
2529
import org.springframework.retry.interceptor.RetryOperationsInterceptor;
2630
import org.springframework.retry.support.RetryTemplate;
2731

@@ -53,21 +57,27 @@ public RetryOperationsInterceptor getObject() {
5357
retryTemplate = new RetryTemplate();
5458
}
5559
retryInterceptor.setRetryOperations(retryTemplate);
60+
retryInterceptor.setRecoverer(createRecoverer());
61+
return retryInterceptor;
62+
63+
}
5664

57-
final MessageRecoverer messageRecoverer = getMessageRecoverer();
58-
retryInterceptor.setRecoverer((args, cause) -> {
59-
Message message = (Message) args[1];
65+
@SuppressWarnings("unchecked")
66+
private MethodInvocationRecoverer<?> createRecoverer() {
67+
return (args, cause) -> {
68+
MessageRecoverer messageRecoverer = getMessageRecoverer();
69+
Object arg = args[1];
6070
if (messageRecoverer == null) {
61-
logger.warn("Message dropped on recovery: " + message, cause);
71+
logger.warn("Message(s) dropped on recovery: " + arg, cause);
6272
}
63-
else {
64-
messageRecoverer.recover(message, cause);
73+
else if (arg instanceof Message) {
74+
messageRecoverer.recover((Message) arg, cause);
75+
}
76+
else if (arg instanceof List && messageRecoverer instanceof MessageBatchRecoverer) {
77+
((MessageBatchRecoverer) messageRecoverer).recover((List<Message>) arg, cause);
6578
}
6679
return null;
67-
});
68-
69-
return retryInterceptor;
70-
80+
};
7181
}
7282

7383
@Override
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2019 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.amqp.rabbit.retry;
18+
19+
import java.util.List;
20+
21+
import org.springframework.amqp.core.Message;
22+
23+
/**
24+
* A retry recoverer for use with a batch listener. Users should consider throwing an
25+
* exception containing the index within the batch where the exception occurred, allowing
26+
* the recoverer to properly recover the remaining records.
27+
*
28+
* @author Gary Russell
29+
* @since 2.2
30+
*
31+
*/
32+
@FunctionalInterface
33+
public interface MessageBatchRecoverer extends MessageRecoverer {
34+
35+
@Override
36+
default void recover(Message message, Throwable cause) {
37+
throw new IllegalStateException("MessageBatchRecoverer configured with a non-batch listener");
38+
}
39+
40+
/**
41+
* Callback for message batch that was consumed but failed all retry attempts.
42+
*
43+
* @param messages the messages to recover
44+
* @param cause the cause of the error
45+
*/
46+
void recover(List<Message> messages, Throwable cause);
47+
48+
}

spring-rabbit/src/test/java/org/springframework/amqp/rabbit/listener/MessageListenerContainerRetryIntegrationTests.java

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.junit.jupiter.api.Test;
3232

3333
import org.springframework.amqp.core.AcknowledgeMode;
34+
import org.springframework.amqp.core.BatchMessageListener;
3435
import org.springframework.amqp.core.Queue;
3536
import org.springframework.amqp.rabbit.config.AbstractRetryOperationsInterceptorFactoryBean;
3637
import org.springframework.amqp.rabbit.config.StatefulRetryOperationsInterceptorFactoryBean;
@@ -41,9 +42,9 @@
4142
import org.springframework.amqp.rabbit.junit.LogLevels;
4243
import org.springframework.amqp.rabbit.junit.RabbitAvailable;
4344
import org.springframework.amqp.rabbit.listener.adapter.MessageListenerAdapter;
45+
import org.springframework.amqp.rabbit.retry.MessageBatchRecoverer;
4446
import org.springframework.amqp.support.converter.MessageConverter;
4547
import org.springframework.amqp.support.converter.SimpleMessageConverter;
46-
import org.springframework.amqp.utils.SerializationUtils;
4748
import org.springframework.beans.factory.DisposableBean;
4849
import org.springframework.retry.policy.MapRetryContextCache;
4950
import org.springframework.retry.support.RetryTemplate;
@@ -90,7 +91,43 @@ private RabbitTemplate createTemplate(int concurrentConsumers) {
9091
}
9192

9293
@Test
93-
public void testStatefulRetryWithAllMessagesFailing() throws Exception {
94+
void testStatelessRetryWithBatchListener() throws Exception {
95+
doTestRetryWithBatchListener(false);
96+
}
97+
98+
@Test
99+
void testStatefulRetryWithBatchListener() throws Exception {
100+
doTestRetryWithBatchListener(true);
101+
}
102+
103+
private void doTestRetryWithBatchListener(boolean stateful) throws Exception {
104+
RabbitTemplate template = createTemplate(1);
105+
template.convertAndSend(queue.getName(), "foo");
106+
template.convertAndSend(queue.getName(), "bar");
107+
108+
final SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(
109+
template.getConnectionFactory());
110+
container.setMessageListener((BatchMessageListener) messages -> {
111+
throw new RuntimeException("intended");
112+
});
113+
container.setAcknowledgeMode(AcknowledgeMode.AUTO);
114+
container.setConsumerBatchEnabled(true);
115+
container.setBatchSize(2);
116+
117+
final CountDownLatch latch = new CountDownLatch(1);
118+
container.setAdviceChain(new Advice[] { createRetryInterceptor(latch, stateful, true) });
119+
120+
container.setQueueNames(queue.getName());
121+
container.setReceiveTimeout(50);
122+
container.afterPropertiesSet();
123+
container.start();
124+
125+
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
126+
container.stop();
127+
}
128+
129+
@Test
130+
void testStatefulRetryWithAllMessagesFailing() throws Exception {
94131

95132
int messageCount = 10;
96133
int txSize = 1;
@@ -101,7 +138,7 @@ public void testStatefulRetryWithAllMessagesFailing() throws Exception {
101138
}
102139

103140
@Test
104-
public void testStatelessRetryWithAllMessagesFailing() throws Exception {
141+
void testStatelessRetryWithAllMessagesFailing() throws Exception {
105142

106143
int messageCount = 10;
107144
int txSize = 1;
@@ -112,7 +149,7 @@ public void testStatelessRetryWithAllMessagesFailing() throws Exception {
112149
}
113150

114151
@Test
115-
public void testStatefulRetryWithNoMessageIds() {
152+
void testStatefulRetryWithNoMessageIds() {
116153

117154
int messageCount = 2;
118155
int txSize = 1;
@@ -135,7 +172,7 @@ public void testStatefulRetryWithNoMessageIds() {
135172
}
136173

137174
@RepeatedTest(10)
138-
public void testStatefulRetryWithTxSizeAndIntermittentFailure() throws Exception {
175+
void testStatefulRetryWithTxSizeAndIntermittentFailure() throws Exception {
139176

140177
int messageCount = 10;
141178
int txSize = 4;
@@ -146,7 +183,7 @@ public void testStatefulRetryWithTxSizeAndIntermittentFailure() throws Exception
146183
}
147184

148185
@Test
149-
public void testStatefulRetryWithMoreMessages() throws Exception {
186+
void testStatefulRetryWithMoreMessages() throws Exception {
150187

151188
int messageCount = 200;
152189
int txSize = 10;
@@ -157,18 +194,29 @@ public void testStatefulRetryWithMoreMessages() throws Exception {
157194
}
158195

159196
private Advice createRetryInterceptor(final CountDownLatch latch, boolean stateful) throws Exception {
197+
return createRetryInterceptor(latch, stateful, false);
198+
}
199+
200+
private Advice createRetryInterceptor(final CountDownLatch latch, boolean stateful, boolean listRecoverer)
201+
throws Exception {
202+
160203
AbstractRetryOperationsInterceptorFactoryBean factory;
161204
if (stateful) {
162205
factory = new StatefulRetryOperationsInterceptorFactoryBean();
163206
}
164207
else {
165208
factory = new StatelessRetryOperationsInterceptorFactoryBean();
166209
}
167-
factory.setMessageRecoverer((message, cause) -> {
168-
logger.warn("Recovered: [" + SerializationUtils.deserialize(message.getBody()).toString() +
169-
"], message: " + message);
170-
latch.countDown();
171-
});
210+
if (listRecoverer) {
211+
factory.setMessageRecoverer((MessageBatchRecoverer) (messages, cause) -> {
212+
latch.countDown();
213+
});
214+
}
215+
else {
216+
factory.setMessageRecoverer((message, cause) -> {
217+
latch.countDown();
218+
});
219+
}
172220
if (retryTemplate == null) {
173221
retryTemplate = new RetryTemplate();
174222
}

src/reference/asciidoc/amqp.adoc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5898,6 +5898,17 @@ Only a subset of retry capabilities can be configured this way.
58985898
More advanced features would need the configuration of a `RetryTemplate` as a Spring bean.
58995899
See the https://docs.spring.io/spring-retry/docs/api/current/[Spring Retry Javadoc] for complete information about available policies and their configuration.
59005900

5901+
[[batch-retry]]
5902+
===== Retry with Batch Listeners
5903+
5904+
It is not recommended to configure retry with a batch listener, unless the batch was created by the producer, in a single record.
5905+
See <<de-batching>> for information about consumer and producer-created batches.
5906+
With a consumer-created batch, the framework has no knowledge about which message in the batch caused the failure so recovery after the retries are exhausted is not possible.
5907+
With producer-created batches, since there is only one message that actually failed, the whole message can be recovered.
5908+
Applications may want to inform a custom recoverer where in the batch the failure occurred, perhaps by setting an index property of the thrown exception.
5909+
5910+
A retry recoverer for a batch listener must implement `MessageBatchRecoverer`.
5911+
59015912
[[async-listeners]]
59025913
===== Message Listeners and the Asynchronous Case
59035914

src/reference/asciidoc/whats-new.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,6 @@ See <<builder-api>> for more information.
112112

113113
Outbound headers with values of type `Class<?>` are now mapped using `getName()` instead of `toString()`.
114114
See <<message-properties-converters>> for more information.
115+
116+
Recovery of failed producer-created batches is now supported.
117+
See <<batch-retry>> for more information.

0 commit comments

Comments
 (0)