Skip to content

Commit 6e8f766

Browse files
sasjoikalachy
andauthored
fix: Reduce number of round trips for redis (#172)
Co-authored-by: C5371248 <ivan.kalachyev@sap.com> Co-authored-by: C5371248 <baton4ik@gmail.com>
1 parent 2ba6c1c commit 6e8f766

File tree

6 files changed

+142
-30
lines changed

6 files changed

+142
-30
lines changed

.github/workflows/pre-commit.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ jobs:
1818
uses: actions/setup-python@v6
1919

2020
- name: Run pre-commit
21-
uses: pre-commit/actions@v3.0.1
21+
uses: pre-commit/action@v3.0.1
2222
with:
2323
extra_args: --from-ref=${{ github.event.pull_request.base.sha }} --to-ref=${{ github.sha }}

.github/workflows/pull_request.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
branches: [ master ]
55
types: [ opened, synchronize, reopened ]
66

7+
env:
8+
MAVEN_INIT: 'false'
9+
710
jobs:
811
test:
912
runs-on: ubuntu-latest
@@ -22,7 +25,7 @@ jobs:
2225
run: ./mvnw verify
2326

2427
- name: Scan with SonarCloud
25-
if: ${{ env.SECRET_AUTH != '' }}
28+
if: github.repository == 'extenda/vertx-redis-clustermanager'
2629
uses: extenda/actions/sonar-scanner@v0
2730
with:
2831
sonar-host: https://sonarcloud.io

src/main/java/com/retailsvc/vertx/spi/cluster/redis/RedisClusterManager.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,12 @@ public void nodeListener(NodeListener listener) {
125125

126126
@Override
127127
public void setNodeInfo(NodeInfo nodeInfo, Promise<Void> promise) {
128-
try (var ignored = CloseableLock.lock(lock)) {
129-
this.nodeInfo = nodeInfo;
130-
}
131128
vertx
132129
.<Void>executeBlocking(
133130
() -> {
131+
try (var ignored = CloseableLock.lock(lock)) {
132+
this.nodeInfo = nodeInfo;
133+
}
134134
nodeInfoCatalog.setNodeInfo(nodeInfo);
135135
return null;
136136
},

src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/RedissonContext.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public final class RedissonContext {
3535

3636
private RedissonClient client;
3737
private ExecutorService lockReleaseExec;
38-
private final Lock lock = new ReentrantLock();
38+
private final Lock lock = new ReentrantLock(true);
3939

4040
/**
4141
* Create a new Redisson context with specified configuration.
@@ -58,9 +58,8 @@ public RedissonContext(RedisConfig config, ClassLoader dataClassLoader) {
5858

5959
BaseConfig<?> serverConfig =
6060
switch (config.getClientType()) {
61-
case STANDALONE -> redisConfig
62-
.useSingleServer()
63-
.setAddress(config.getEndpoints().getFirst());
61+
case STANDALONE ->
62+
redisConfig.useSingleServer().setAddress(config.getEndpoints().getFirst());
6463
case CLUSTER -> {
6564
ClusterServersConfig clusterConfig = redisConfig.useClusterServers();
6665
clusterConfig.setNodeAddresses(config.getEndpoints());

src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/SubscriptionCatalog.java

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
import java.util.ArrayList;
99
import java.util.Collection;
1010
import java.util.Collections;
11+
import java.util.HashMap;
1112
import java.util.HashSet;
1213
import java.util.List;
14+
import java.util.Map;
1315
import java.util.Set;
1416
import java.util.concurrent.ConcurrentHashMap;
1517
import java.util.concurrent.ConcurrentMap;
18+
import java.util.concurrent.atomic.AtomicInteger;
1619
import java.util.concurrent.locks.Lock;
1720
import java.util.concurrent.locks.ReadWriteLock;
1821
import java.util.concurrent.locks.ReentrantReadWriteLock;
22+
import org.redisson.api.RSet;
1923
import org.redisson.api.RSetMultimap;
2024
import org.redisson.api.RTopic;
2125
import org.redisson.api.RedissonClient;
@@ -37,10 +41,9 @@ public class SubscriptionCatalog {
3741
private final NodeSelector nodeSelector;
3842
private final int listenerId;
3943
private final RTopic topic;
40-
4144
private final ConcurrentMap<String, Set<RegistrationInfo>> localSubs = new ConcurrentHashMap<>();
4245
private final ConcurrentMap<String, Set<RegistrationInfo>> ownSubs = new ConcurrentHashMap<>();
43-
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
46+
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(true);
4447
private final Throttling throttling;
4548

4649
/**
@@ -219,31 +222,103 @@ public void removeAllForNodes(Set<String> nodeIds) {
219222
* Remove subscriptions for nodes that are not part of the <code>availableNodeIds</code>
220223
* collection. Own subscriptions are never removed.
221224
*
222-
* <p>Unknown nodes with lingering state can observed in clusters that has scaled down to one and
223-
* then crashes. If a new node is not available to receive events when node entries expire in
225+
* <p>Unknown nodes with lingering state can be observed in clusters that has scaled down to one
226+
* and then crashes. If a new node is not available to receive events when node entries expire in
224227
* Redis, the subscriptions will remain registered in Redis.
225228
*
226229
* @param self the node ID of this process
227230
* @param availableNodeIds a set of available nodes
228231
*/
229232
public void removeUnknownSubs(String self, Collection<String> availableNodeIds) {
230-
Set<String> known = new HashSet<>(availableNodeIds);
231-
known.add(self);
232233

233-
Set<String> updated = new HashSet<>();
234-
subsMap
235-
.entries()
236-
.forEach(
237-
entry -> {
238-
if (!known.contains(entry.getValue().nodeId())) {
239-
log.warn(
240-
"Remove lingering subscriptions from unknown node [{}]",
241-
entry.getValue().nodeId());
242-
subsMap.remove(entry.getKey(), entry.getValue());
243-
updated.add(entry.getKey());
244-
}
245-
});
246-
updated.forEach(topic::publish);
234+
// Build set of known nodes (cluster members + self)
235+
Set<String> knownNodes = new HashSet<>(availableNodeIds);
236+
knownNodes.add(self);
237+
238+
// Group stale subs by subscription key (address)
239+
Map<String, List<RegistrationInfo>> cleanupByKey = new HashMap<>();
240+
241+
for (Map.Entry<String, RegistrationInfo> entry : subsMap.entries()) {
242+
RegistrationInfo info = entry.getValue();
243+
if (!knownNodes.contains(info.nodeId())) {
244+
cleanupByKey.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(info);
245+
}
246+
}
247+
248+
if (cleanupByKey.isEmpty()) {
249+
return;
250+
}
251+
252+
// Bulk remove all unknown subscriptions
253+
List<String> updatedKeys = bulkRemoveUnknownSubsByKey(cleanupByKey, subsMap);
254+
255+
// Notify all updated keys across the cluster nodes
256+
updatedKeys.forEach(topic::publish);
257+
}
258+
259+
/**
260+
* Bulk remove unknown subscriptions grouped by key (address) to reduce round trips to Redis. If a
261+
* bulk deletion fails, this method falls back to individual deletion per entry.
262+
*
263+
* @param cleanupByKey the stale subscriptions to remove, grouped by key (address)
264+
* @param subscriptions the subscriptions map in Redis
265+
* @return the updated keys (addresses)
266+
*/
267+
static List<String> bulkRemoveUnknownSubsByKey(
268+
Map<String, List<RegistrationInfo>> cleanupByKey,
269+
RSetMultimap<String, RegistrationInfo> subscriptions) {
270+
// Stats
271+
AtomicInteger totalRemoved = new AtomicInteger();
272+
AtomicInteger totalFailed = new AtomicInteger();
273+
Map<String, Integer> removedPerNode = new HashMap<>();
274+
275+
List<String> updatedKeys = new ArrayList<>();
276+
for (Map.Entry<String, List<RegistrationInfo>> entry : cleanupByKey.entrySet()) {
277+
String key = entry.getKey();
278+
List<RegistrationInfo> staleValues = entry.getValue();
279+
try {
280+
RSet<RegistrationInfo> set = subscriptions.get(key);
281+
282+
// Bulk remove all (single round trip to Redis)
283+
set.removeAll(staleValues);
284+
285+
// Mark as updated because stale entries existed
286+
updatedKeys.add(key);
287+
288+
// Stats
289+
for (RegistrationInfo info : staleValues) {
290+
removedPerNode.merge(info.nodeId(), 1, Integer::sum);
291+
totalRemoved.incrementAndGet();
292+
}
293+
} catch (Exception e) {
294+
// Fallback to safe per-value removal
295+
log.warn("Bulk removal failed for key [{}], retrying individually", key, e);
296+
297+
for (RegistrationInfo info : staleValues) {
298+
boolean ok = subscriptions.remove(key, info);
299+
if (ok) {
300+
removedPerNode.merge(info.nodeId(), 1, Integer::sum);
301+
totalRemoved.incrementAndGet();
302+
} else {
303+
totalFailed.incrementAndGet();
304+
}
305+
}
306+
307+
updatedKeys.add(key);
308+
}
309+
}
310+
311+
// Logging summary
312+
log.warn(
313+
"Removed {} lingering subscriptions from {} unknown node(s). Breakdown: {}",
314+
totalRemoved.get(),
315+
removedPerNode.size(),
316+
removedPerNode);
317+
318+
if (totalFailed.get() > 0) {
319+
log.warn("Failed to remove {} subscriptions", totalFailed.get());
320+
}
321+
return updatedKeys;
247322
}
248323

249324
/** Republish subscriptions that belongs to the current node (in which this is executed). */

src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/ITSubscriptionCatalog.java

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import static org.mockito.ArgumentMatchers.any;
88
import static org.mockito.ArgumentMatchers.anyString;
99
import static org.mockito.Mockito.mock;
10+
import static org.mockito.Mockito.spy;
1011
import static org.mockito.Mockito.timeout;
1112
import static org.mockito.Mockito.verify;
1213
import static org.mockito.Mockito.when;
@@ -15,9 +16,13 @@
1516
import io.vertx.core.spi.cluster.NodeSelector;
1617
import io.vertx.core.spi.cluster.RegistrationInfo;
1718
import io.vertx.core.spi.cluster.RegistrationUpdateEvent;
19+
import java.util.List;
20+
import java.util.Map;
1821
import org.junit.jupiter.api.BeforeEach;
1922
import org.junit.jupiter.api.Test;
2023
import org.redisson.Redisson;
24+
import org.redisson.api.RSet;
25+
import org.redisson.api.RSetMultimap;
2126
import org.redisson.api.RedissonClient;
2227
import org.redisson.config.Config;
2328
import org.testcontainers.containers.GenericContainer;
@@ -32,6 +37,7 @@ class ITSubscriptionCatalog {
3237
private final RedisKeyFactory keyFactory = new RedisKeyFactory("test");
3338
private NodeSelector nodeSelector;
3439
private SubscriptionCatalog subsCatalog;
40+
private RedissonClient redisson;
3541

3642
@BeforeEach
3743
void beforeEach() {
@@ -40,7 +46,7 @@ void beforeEach() {
4046
String redisUrl = "redis://" + redis.getHost() + ":" + redis.getFirstMappedPort();
4147
Config config = new Config();
4248
config.useSingleServer().setAddress(redisUrl);
43-
RedissonClient redisson = Redisson.create(config);
49+
redisson = Redisson.create(config);
4450
subsCatalog = new SubscriptionCatalog(redisson, keyFactory, nodeSelector);
4551
when(nodeSelector.wantsUpdatesFor(anyString())).thenReturn(true);
4652
}
@@ -110,6 +116,35 @@ void removeUnknownSubsNoOp() {
110116
assertThat(subsCatalog.get("sub-2")).hasSize(1);
111117
}
112118

119+
@Test
120+
void removeUnknownSubsBulkException() {
121+
putSubs();
122+
subsCatalog.put("sub-1", new RegistrationInfo("node3", 4, false));
123+
subsCatalog.put("sub-1", new RegistrationInfo("node4", 5, false));
124+
125+
List<RegistrationInfo> subs1 = List.of(new RegistrationInfo("node1", 1, false));
126+
List<RegistrationInfo> subs2 = List.of(new RegistrationInfo("node1", 2, false));
127+
128+
// Spy and mock the subs map to throw exception
129+
RSetMultimap<String, RegistrationInfo> subsMap =
130+
spy(redisson.getSetMultimap(keyFactory.vertx("subs")));
131+
@SuppressWarnings("unchecked")
132+
RSet<RegistrationInfo> setMock = mock(RSet.class);
133+
when(subsMap.get("sub-1")).thenReturn(setMock);
134+
when(setMock.removeAll(subs1)).thenThrow(new RuntimeException("TEST"));
135+
136+
SubscriptionCatalog.bulkRemoveUnknownSubsByKey(Map.of("sub-1", subs1, "sub-2", subs2), subsMap);
137+
138+
assertThat(subsCatalog.get("sub-1"))
139+
.containsOnly(
140+
new RegistrationInfo("node2", 3, false),
141+
new RegistrationInfo("node3", 4, false),
142+
new RegistrationInfo("node4", 5, false));
143+
assertThat(subsCatalog.get("sub-2")).isEmpty();
144+
verify(nodeSelector, timeout(100).atLeast(1))
145+
.registrationsUpdated(any(RegistrationUpdateEvent.class));
146+
}
147+
113148
@Test
114149
void removeForAllNodes() {
115150
putSubs();

0 commit comments

Comments
 (0)