Skip to content

Commit 3024c0c

Browse files
committed
fix: implement requester-side deduplication of reply messages by replyIndex
1 parent 0f68c81 commit 3024c0c

14 files changed

Lines changed: 296 additions & 53 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## 5.3.5 - 2026-02-05
8+
### Fixed
9+
- Requester now deduplicates reply messages by `replyIndex` to prevent corrupt results when replies are duplicated (e.g. after broker disconnect/reconnect during in-place Solace broker updates).
10+
- Dedup bookkeeping is cleaned up when a request completes, errors, or times out.
11+
712
## 5.3.4 - 2026-01-23
813
### Changed
914
- Add support to not respond to requests

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,23 @@ More precisely:
599599
- If a service dies, any relations are forgotten and replies can no longer be related to request,
600600
potentially resulting in message loss.
601601

602+
### Reply duplication & requester-side deduplication
603+
604+
In some operational scenarios (e.g. Solace in-place broker updates / short disconnects / reconnects) the same request can be sent twice and the replier may therefore produce **duplicate replies**.
605+
606+
To make request-reply robust against such duplicate delivery, the requester keeps per-request bookkeeping and **deduplicates incoming reply messages by `replyIndex`**:
607+
608+
- If the requester receives multiple messages with the same `replyIndex`, only the first one is processed; later duplicates are ignored.
609+
- This also supports range indices such as `replyIndex="0-45"` (used when replies are grouped into an SDTStream). The entire grouped message will be consumed only once.
610+
- Terminal messages (finish/error) are always processed, even if they share a `replyIndex` with another message.
611+
612+
#### Memory / cleanup
613+
614+
The dedup bookkeeping is scoped to a single in-flight request (correlationId).
615+
It is cleared automatically when the request completes successfully, completes with a remote error, or times out.
616+
617+
> Note: For best results, repliers should always set `totalReplies` and `replyIndex` consistently on every reply message (this is done automatically when using `RequestReplyMessageHeaderSupportService.wrap*`).
618+
602619
## External Links
603620
- [Spring Cloud Stream Solace Samples](https://solace.com/samples/solace-samples-spring/spring-cloud-stream/)
604621

src/main/java/community/solace/spring/cloud/requestreply/service/RequestReplyServiceImpl.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,13 @@ void onReplyReceived(final Message<?> message) {
453453
if (handler == null) {
454454
requestReplyLogger.log(LOG, Level.INFO, "Received unexpected message or maybe too late response: {}", message);
455455
} else {
456+
String replyIndex = messageHeaderSupportService.getReplyIndex(message);
457+
// Allow terminal messages (EMPTY_RESPONSE) to share replyIndex with a previous message,
458+
// because they don't carry a unique index but are required to complete/error the request.
459+
if (StringUtils.hasText(replyIndex) && (totalReplies == null || totalReplies != EMPTY_RESPONSE) && handler.checkDuplicate(replyIndex)) {
460+
return;
461+
}
462+
456463
if (totalReplies != null) {
457464
if (totalReplies == UNKNOWN_SIZE) {
458465
handler.setUnknownReplies();
@@ -493,18 +500,28 @@ private static boolean isMultiResponse(Message<?> message) {
493500
private static List<Message<?>> parseMultiResponse(Message<SDTStream> message) {
494501
try {
495502
List<Message<?>> msgs = new ArrayList<>();
503+
504+
// When creating grouped (SDTStream) responses, we temporarily move the content-type to
505+
// SpringHeaderParser.GROUPED_CONTENT_TYPE to prevent Spring from re-encoding the outer message.
506+
// When unpacking, restore it so downstream conversion (e.g. JSON -> POJO) works as expected.
507+
Map<String, Object> baseHeaders = new IntegrationMessageHeaderAccessor(message).toMap();
508+
Object groupedContentType = baseHeaders.get(SpringHeaderParser.GROUPED_CONTENT_TYPE);
509+
if (groupedContentType != null && baseHeaders.get(MessageHeaders.CONTENT_TYPE) == null) {
510+
baseHeaders.put(MessageHeaders.CONTENT_TYPE, groupedContentType);
511+
}
512+
496513
while (message.getPayload().hasRemaining()) {
497514
switch (message.getPayload().readString()) {
498515
case "BytesMessage" -> msgs.add(
499516
MessageBuilder
500517
.withPayload(message.getPayload().readBytes())
501-
.copyHeaders(new IntegrationMessageHeaderAccessor(message).toMap())
518+
.copyHeaders(baseHeaders)
502519
.build()
503520
);
504521
case "TextMessage", "XMLContentMessage" -> msgs.add(
505522
MessageBuilder
506523
.withPayload(new String(message.getPayload().readBytes(), StandardCharsets.UTF_8))
507-
.copyHeaders(new IntegrationMessageHeaderAccessor(message).toMap())
524+
.copyHeaders(baseHeaders)
508525
.build()
509526
);
510527
case "StreamMessage", "MapMessage" -> throw new IllegalArgumentException(

src/main/java/community/solace/spring/cloud/requestreply/service/ResponseHandler.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
import java.time.Duration;
1313
import java.time.Instant;
14+
import java.util.Set;
15+
import java.util.concurrent.ConcurrentHashMap;
1416
import java.util.concurrent.CountDownLatch;
1517
import java.util.concurrent.atomic.AtomicLong;
1618
import java.util.function.Consumer;
@@ -31,6 +33,7 @@ public class ResponseHandler {
3133
private String errorMessage;
3234

3335
private final RequestReplyLogger requestReplyLogger;
36+
private final Set<String> receivedIndices = ConcurrentHashMap.newKeySet();
3437

3538
public ResponseHandler(Consumer<Message<?>> responseMessageConsumer, boolean supportMultipleResponses, Timer timer, RequestReplyLogger requestReplyLogger) {
3639
this.countDownLatch = new CountDownLatch(1);
@@ -54,6 +57,18 @@ public void receive(Message<?> message) {
5457
requestReplyLogger.logReply(LOG, Level.DEBUG, "received response(remaining={}) {}", remainingReplies, message);
5558
}
5659

60+
public void receive(Message<?> message, String replyIndex) {
61+
receive(message);
62+
}
63+
64+
public boolean checkDuplicate(String replyIndex) {
65+
if (replyIndex != null && !receivedIndices.add(replyIndex)) {
66+
requestReplyLogger.log(LOG, Level.WARN, "received duplicate response(index={})", replyIndex);
67+
return true;
68+
}
69+
return false;
70+
}
71+
5772
public void await() throws RemoteErrorException, InterruptedException {
5873
countDownLatch.await();
5974
if (StringUtils.hasText(errorMessage)) {
@@ -88,6 +103,10 @@ public void errorResponse(String errorMessage) {
88103
}
89104

90105
private void finished() {
106+
// Clear per-request dedup bookkeeping to avoid retaining replyIndex values
107+
// longer than necessary (success, error, or timeout/abort paths all call finished()).
108+
receivedIndices.clear();
109+
91110
if (timer != null) {
92111
timer.record(Duration.between(requestTime, Instant.now()));
93112
}

src/main/java/community/solace/spring/cloud/requestreply/service/header/RequestReplyMessageHeaderSupportService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import community.solace.spring.cloud.requestreply.service.header.parser.errormessage.MessageErrorMessageParser;
1919
import community.solace.spring.cloud.requestreply.service.header.parser.replyto.MessageReplyToParser;
2020
import community.solace.spring.cloud.requestreply.service.header.parser.totalreplies.MessageTotalRepliesParser;
21+
import community.solace.spring.cloud.requestreply.service.header.parser.replyindex.MessageReplyIndexParser;
2122
import community.solace.spring.cloud.requestreply.service.messageinterceptor.ReplyWrappingInterceptor;
2223
import community.solace.spring.cloud.requestreply.util.MessageChunker;
2324
import org.apache.commons.lang3.tuple.Pair;
@@ -57,6 +58,9 @@ public class RequestReplyMessageHeaderSupportService {
5758
@Autowired
5859
private List<MessageTotalRepliesParser> totalRepliesParsers;
5960

61+
@Autowired
62+
private List<MessageReplyIndexParser> replyIndexParsers;
63+
6064
@Autowired
6165
private List<MessageErrorMessageParser> errorMessageParsers;
6266

@@ -113,6 +117,17 @@ Long getTotalReplies(Message<?> message) {
113117
.orElse(null);
114118
}
115119

120+
public @Nullable
121+
String getReplyIndex(Message<?> message) {
122+
return message == null ? null
123+
: replyIndexParsers
124+
.stream()
125+
.map(p -> p.getReplyIndex(message))
126+
.filter(Objects::nonNull)
127+
.findFirst()
128+
.orElse(null);
129+
}
130+
116131
public @Nullable
117132
String getErrorMessage(Message<?> message) {
118133
return message == null ? null

src/main/java/community/solace/spring/cloud/requestreply/service/header/parser/SpringHeaderParser.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package community.solace.spring.cloud.requestreply.service.header.parser;
22

33
import community.solace.spring.cloud.requestreply.service.header.parser.errormessage.MessageHeaderErrorMessageParser;
4+
import community.solace.spring.cloud.requestreply.service.header.parser.replyindex.MessageHeaderReplyIndexParser;
45
import community.solace.spring.cloud.requestreply.service.header.parser.replyto.MessageHeaderReplyToParser;
56
import community.solace.spring.cloud.requestreply.service.header.parser.totalreplies.MessageHeaderTotalRepliesParser;
67
import org.springframework.core.annotation.Order;
@@ -9,7 +10,7 @@
910

1011
@Service
1112
@Order(40000)
12-
public class SpringHeaderParser implements MessageHeaderReplyToParser, MessageHeaderTotalRepliesParser, MessageHeaderErrorMessageParser {
13+
public class SpringHeaderParser implements MessageHeaderReplyToParser, MessageHeaderTotalRepliesParser, MessageHeaderErrorMessageParser, MessageHeaderReplyIndexParser {
1314
public final static String MULTI_TOTAL_REPLIES = "totalReplies";
1415
public final static String MULTI_REPLY_INDEX = "replyIndex";
1516
public final static String GROUPED_MESSAGES = "groupedMessages";
@@ -49,4 +50,13 @@ public String getErrorMessage(MessageHeaders headers) {
4950

5051
return null;
5152
}
53+
54+
@Override
55+
public String getReplyIndex(MessageHeaders headers) {
56+
Object index = headers.get(MULTI_REPLY_INDEX);
57+
if (index == null) {
58+
return null;
59+
}
60+
return index.toString();
61+
}
5262
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright © Schweizerische Bundesbahnen SBB, 2026.
3+
*/
4+
5+
package community.solace.spring.cloud.requestreply.service.header.parser.replyindex;
6+
7+
import org.springframework.lang.Nullable;
8+
import org.springframework.messaging.Message;
9+
import org.springframework.messaging.MessageHeaders;
10+
11+
@FunctionalInterface
12+
public interface MessageHeaderReplyIndexParser extends MessageReplyIndexParser {
13+
@Override
14+
default String getReplyIndex(Message<?> message) {
15+
return message == null ?
16+
null :
17+
getReplyIndex(message.getHeaders());
18+
}
19+
20+
@Nullable
21+
String getReplyIndex(MessageHeaders headers);
22+
}
23+
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright © Schweizerische Bundesbahnen SBB, 2026.
3+
*/
4+
5+
package community.solace.spring.cloud.requestreply.service.header.parser.replyindex;
6+
7+
import org.springframework.lang.Nullable;
8+
import org.springframework.messaging.Message;
9+
10+
@FunctionalInterface
11+
public interface MessageReplyIndexParser {
12+
@Nullable
13+
String getReplyIndex(Message<?> message);
14+
}
15+

src/test/java/community/solace/spring/cloud/requestreply/AbstractRequestReplyLoggingIT.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ public abstract class AbstractRequestReplyLoggingIT {
4444
public static final String PROFILE_TEST_LOGGING = "testLogging";
4545
public static final String PROFILE_LOCAL_APP = "localApp";
4646

47-
public final Timestamp Ten_oClock = new Timestamp(1682928000); // 2023-05-01 10:00:00
48-
public final Timestamp Eleven_oClock = new Timestamp(1682931600); // 2023-05-01 11:00:00
49-
public final Timestamp Twelve_oClock = new Timestamp(1682935200); // 2023-05-01 12:00:00
47+
public final Timestamp Ten_oClock = new Timestamp(1682928000000L); // 2023-05-01 10:00:00
48+
public final Timestamp Eleven_oClock = new Timestamp(1682931600000L); // 2023-05-01 11:00:00
49+
public final Timestamp Twelve_oClock = new Timestamp(1682935200000L); // 2023-05-01 12:00:00
5050

5151
private static List<Object> mocks = new ArrayList<>();
5252

@@ -65,7 +65,7 @@ protected void resetMocks() {
6565
}
6666

6767
@AfterEach
68-
private void validateTestEndpoint() {
68+
public void validateTestEndpoint() {
6969
try {
7070
assertFalse(testEndpoint.hasExceptions());
7171
}

src/test/java/community/solace/spring/cloud/requestreply/AbstractRequestReplyMessageInterceptorIT.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ public abstract class AbstractRequestReplyMessageInterceptorIT {
4545
public static final String PROFILE_TEST_MESSAGE_INTERCEPTOR = "testMessageInterceptor";
4646
public static final String PROFILE_LOCAL_APP = "localApp";
4747

48-
public final Timestamp Ten_oClock = new Timestamp(1682928000); // 2023-05-01 10:00:00
49-
public final Timestamp Eleven_oClock = new Timestamp(1682931600); // 2023-05-01 11:00:00
50-
public final Timestamp Twelve_oClock = new Timestamp(1682935200); // 2023-05-01 12:00:00
48+
public final Timestamp Ten_oClock = new Timestamp(1682928000000L); // 2023-05-01 10:00:00
49+
public final Timestamp Eleven_oClock = new Timestamp(1682931600000L); // 2023-05-01 11:00:00
50+
public final Timestamp Twelve_oClock = new Timestamp(1682935200000L); // 2023-05-01 12:00:00
5151

5252
private static List<Object> mocks = new ArrayList<>();
5353

@@ -66,7 +66,7 @@ protected void resetMocks() {
6666
}
6767

6868
@AfterEach
69-
private void validateTestEndpoint() {
69+
public void validateTestEndpoint() {
7070
try {
7171
assertFalse(testEndpoint.hasExceptions());
7272
}

0 commit comments

Comments
 (0)