From 9d5f0f3666eac76857c1e9293bddeff61920dec2 Mon Sep 17 00:00:00 2001 From: ggivo Date: Fri, 20 Jun 2025 08:09:05 +0300 Subject: [PATCH 01/23] Introduce push handler - Preparation step for processing custom push notifications - Push notification can appear out-of band in-between executed commands - Current Connection implementation does not support out of band Push notifications - Meaning it will crash if "CLIENT TRACKING ON is enabled" on regular Jedis Connection and "invalidation" push event is triggered This commit provides a way to register push handler for the connection which process incoming push messages, before actual command is executed. To preserve backward compatibility unprocessed push messages are forward to application logic as before. - By default Connection will start with NOOP push handler which marks any incoming push event as processed and skips it - On subcsribe/psubscribe a dedicated push handler is registered which propagates to the app only supported push vents such as (message, subscribe, unsubscribe ...) - CacheConection is refactored to use a push handler handling "invalidate" push events only, and skipping any other --- .../java/redis/clients/jedis/Connection.java | 80 ++++- .../redis/clients/jedis/JedisPubSubBase.java | 22 +- .../clients/jedis/JedisShardedPubSubBase.java | 98 +++--- .../java/redis/clients/jedis/Protocol.java | 74 ++++- .../java/redis/clients/jedis/PushEvent.java | 25 ++ .../java/redis/clients/jedis/PushHandler.java | 15 + .../clients/jedis/PushHandlerOutput.java | 19 ++ .../clients/jedis/csc/CacheConnection.java | 29 +- .../clients/jedis/JedisPubSubBaseTest.java | 3 +- .../jedis/JedisShardedPubSubBaseTest.java | 3 +- .../redis/clients/jedis/ProtocolTest.java | 76 +++++ .../clients/jedis/PushNotificationTest.java | 293 ++++++++++++++++++ .../jedis/PublishSubscribeCommandsTest.java | 1 - .../jedis/csc/ClientSideCacheTestBase.java | 2 + .../UnifiedJedisClientSideCacheTestBase.java | 5 +- 15 files changed, 676 insertions(+), 69 deletions(-) create mode 100644 src/main/java/redis/clients/jedis/PushEvent.java create mode 100644 src/main/java/redis/clients/jedis/PushHandler.java create mode 100644 src/main/java/redis/clients/jedis/PushHandlerOutput.java create mode 100644 src/test/java/redis/clients/jedis/PushNotificationTest.java diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index de473d0b8e..2db276d1c0 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -1,5 +1,7 @@ package redis.clients.jedis; +import static redis.clients.jedis.Protocol.PROPAGATE_ALL_PUSH_EVENT; +import static redis.clients.jedis.Protocol.PROPAGATE_NONE_PUSH_EVENT; import static redis.clients.jedis.util.SafeEncoder.encode; import java.io.Closeable; @@ -33,6 +35,7 @@ public class Connection implements Closeable { + private ConnectionPool memberOf; protected RedisProtocol protocol; private final JedisSocketFactory socketFactory; @@ -49,6 +52,8 @@ public class Connection implements Closeable { private AtomicReference currentCredentials = new AtomicReference<>(null); private AuthXManager authXManager; + private PushHandler pushListener = PROPAGATE_NONE_PUSH_EVENT; + public Connection() { this(Protocol.DEFAULT_HOST, Protocol.DEFAULT_PORT); } @@ -303,7 +308,7 @@ public void setBroken() { public String getStatusCodeReply() { flush(); - final byte[] resp = (byte[]) readProtocolWithCheckingBroken(); + final byte[] resp = (byte[]) readProtocolWithCheckingBroken(pushListener); if (null == resp) { return null; } else { @@ -322,12 +327,12 @@ public String getBulkReply() { public byte[] getBinaryBulkReply() { flush(); - return (byte[]) readProtocolWithCheckingBroken(); + return (byte[]) readProtocolWithCheckingBroken(pushListener); } public Long getIntegerReply() { flush(); - return (Long) readProtocolWithCheckingBroken(); + return (Long) readProtocolWithCheckingBroken(pushListener); } public List getMultiBulkReply() { @@ -337,7 +342,7 @@ public List getMultiBulkReply() { @SuppressWarnings("unchecked") public List getBinaryMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(); + return (List) readProtocolWithCheckingBroken(pushListener); } /** @@ -346,28 +351,28 @@ public List getBinaryMultiBulkReply() { @Deprecated @SuppressWarnings("unchecked") public List getUnflushedObjectMultiBulkReply() { - return (List) readProtocolWithCheckingBroken(); + return (List) readProtocolWithCheckingBroken(pushListener); } @SuppressWarnings("unchecked") - public Object getUnflushedObject() { - return readProtocolWithCheckingBroken(); + public Object getUnflushedObject(PushHandler listener) { + return readProtocolWithCheckingBroken(listener); } public List getObjectMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(); + return (List) readProtocolWithCheckingBroken(pushListener); } @SuppressWarnings("unchecked") public List getIntegerMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(); + return (List) readProtocolWithCheckingBroken(pushListener); } public Object getOne() { flush(); - return readProtocolWithCheckingBroken(); + return readProtocolWithCheckingBroken(pushListener); } protected void flush() { @@ -379,28 +384,67 @@ protected void flush() { } } + // ----------- + // PUB_SUB + // --------- + // we can consume pending pushes before reading the next reply + // do { + // obj = Protocol.read(is); + // } while (obj == PushOutput && PushOutput.skip() == true); + // + // Not filtered push messages are propagated to the client + // Will block and wait for next not-filtered PushMessage or command output + // + // --- + // executeCommand() + // --------------- + // we can consume pending pushes before reading the next reply + // do { + // obj = Protocol.read(is); + // } while (obj == PushOutput && PushOutput.skip() == true); + // this will block and wait for next not-filtered PushMessage or command output + // @Experimental - protected Object protocolRead(RedisInputStream is) { - return Protocol.read(is); + protected Object protocolRead(RedisInputStream is, PushHandler listener) { + + return Protocol.read(is, listener); } +// @Experimental +// protected Object protocolRead(RedisInputStream is) { +// return Protocol.read(is); +// } + @Experimental protected void protocolReadPushes(RedisInputStream is) { } - protected Object readProtocolWithCheckingBroken() { + protected Object readProtocolWithCheckingBroken(PushHandler listener) { if (broken) { throw new JedisConnectionException("Attempting to read from a broken connection."); } try { - return protocolRead(inputStream); + return protocolRead(inputStream, listener); } catch (JedisConnectionException exc) { broken = true; throw exc; } } +// protected Object readProtocolWithCheckingBroken() { +// if (broken) { +// throw new JedisConnectionException("Attempting to read from a broken connection."); +// } +// +// try { +// return protocolRead(inputStream); +// } catch (JedisConnectionException exc) { +// broken = true; +// throw exc; +// } +// } + protected void readPushesWithCheckingBroken() { if (broken) { throw new JedisConnectionException("Attempting to read from a broken connection."); @@ -424,7 +468,7 @@ public List getMany(final int count) { final List responses = new ArrayList<>(count); for (int i = 0; i < count; i++) { try { - responses.add(readProtocolWithCheckingBroken()); + responses.add(readProtocolWithCheckingBroken(pushListener)); } catch (JedisDataException e) { responses.add(e); } @@ -614,4 +658,10 @@ protected boolean isTokenBasedAuthenticationEnabled() { protected AuthXManager getAuthXManager() { return authXManager; } + + @Experimental + public void setPushHandler(PushHandler listener) { + this.pushListener = listener; + } + } diff --git a/src/main/java/redis/clients/jedis/JedisPubSubBase.java b/src/main/java/redis/clients/jedis/JedisPubSubBase.java index 91fee36c58..8a464374dc 100644 --- a/src/main/java/redis/clients/jedis/JedisPubSubBase.java +++ b/src/main/java/redis/clients/jedis/JedisPubSubBase.java @@ -1,9 +1,12 @@ package redis.clients.jedis; +import static redis.clients.jedis.Protocol.PROPAGATE_ALL_PUSH_EVENT; import static redis.clients.jedis.Protocol.ResponseKeyword.*; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.function.Consumer; import redis.clients.jedis.Protocol.Command; @@ -127,11 +130,28 @@ public final void proceedWithPatterns(Connection client, T... patterns) { protected abstract T encode(byte[] raw); + + private static final Set pubSubCommands = new HashSet<>(); + static { + pubSubCommands.add("message"); + pubSubCommands.add("smessage"); + pubSubCommands.add("subscribe"); + pubSubCommands.add("ssubscribe"); + pubSubCommands.add("psubscribe"); + pubSubCommands.add("unsubscribe"); + pubSubCommands.add("sunsubscribe"); + pubSubCommands.add("punsubscribe"); + } + + private boolean isPubSubType(String type) { + return pubSubCommands.contains(type); + } + // private void process(Client client) { private void process() { do { - Object reply = authenticator.client.getUnflushedObject(); + Object reply = authenticator.client.getUnflushedObject(PROPAGATE_ALL_PUSH_EVENT); if (reply instanceof List) { List listReply = (List) reply; diff --git a/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java b/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java index 9020693929..e0fe3d25f8 100644 --- a/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java +++ b/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java @@ -1,9 +1,12 @@ package redis.clients.jedis; +import static redis.clients.jedis.Protocol.PROPAGATE_ALL_PUSH_EVENT; import static redis.clients.jedis.Protocol.ResponseKeyword.*; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.function.Consumer; import redis.clients.jedis.Protocol.Command; @@ -70,49 +73,70 @@ public final void proceed(Connection client, T... channels) { protected abstract T encode(byte[] raw); + private static final Set pubSubCommands = new HashSet<>(); + static { + pubSubCommands.add("message"); + pubSubCommands.add("smessage"); + pubSubCommands.add("subscribe"); + pubSubCommands.add("ssubscribe"); + pubSubCommands.add("psubscribe"); + pubSubCommands.add("unsubscribe"); + pubSubCommands.add("sunsubscribe"); + pubSubCommands.add("punsubscribe"); + } + + private boolean isPubSubType(String type) { + return pubSubCommands.contains(type); + } + private void process() { do { - Object reply = authenticator.client.getUnflushedObject(); - - if (reply instanceof List) { - List listReply = (List) reply; - final Object firstObj = listReply.get(0); - if (!(firstObj instanceof byte[])) { - throw new JedisException("Unknown message type: " + firstObj); - } - final byte[] resp = (byte[]) firstObj; - if (Arrays.equals(SSUBSCRIBE.getRaw(), resp)) { - subscribedChannels = ((Long) listReply.get(2)).intValue(); - final byte[] bchannel = (byte[]) listReply.get(1); - final T enchannel = (bchannel == null) ? null : encode(bchannel); - onSSubscribe(enchannel, subscribedChannels); - } else if (Arrays.equals(SUNSUBSCRIBE.getRaw(), resp)) { - subscribedChannels = ((Long) listReply.get(2)).intValue(); - final byte[] bchannel = (byte[]) listReply.get(1); - final T enchannel = (bchannel == null) ? null : encode(bchannel); - onSUnsubscribe(enchannel, subscribedChannels); - } else if (Arrays.equals(SMESSAGE.getRaw(), resp)) { - final byte[] bchannel = (byte[]) listReply.get(1); - final byte[] bmesg = (byte[]) listReply.get(2); - final T enchannel = (bchannel == null) ? null : encode(bchannel); - final T enmesg = (bmesg == null) ? null : encode(bmesg); - onSMessage(enchannel, enmesg); - } else { - throw new JedisException("Unknown message type: " + firstObj); - } - } else if (reply instanceof byte[]) { - Consumer resultHandler = authenticator.resultHandler.poll(); - if (resultHandler == null) { - throw new JedisException("Unexpected message : " + SafeEncoder.encode((byte[]) reply)); - } - resultHandler.accept(reply); - } else { - throw new JedisException("Unknown message type: " + reply); - } + Object reply = authenticator.client.getUnflushedObject(PROPAGATE_ALL_PUSH_EVENT); + + processReply(reply); } while (!Thread.currentThread().isInterrupted() && isSubscribed()); // /* Invalidate instance since this thread is no longer listening */ // this.client = null; } + + private void processReply(Object reply) { + if (reply instanceof List) { + List listReply = (List) reply; + final Object firstObj = listReply.get(0); + if (!(firstObj instanceof byte[])) { + throw new JedisException("Unknown message type: " + firstObj); + } + final byte[] resp = (byte[]) firstObj; + if (Arrays.equals(SSUBSCRIBE.getRaw(), resp)) { + subscribedChannels = ((Long) listReply.get(2)).intValue(); + final byte[] bchannel = (byte[]) listReply.get(1); + final T enchannel = (bchannel == null) ? null : encode(bchannel); + onSSubscribe(enchannel, subscribedChannels); + } else if (Arrays.equals(SUNSUBSCRIBE.getRaw(), resp)) { + subscribedChannels = ((Long) listReply.get(2)).intValue(); + final byte[] bchannel = (byte[]) listReply.get(1); + final T enchannel = (bchannel == null) ? null : encode(bchannel); + onSUnsubscribe(enchannel, subscribedChannels); + } else if (Arrays.equals(SMESSAGE.getRaw(), resp)) { + final byte[] bchannel = (byte[]) listReply.get(1); + final byte[] bmesg = (byte[]) listReply.get(2); + final T enchannel = (bchannel == null) ? null : encode(bchannel); + final T enmesg = (bmesg == null) ? null : encode(bmesg); + onSMessage(enchannel, enmesg); + } else { + throw new JedisException("Unknown message type: " + firstObj); + } + } else if (reply instanceof byte[]) { + Consumer resultHandler = authenticator.resultHandler.poll(); + if (resultHandler == null) { + throw new JedisException("Unexpected message : " + SafeEncoder.encode((byte[]) reply)); + } + resultHandler.accept(reply); + } else { + throw new JedisException("Unknown message type: " + reply); + } + } + } diff --git a/src/main/java/redis/clients/jedis/Protocol.java b/src/main/java/redis/clients/jedis/Protocol.java index 6d59a8b913..8a6c430a70 100644 --- a/src/main/java/redis/clients/jedis/Protocol.java +++ b/src/main/java/redis/clients/jedis/Protocol.java @@ -65,6 +65,16 @@ public final class Protocol { private static final String NOPERM_PREFIX = "NOPERM"; private static final byte[] INVALIDATE_BYTES = SafeEncoder.encode("invalidate"); + public static final PushHandler PROPAGATE_ALL_PUSH_EVENT = (pe) ->{ + System.out.println("PROPAGATE_ALL_PUSH_EVENT" + pe.getType()); + return new PushHandlerOutput(pe, false); + }; + + public static final PushHandler PROPAGATE_NONE_PUSH_EVENT = (pe) ->{ + + System.out.println("PROPAGATE_NONE_PUSH_EVENT.handlePushMessage: " + pe.getType()); + return new PushHandlerOutput(pe, true); + }; private Protocol() { throw new InstantiationError("Must not instantiate this class"); @@ -127,9 +137,9 @@ private static String[] parseTargetHostAndSlot(String clusterRedirectResponse) { return response; } - private static Object process(final RedisInputStream is) { + private static Object process(final RedisInputStream is, PushHandler handler) { final byte b = is.readByte(); - // System.out.println("BYTE: " + (char) b); + //System.out.println("BYTE: " + (char) b); switch (b) { case PLUS_BYTE: return is.readLineBytes(); @@ -153,7 +163,9 @@ private static Object process(final RedisInputStream is) { case TILDE_BYTE: // TODO: return processMultiBulkReply(is); case GREATER_THAN_BYTE: - return processMultiBulkReply(is); + // return processMultiBulkReply(is); + // returns PushHandlerOutput - wraps a PushMessage + return processPush(is, handler); case MINUS_BYTE: processError(is); return null; @@ -193,7 +205,7 @@ private static List processMultiBulkReply(final RedisInputStream is) { final List ret = new ArrayList<>(num); for (int i = 0; i < num; i++) { try { - ret.add(process(is)); + ret.add(process(is, null)); } catch (JedisDataException e) { ret.add(e); } @@ -211,23 +223,61 @@ private static List processMapKeyValueReply(final RedisInputStream is) default: final List ret = new ArrayList<>(num); for (int i = 0; i < num; i++) { - ret.add(new KeyValue(process(is), process(is))); + ret.add(new KeyValue(process(is, null), process(is,null))); } return ret; } } public static Object read(final RedisInputStream is) { - return process(is); + // for backward compatibility + // propagate all push events to application + Object reply = process(is, PROPAGATE_ALL_PUSH_EVENT); + + if (reply != null & reply instanceof PushHandlerOutput) { + if (((PushHandlerOutput) reply).isProcessed()) { + return null; + } + return ((PushHandlerOutput) reply).getMessage().getContent(); + } + + return reply; } @Experimental - public static Object read(final RedisInputStream is, final Cache cache) { - Object unhandledPush = readPushes(is, cache, false); - return unhandledPush == null ? process(is) : unhandledPush; + public static Object read(final RedisInputStream is, PushHandler pushConsumer) { + // read until we have a non-push event, + // or push-event is not handled and need to be propagated to application + Object reply; + do { + reply = process(is, pushConsumer); + + } while (isPush(reply) && isProcessed((PushHandlerOutput) reply) ); + + if ( isPush(reply)) { + return ((PushHandlerOutput) reply).getMessage().getContent(); + } + + return reply; + } + + private static boolean isProcessed(PushHandlerOutput reply) { + return reply.isProcessed(); } + private static boolean isPush(Object reply) { + return reply instanceof PushHandlerOutput; + } + + // @Experimental +// public static Object read(final RedisInputStream is, final Cache cache) { +// Object unhandledPush = readPushes(is, cache, false); +// return unhandledPush == null ? process(is) : unhandledPush; +// } + + @Experimental + // TODO : Refactor to use PushHandler public static Object readPushes(final RedisInputStream is, final Cache cache, boolean onlyPendingBuffer) { Object unhandledPush = null; @@ -247,6 +297,12 @@ public static Object readPushes(final RedisInputStream is, final Cache cache, return unhandledPush; } + private static PushHandlerOutput processPush(final RedisInputStream is, PushHandler handler) { + List list = processMultiBulkReply(is); + + return handler.handlePushMessage(new PushEvent(list)); + } + private static Object processPush(final RedisInputStream is, Cache cache) { is.readByte(); List list = processMultiBulkReply(is); diff --git a/src/main/java/redis/clients/jedis/PushEvent.java b/src/main/java/redis/clients/jedis/PushEvent.java new file mode 100644 index 0000000000..4368d6c557 --- /dev/null +++ b/src/main/java/redis/clients/jedis/PushEvent.java @@ -0,0 +1,25 @@ +package redis.clients.jedis; + +import redis.clients.jedis.util.SafeEncoder; + +import java.util.List; + +public class PushEvent { + String type; + List content; + + public PushEvent(List content) { + this.content = content; + if (content.size() > 0) { + type = SafeEncoder.encode((byte[]) content.get(0)); + } + } + + public String getType(){ + return type; + } + + public List getContent(){ + return content; + } +} \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/PushHandler.java b/src/main/java/redis/clients/jedis/PushHandler.java new file mode 100644 index 0000000000..9e59bc9624 --- /dev/null +++ b/src/main/java/redis/clients/jedis/PushHandler.java @@ -0,0 +1,15 @@ +package redis.clients.jedis; + +@FunctionalInterface +public interface PushHandler { + + /** + * Handle a push message. + * + * + * @param message message to respond to. + * @return push message to propagate, or null to stop propagation. + */ + PushHandlerOutput handlePushMessage(PushEvent message); + +} diff --git a/src/main/java/redis/clients/jedis/PushHandlerOutput.java b/src/main/java/redis/clients/jedis/PushHandlerOutput.java new file mode 100644 index 0000000000..526d4a0552 --- /dev/null +++ b/src/main/java/redis/clients/jedis/PushHandlerOutput.java @@ -0,0 +1,19 @@ +package redis.clients.jedis; + +public class PushHandlerOutput { + PushEvent message; + boolean processed; + + public PushHandlerOutput(PushEvent message, boolean processed) { + this.message = message; + this.processed = processed; + } + + public PushEvent getMessage() { + return message; + } + + public boolean isProcessed() { + return processed; + } +} diff --git a/src/main/java/redis/clients/jedis/csc/CacheConnection.java b/src/main/java/redis/clients/jedis/csc/CacheConnection.java index f157d95a94..1745c5b1b1 100644 --- a/src/main/java/redis/clients/jedis/csc/CacheConnection.java +++ b/src/main/java/redis/clients/jedis/csc/CacheConnection.java @@ -1,5 +1,6 @@ package redis.clients.jedis.csc; +import java.util.List; import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; @@ -8,6 +9,9 @@ import redis.clients.jedis.JedisClientConfig; import redis.clients.jedis.JedisSocketFactory; import redis.clients.jedis.Protocol; +import redis.clients.jedis.PushEvent; +import redis.clients.jedis.PushHandler; +import redis.clients.jedis.PushHandlerOutput; import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.exceptions.JedisException; import redis.clients.jedis.util.RedisInputStream; @@ -15,10 +19,28 @@ public class CacheConnection extends Connection { private final Cache cache; + private final PushEventInvalidateHandler invalidateHandler; private ReentrantLock lock; private static final String REDIS = "redis"; private static final String MIN_REDIS_VERSION = "7.4"; + private static class PushEventInvalidateHandler implements PushHandler { + private final Cache cache; + public PushEventInvalidateHandler(Cache cache) { + this.cache = cache; + } + + @Override + public PushHandlerOutput handlePushMessage(PushEvent message) { + if (message.getType().equals("invalidate")) { + System.out.println("PushEventInvalidateHandler.handlePushMessage: " + message.getType()); + cache.deleteByRedisKeys((List) message.getContent().get(1)); + return new PushHandlerOutput(message, true); + } + return new PushHandlerOutput(message, false); + } + } + public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig clientConfig, Cache cache) { super(socketFactory, clientConfig); @@ -33,6 +55,8 @@ public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig } } this.cache = Objects.requireNonNull(cache); + this.invalidateHandler = new PushEventInvalidateHandler(cache); + setPushHandler(invalidateHandler); initializeClientSideCache(); } @@ -43,10 +67,11 @@ protected void initializeFromClientConfig(JedisClientConfig config) { } @Override - protected Object protocolRead(RedisInputStream inputStream) { + protected Object protocolRead(RedisInputStream inputStream, PushHandler listener) { lock.lock(); try { - return Protocol.read(inputStream, cache); + // return Protocol.read(inputStream, cache); + return Protocol.read(inputStream, invalidateHandler); } finally { lock.unlock(); } diff --git a/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java b/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java index 98b0907735..1525ef59bf 100644 --- a/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java +++ b/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static redis.clients.jedis.Protocol.ResponseKeyword.MESSAGE; @@ -40,7 +41,7 @@ protected String encode(byte[] raw) { MESSAGE.getRaw(), "channel".getBytes(), "message".getBytes() ); - when(mockConnection.getUnflushedObject()). + when(mockConnection.getUnflushedObject(any())). thenReturn(mockSubscribe, mockResponse); diff --git a/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java b/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java index 6803d44e96..0897070b03 100644 --- a/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java +++ b/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static redis.clients.jedis.Protocol.ResponseKeyword.SMESSAGE; @@ -40,7 +41,7 @@ protected String encode(byte[] raw) { final List mockResponse = Arrays.asList( SMESSAGE.getRaw(), "channel".getBytes(), "message".getBytes() ); - when(mockConnection.getUnflushedObject()).thenReturn(mockSubscribe, mockResponse); + when(mockConnection.getUnflushedObject(any())).thenReturn(mockSubscribe, mockResponse); final CountDownLatch countDownLatch = new CountDownLatch(1); diff --git a/src/test/java/redis/clients/jedis/ProtocolTest.java b/src/test/java/redis/clients/jedis/ProtocolTest.java index e9891b3a93..4996a42b44 100644 --- a/src/test/java/redis/clients/jedis/ProtocolTest.java +++ b/src/test/java/redis/clients/jedis/ProtocolTest.java @@ -4,8 +4,10 @@ import redis.clients.jedis.util.FragmentedByteArrayInputStream; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static redis.clients.jedis.util.AssertUtil.assertByteArrayListEquals; @@ -139,4 +141,78 @@ public void busyReply() { } fail("Expected a JedisBusyException to be thrown."); } + + @Test + public void readWithPushListener() { + // Create a mock push listener + final List receivedMessages = new ArrayList<>(); + PushHandler listener = message -> { receivedMessages.add(message); return null; }; + + // Create a stream with a push message followed by a regular response + byte[] data = (">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nfoo\r\n+OK\r\n").getBytes(); + RedisInputStream is = new RedisInputStream(new ByteArrayInputStream(data)); + + // Read the response, which should process the push message first + Object response = Protocol.read(is, listener); + + // Verify the response + assertArrayEquals(SafeEncoder.encode("OK"), (byte[]) response); + + // Verify the push message was received + assertEquals(1, receivedMessages.size()); + PushEvent pushEvent = receivedMessages.get(0); + assertEquals(2, pushEvent.getContent().size()); + assertEquals("invalidate", pushEvent.getType()); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushEvent.getContent().get(0)); + + // The second element should be a list with one element "foo" + assertInstanceOf(List.class, pushEvent.getContent().get(1)); + List keys = (List) pushEvent.getContent().get(1); + assertEquals(1, keys.size()); + assertArrayEquals(SafeEncoder.encode("foo"), (byte[]) keys.get(0)); + } + + @Test + public void readWithMultiplePushMessages() { + // Create a mock push listener + final List receivedMessages = new ArrayList<>(); + PushHandler listener = message -> { receivedMessages.add(message); return null; }; + + + // Create a stream with multiple push messages followed by a regular response + byte[] data = ( + ">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nfoo\r\n" + + ">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nbar\r\n" + + ">2\r\n$7\r\nmessage\r\n$5\r\nhello\r\n" + + ":123\r\n" + ).getBytes(); + RedisInputStream is = new RedisInputStream(new ByteArrayInputStream(data)); + + // Read the response, which should process all push messages first + Object response = Protocol.read(is, listener); + + // Verify the response + assertEquals(123L, response); + + // Verify all push messages were received + assertEquals(3, receivedMessages.size()); + + // First push message (invalidate foo) + PushEvent pushEvent1 = receivedMessages.get(0); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushEvent1.getContent().get(0)); + List keys1 = (List) pushEvent1.getContent().get(1); + assertArrayEquals(SafeEncoder.encode("foo"), (byte[]) keys1.get(0)); + + // Second push message (invalidate bar) + PushEvent pushEvent2 = receivedMessages.get(1); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushEvent2.getContent().get(0)); + List keys2 = (List) pushEvent2.getContent().get(1); + assertArrayEquals(SafeEncoder.encode("bar"), (byte[]) keys2.get(0)); + + // Third push message (message hello) + PushEvent pushEvent3 = receivedMessages.get(2); + assertArrayEquals(SafeEncoder.encode("message"), (byte[]) pushEvent3.getContent().get(0)); + assertArrayEquals(SafeEncoder.encode("hello"), (byte[]) pushEvent3.getContent().get(1)); + } + } diff --git a/src/test/java/redis/clients/jedis/PushNotificationTest.java b/src/test/java/redis/clients/jedis/PushNotificationTest.java new file mode 100644 index 0000000000..2e7687f6d4 --- /dev/null +++ b/src/test/java/redis/clients/jedis/PushNotificationTest.java @@ -0,0 +1,293 @@ +package redis.clients.jedis; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.redis.test.annotations.SinceRedisVersion; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import redis.clients.jedis.Protocol.Command; +import redis.clients.jedis.util.RedisVersionCondition; + +/** + * Tests for Redis RESP3 push notifications functionality. + */ +@SinceRedisVersion("6.0.0") +public class PushNotificationTest { + + private static final EndpointConfig endpoint = HostAndPorts.getRedisEndpoint("standalone0"); + + @RegisterExtension + public RedisVersionCondition versionCondition = new RedisVersionCondition(endpoint); + + private Connection connection; + private UnifiedJedis unifiedJedis; + private final String testKey = "tracking:test:key"; + private final String initialValue = "initial"; + private final String modifiedValue = "modified"; + + @BeforeEach + public void setUp() { + // Nothing to set up by default - connections are created in each test + } + + @AfterEach + public void tearDown() { + if (connection != null) { + connection.close(); + connection = null; + } + + if (unifiedJedis != null) { + try { + unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "OFF"); + } catch (Exception e) { + // Ignore exceptions during cleanup + } + unifiedJedis.close(); + unifiedJedis = null; + } + } + + /** + * Helper method to modify a key using a separate connection to trigger invalidation. + * + * @param key The key to modify + * @param value The new value to set + */ + private void triggerKeyInvalidation(String key, String value) { + try (Jedis modifierClient = new Jedis(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build())) { + modifierClient.set(key, value); + } + } + + /** + * Helper method to enable client tracking on a connection. + * + * @param connection The connection on which to enable tracking + */ + private void enableClientTracking(Connection connection) { + connection.sendCommand(Command.CLIENT, "TRACKING", "ON"); + assertEquals("OK", connection.getStatusCodeReply()); + } + + @Test + public void testConnectionResp3PushNotifications() { + connection = new Connection(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); + connection.connect(); + + // Enable client tracking + enableClientTracking(connection); + + // Set initial value + CommandArguments comArgs = new CommandArguments(Command.SET); + CommandObject set = new CommandObject<>(comArgs.key(testKey).add(initialValue), BuilderFactory.STRING); + String setResult = connection.executeCommand(set); + assertEquals("OK", setResult); + + // Get the key to track it + CommandObject get = new CommandObject<>(new CommandArguments(Command.GET).key(testKey), BuilderFactory.STRING); + String getResponse = connection.executeCommand(get); + assertEquals(initialValue, getResponse); + + // Modify the key from another connection to trigger invalidation + triggerKeyInvalidation(testKey, modifiedValue); + + // Send PING and expect to receive invalidation message first, then PONG + CommandObject ping = new CommandObject<>(new CommandArguments(Command.PING), BuilderFactory.STRING); + String pingResponse = connection.executeCommand(ping); + assertEquals("PONG", pingResponse); + } + + @Test + public void testUnifiedJedisResp3PushNotifications() { + unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); + + // Enable client tracking + unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); + + // Set initial value + unifiedJedis.set(testKey, initialValue); + + // Get the key to track it + String getResponse = unifiedJedis.get(testKey); + assertEquals(initialValue, getResponse); + + // Modify the key from another connection to trigger invalidation + triggerKeyInvalidation(testKey, modifiedValue); + + // Send PING command + String pingResponse = unifiedJedis.ping(); + // Next reply should be PONG + assertEquals("PONG", pingResponse); + } + + + @Test + public void testConnectionResp3PushNotificationsWithCustomListener() { + // Create a list to store received push messages + List receivedMessages = new ArrayList<>(); + + // Create a custom push listener + PushHandler listener = message -> { receivedMessages.add(message); return null; }; + + + // Create connection with RESP3 protocol + connection = new Connection(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); + connection.connect(); + + // Set the push listener + connection.setPushHandler(listener); + + // Enable client tracking + enableClientTracking(connection); + + // Set and get a key to track it + CommandArguments setArgs = new CommandArguments(Command.SET); + CommandObject setCmd = new CommandObject<>(setArgs.key(testKey).add(initialValue), BuilderFactory.STRING); + connection.executeCommand(setCmd); + + CommandObject getCmd = new CommandObject<>(new CommandArguments(Command.GET).key(testKey), BuilderFactory.STRING); + connection.executeCommand(getCmd); + + // Modify the key from another connection to trigger invalidation + triggerKeyInvalidation(testKey, modifiedValue); + + // Send a command to trigger processing of any pending push messages + CommandObject pingCmd = new CommandObject<>(new CommandArguments(Command.PING), BuilderFactory.STRING); + String pingResponse = connection.executeCommand(pingCmd); + assertEquals("PONG", pingResponse); + + // Verify we received at least one push message + assertTrue(!receivedMessages.isEmpty(), "Should have received at least one push message"); + + // Verify the message is an invalidation message + PushEvent pushEvent = receivedMessages.get(0); + assertNotNull(pushEvent); + assertEquals("invalidate", pushEvent.getType()); + } + + @ParameterizedTest + @MethodSource("redis.clients.jedis.commands.CommandsTestsParameters#respVersions") + public void testUnifiedJedisPubSubWithResp3PushNotifications(RedisProtocol protocol) throws InterruptedException { + // Create a UnifiedJedis instance with RESP3 protocol for subscribing + unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(protocol).build()); + + // Enable client tracking to generate push notifications + unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); + + // Set initial value to track + unifiedJedis.set(testKey, initialValue); + + // Get the key to track it + String getResponse = unifiedJedis.get(testKey); + assertEquals(initialValue, getResponse); + + // Create a list to store received pub/sub messages + final List receivedMessages = new ArrayList<>(); + + // Create an atomic counter to track received messages + final AtomicInteger messageCounter = new AtomicInteger(0); + + // Create a latch to signal when subscription is ready + final CountDownLatch subscriptionLatch = new CountDownLatch(1); + + // Create a JedisPubSub instance to handle pub/sub messages + JedisPubSub pubSub = new JedisPubSub() { + @Override + public void onMessage(String channel, String message) { + System.out.println("onMEssage from " + channel + " : " + message); + receivedMessages.add(message); + + // If we've received both messages, unsubscribe + if (messageCounter.incrementAndGet() == 2) { + this.unsubscribe("test-channel"); + } + } + + @Override + public void onUnsubscribe(String channel, int subscribedChannels) { + // Signal that subscription is ready + System.out.println("Unsubscribed from " + channel); + } + + @Override + public void onSubscribe(String channel, int subscribedChannels) { + // Signal that subscription is ready + subscriptionLatch.countDown(); + } + }; + + // Start a thread to handle the subscription + Thread subscriberThread = new Thread(() -> { + unifiedJedis.subscribe(pubSub, "test-channel"); + }); + + // Start the subscriber thread + subscriberThread.start(); + + // Start a thread to publish messages and trigger key invalidation + Thread publisherThread = new Thread(() -> { + try (UnifiedJedis publisher = new UnifiedJedis(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build())) { + + // Wait for subscription to be ready + try { + if (!subscriptionLatch.await(5, TimeUnit.SECONDS)) { + System.err.println("Timed out waiting for subscription to be ready"); + return; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // Publish a message + publisher.publish("test-channel", "test-message-1"); + + // Trigger key invalidation to generate a push notification + triggerKeyInvalidation(testKey, modifiedValue); + + // Publish another message + publisher.publish("test-channel", "test-message-2"); + } catch (Exception e) { + e.printStackTrace(); + } + }); + + // Start the publisher thread + publisherThread.start(); + + // Wait for the subscriber thread to complete (it will complete when unsubscribe is called) + subscriberThread.join(); + + // Wait for the publisher thread to complete + publisherThread.join(); + + // Verify that we received both pub/sub messages + assertEquals(2, receivedMessages.size(), "Should have received both pub/sub messages"); + assertEquals("test-message-1", receivedMessages.get(0)); + assertEquals("test-message-2", receivedMessages.get(1)); + + // Send a PING command to process any pending push messages + String pingResponse = unifiedJedis.ping(); + assertEquals("PONG", pingResponse); + } +} diff --git a/src/test/java/redis/clients/jedis/commands/jedis/PublishSubscribeCommandsTest.java b/src/test/java/redis/clients/jedis/commands/jedis/PublishSubscribeCommandsTest.java index 8d52908d27..fc698ad40e 100644 --- a/src/test/java/redis/clients/jedis/commands/jedis/PublishSubscribeCommandsTest.java +++ b/src/test/java/redis/clients/jedis/commands/jedis/PublishSubscribeCommandsTest.java @@ -22,7 +22,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.params.ParameterizedClass; diff --git a/src/test/java/redis/clients/jedis/csc/ClientSideCacheTestBase.java b/src/test/java/redis/clients/jedis/csc/ClientSideCacheTestBase.java index 7e13d98da3..808095d018 100644 --- a/src/test/java/redis/clients/jedis/csc/ClientSideCacheTestBase.java +++ b/src/test/java/redis/clients/jedis/csc/ClientSideCacheTestBase.java @@ -6,10 +6,12 @@ import org.apache.commons.pool2.impl.GenericObjectPoolConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.extension.RegisterExtension; import redis.clients.jedis.*; import redis.clients.jedis.util.RedisVersionCondition; +@Tag("ClientSideCache") @SinceRedisVersion(value = "7.4.0", message = "Jedis client-side caching is only supported with Redis 7.4 or later.") public abstract class ClientSideCacheTestBase { diff --git a/src/test/java/redis/clients/jedis/csc/UnifiedJedisClientSideCacheTestBase.java b/src/test/java/redis/clients/jedis/csc/UnifiedJedisClientSideCacheTestBase.java index fa4043799e..d70ac0e082 100644 --- a/src/test/java/redis/clients/jedis/csc/UnifiedJedisClientSideCacheTestBase.java +++ b/src/test/java/redis/clients/jedis/csc/UnifiedJedisClientSideCacheTestBase.java @@ -15,10 +15,12 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import redis.clients.jedis.JedisPubSub; import redis.clients.jedis.UnifiedJedis; +@Tag("ClientSideCache") public abstract class UnifiedJedisClientSideCacheTestBase { protected UnifiedJedis control; @@ -73,8 +75,7 @@ public void flushAll() { control.set("foo", "bar"); assertEquals("bar", jedis.get("foo")); control.flushAll(); - await().atMost(5, TimeUnit.SECONDS).pollInterval(50, TimeUnit.MILLISECONDS) - .untilAsserted(() -> assertNull(jedis.get("foo"))); + await().untilAsserted(() -> assertNull(jedis.get("foo"))); } } From 0f96e2d8d5a0228c39f5ac6e59639f921f8a0f44 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 23 Jun 2025 20:08:12 +0300 Subject: [PATCH 02/23] Introduce PushHandlerChain for composable push event handling This commit adds a new PushHandlerChain class that implements the Chain of Responsibility pattern for Redis RESP3 push message handling. Key features: - Allows composing multiple PushHandlers in a processing chain - Push events propagate through the complete chain in sequence - Events marked as not processed are propagated to the client application - Provides both constructor-based and fluent builder API for chain creation - Includes predefined handlers for common use cases (CONSUME_ALL, PROPAGATE_ALL) - Supports immutable chain transformations via methods like then(), The chain approach provides a flexible way to handle different types of push messages (invalidations, pub/sub, etc.) with specialized handlers while maintaining a clean separation of concerns. Example usage: PushHandlerChain chain = PushHandlerChain.of(loggingHandler) .then(invalidationHandler) .then(PushHandlerChain.PROPAGATE_PUB_SUB_PUSH_HANDLER); --- .../java/redis/clients/jedis/Connection.java | 40 ++++---- .../redis/clients/jedis/JedisPubSubBase.java | 22 +---- .../clients/jedis/JedisShardedPubSubBase.java | 96 +++++++------------ .../java/redis/clients/jedis/Protocol.java | 38 +++----- .../java/redis/clients/jedis/PushHandler.java | 7 +- ...lerOutput.java => PushHandlerContext.java} | 14 ++- .../clients/jedis/csc/CacheConnection.java | 26 ++--- .../clients/jedis/JedisPubSubBaseTest.java | 2 +- .../jedis/JedisShardedPubSubBaseTest.java | 2 +- .../redis/clients/jedis/ProtocolTest.java | 4 +- .../clients/jedis/PushNotificationTest.java | 8 +- 11 files changed, 112 insertions(+), 147 deletions(-) rename src/main/java/redis/clients/jedis/{PushHandlerOutput.java => PushHandlerContext.java} (52%) diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index 2db276d1c0..fa35b0221b 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -1,7 +1,5 @@ package redis.clients.jedis; -import static redis.clients.jedis.Protocol.PROPAGATE_ALL_PUSH_EVENT; -import static redis.clients.jedis.Protocol.PROPAGATE_NONE_PUSH_EVENT; import static redis.clients.jedis.util.SafeEncoder.encode; import java.io.Closeable; @@ -52,7 +50,11 @@ public class Connection implements Closeable { private AtomicReference currentCredentials = new AtomicReference<>(null); private AuthXManager authXManager; - private PushHandler pushListener = PROPAGATE_NONE_PUSH_EVENT; + public static final PushHandlerChain DEFAULT_PUSH_HANDLER_CHAIN = PushHandlerChain.of( + PushHandlerChain.CONSUME_ALL_HANDLER, // Default to don't propagate any push events to application + PushHandlerChain.PUBSUB_ONLY_HANDLER); // except for pub/sub events, + + private PushHandlerChain pushHandlers = DEFAULT_PUSH_HANDLER_CHAIN; public Connection() { this(Protocol.DEFAULT_HOST, Protocol.DEFAULT_PORT); @@ -308,7 +310,7 @@ public void setBroken() { public String getStatusCodeReply() { flush(); - final byte[] resp = (byte[]) readProtocolWithCheckingBroken(pushListener); + final byte[] resp = (byte[]) readProtocolWithCheckingBroken(pushHandlers); if (null == resp) { return null; } else { @@ -327,12 +329,12 @@ public String getBulkReply() { public byte[] getBinaryBulkReply() { flush(); - return (byte[]) readProtocolWithCheckingBroken(pushListener); + return (byte[]) readProtocolWithCheckingBroken(pushHandlers); } public Long getIntegerReply() { flush(); - return (Long) readProtocolWithCheckingBroken(pushListener); + return (Long) readProtocolWithCheckingBroken(pushHandlers); } public List getMultiBulkReply() { @@ -342,7 +344,7 @@ public List getMultiBulkReply() { @SuppressWarnings("unchecked") public List getBinaryMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(pushListener); + return (List) readProtocolWithCheckingBroken(pushHandlers); } /** @@ -351,28 +353,28 @@ public List getBinaryMultiBulkReply() { @Deprecated @SuppressWarnings("unchecked") public List getUnflushedObjectMultiBulkReply() { - return (List) readProtocolWithCheckingBroken(pushListener); + return (List) readProtocolWithCheckingBroken(pushHandlers); } @SuppressWarnings("unchecked") - public Object getUnflushedObject(PushHandler listener) { - return readProtocolWithCheckingBroken(listener); + public Object getUnflushedObject() { + return readProtocolWithCheckingBroken(pushHandlers); } public List getObjectMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(pushListener); + return (List) readProtocolWithCheckingBroken(pushHandlers); } @SuppressWarnings("unchecked") public List getIntegerMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(pushListener); + return (List) readProtocolWithCheckingBroken(pushHandlers); } public Object getOne() { flush(); - return readProtocolWithCheckingBroken(pushListener); + return readProtocolWithCheckingBroken(pushHandlers); } protected void flush() { @@ -468,7 +470,7 @@ public List getMany(final int count) { final List responses = new ArrayList<>(count); for (int i = 0; i < count; i++) { try { - responses.add(readProtocolWithCheckingBroken(pushListener)); + responses.add(readProtocolWithCheckingBroken(pushHandlers)); } catch (JedisDataException e) { responses.add(e); } @@ -660,8 +662,14 @@ protected AuthXManager getAuthXManager() { } @Experimental - public void setPushHandler(PushHandler listener) { - this.pushListener = listener; + public void setPushHandlers(PushHandlerChain handlers) { + this.pushHandlers = handlers; } + @Experimental + public PushHandlerChain getPushHandlers() { + return this.pushHandlers; + } + + } diff --git a/src/main/java/redis/clients/jedis/JedisPubSubBase.java b/src/main/java/redis/clients/jedis/JedisPubSubBase.java index 8a464374dc..91fee36c58 100644 --- a/src/main/java/redis/clients/jedis/JedisPubSubBase.java +++ b/src/main/java/redis/clients/jedis/JedisPubSubBase.java @@ -1,12 +1,9 @@ package redis.clients.jedis; -import static redis.clients.jedis.Protocol.PROPAGATE_ALL_PUSH_EVENT; import static redis.clients.jedis.Protocol.ResponseKeyword.*; import java.util.Arrays; -import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.function.Consumer; import redis.clients.jedis.Protocol.Command; @@ -130,28 +127,11 @@ public final void proceedWithPatterns(Connection client, T... patterns) { protected abstract T encode(byte[] raw); - - private static final Set pubSubCommands = new HashSet<>(); - static { - pubSubCommands.add("message"); - pubSubCommands.add("smessage"); - pubSubCommands.add("subscribe"); - pubSubCommands.add("ssubscribe"); - pubSubCommands.add("psubscribe"); - pubSubCommands.add("unsubscribe"); - pubSubCommands.add("sunsubscribe"); - pubSubCommands.add("punsubscribe"); - } - - private boolean isPubSubType(String type) { - return pubSubCommands.contains(type); - } - // private void process(Client client) { private void process() { do { - Object reply = authenticator.client.getUnflushedObject(PROPAGATE_ALL_PUSH_EVENT); + Object reply = authenticator.client.getUnflushedObject(); if (reply instanceof List) { List listReply = (List) reply; diff --git a/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java b/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java index e0fe3d25f8..3a81bde3f7 100644 --- a/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java +++ b/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java @@ -1,6 +1,5 @@ package redis.clients.jedis; -import static redis.clients.jedis.Protocol.PROPAGATE_ALL_PUSH_EVENT; import static redis.clients.jedis.Protocol.ResponseKeyword.*; import java.util.Arrays; @@ -73,70 +72,49 @@ public final void proceed(Connection client, T... channels) { protected abstract T encode(byte[] raw); - private static final Set pubSubCommands = new HashSet<>(); - static { - pubSubCommands.add("message"); - pubSubCommands.add("smessage"); - pubSubCommands.add("subscribe"); - pubSubCommands.add("ssubscribe"); - pubSubCommands.add("psubscribe"); - pubSubCommands.add("unsubscribe"); - pubSubCommands.add("sunsubscribe"); - pubSubCommands.add("punsubscribe"); - } - - private boolean isPubSubType(String type) { - return pubSubCommands.contains(type); - } - private void process() { do { - Object reply = authenticator.client.getUnflushedObject(PROPAGATE_ALL_PUSH_EVENT); - - processReply(reply); + Object reply = authenticator.client.getUnflushedObject(); + + if (reply instanceof List) { + List listReply = (List) reply; + final Object firstObj = listReply.get(0); + if (!(firstObj instanceof byte[])) { + throw new JedisException("Unknown message type: " + firstObj); + } + final byte[] resp = (byte[]) firstObj; + if (Arrays.equals(SSUBSCRIBE.getRaw(), resp)) { + subscribedChannels = ((Long) listReply.get(2)).intValue(); + final byte[] bchannel = (byte[]) listReply.get(1); + final T enchannel = (bchannel == null) ? null : encode(bchannel); + onSSubscribe(enchannel, subscribedChannels); + } else if (Arrays.equals(SUNSUBSCRIBE.getRaw(), resp)) { + subscribedChannels = ((Long) listReply.get(2)).intValue(); + final byte[] bchannel = (byte[]) listReply.get(1); + final T enchannel = (bchannel == null) ? null : encode(bchannel); + onSUnsubscribe(enchannel, subscribedChannels); + } else if (Arrays.equals(SMESSAGE.getRaw(), resp)) { + final byte[] bchannel = (byte[]) listReply.get(1); + final byte[] bmesg = (byte[]) listReply.get(2); + final T enchannel = (bchannel == null) ? null : encode(bchannel); + final T enmesg = (bmesg == null) ? null : encode(bmesg); + onSMessage(enchannel, enmesg); + } else { + throw new JedisException("Unknown message type: " + firstObj); + } + } else if (reply instanceof byte[]) { + Consumer resultHandler = authenticator.resultHandler.poll(); + if (resultHandler == null) { + throw new JedisException("Unexpected message : " + SafeEncoder.encode((byte[]) reply)); + } + resultHandler.accept(reply); + } else { + throw new JedisException("Unknown message type: " + reply); + } } while (!Thread.currentThread().isInterrupted() && isSubscribed()); // /* Invalidate instance since this thread is no longer listening */ // this.client = null; } - - private void processReply(Object reply) { - if (reply instanceof List) { - List listReply = (List) reply; - final Object firstObj = listReply.get(0); - if (!(firstObj instanceof byte[])) { - throw new JedisException("Unknown message type: " + firstObj); - } - final byte[] resp = (byte[]) firstObj; - if (Arrays.equals(SSUBSCRIBE.getRaw(), resp)) { - subscribedChannels = ((Long) listReply.get(2)).intValue(); - final byte[] bchannel = (byte[]) listReply.get(1); - final T enchannel = (bchannel == null) ? null : encode(bchannel); - onSSubscribe(enchannel, subscribedChannels); - } else if (Arrays.equals(SUNSUBSCRIBE.getRaw(), resp)) { - subscribedChannels = ((Long) listReply.get(2)).intValue(); - final byte[] bchannel = (byte[]) listReply.get(1); - final T enchannel = (bchannel == null) ? null : encode(bchannel); - onSUnsubscribe(enchannel, subscribedChannels); - } else if (Arrays.equals(SMESSAGE.getRaw(), resp)) { - final byte[] bchannel = (byte[]) listReply.get(1); - final byte[] bmesg = (byte[]) listReply.get(2); - final T enchannel = (bchannel == null) ? null : encode(bchannel); - final T enmesg = (bmesg == null) ? null : encode(bmesg); - onSMessage(enchannel, enmesg); - } else { - throw new JedisException("Unknown message type: " + firstObj); - } - } else if (reply instanceof byte[]) { - Consumer resultHandler = authenticator.resultHandler.poll(); - if (resultHandler == null) { - throw new JedisException("Unexpected message : " + SafeEncoder.encode((byte[]) reply)); - } - resultHandler.accept(reply); - } else { - throw new JedisException("Unknown message type: " + reply); - } - } - } diff --git a/src/main/java/redis/clients/jedis/Protocol.java b/src/main/java/redis/clients/jedis/Protocol.java index 8a6c430a70..6e8b748547 100644 --- a/src/main/java/redis/clients/jedis/Protocol.java +++ b/src/main/java/redis/clients/jedis/Protocol.java @@ -19,6 +19,8 @@ import redis.clients.jedis.util.RedisOutputStream; import redis.clients.jedis.util.SafeEncoder; +import static redis.clients.jedis.PushHandlerChain.PROPAGATE_ALL_HANDLER; + public final class Protocol { public static final String DEFAULT_HOST = "127.0.0.1"; @@ -65,16 +67,6 @@ public final class Protocol { private static final String NOPERM_PREFIX = "NOPERM"; private static final byte[] INVALIDATE_BYTES = SafeEncoder.encode("invalidate"); - public static final PushHandler PROPAGATE_ALL_PUSH_EVENT = (pe) ->{ - System.out.println("PROPAGATE_ALL_PUSH_EVENT" + pe.getType()); - return new PushHandlerOutput(pe, false); - }; - - public static final PushHandler PROPAGATE_NONE_PUSH_EVENT = (pe) ->{ - - System.out.println("PROPAGATE_NONE_PUSH_EVENT.handlePushMessage: " + pe.getType()); - return new PushHandlerOutput(pe, true); - }; private Protocol() { throw new InstantiationError("Must not instantiate this class"); @@ -230,15 +222,14 @@ private static List processMapKeyValueReply(final RedisInputStream is) } public static Object read(final RedisInputStream is) { - // for backward compatibility - // propagate all push events to application - Object reply = process(is, PROPAGATE_ALL_PUSH_EVENT); + // for backward compatibility propagate all push events to application + Object reply = process(is, PROPAGATE_ALL_HANDLER); - if (reply != null & reply instanceof PushHandlerOutput) { - if (((PushHandlerOutput) reply).isProcessed()) { + if (reply != null & reply instanceof PushHandlerContext) { + if (((PushHandlerContext) reply).isProcessed()) { return null; } - return ((PushHandlerOutput) reply).getMessage().getContent(); + return ((PushHandlerContext) reply).getMessage().getContent(); } return reply; @@ -252,21 +243,21 @@ public static Object read(final RedisInputStream is, PushHandler pushConsumer) { do { reply = process(is, pushConsumer); - } while (isPush(reply) && isProcessed((PushHandlerOutput) reply) ); + } while (isPush(reply) && isProcessed((PushHandlerContext) reply) ); if ( isPush(reply)) { - return ((PushHandlerOutput) reply).getMessage().getContent(); + return ((PushHandlerContext) reply).getMessage().getContent(); } return reply; } - private static boolean isProcessed(PushHandlerOutput reply) { + private static boolean isProcessed(PushHandlerContext reply) { return reply.isProcessed(); } private static boolean isPush(Object reply) { - return reply instanceof PushHandlerOutput; + return reply instanceof PushHandlerContext; } // @Experimental @@ -297,10 +288,11 @@ public static Object readPushes(final RedisInputStream is, final Cache cache, return unhandledPush; } - private static PushHandlerOutput processPush(final RedisInputStream is, PushHandler handler) { + private static PushHandlerContext processPush(final RedisInputStream is, PushHandler handler) { List list = processMultiBulkReply(is); - - return handler.handlePushMessage(new PushEvent(list)); + PushHandlerContext context = new PushHandlerContext(new PushEvent(list)); + handler.handlePushMessage(context); + return context; } private static Object processPush(final RedisInputStream is, Cache cache) { diff --git a/src/main/java/redis/clients/jedis/PushHandler.java b/src/main/java/redis/clients/jedis/PushHandler.java index 9e59bc9624..b1fa1f58b9 100644 --- a/src/main/java/redis/clients/jedis/PushHandler.java +++ b/src/main/java/redis/clients/jedis/PushHandler.java @@ -6,10 +6,11 @@ public interface PushHandler { /** * Handle a push message. * + * Messages are not processed by default. Handlers should update the context's processed flag to true if they + * have processed the message. * - * @param message message to respond to. - * @return push message to propagate, or null to stop propagation. + * @param context The context of the message to respond to. */ - PushHandlerOutput handlePushMessage(PushEvent message); + void handlePushMessage(PushHandlerContext context); } diff --git a/src/main/java/redis/clients/jedis/PushHandlerOutput.java b/src/main/java/redis/clients/jedis/PushHandlerContext.java similarity index 52% rename from src/main/java/redis/clients/jedis/PushHandlerOutput.java rename to src/main/java/redis/clients/jedis/PushHandlerContext.java index 526d4a0552..2eb016f77d 100644 --- a/src/main/java/redis/clients/jedis/PushHandlerOutput.java +++ b/src/main/java/redis/clients/jedis/PushHandlerContext.java @@ -1,12 +1,11 @@ package redis.clients.jedis; -public class PushHandlerOutput { - PushEvent message; - boolean processed; +public class PushHandlerContext { + private final PushEvent message; + private boolean processed = false; - public PushHandlerOutput(PushEvent message, boolean processed) { + public PushHandlerContext(PushEvent message) { this.message = message; - this.processed = processed; } public PushEvent getMessage() { @@ -16,4 +15,9 @@ public PushEvent getMessage() { public boolean isProcessed() { return processed; } + + public void setProcessed(boolean processed) { + this.processed = processed; + } + } diff --git a/src/main/java/redis/clients/jedis/csc/CacheConnection.java b/src/main/java/redis/clients/jedis/csc/CacheConnection.java index 1745c5b1b1..33c9ece611 100644 --- a/src/main/java/redis/clients/jedis/csc/CacheConnection.java +++ b/src/main/java/redis/clients/jedis/csc/CacheConnection.java @@ -9,9 +9,9 @@ import redis.clients.jedis.JedisClientConfig; import redis.clients.jedis.JedisSocketFactory; import redis.clients.jedis.Protocol; -import redis.clients.jedis.PushEvent; import redis.clients.jedis.PushHandler; -import redis.clients.jedis.PushHandlerOutput; +import redis.clients.jedis.PushHandlerChain; +import redis.clients.jedis.PushHandlerContext; import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.exceptions.JedisException; import redis.clients.jedis.util.RedisInputStream; @@ -19,11 +19,11 @@ public class CacheConnection extends Connection { private final Cache cache; - private final PushEventInvalidateHandler invalidateHandler; private ReentrantLock lock; private static final String REDIS = "redis"; private static final String MIN_REDIS_VERSION = "7.4"; + private final PushHandlerChain pushHandlerChain; private static class PushEventInvalidateHandler implements PushHandler { private final Cache cache; public PushEventInvalidateHandler(Cache cache) { @@ -31,13 +31,14 @@ public PushEventInvalidateHandler(Cache cache) { } @Override - public PushHandlerOutput handlePushMessage(PushEvent message) { - if (message.getType().equals("invalidate")) { - System.out.println("PushEventInvalidateHandler.handlePushMessage: " + message.getType()); - cache.deleteByRedisKeys((List) message.getContent().get(1)); - return new PushHandlerOutput(message, true); + public void handlePushMessage(PushHandlerContext event) { + if (event.getMessage().getType().equals("invalidate")) { + System.out.println("PushEventInvalidateHandler.handlePushMessage: " + event.getMessage().getType()); + cache.deleteByRedisKeys((List) event.getMessage().getContent().get(1)); + event.setProcessed(true); + //return new PushHandlerContext(message, true); } - return new PushHandlerOutput(message, false); + //return new PushHandlerContext(message, false); } } @@ -55,8 +56,9 @@ public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig } } this.cache = Objects.requireNonNull(cache); - this.invalidateHandler = new PushEventInvalidateHandler(cache); - setPushHandler(invalidateHandler); + + this.pushHandlerChain = PushHandlerChain.of(new PushEventInvalidateHandler(cache)); + setPushHandlers(pushHandlerChain); initializeClientSideCache(); } @@ -71,7 +73,7 @@ protected Object protocolRead(RedisInputStream inputStream, PushHandler listener lock.lock(); try { // return Protocol.read(inputStream, cache); - return Protocol.read(inputStream, invalidateHandler); + return Protocol.read(inputStream, getPushHandlers()); } finally { lock.unlock(); } diff --git a/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java b/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java index 1525ef59bf..1c3d58025e 100644 --- a/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java +++ b/src/test/java/redis/clients/jedis/JedisPubSubBaseTest.java @@ -41,7 +41,7 @@ protected String encode(byte[] raw) { MESSAGE.getRaw(), "channel".getBytes(), "message".getBytes() ); - when(mockConnection.getUnflushedObject(any())). + when(mockConnection.getUnflushedObject()). thenReturn(mockSubscribe, mockResponse); diff --git a/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java b/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java index 0897070b03..af22522869 100644 --- a/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java +++ b/src/test/java/redis/clients/jedis/JedisShardedPubSubBaseTest.java @@ -41,7 +41,7 @@ protected String encode(byte[] raw) { final List mockResponse = Arrays.asList( SMESSAGE.getRaw(), "channel".getBytes(), "message".getBytes() ); - when(mockConnection.getUnflushedObject(any())).thenReturn(mockSubscribe, mockResponse); + when(mockConnection.getUnflushedObject()).thenReturn(mockSubscribe, mockResponse); final CountDownLatch countDownLatch = new CountDownLatch(1); diff --git a/src/test/java/redis/clients/jedis/ProtocolTest.java b/src/test/java/redis/clients/jedis/ProtocolTest.java index 4996a42b44..b456834776 100644 --- a/src/test/java/redis/clients/jedis/ProtocolTest.java +++ b/src/test/java/redis/clients/jedis/ProtocolTest.java @@ -146,7 +146,7 @@ public void busyReply() { public void readWithPushListener() { // Create a mock push listener final List receivedMessages = new ArrayList<>(); - PushHandler listener = message -> { receivedMessages.add(message); return null; }; + PushHandler listener = pushContext -> { receivedMessages.add(pushContext.getMessage()); }; // Create a stream with a push message followed by a regular response byte[] data = (">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nfoo\r\n+OK\r\n").getBytes(); @@ -176,7 +176,7 @@ public void readWithPushListener() { public void readWithMultiplePushMessages() { // Create a mock push listener final List receivedMessages = new ArrayList<>(); - PushHandler listener = message -> { receivedMessages.add(message); return null; }; + PushHandler listener = pushContext -> { receivedMessages.add(pushContext.getMessage()); pushContext.setProcessed(true); }; // Create a stream with multiple push messages followed by a regular response diff --git a/src/test/java/redis/clients/jedis/PushNotificationTest.java b/src/test/java/redis/clients/jedis/PushNotificationTest.java index 2e7687f6d4..56900c20c3 100644 --- a/src/test/java/redis/clients/jedis/PushNotificationTest.java +++ b/src/test/java/redis/clients/jedis/PushNotificationTest.java @@ -144,8 +144,7 @@ public void testConnectionResp3PushNotificationsWithCustomListener() { List receivedMessages = new ArrayList<>(); // Create a custom push listener - PushHandler listener = message -> { receivedMessages.add(message); return null; }; - + PushHandler listener = pushContext -> { receivedMessages.add(pushContext.getMessage());}; // Create connection with RESP3 protocol connection = new Connection(endpoint.getHostAndPort(), @@ -153,7 +152,8 @@ public void testConnectionResp3PushNotificationsWithCustomListener() { connection.connect(); // Set the push listener - connection.setPushHandler(listener); + PushHandlerChain chain = PushHandlerChain.of(PushHandlerChain.CONSUME_ALL_HANDLER).add(listener); + connection.setPushHandlers(chain); // Enable client tracking enableClientTracking(connection); @@ -213,7 +213,7 @@ public void testUnifiedJedisPubSubWithResp3PushNotifications(RedisProtocol proto JedisPubSub pubSub = new JedisPubSub() { @Override public void onMessage(String channel, String message) { - System.out.println("onMEssage from " + channel + " : " + message); + System.out.println("onMessage from " + channel + " : " + message); receivedMessages.add(message); // If we've received both messages, unsubscribe From 4aec187225f1b55b0e8cbb7851fdfec2274b3e76 Mon Sep 17 00:00:00 2001 From: ggivo Date: Wed, 25 Jun 2025 19:52:56 +0300 Subject: [PATCH 03/23] Handle relax timeout for maintenance events - code clean up - added relaxed timeout configuration - fix unit tests --- .../clients/jedis/AdaptiveTimeoutHandler.java | 56 +++++ .../java/redis/clients/jedis/Connection.java | 205 ++++++++++++----- .../clients/jedis/ConnectionFactory.java | 28 ++- .../redis/clients/jedis/ConnectionPool.java | 6 + .../jedis/DefaultJedisClientConfig.java | 17 ++ .../clients/jedis/JedisClientConfig.java | 4 + .../java/redis/clients/jedis/Protocol.java | 40 ++-- .../redis/clients/jedis/PushConsumer.java | 19 ++ .../clients/jedis/PushConsumerChain.java | 210 ++++++++++++++++++ ...rContext.java => PushConsumerContext.java} | 11 +- .../redis/clients/jedis/PushHandelrImpl.java | 24 ++ .../java/redis/clients/jedis/PushHandler.java | 61 ++++- .../redis/clients/jedis/PushListener.java | 16 ++ .../{PushEvent.java => PushMessage.java} | 4 +- .../redis/clients/jedis/TimeoutOptions.java | 79 +++++++ .../redis/clients/jedis/UnifiedJedis.java | 35 ++- .../clients/jedis/csc/CacheConnection.java | 46 ++-- .../providers/PooledConnectionProvider.java | 7 + .../clients/jedis/util/JedisAsserts.java | 25 +++ .../redis/clients/jedis/ProtocolTest.java | 85 +++++-- ....java => PushMessageNotificationTest.java} | 43 +++- 21 files changed, 872 insertions(+), 149 deletions(-) create mode 100644 src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java create mode 100644 src/main/java/redis/clients/jedis/PushConsumer.java create mode 100644 src/main/java/redis/clients/jedis/PushConsumerChain.java rename src/main/java/redis/clients/jedis/{PushHandlerContext.java => PushConsumerContext.java} (57%) create mode 100644 src/main/java/redis/clients/jedis/PushHandelrImpl.java create mode 100644 src/main/java/redis/clients/jedis/PushListener.java rename src/main/java/redis/clients/jedis/{PushEvent.java => PushMessage.java} (84%) create mode 100644 src/main/java/redis/clients/jedis/TimeoutOptions.java create mode 100644 src/main/java/redis/clients/jedis/util/JedisAsserts.java rename src/test/java/redis/clients/jedis/{PushNotificationTest.java => PushMessageNotificationTest.java} (88%) diff --git a/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java b/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java new file mode 100644 index 0000000000..9385321140 --- /dev/null +++ b/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java @@ -0,0 +1,56 @@ +package redis.clients.jedis; + +/** + * Implementation of MaintenanceListener that manages connection timeout relaxation + * during Redis server maintenance events like migration and failover. + */ +public class AdaptiveTimeoutHandler implements PushConsumer { + + Connection connection; + + + /** + * Creates a new maintenance listener for the specified connection. + * + * @param connection The connection to manage timeouts for + */ + public AdaptiveTimeoutHandler(Connection connection) { + this.connection = connection; + } + + @Override + public void accept(PushConsumerContext context) { + String type = context.getMessage().getType(); + + switch (type) { + case "MIGRATING": + onMigrating(); + break; + case "MIGRATED": + onMigrated();; + break; + case "FAILING_OVER": + onFailOver(); + break; + case "FAILED_OVER": + onFailedOver(); + break; + } + } + + private void onMigrating() { + connection.relaxTimeouts(); + } + + private void onMigrated() { + connection.disableRelaxedTimeout(); + } + + private void onFailOver() { + connection.relaxTimeouts(); + } + + private void onFailedOver() { + connection.disableRelaxedTimeout(); + } +} \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index fa35b0221b..02dd0c2906 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -1,5 +1,6 @@ package redis.clients.jedis; +import static redis.clients.jedis.PushConsumerChain.PROPAGATE_ALL_HANDLER; import static redis.clients.jedis.util.SafeEncoder.encode; import java.io.Closeable; @@ -9,6 +10,7 @@ import java.net.SocketException; import java.nio.ByteBuffer; import java.nio.CharBuffer; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -16,6 +18,8 @@ import java.util.function.Supplier; import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import redis.clients.jedis.Protocol.Command; import redis.clients.jedis.Protocol.Keyword; import redis.clients.jedis.annots.Experimental; @@ -32,7 +36,7 @@ import redis.clients.jedis.util.RedisOutputStream; public class Connection implements Closeable { - + public static Logger logger = LoggerFactory.getLogger(Connection.class); private ConnectionPool memberOf; protected RedisProtocol protocol; @@ -41,6 +45,7 @@ public class Connection implements Closeable { private RedisOutputStream outputStream; private RedisInputStream inputStream; private int soTimeout = 0; + private Duration relaxedTimeout = TimeoutOptions.DISABLED_TIMEOUT; private int infiniteSoTimeout = 0; private boolean broken = false; private boolean strValActive; @@ -49,12 +54,11 @@ public class Connection implements Closeable { protected String version; private AtomicReference currentCredentials = new AtomicReference<>(null); private AuthXManager authXManager; + private boolean relaxedTimeoutActive = false; - public static final PushHandlerChain DEFAULT_PUSH_HANDLER_CHAIN = PushHandlerChain.of( - PushHandlerChain.CONSUME_ALL_HANDLER, // Default to don't propagate any push events to application - PushHandlerChain.PUBSUB_ONLY_HANDLER); // except for pub/sub events, - private PushHandlerChain pushHandlers = DEFAULT_PUSH_HANDLER_CHAIN; + protected PushConsumerChain pushConsumer; + private PushHandler pushHandler; public Connection() { this(Protocol.DEFAULT_HOST, Protocol.DEFAULT_PORT); @@ -75,15 +79,60 @@ public Connection(final HostAndPort hostAndPort, final JedisClientConfig clientC public Connection(final JedisSocketFactory socketFactory) { this.socketFactory = socketFactory; this.authXManager = null; + + initPushConsumers(null, null); } public Connection(final JedisSocketFactory socketFactory, JedisClientConfig clientConfig) { this.socketFactory = socketFactory; this.soTimeout = clientConfig.getSocketTimeoutMillis(); this.infiniteSoTimeout = clientConfig.getBlockingSocketTimeoutMillis(); + this.relaxedTimeout = clientConfig.getTimeoutOptions().getRelaxedTimeout(); + + initPushConsumers(null, clientConfig); initializeFromClientConfig(clientConfig); } + public Connection(final JedisSocketFactory socketFactory, JedisClientConfig clientConfig, PushHandler pushHandler) { + this.socketFactory = socketFactory; + this.soTimeout = clientConfig.getSocketTimeoutMillis(); + this.infiniteSoTimeout = clientConfig.getBlockingSocketTimeoutMillis(); + this.relaxedTimeout = clientConfig.getTimeoutOptions().getRelaxedTimeout(); + + initPushConsumers(pushHandler, clientConfig); + initializeFromClientConfig(clientConfig); + } + + protected void initPushConsumers(PushHandler pushHandler, JedisClientConfig config) { + /* + * Default consumers to process push messages. + * Marks all @{link PushMessage}s as processed, except for pub/sub. + * Pub/sub messages are propagated to the client. + */ + this.pushConsumer = PushConsumerChain.of( + PushConsumerChain.CONSUME_ALL_HANDLER, + PushConsumerChain.PUBSUB_ONLY_HANDLER + ); + + /* + * If the user has enabled relaxed timeouts, add consumer to handle push messages + * related to server maintenance events. + */ + if (TimeoutOptions.isRelaxedTimeoutEnabled(config.getTimeoutOptions().getRelaxedTimeout())) { + PushConsumer maintenanceHandler = new AdaptiveTimeoutHandler(Connection.this); + this.pushConsumer.add(maintenanceHandler); + } + + /* + * If the user has provided a {@link PushHandler}, + * add consumer to notify {@link PushListener}s, without changing the processed flag. + */ + this.pushHandler = pushHandler; + if (this.pushHandler != null) { + this.pushConsumer.add(new ListenerNotificationConsumer(pushHandler)); + } + } + @Override public String toString() { return getClass().getSimpleName() + "{" + socketFactory + "}"; @@ -310,7 +359,7 @@ public void setBroken() { public String getStatusCodeReply() { flush(); - final byte[] resp = (byte[]) readProtocolWithCheckingBroken(pushHandlers); + final byte[] resp = (byte[]) readProtocolWithCheckingBroken(pushConsumer); if (null == resp) { return null; } else { @@ -329,12 +378,12 @@ public String getBulkReply() { public byte[] getBinaryBulkReply() { flush(); - return (byte[]) readProtocolWithCheckingBroken(pushHandlers); + return (byte[]) readProtocolWithCheckingBroken(pushConsumer); } public Long getIntegerReply() { flush(); - return (Long) readProtocolWithCheckingBroken(pushHandlers); + return (Long) readProtocolWithCheckingBroken(pushConsumer); } public List getMultiBulkReply() { @@ -344,7 +393,7 @@ public List getMultiBulkReply() { @SuppressWarnings("unchecked") public List getBinaryMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(pushHandlers); + return (List) readProtocolWithCheckingBroken(pushConsumer); } /** @@ -353,28 +402,28 @@ public List getBinaryMultiBulkReply() { @Deprecated @SuppressWarnings("unchecked") public List getUnflushedObjectMultiBulkReply() { - return (List) readProtocolWithCheckingBroken(pushHandlers); + return (List) readProtocolWithCheckingBroken(pushConsumer); } @SuppressWarnings("unchecked") public Object getUnflushedObject() { - return readProtocolWithCheckingBroken(pushHandlers); + return readProtocolWithCheckingBroken(pushConsumer); } public List getObjectMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(pushHandlers); + return (List) readProtocolWithCheckingBroken(pushConsumer); } @SuppressWarnings("unchecked") public List getIntegerMultiBulkReply() { flush(); - return (List) readProtocolWithCheckingBroken(pushHandlers); + return (List) readProtocolWithCheckingBroken(pushConsumer); } public Object getOne() { flush(); - return readProtocolWithCheckingBroken(pushHandlers); + return readProtocolWithCheckingBroken(pushConsumer); } protected void flush() { @@ -386,66 +435,45 @@ protected void flush() { } } - // ----------- - // PUB_SUB - // --------- - // we can consume pending pushes before reading the next reply - // do { - // obj = Protocol.read(is); - // } while (obj == PushOutput && PushOutput.skip() == true); - // - // Not filtered push messages are propagated to the client - // Will block and wait for next not-filtered PushMessage or command output - // - // --- - // executeCommand() - // --------------- - // we can consume pending pushes before reading the next reply - // do { - // obj = Protocol.read(is); - // } while (obj == PushOutput && PushOutput.skip() == true); - // this will block and wait for next not-filtered PushMessage or command output - // @Experimental - protected Object protocolRead(RedisInputStream is, PushHandler listener) { - - return Protocol.read(is, listener); + protected Object protocolRead(RedisInputStream is, PushConsumer handler) { + return Protocol.read(is, handler); } -// @Experimental -// protected Object protocolRead(RedisInputStream is) { -// return Protocol.read(is); -// } - @Experimental protected void protocolReadPushes(RedisInputStream is) { } - protected Object readProtocolWithCheckingBroken(PushHandler listener) { + protected Object readProtocolWithCheckingBroken(PushConsumer handler) { if (broken) { throw new JedisConnectionException("Attempting to read from a broken connection."); } try { - return protocolRead(inputStream, listener); + return protocolRead(inputStream, handler); } catch (JedisConnectionException exc) { broken = true; throw exc; } } -// protected Object readProtocolWithCheckingBroken() { -// if (broken) { -// throw new JedisConnectionException("Attempting to read from a broken connection."); -// } -// -// try { -// return protocolRead(inputStream); -// } catch (JedisConnectionException exc) { -// broken = true; -// throw exc; -// } -// } + /** + * @deprecated Use {@link #readProtocolWithCheckingBroken(PushConsumer)} + * @return + */ + @Deprecated + protected Object readProtocolWithCheckingBroken() { + if (broken) { + throw new JedisConnectionException("Attempting to read from a broken connection."); + } + + try { + return protocolRead(inputStream, PROPAGATE_ALL_HANDLER); + } catch (JedisConnectionException exc) { + broken = true; + throw exc; + } + } protected void readPushesWithCheckingBroken() { if (broken) { @@ -470,7 +498,7 @@ public List getMany(final int count) { final List responses = new ArrayList<>(count); for (int i = 0; i < count; i++) { try { - responses.add(readProtocolWithCheckingBroken(pushHandlers)); + responses.add(readProtocolWithCheckingBroken(pushConsumer)); } catch (JedisDataException e) { responses.add(e); } @@ -662,14 +690,69 @@ protected AuthXManager getAuthXManager() { } @Experimental - public void setPushHandlers(PushHandlerChain handlers) { - this.pushHandlers = handlers; + public PushConsumerChain getPushConsumer() { + return this.pushConsumer; } @Experimental - public PushHandlerChain getPushHandlers() { - return this.pushHandlers; + public void relaxTimeouts() { + if (!relaxedTimeoutActive && !TimeoutOptions.isRelaxedTimeoutDisabled(relaxedTimeout)) { + relaxedTimeoutActive = true; + try { + if (isConnected()) { + socket.setSoTimeout((int) relaxedTimeout.toMillis()); + } + } catch (SocketException ex) { + setBroken(); + throw new JedisConnectionException(ex); + } + } + } + + @Experimental + public void disableRelaxedTimeout() { + if (relaxedTimeoutActive) { + relaxedTimeoutActive = false; + try { + if (isConnected()) { + socket.setSoTimeout(soTimeout); + } + } catch (SocketException ex) { + setBroken(); + throw new JedisConnectionException(ex); + } + } } + /** + * Push consumer that delegates to a {@link PushHandler} for listener notification. + */ + private static class ListenerNotificationConsumer implements PushConsumer { + private final PushHandler pushHandler; + + public ListenerNotificationConsumer(PushHandler pushHandler) { + this.pushHandler = pushHandler; + } + @Override + public void accept(PushConsumerContext context) { + if (pushHandler != null) { + notifyListeners(context.getMessage()); + } + } + + private void notifyListeners(PushMessage pushMessage) { + try { + pushHandler.getPushListeners().forEach(pushListener -> { + try { + pushListener.onPush(pushMessage); + } catch (Exception e) { + // Log individual listener failures + } + }); + } catch (Exception e) { + // Log notification failures + } + } + } } diff --git a/src/main/java/redis/clients/jedis/ConnectionFactory.java b/src/main/java/redis/clients/jedis/ConnectionFactory.java index 7440417152..99afad6868 100644 --- a/src/main/java/redis/clients/jedis/ConnectionFactory.java +++ b/src/main/java/redis/clients/jedis/ConnectionFactory.java @@ -29,32 +29,45 @@ public class ConnectionFactory implements PooledObjectFactory { private final Supplier objectMaker; private final AuthXEventListener authXEventListener; + private final PushHandler pushHandler; public ConnectionFactory(final HostAndPort hostAndPort) { - this(hostAndPort, DefaultJedisClientConfig.builder().build(), null); + this(hostAndPort, DefaultJedisClientConfig.builder().build(), (Cache) null); } public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig) { - this(hostAndPort, clientConfig, null); + this(hostAndPort, clientConfig, (Cache) null); + } + + @Experimental + public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig, PushHandler pushHandler) { + this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, (Cache) null, pushHandler); } @Experimental public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig, Cache csCache) { - this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, csCache); + this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, csCache, null); + } + + @Experimental + public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig, + Cache csCache, PushHandler pushHandler) { + this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, csCache, pushHandler); } public ConnectionFactory(final JedisSocketFactory jedisSocketFactory, final JedisClientConfig clientConfig) { - this(jedisSocketFactory, clientConfig, null); + this(jedisSocketFactory, clientConfig, null, null); } private ConnectionFactory(final JedisSocketFactory jedisSocketFactory, - final JedisClientConfig clientConfig, Cache csCache) { + final JedisClientConfig clientConfig, Cache csCache, PushHandler pushHandler) { this.jedisSocketFactory = jedisSocketFactory; this.clientSideCache = csCache; this.clientConfig = clientConfig; + this.pushHandler = pushHandler; AuthXManager authXManager = clientConfig.getAuthXManager(); if (authXManager == null) { @@ -69,8 +82,9 @@ private ConnectionFactory(final JedisSocketFactory jedisSocketFactory, } private Supplier connectionSupplier() { - return clientSideCache == null ? () -> new Connection(jedisSocketFactory, clientConfig) - : () -> new CacheConnection(jedisSocketFactory, clientConfig, clientSideCache); + + return clientSideCache == null ? () -> new Connection(jedisSocketFactory, clientConfig, pushHandler) + : () -> new CacheConnection(jedisSocketFactory, clientConfig, clientSideCache, pushHandler); } @Override diff --git a/src/main/java/redis/clients/jedis/ConnectionPool.java b/src/main/java/redis/clients/jedis/ConnectionPool.java index 2ae1401081..5a6ebc0266 100644 --- a/src/main/java/redis/clients/jedis/ConnectionPool.java +++ b/src/main/java/redis/clients/jedis/ConnectionPool.java @@ -18,6 +18,12 @@ public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig) { attachAuthenticationListener(clientConfig.getAuthXManager()); } + @Experimental + public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig, PushHandler pushHandler) { + this(new ConnectionFactory(hostAndPort, clientConfig, pushHandler)); + attachAuthenticationListener(clientConfig.getAuthXManager()); + } + @Experimental public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig, Cache clientSideCache) { diff --git a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java index 25a4737ec0..353e8145eb 100644 --- a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java @@ -33,6 +33,8 @@ public final class DefaultJedisClientConfig implements JedisClientConfig { private final AuthXManager authXManager; + private final TimeoutOptions timeoutOptions; + private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.redisProtocol = builder.redisProtocol; this.connectionTimeoutMillis = builder.connectionTimeoutMillis; @@ -50,6 +52,7 @@ private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.clientSetInfoConfig = builder.clientSetInfoConfig; this.readOnlyForRedisClusterReplicas = builder.readOnlyForRedisClusterReplicas; this.authXManager = builder.authXManager; + this.timeoutOptions = builder.timeoutOptions; } @Override @@ -143,6 +146,11 @@ public boolean isReadOnlyForRedisClusterReplicas() { return readOnlyForRedisClusterReplicas; } + @Override + public TimeoutOptions getTimeoutOptions() { + return timeoutOptions; + } + public static Builder builder() { return new Builder(); } @@ -175,6 +183,8 @@ public static class Builder { private AuthXManager authXManager = null; + private TimeoutOptions timeoutOptions = TimeoutOptions.create(); + private Builder() { } @@ -297,6 +307,11 @@ public Builder authXManager(AuthXManager authXManager) { return this; } + public Builder timeoutOptions(TimeoutOptions timeoutOptions) { + this.timeoutOptions = timeoutOptions; + return this; + } + public Builder from(JedisClientConfig instance) { this.redisProtocol = instance.getRedisProtocol(); this.connectionTimeoutMillis = instance.getConnectionTimeoutMillis(); @@ -314,6 +329,7 @@ public Builder from(JedisClientConfig instance) { this.clientSetInfoConfig = instance.getClientSetInfoConfig(); this.readOnlyForRedisClusterReplicas = instance.isReadOnlyForRedisClusterReplicas(); this.authXManager = instance.getAuthXManager(); + this.timeoutOptions = instance.getTimeoutOptions(); return this; } } @@ -375,6 +391,7 @@ public static DefaultJedisClientConfig copyConfig(JedisClientConfig copy) { } builder.authXManager(copy.getAuthXManager()); + builder.timeoutOptions(copy.getTimeoutOptions()); return builder.build(); } diff --git a/src/main/java/redis/clients/jedis/JedisClientConfig.java b/src/main/java/redis/clients/jedis/JedisClientConfig.java index ce7fd82de4..9475a903ef 100644 --- a/src/main/java/redis/clients/jedis/JedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/JedisClientConfig.java @@ -35,6 +35,10 @@ default int getBlockingSocketTimeoutMillis() { return 0; } + default TimeoutOptions getTimeoutOptions() { + return TimeoutOptions.create(); + } + /** * @return Redis ACL user */ diff --git a/src/main/java/redis/clients/jedis/Protocol.java b/src/main/java/redis/clients/jedis/Protocol.java index 6e8b748547..88258d71e4 100644 --- a/src/main/java/redis/clients/jedis/Protocol.java +++ b/src/main/java/redis/clients/jedis/Protocol.java @@ -19,7 +19,7 @@ import redis.clients.jedis.util.RedisOutputStream; import redis.clients.jedis.util.SafeEncoder; -import static redis.clients.jedis.PushHandlerChain.PROPAGATE_ALL_HANDLER; +import static redis.clients.jedis.PushConsumerChain.PROPAGATE_ALL_HANDLER; public final class Protocol { @@ -129,7 +129,7 @@ private static String[] parseTargetHostAndSlot(String clusterRedirectResponse) { return response; } - private static Object process(final RedisInputStream is, PushHandler handler) { + private static Object process(final RedisInputStream is, PushConsumer pushConsumer) { final byte b = is.readByte(); //System.out.println("BYTE: " + (char) b); switch (b) { @@ -155,9 +155,8 @@ private static Object process(final RedisInputStream is, PushHandler handler) { case TILDE_BYTE: // TODO: return processMultiBulkReply(is); case GREATER_THAN_BYTE: - // return processMultiBulkReply(is); - // returns PushHandlerOutput - wraps a PushMessage - return processPush(is, handler); + // return processMultiBulkReply(is) + return processPush(is, pushConsumer); case MINUS_BYTE: processError(is); return null; @@ -225,50 +224,43 @@ public static Object read(final RedisInputStream is) { // for backward compatibility propagate all push events to application Object reply = process(is, PROPAGATE_ALL_HANDLER); - if (reply != null & reply instanceof PushHandlerContext) { - if (((PushHandlerContext) reply).isProcessed()) { + if (reply != null & reply instanceof PushConsumerContext) { + if (((PushConsumerContext) reply).isProcessed()) { return null; } - return ((PushHandlerContext) reply).getMessage().getContent(); + return ((PushConsumerContext) reply).getMessage().getContent(); } return reply; } @Experimental - public static Object read(final RedisInputStream is, PushHandler pushConsumer) { + public static Object read(final RedisInputStream is, PushConsumer pushConsumer) { // read until we have a non-push event, // or push-event is not handled and need to be propagated to application Object reply; do { reply = process(is, pushConsumer); - } while (isPush(reply) && isProcessed((PushHandlerContext) reply) ); + } while (isPush(reply) && isProcessed((PushConsumerContext) reply) ); if ( isPush(reply)) { - return ((PushHandlerContext) reply).getMessage().getContent(); + return ((PushConsumerContext) reply).getMessage().getContent(); } return reply; } - private static boolean isProcessed(PushHandlerContext reply) { + private static boolean isProcessed(PushConsumerContext reply) { return reply.isProcessed(); } private static boolean isPush(Object reply) { - return reply instanceof PushHandlerContext; + return reply instanceof PushConsumerContext; } - // @Experimental -// public static Object read(final RedisInputStream is, final Cache cache) { -// Object unhandledPush = readPushes(is, cache, false); -// return unhandledPush == null ? process(is) : unhandledPush; -// } - - - @Experimental // TODO : Refactor to use PushHandler + @Experimental public static Object readPushes(final RedisInputStream is, final Cache cache, boolean onlyPendingBuffer) { Object unhandledPush = null; @@ -288,10 +280,10 @@ public static Object readPushes(final RedisInputStream is, final Cache cache, return unhandledPush; } - private static PushHandlerContext processPush(final RedisInputStream is, PushHandler handler) { + private static PushConsumerContext processPush(final RedisInputStream is, PushConsumer handler) { List list = processMultiBulkReply(is); - PushHandlerContext context = new PushHandlerContext(new PushEvent(list)); - handler.handlePushMessage(context); + PushConsumerContext context = new PushConsumerContext(new PushMessage(list)); + handler.accept(context); return context; } diff --git a/src/main/java/redis/clients/jedis/PushConsumer.java b/src/main/java/redis/clients/jedis/PushConsumer.java new file mode 100644 index 0000000000..73ff3c04c4 --- /dev/null +++ b/src/main/java/redis/clients/jedis/PushConsumer.java @@ -0,0 +1,19 @@ +package redis.clients.jedis; + +import redis.clients.jedis.annots.Internal; + +@Internal +@FunctionalInterface +public interface PushConsumer { + + /** + * Handle a push message. + * + * Messages are not processed by default. Handlers should update the context's processed flag to true if they + * have processed the message. + * + * @param context The context of the message to respond to. + */ + void accept(PushConsumerContext context); + +} diff --git a/src/main/java/redis/clients/jedis/PushConsumerChain.java b/src/main/java/redis/clients/jedis/PushConsumerChain.java new file mode 100644 index 0000000000..12e5c1507a --- /dev/null +++ b/src/main/java/redis/clients/jedis/PushConsumerChain.java @@ -0,0 +1,210 @@ +package redis.clients.jedis; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.annots.Internal; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A chain of PushHandlers that processes events in order. + * Uses a context object for tracking the processed state. + */ +@Internal +public final class PushConsumerChain implements PushConsumer { + private static final Logger log = LoggerFactory.getLogger(PushConsumerChain.class); + + private final List consumers; + + /** + * Handler that consumes all push events, preventing them from being propagated to the client. + * Always marks events as processed. + */ + public static final PushConsumer CONSUME_ALL_HANDLER = (context) -> { + // Always mark as processed, never propagate + context.setProcessed(true); + }; + + /** + * Handler that allows all push events to be propagated to the client. + */ + public static final PushConsumer PROPAGATE_ALL_HANDLER = (context) -> { + // mark as not-processed, always propagate + context.setProcessed(false); + }; + + + /** + * Handler that allows only pub/sub related events to be propagated to the client. + * Marks non-pub/sub events as processed, preventing their propagation. + */ + public static final PushConsumer PUBSUB_ONLY_HANDLER = new PushConsumer(){ + final Set pubSubCommands = new HashSet<>(); + { + pubSubCommands.add("message"); + pubSubCommands.add("pmessage"); + pubSubCommands.add("smessage"); + pubSubCommands.add("subscribe"); + pubSubCommands.add("ssubscribe"); + pubSubCommands.add("psubscribe"); + pubSubCommands.add("unsubscribe"); + pubSubCommands.add("sunsubscribe"); + pubSubCommands.add("punsubscribe"); + } + + @Override + public void accept(PushConsumerContext context) { + if (isPubSubType(context.getMessage().getType())) { + // Ensure pub/sub events are propagated to application + context.setProcessed(false); + } + } + + private boolean isPubSubType(String type) { + return pubSubCommands.contains(type); + } + }; + + + /** + * Create a new empty handler chain. + */ + public PushConsumerChain() { + this.consumers = new ArrayList<>(); + } + + /** + * Create a chain with the specified handlers. + * + * @param consumers The handlers to add to the chain + */ + public PushConsumerChain(PushConsumer... consumers) { + this.consumers = new ArrayList<>(Arrays.asList(consumers)); + } + + /** + * Create a chain with a single handler. + * + * @param handler The handler to include in the chain + * @return A new handler chain with the specified handler + */ + public static PushConsumerChain of(PushConsumer handler) { + return new PushConsumerChain(handler); + } + + /** + * Create a chain with the specified handlers. + * + * @param handlers The handlers to add to the chain + * @return A new handler chain with the specified handlers + */ + public static PushConsumerChain of(PushConsumer... handlers) { + return new PushConsumerChain(handlers); + } + + /** + * Add a handler to be executed after this chain. + * + * @param handler The handler to add + * @return A new chain with the handler added + */ + public PushConsumerChain then(PushConsumer handler) { + if (handler != null) { + PushConsumerChain newChain = new PushConsumerChain(); + for (PushConsumer h : consumers) { + newChain.add(h); + } + newChain.add(handler); + return newChain; + } + return this; + } + + /** + * Add a handler to the end of the chain. + * + * @param handler The handler to add + * @return this chain for method chaining + */ + public PushConsumerChain add(PushConsumer handler) { + if (handler != null) { + consumers.add(handler); + } + return this; + } + + /** + * Insert a handler at the specified position. + * + * @param index The position to insert at (0-based) + * @param handler The handler to insert + * @return this chain for method chaining + */ + public PushConsumerChain insert(int index, PushConsumer handler) { + if (handler != null) { + consumers.add(index, handler); + } + return this; + } + + /** + * Remove a handler from the chain. + * + * @param handler The handler to remove + * @return true if the handler was removed + */ + public boolean remove(PushConsumer handler) { + return consumers.remove(handler); + } + + /** + * Remove the handler at the specified position. + * + * @param index The position to remove from (0-based) + * @return The removed handler + */ + public PushConsumer removeAt(int index) { + return consumers.remove(index); + } + + /** + * Get the handler at the specified position. + * + * @param index The position to get (0-based) + * @return The handler at that position + */ + public PushConsumer get(int index) { + return consumers.get(index); + } + + /** + * Get the number of handlers in the chain. + * + * @return The number of handlers + */ + public int size() { + return consumers.size(); + } + + /** + * Clear all handlers from the chain. + */ + public void clear() { + consumers.clear(); + } + + @Override + public void accept(PushConsumerContext context) { + if (consumers.isEmpty()) { + return; + } + + for (PushConsumer handler : consumers) { + handler.accept(context); + } + } +} \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/PushHandlerContext.java b/src/main/java/redis/clients/jedis/PushConsumerContext.java similarity index 57% rename from src/main/java/redis/clients/jedis/PushHandlerContext.java rename to src/main/java/redis/clients/jedis/PushConsumerContext.java index 2eb016f77d..65977c4865 100644 --- a/src/main/java/redis/clients/jedis/PushHandlerContext.java +++ b/src/main/java/redis/clients/jedis/PushConsumerContext.java @@ -1,14 +1,17 @@ package redis.clients.jedis; -public class PushHandlerContext { - private final PushEvent message; +import redis.clients.jedis.annots.Internal; + +@Internal +public class PushConsumerContext { + private final PushMessage message; private boolean processed = false; - public PushHandlerContext(PushEvent message) { + public PushConsumerContext(PushMessage message) { this.message = message; } - public PushEvent getMessage() { + public PushMessage getMessage() { return message; } diff --git a/src/main/java/redis/clients/jedis/PushHandelrImpl.java b/src/main/java/redis/clients/jedis/PushHandelrImpl.java new file mode 100644 index 0000000000..d3e02f2e15 --- /dev/null +++ b/src/main/java/redis/clients/jedis/PushHandelrImpl.java @@ -0,0 +1,24 @@ +package redis.clients.jedis; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +class PushHandlerImpl implements PushHandler { + private final List pushListeners = new CopyOnWriteArrayList<>(); + + @Override + public void addListener(PushListener listener) { + pushListeners.add(listener); + } + + @Override + public void removeListener(PushListener listener) { + pushListeners.remove(listener); + } + + @Override + public Collection getPushListeners() { + return pushListeners; + } +} diff --git a/src/main/java/redis/clients/jedis/PushHandler.java b/src/main/java/redis/clients/jedis/PushHandler.java index b1fa1f58b9..724c7e471b 100644 --- a/src/main/java/redis/clients/jedis/PushHandler.java +++ b/src/main/java/redis/clients/jedis/PushHandler.java @@ -1,16 +1,65 @@ package redis.clients.jedis; -@FunctionalInterface +import java.util.Collection; +import java.util.Collections; + +/** + * A handler object that provides access to {@link PushListener}. + * + * @author Ivo Gaydajiev + * @since 6.1 + */ public interface PushHandler { /** - * Handle a push message. + * Add a new {@link PushListener listener}. + * + * @param listener the listener, must not be {@code null}. + */ + void addListener(PushListener listener); + + /** + * Remove an existing {@link PushListener listener}. * - * Messages are not processed by default. Handlers should update the context's processed flag to true if they - * have processed the message. + * @param listener the listener, must not be {@code null}. + */ + void removeListener(PushListener listener); + + /** + * Returns a collection of {@link PushListener}. * - * @param context The context of the message to respond to. + * @return the collection of listeners. */ - void handlePushMessage(PushHandlerContext context); + Collection getPushListeners(); + /** + * A no-operation implementation of PushHandler that doesn't maintain any listeners. + * All operations are no-ops and getPushListeners() returns an empty list. + * Implemented as a singleton to avoid unnecessary object creation. + */ + PushHandler NOOP = new NoOpPushHandler(); } + +/** + * Singleton implementation of a no-operation PushHandler. + */ +final class NoOpPushHandler implements PushHandler { + + // Package-private constructor to prevent external instantiation + NoOpPushHandler() {} + + @Override + public void addListener(PushListener listener) { + // No-op + } + + @Override + public void removeListener(PushListener listener) { + // No-op + } + + @Override + public Collection getPushListeners() { + return Collections.emptyList(); + } +} \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/PushListener.java b/src/main/java/redis/clients/jedis/PushListener.java new file mode 100644 index 0000000000..345f102c83 --- /dev/null +++ b/src/main/java/redis/clients/jedis/PushListener.java @@ -0,0 +1,16 @@ +package redis.clients.jedis; + +@FunctionalInterface +public interface PushListener { + + /** + * Interface to be implemented by push message listeners that are interested in listening to {@link PushMessage}. Requires Redis + * 6+ using RESP3. + * + * @author Ivo Gaydajiev + * @since 6.1 + * @see PushMessage + */ + void onPush(PushMessage push); + +} diff --git a/src/main/java/redis/clients/jedis/PushEvent.java b/src/main/java/redis/clients/jedis/PushMessage.java similarity index 84% rename from src/main/java/redis/clients/jedis/PushEvent.java rename to src/main/java/redis/clients/jedis/PushMessage.java index 4368d6c557..ffb3bba516 100644 --- a/src/main/java/redis/clients/jedis/PushEvent.java +++ b/src/main/java/redis/clients/jedis/PushMessage.java @@ -4,11 +4,11 @@ import java.util.List; -public class PushEvent { +public class PushMessage { String type; List content; - public PushEvent(List content) { + public PushMessage(List content) { this.content = content; if (content.size() > 0) { type = SafeEncoder.encode((byte[]) content.get(0)); diff --git a/src/main/java/redis/clients/jedis/TimeoutOptions.java b/src/main/java/redis/clients/jedis/TimeoutOptions.java new file mode 100644 index 0000000000..b6cad7a9a6 --- /dev/null +++ b/src/main/java/redis/clients/jedis/TimeoutOptions.java @@ -0,0 +1,79 @@ +package redis.clients.jedis; + +import redis.clients.jedis.util.JedisAsserts; + +import java.time.Duration; + +public class TimeoutOptions { + + public static final Duration DISABLED_TIMEOUT = Duration.ZERO.minusSeconds(1); + + public static final Duration DEFAULT_RELAXED_TIMEOUT = DISABLED_TIMEOUT; + + private final Duration relaxedTimeout; + + private TimeoutOptions(Duration relaxedTimeout) { + this.relaxedTimeout = relaxedTimeout; + } + + public static boolean isRelaxedTimeoutEnabled(Duration relaxedTimeout) { + return relaxedTimeout != null && !relaxedTimeout.equals(DISABLED_TIMEOUT); + } + + public static boolean isRelaxedTimeoutDisabled(Duration relaxedTimeout) { + return relaxedTimeout == null || relaxedTimeout.equals(DISABLED_TIMEOUT); + } + + /** + * @return the {@link Duration} to relax timeouts proactively, {@link #DISABLED_TIMEOUT} if disabled. + */ + public Duration getRelaxedTimeout() { + return relaxedTimeout; + } + + /** + * Returns a new {@link TimeoutOptions.Builder} to construct {@link TimeoutOptions}. + * + * @return a new {@link TimeoutOptions.Builder} to construct {@link TimeoutOptions}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Create a new instance of {@link TimeoutOptions} with default settings. + * + * @return a new instance of {@link TimeoutOptions} with default settings. + */ + public static TimeoutOptions create() { + return builder().build(); + } + + public static class Builder { + private Duration relaxedTimeout = DEFAULT_RELAXED_TIMEOUT; + + /** + * Enable proactive timeout relaxing. Disabled by default, see {@link #DEFAULT_RELAXED_TIMEOUT}. + *

+ * If the Redis server supports this, and the client is set up to use it + * , the client would listen to notifications that the current + * endpoint is about to go down (as part of some maintenance activity, for example). In such cases, the driver could + * extend the existing timeout settings for newly issued commands, or such that are in flight, to make sure they do not + * time out during this process. + * + * @param duration {@link Duration} to relax timeouts proactively, must not be {@code null}. + * @return {@code this} + */ + public Builder proactiveTimeoutsRelaxing(Duration duration) { + JedisAsserts.notNull(duration, "Duration must not be null"); + + this.relaxedTimeout = duration; + return this; + } + + public TimeoutOptions build() { + return new TimeoutOptions(relaxedTimeout); + } + } + +} diff --git a/src/main/java/redis/clients/jedis/UnifiedJedis.java b/src/main/java/redis/clients/jedis/UnifiedJedis.java index e3960862fa..d1edc2c7ef 100644 --- a/src/main/java/redis/clients/jedis/UnifiedJedis.java +++ b/src/main/java/redis/clients/jedis/UnifiedJedis.java @@ -2,6 +2,7 @@ import java.net.URI; import java.time.Duration; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; @@ -56,6 +57,7 @@ public class UnifiedJedis implements JedisCommands, JedisBinaryCommands, protected final CommandObjects commandObjects; private JedisBroadcastAndRoundRobinConfig broadcastAndRoundRobinConfig = null; private final Cache cache; + private final PushHandler pushHandler; public UnifiedJedis() { this(new HostAndPort(Protocol.DEFAULT_HOST, Protocol.DEFAULT_PORT)); @@ -101,7 +103,11 @@ public UnifiedJedis(final URI uri, JedisClientConfig config) { } public UnifiedJedis(HostAndPort hostAndPort, JedisClientConfig clientConfig) { - this(new PooledConnectionProvider(hostAndPort, clientConfig), clientConfig.getRedisProtocol()); + this(hostAndPort, clientConfig, new PushHandlerImpl()); + } + + private UnifiedJedis(HostAndPort hostAndPort, JedisClientConfig clientConfig, PushHandler pushHandler) { + this(new PooledConnectionProvider(hostAndPort, clientConfig, pushHandler), clientConfig.getRedisProtocol(), pushHandler); } @Experimental @@ -122,6 +128,10 @@ protected UnifiedJedis(ConnectionProvider provider, RedisProtocol protocol) { this(new DefaultCommandExecutor(provider), provider, new CommandObjects(), protocol); } + private UnifiedJedis(ConnectionProvider provider, RedisProtocol protocol, PushHandler pushHandler) { + this(new DefaultCommandExecutor(provider), provider, new CommandObjects(), protocol, null, pushHandler); + } + @Experimental protected UnifiedJedis(ConnectionProvider provider, RedisProtocol protocol, Cache cache) { this(new DefaultCommandExecutor(provider), provider, new CommandObjects(), protocol, cache); @@ -157,6 +167,7 @@ public UnifiedJedis(Connection connection) { this.provider = null; this.executor = new SimpleCommandExecutor(connection); this.commandObjects = new CommandObjects(); + this.pushHandler = null; RedisProtocol proto = connection.getRedisProtocol(); if (proto != null) { this.commandObjects.setProtocol(proto); @@ -280,6 +291,12 @@ private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, Comm @Experimental private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, CommandObjects commandObjects, RedisProtocol protocol, Cache cache) { + this(executor, provider, commandObjects, protocol, cache, null); + } + + @Experimental + private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, CommandObjects commandObjects, + RedisProtocol protocol, Cache cache, PushHandler pushHandler) { if (cache != null && protocol != RedisProtocol.RESP3) { throw new IllegalArgumentException("Client-side caching is only supported with RESP3."); @@ -294,6 +311,7 @@ private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, Comm } this.cache = cache; + this.pushHandler = pushHandler; } @Override @@ -307,6 +325,21 @@ protected final void setProtocol(RedisProtocol protocol) { this.commandObjects.setProtocol(this.protocol); } + @Experimental + public void addListener(PushListener listener) { + pushHandler.addListener(listener); + } + + @Experimental + public void removeListener(PushListener listener) { + pushHandler.removeListener(listener); + } + + @Experimental + public Collection getPushListeners() { + return pushHandler.getPushListeners(); + } + public final T executeCommand(CommandObject commandObject) { return executor.executeCommand(commandObject); } diff --git a/src/main/java/redis/clients/jedis/csc/CacheConnection.java b/src/main/java/redis/clients/jedis/csc/CacheConnection.java index 33c9ece611..27a67d4881 100644 --- a/src/main/java/redis/clients/jedis/csc/CacheConnection.java +++ b/src/main/java/redis/clients/jedis/csc/CacheConnection.java @@ -9,9 +9,9 @@ import redis.clients.jedis.JedisClientConfig; import redis.clients.jedis.JedisSocketFactory; import redis.clients.jedis.Protocol; +import redis.clients.jedis.PushConsumer; +import redis.clients.jedis.PushConsumerContext; import redis.clients.jedis.PushHandler; -import redis.clients.jedis.PushHandlerChain; -import redis.clients.jedis.PushHandlerContext; import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.exceptions.JedisException; import redis.clients.jedis.util.RedisInputStream; @@ -23,22 +23,18 @@ public class CacheConnection extends Connection { private static final String REDIS = "redis"; private static final String MIN_REDIS_VERSION = "7.4"; - private final PushHandlerChain pushHandlerChain; - private static class PushEventInvalidateHandler implements PushHandler { + private static class PushInvalidateConsumer implements PushConsumer { private final Cache cache; - public PushEventInvalidateHandler(Cache cache) { + public PushInvalidateConsumer(Cache cache) { this.cache = cache; } @Override - public void handlePushMessage(PushHandlerContext event) { + public void accept(PushConsumerContext event) { if (event.getMessage().getType().equals("invalidate")) { - System.out.println("PushEventInvalidateHandler.handlePushMessage: " + event.getMessage().getType()); cache.deleteByRedisKeys((List) event.getMessage().getContent().get(1)); event.setProcessed(true); - //return new PushHandlerContext(message, true); } - //return new PushHandlerContext(message, false); } } @@ -57,11 +53,35 @@ public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig } this.cache = Objects.requireNonNull(cache); - this.pushHandlerChain = PushHandlerChain.of(new PushEventInvalidateHandler(cache)); - setPushHandlers(pushHandlerChain); + initializeClientSideCache(); } + public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig clientConfig, Cache cache, PushHandler pushHandler) { + super(socketFactory, clientConfig, pushHandler); + + if (protocol != RedisProtocol.RESP3) { + throw new JedisException("Client side caching is only supported with RESP3."); + } + if (!cache.compatibilityMode()) { + RedisVersion current = new RedisVersion(version); + RedisVersion required = new RedisVersion(MIN_REDIS_VERSION); + if (!REDIS.equals(server) || current.compareTo(required) < 0) { + throw new JedisException(String.format("Client side caching is only supported with 'Redis %s' or later.", MIN_REDIS_VERSION)); + } + } + this.cache = Objects.requireNonNull(cache); + + + initializeClientSideCache(); + } + + @Override + protected void initPushConsumers(PushHandler pushHandler, JedisClientConfig config) { + super.initPushConsumers(pushHandler, config); + this.pushConsumer.add(new PushInvalidateConsumer(cache)); + } + @Override protected void initializeFromClientConfig(JedisClientConfig config) { lock = new ReentrantLock(); @@ -69,11 +89,11 @@ protected void initializeFromClientConfig(JedisClientConfig config) { } @Override - protected Object protocolRead(RedisInputStream inputStream, PushHandler listener) { + protected Object protocolRead(RedisInputStream inputStream, PushConsumer listener) { lock.lock(); try { // return Protocol.read(inputStream, cache); - return Protocol.read(inputStream, getPushHandlers()); + return Protocol.read(inputStream, pushConsumer); } finally { lock.unlock(); } diff --git a/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java b/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java index ddbd768f9b..6dc5361eee 100644 --- a/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java +++ b/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java @@ -11,6 +11,7 @@ import redis.clients.jedis.ConnectionPool; import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisClientConfig; +import redis.clients.jedis.PushHandler; import redis.clients.jedis.annots.Experimental; import redis.clients.jedis.csc.Cache; import redis.clients.jedis.util.Pool; @@ -30,6 +31,12 @@ public PooledConnectionProvider(HostAndPort hostAndPort, JedisClientConfig clien this.connectionMapKey = hostAndPort; } + @Experimental + public PooledConnectionProvider(HostAndPort hostAndPort, JedisClientConfig clientConfig, PushHandler pushHandler) { + this(new ConnectionPool(hostAndPort, clientConfig, pushHandler)); + this.connectionMapKey = hostAndPort; + } + @Experimental public PooledConnectionProvider(HostAndPort hostAndPort, JedisClientConfig clientConfig, Cache clientSideCache) { this(new ConnectionPool(hostAndPort, clientConfig, clientSideCache)); diff --git a/src/main/java/redis/clients/jedis/util/JedisAsserts.java b/src/main/java/redis/clients/jedis/util/JedisAsserts.java new file mode 100644 index 0000000000..54970bf2c9 --- /dev/null +++ b/src/main/java/redis/clients/jedis/util/JedisAsserts.java @@ -0,0 +1,25 @@ +package redis.clients.jedis.util; + +import java.time.Duration; + +/** + * Assertion utility class that assists in validating arguments. This class is part of the internal API and may change without + * further notice. + * + * @author ivo.gaydazhiev + */ +public class JedisAsserts { + + /** + * Assert that an object is not {@code null} . + * + * @param object the object to check + * @param message the exception message to use if the assertion fails + * @throws IllegalArgumentException if the object is {@code null} + */ + public static void notNull(Object object, String message) { + if (object == null) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/test/java/redis/clients/jedis/ProtocolTest.java b/src/test/java/redis/clients/jedis/ProtocolTest.java index b456834776..68515d6f04 100644 --- a/src/test/java/redis/clients/jedis/ProtocolTest.java +++ b/src/test/java/redis/clients/jedis/ProtocolTest.java @@ -143,40 +143,43 @@ public void busyReply() { } @Test - public void readWithPushListener() { + public void readPushEventsAreNotPropagatedAsReadOutputIfProcessed() { // Create a mock push listener - final List receivedMessages = new ArrayList<>(); - PushHandler listener = pushContext -> { receivedMessages.add(pushContext.getMessage()); }; + final List receivedMessages = new ArrayList<>(); + PushConsumer handler = pushContext -> { + receivedMessages.add(pushContext.getMessage()); + pushContext.setProcessed(true); + }; // Create a stream with a push message followed by a regular response byte[] data = (">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nfoo\r\n+OK\r\n").getBytes(); RedisInputStream is = new RedisInputStream(new ByteArrayInputStream(data)); // Read the response, which should process the push message first - Object response = Protocol.read(is, listener); + Object response = Protocol.read(is, handler); // Verify the response assertArrayEquals(SafeEncoder.encode("OK"), (byte[]) response); // Verify the push message was received assertEquals(1, receivedMessages.size()); - PushEvent pushEvent = receivedMessages.get(0); - assertEquals(2, pushEvent.getContent().size()); - assertEquals("invalidate", pushEvent.getType()); - assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushEvent.getContent().get(0)); + PushMessage pushMessage = receivedMessages.get(0); + assertEquals(2, pushMessage.getContent().size()); + assertEquals("invalidate", pushMessage.getType()); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushMessage.getContent().get(0)); // The second element should be a list with one element "foo" - assertInstanceOf(List.class, pushEvent.getContent().get(1)); - List keys = (List) pushEvent.getContent().get(1); + assertInstanceOf(List.class, pushMessage.getContent().get(1)); + List keys = (List) pushMessage.getContent().get(1); assertEquals(1, keys.size()); assertArrayEquals(SafeEncoder.encode("foo"), (byte[]) keys.get(0)); } @Test - public void readWithMultiplePushMessages() { + public void readMultiplePushEventsAreNotPropagatedAsReadOutputIfProcessed() { // Create a mock push listener - final List receivedMessages = new ArrayList<>(); - PushHandler listener = pushContext -> { receivedMessages.add(pushContext.getMessage()); pushContext.setProcessed(true); }; + final List receivedMessages = new ArrayList<>(); + PushConsumer handler = pushContext -> { receivedMessages.add(pushContext.getMessage()); pushContext.setProcessed(true); }; // Create a stream with multiple push messages followed by a regular response @@ -189,7 +192,7 @@ public void readWithMultiplePushMessages() { RedisInputStream is = new RedisInputStream(new ByteArrayInputStream(data)); // Read the response, which should process all push messages first - Object response = Protocol.read(is, listener); + Object response = Protocol.read(is, handler); // Verify the response assertEquals(123L, response); @@ -198,21 +201,57 @@ public void readWithMultiplePushMessages() { assertEquals(3, receivedMessages.size()); // First push message (invalidate foo) - PushEvent pushEvent1 = receivedMessages.get(0); - assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushEvent1.getContent().get(0)); - List keys1 = (List) pushEvent1.getContent().get(1); + PushMessage pushMessage1 = receivedMessages.get(0); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushMessage1.getContent().get(0)); + List keys1 = (List) pushMessage1.getContent().get(1); assertArrayEquals(SafeEncoder.encode("foo"), (byte[]) keys1.get(0)); // Second push message (invalidate bar) - PushEvent pushEvent2 = receivedMessages.get(1); - assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushEvent2.getContent().get(0)); - List keys2 = (List) pushEvent2.getContent().get(1); + PushMessage pushMessage2 = receivedMessages.get(1); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) pushMessage2.getContent().get(0)); + List keys2 = (List) pushMessage2.getContent().get(1); assertArrayEquals(SafeEncoder.encode("bar"), (byte[]) keys2.get(0)); // Third push message (message hello) - PushEvent pushEvent3 = receivedMessages.get(2); - assertArrayEquals(SafeEncoder.encode("message"), (byte[]) pushEvent3.getContent().get(0)); - assertArrayEquals(SafeEncoder.encode("hello"), (byte[]) pushEvent3.getContent().get(1)); + PushMessage pushMessage3 = receivedMessages.get(2); + assertArrayEquals(SafeEncoder.encode("message"), (byte[]) pushMessage3.getContent().get(0)); + assertArrayEquals(SafeEncoder.encode("hello"), (byte[]) pushMessage3.getContent().get(1)); } + @Test + public void readPushEventsArePropagateAsReadOutputIfNotProcessed() { + // Create a mock push listener + final List receivedMessages = new ArrayList<>(); + PushConsumer handler = pushContext -> { + receivedMessages.add(pushContext.getMessage()); + pushContext.setProcessed(false); + }; + + // Create a stream with a push message followed by a regular response + byte[] data = (">2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nfoo\r\n+OK\r\n").getBytes(); + RedisInputStream is = new RedisInputStream(new ByteArrayInputStream(data)); + + // Read the response, which should return + // - invoke the push handler with the push message + // - propagate the push message as the read output since it was not processed + Object pushMessage = Protocol.read(is, handler); + + // Verify the push message is propagated as the read output + assertInstanceOf(ArrayList.class, pushMessage); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) ((ArrayList) pushMessage).get(0)); + + // Verify the handler receives the push message + assertEquals(1, receivedMessages.size()); + PushMessage push = receivedMessages.get(0); + assertEquals(2, push.getContent().size()); + assertEquals("invalidate", push.getType()); + assertArrayEquals(SafeEncoder.encode("invalidate"), (byte[]) push.getContent().get(0)); + + + // Second read should return the command response itself + Object commandResponse = Protocol.read(is, handler); + + // Verify the response + assertArrayEquals(SafeEncoder.encode("OK"), (byte[]) commandResponse); + } } diff --git a/src/test/java/redis/clients/jedis/PushNotificationTest.java b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java similarity index 88% rename from src/test/java/redis/clients/jedis/PushNotificationTest.java rename to src/test/java/redis/clients/jedis/PushMessageNotificationTest.java index 56900c20c3..be39788068 100644 --- a/src/test/java/redis/clients/jedis/PushNotificationTest.java +++ b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java @@ -25,7 +25,7 @@ * Tests for Redis RESP3 push notifications functionality. */ @SinceRedisVersion("6.0.0") -public class PushNotificationTest { +public class PushMessageNotificationTest { private static final EndpointConfig endpoint = HostAndPorts.getRedisEndpoint("standalone0"); @@ -136,15 +136,43 @@ public void testUnifiedJedisResp3PushNotifications() { // Next reply should be PONG assertEquals("PONG", pingResponse); } + + @Test + public void testUnifiedJedisCustomPushListener() { + unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); + + List receivedMessages = new ArrayList<>(); + unifiedJedis.addListener(receivedMessages::add); + + // Enable client tracking + unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); + + // Set initial value + unifiedJedis.set(testKey, initialValue); + + // Get the key to track it + assertEquals(initialValue, unifiedJedis.get(testKey)); + + // Modify the key from another connection to trigger invalidation + triggerKeyInvalidation(testKey, modifiedValue); + + // Send PING command + String pingResponse = unifiedJedis.ping(); + // Next reply should be PONG + assertEquals("PONG", pingResponse); + assertEquals(1, receivedMessages.size()); + assertEquals("invalidate", receivedMessages.get(0).getType()); + } @Test public void testConnectionResp3PushNotificationsWithCustomListener() { // Create a list to store received push messages - List receivedMessages = new ArrayList<>(); + List receivedMessages = new ArrayList<>(); // Create a custom push listener - PushHandler listener = pushContext -> { receivedMessages.add(pushContext.getMessage());}; + PushConsumer listener = pushContext -> { receivedMessages.add(pushContext.getMessage());}; // Create connection with RESP3 protocol connection = new Connection(endpoint.getHostAndPort(), @@ -152,8 +180,7 @@ public void testConnectionResp3PushNotificationsWithCustomListener() { connection.connect(); // Set the push listener - PushHandlerChain chain = PushHandlerChain.of(PushHandlerChain.CONSUME_ALL_HANDLER).add(listener); - connection.setPushHandlers(chain); + connection.getPushConsumer().add(listener); // Enable client tracking enableClientTracking(connection); @@ -178,9 +205,9 @@ public void testConnectionResp3PushNotificationsWithCustomListener() { assertTrue(!receivedMessages.isEmpty(), "Should have received at least one push message"); // Verify the message is an invalidation message - PushEvent pushEvent = receivedMessages.get(0); - assertNotNull(pushEvent); - assertEquals("invalidate", pushEvent.getType()); + PushMessage pushMessage = receivedMessages.get(0); + assertNotNull(pushMessage); + assertEquals("invalidate", pushMessage.getType()); } @ParameterizedTest From ab62e9d5bbf07dfc5b14a7c5b513b17d70984965 Mon Sep 17 00:00:00 2001 From: ggivo Date: Tue, 1 Jul 2025 14:52:18 +0300 Subject: [PATCH 04/23] Support custom Push listeners for Jedis client --- .../java/redis/clients/jedis/Connection.java | 2 +- src/main/java/redis/clients/jedis/Jedis.java | 85 +++++++++++++++++++ .../jedis/PushMessageNotificationTest.java | 32 ++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index 02dd0c2906..57fe28a3bd 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -690,7 +690,7 @@ protected AuthXManager getAuthXManager() { } @Experimental - public PushConsumerChain getPushConsumer() { + PushConsumerChain getPushConsumer() { return this.pushConsumer; } diff --git a/src/main/java/redis/clients/jedis/Jedis.java b/src/main/java/redis/clients/jedis/Jedis.java index e19a4fa619..7eaec5c2cd 100644 --- a/src/main/java/redis/clients/jedis/Jedis.java +++ b/src/main/java/redis/clients/jedis/Jedis.java @@ -8,6 +8,7 @@ import java.io.Closeable; import java.net.URI; +import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -21,6 +22,7 @@ import javax.net.ssl.SSLSocketFactory; import redis.clients.jedis.Protocol.*; +import redis.clients.jedis.annots.Experimental; import redis.clients.jedis.args.*; import redis.clients.jedis.commands.*; import redis.clients.jedis.exceptions.InvalidURIException; @@ -38,6 +40,7 @@ public class Jedis implements ServerCommands, DatabaseCommands, JedisCommands, J protected final Connection connection; private final CommandObjects commandObjects = new CommandObjects(); + private final PushHandler pushHandler = new PushHandlerImpl(); private int db = 0; private Transaction transaction = null; private boolean isInMulti = false; @@ -49,6 +52,7 @@ public class Jedis implements ServerCommands, DatabaseCommands, JedisCommands, J public Jedis() { connection = new Connection(); + initializePushHandler(); } /** @@ -62,10 +66,12 @@ public Jedis(final String url) { public Jedis(final HostAndPort hp) { connection = new Connection(hp); + initializePushHandler(); } public Jedis(final String host, final int port) { connection = new Connection(host, port); + initializePushHandler(); } public Jedis(final String host, final int port, final JedisClientConfig config) { @@ -76,6 +82,7 @@ public Jedis(final HostAndPort hostPort, final JedisClientConfig config) { connection = new Connection(hostPort, config); RedisProtocol proto = config.getRedisProtocol(); if (proto != null) commandObjects.setProtocol(proto); + initializePushHandler(); } public Jedis(final String host, final int port, final boolean ssl) { @@ -154,6 +161,7 @@ public Jedis(URI uri) { .password(JedisURIHelper.getPassword(uri)).database(JedisURIHelper.getDBIndex(uri)) .protocol(JedisURIHelper.getRedisProtocol(uri)) .ssl(JedisURIHelper.isRedisSSLScheme(uri)).build()); + initializePushHandler(); } public Jedis(URI uri, final SSLSocketFactory sslSocketFactory, @@ -223,20 +231,24 @@ public Jedis(final URI uri, JedisClientConfig config) { .build()); RedisProtocol proto = config.getRedisProtocol(); if (proto != null) commandObjects.setProtocol(proto); + initializePushHandler(); } public Jedis(final JedisSocketFactory jedisSocketFactory) { connection = new Connection(jedisSocketFactory); + initializePushHandler(); } public Jedis(final JedisSocketFactory jedisSocketFactory, final JedisClientConfig clientConfig) { connection = new Connection(jedisSocketFactory, clientConfig); RedisProtocol proto = clientConfig.getRedisProtocol(); if (proto != null) commandObjects.setProtocol(proto); + initializePushHandler(); } public Jedis(final Connection connection) { this.connection = connection; + initializePushHandler(); } @Override @@ -342,6 +354,47 @@ public int getDB() { return this.db; } + /** + * Initialize the push handler with the connection's push consumer system. + * This method sets up the listener notification consumer to handle push messages. + */ + private void initializePushHandler() { + if (connection != null && pushHandler != null) { + connection.getPushConsumer().add(new ListenerNotificationConsumer(pushHandler)); + } + } + + /** + * Add a new {@link PushListener listener} to receive push messages. + * Requires Redis 6+ using RESP3 protocol. + * + * @param listener the listener, must not be {@code null}. + */ + @Experimental + public void addListener(PushListener listener) { + pushHandler.addListener(listener); + } + + /** + * Remove an existing {@link PushListener listener}. + * + * @param listener the listener, must not be {@code null}. + */ + @Experimental + public void removeListener(PushListener listener) { + pushHandler.removeListener(listener); + } + + /** + * Returns a collection of {@link PushListener}. + * + * @return the collection of listeners. + */ + @Experimental + public Collection getPushListeners() { + return pushHandler.getPushListeners(); + } + /** * @return PONG */ @@ -9945,4 +9998,36 @@ private static String[] joinParameters(String first, String second, String[] res return result; } + /** + * Push consumer that delegates to a {@link PushHandler} for listener notification. + */ + private static class ListenerNotificationConsumer implements PushConsumer { + private final PushHandler pushHandler; + + public ListenerNotificationConsumer(PushHandler pushHandler) { + this.pushHandler = pushHandler; + } + + @Override + public void accept(PushConsumerContext context) { + if (pushHandler != null) { + notifyListeners(context.getMessage()); + } + } + + private void notifyListeners(PushMessage pushMessage) { + try { + pushHandler.getPushListeners().forEach(pushListener -> { + try { + pushListener.onPush(pushMessage); + } catch (Exception e) { + // Log individual listener failures but don't propagate + } + }); + } catch (Exception e) { + // Log notification failures but don't propagate + } + } + } + } diff --git a/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java index be39788068..e231590754 100644 --- a/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java +++ b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java @@ -164,7 +164,37 @@ public void testUnifiedJedisCustomPushListener() { assertEquals(1, receivedMessages.size()); assertEquals("invalidate", receivedMessages.get(0).getType()); } - + + @Test + public void testJedisCustomPushListener() { + Jedis jedis = new Jedis(endpoint.getHostAndPort(), + endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); + + List receivedMessages = new ArrayList<>(); + jedis.addListener(receivedMessages::add); + + // Enable client tracking + jedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); + + // Set initial value + jedis.set(testKey, initialValue); + + // Get the key to track it + assertEquals(initialValue, jedis.get(testKey)); + + // Modify the key from another connection to trigger invalidation + triggerKeyInvalidation(testKey, modifiedValue); + + // Send PING command + String pingResponse = jedis.ping(); + // Next reply should be PONG + assertEquals("PONG", pingResponse); + assertEquals(1, receivedMessages.size()); + assertEquals("invalidate", receivedMessages.get(0).getType()); + + // Clean up + jedis.close(); + } @Test public void testConnectionResp3PushNotificationsWithCustomListener() { From b36d7f8dba0d28d90c569e730f22cdfd662e347c Mon Sep 17 00:00:00 2001 From: ggivo Date: Thu, 3 Jul 2025 18:47:44 +0300 Subject: [PATCH 05/23] Add proactiveRebindEnabled configuration option --- .../jedis/DefaultJedisClientConfig.java | 19 +++++++++++++++++++ .../clients/jedis/JedisClientConfig.java | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java index 353e8145eb..dc294d4bcc 100644 --- a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java @@ -35,6 +35,8 @@ public final class DefaultJedisClientConfig implements JedisClientConfig { private final TimeoutOptions timeoutOptions; + private final boolean proactiveRebindEnabled; + private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.redisProtocol = builder.redisProtocol; this.connectionTimeoutMillis = builder.connectionTimeoutMillis; @@ -53,6 +55,7 @@ private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.readOnlyForRedisClusterReplicas = builder.readOnlyForRedisClusterReplicas; this.authXManager = builder.authXManager; this.timeoutOptions = builder.timeoutOptions; + this.proactiveRebindEnabled = builder.proactiveRebindEnabled; } @Override @@ -151,6 +154,11 @@ public TimeoutOptions getTimeoutOptions() { return timeoutOptions; } + @Override + public boolean isProactiveRebindEnabled() { + return proactiveRebindEnabled; + } + public static Builder builder() { return new Builder(); } @@ -185,6 +193,8 @@ public static class Builder { private TimeoutOptions timeoutOptions = TimeoutOptions.create(); + private boolean proactiveRebindEnabled = false; + private Builder() { } @@ -312,6 +322,11 @@ public Builder timeoutOptions(TimeoutOptions timeoutOptions) { return this; } + public Builder proactiveRebindEnabled(boolean proactiveRebindEnabled) { + this.proactiveRebindEnabled = proactiveRebindEnabled; + return this; + } + public Builder from(JedisClientConfig instance) { this.redisProtocol = instance.getRedisProtocol(); this.connectionTimeoutMillis = instance.getConnectionTimeoutMillis(); @@ -330,6 +345,7 @@ public Builder from(JedisClientConfig instance) { this.readOnlyForRedisClusterReplicas = instance.isReadOnlyForRedisClusterReplicas(); this.authXManager = instance.getAuthXManager(); this.timeoutOptions = instance.getTimeoutOptions(); + this.proactiveRebindEnabled = instance.isProactiveRebindEnabled(); return this; } } @@ -392,6 +408,9 @@ public static DefaultJedisClientConfig copyConfig(JedisClientConfig copy) { builder.authXManager(copy.getAuthXManager()); builder.timeoutOptions(copy.getTimeoutOptions()); + if (copy.isProactiveRebindEnabled()) { + builder.proactiveRebindEnabled(true); + } return builder.build(); } diff --git a/src/main/java/redis/clients/jedis/JedisClientConfig.java b/src/main/java/redis/clients/jedis/JedisClientConfig.java index 9475a903ef..59ddcc9565 100644 --- a/src/main/java/redis/clients/jedis/JedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/JedisClientConfig.java @@ -39,6 +39,15 @@ default TimeoutOptions getTimeoutOptions() { return TimeoutOptions.create(); } + /** + * Configure whether the driver should listen for server events that indicate the current endpoint is being re-bound. + * When enabled, the proactive re-bind will help with the connection handover and reduce the number of failed commands. + * This feature requires the server to support proactive re-binds. Defaults to {@code false}. + */ + default boolean isProactiveRebindEnabled() { + return false; + } + /** * @return Redis ACL user */ From 0452a931d03c5fe283e1f5b2c64a79181b88ddde Mon Sep 17 00:00:00 2001 From: ggivo Date: Fri, 4 Jul 2025 16:35:03 +0300 Subject: [PATCH 06/23] PushHandler is now provided through JedisClientConfig instead through constructors. --- .../java/redis/clients/jedis/Connection.java | 47 +++++------ .../clients/jedis/ConnectionFactory.java | 28 ++----- .../redis/clients/jedis/ConnectionPool.java | 6 -- .../jedis/DefaultJedisClientConfig.java | 15 ++++ src/main/java/redis/clients/jedis/Jedis.java | 84 +------------------ .../clients/jedis/JedisClientConfig.java | 6 ++ .../redis/clients/jedis/PushHandelrImpl.java | 5 ++ .../java/redis/clients/jedis/PushHandler.java | 18 ++-- .../redis/clients/jedis/UnifiedJedis.java | 35 +------- .../clients/jedis/csc/CacheConnection.java | 23 +---- .../providers/PooledConnectionProvider.java | 7 -- .../jedis/PushMessageNotificationTest.java | 25 ++++-- 12 files changed, 86 insertions(+), 213 deletions(-) diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index 57fe28a3bd..0c3be5dec7 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -80,7 +80,7 @@ public Connection(final JedisSocketFactory socketFactory) { this.socketFactory = socketFactory; this.authXManager = null; - initPushConsumers(null, null); + initPushConsumers(null); } public Connection(final JedisSocketFactory socketFactory, JedisClientConfig clientConfig) { @@ -89,21 +89,12 @@ public Connection(final JedisSocketFactory socketFactory, JedisClientConfig clie this.infiniteSoTimeout = clientConfig.getBlockingSocketTimeoutMillis(); this.relaxedTimeout = clientConfig.getTimeoutOptions().getRelaxedTimeout(); - initPushConsumers(null, clientConfig); + initPushConsumers(clientConfig); initializeFromClientConfig(clientConfig); } - public Connection(final JedisSocketFactory socketFactory, JedisClientConfig clientConfig, PushHandler pushHandler) { - this.socketFactory = socketFactory; - this.soTimeout = clientConfig.getSocketTimeoutMillis(); - this.infiniteSoTimeout = clientConfig.getBlockingSocketTimeoutMillis(); - this.relaxedTimeout = clientConfig.getTimeoutOptions().getRelaxedTimeout(); - initPushConsumers(pushHandler, clientConfig); - initializeFromClientConfig(clientConfig); - } - - protected void initPushConsumers(PushHandler pushHandler, JedisClientConfig config) { + protected void initPushConsumers(JedisClientConfig config) { /* * Default consumers to process push messages. * Marks all @{link PushMessage}s as processed, except for pub/sub. @@ -114,22 +105,24 @@ protected void initPushConsumers(PushHandler pushHandler, JedisClientConfig conf PushConsumerChain.PUBSUB_ONLY_HANDLER ); - /* - * If the user has enabled relaxed timeouts, add consumer to handle push messages - * related to server maintenance events. - */ - if (TimeoutOptions.isRelaxedTimeoutEnabled(config.getTimeoutOptions().getRelaxedTimeout())) { - PushConsumer maintenanceHandler = new AdaptiveTimeoutHandler(Connection.this); - this.pushConsumer.add(maintenanceHandler); - } + if (config != null) { + /* + * If the user has enabled relaxed timeouts, add consumer to handle push messages + * related to server maintenance events. + */ + if (TimeoutOptions.isRelaxedTimeoutEnabled(config.getTimeoutOptions().getRelaxedTimeout())) { + PushConsumer maintenanceHandler = new AdaptiveTimeoutHandler(Connection.this); + this.pushConsumer.add(maintenanceHandler); + } - /* - * If the user has provided a {@link PushHandler}, - * add consumer to notify {@link PushListener}s, without changing the processed flag. - */ - this.pushHandler = pushHandler; - if (this.pushHandler != null) { - this.pushConsumer.add(new ListenerNotificationConsumer(pushHandler)); + /* + * If the user has provided a {@link PushHandler}, + * add consumer to notify {@link PushListener}s, without changing the processed flag. + */ + pushHandler = config.getPushHandler(); + if (this.pushHandler != null) { + this.pushConsumer.add(new ListenerNotificationConsumer(pushHandler)); + } } } diff --git a/src/main/java/redis/clients/jedis/ConnectionFactory.java b/src/main/java/redis/clients/jedis/ConnectionFactory.java index 99afad6868..7440417152 100644 --- a/src/main/java/redis/clients/jedis/ConnectionFactory.java +++ b/src/main/java/redis/clients/jedis/ConnectionFactory.java @@ -29,45 +29,32 @@ public class ConnectionFactory implements PooledObjectFactory { private final Supplier objectMaker; private final AuthXEventListener authXEventListener; - private final PushHandler pushHandler; public ConnectionFactory(final HostAndPort hostAndPort) { - this(hostAndPort, DefaultJedisClientConfig.builder().build(), (Cache) null); + this(hostAndPort, DefaultJedisClientConfig.builder().build(), null); } public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig) { - this(hostAndPort, clientConfig, (Cache) null); - } - - @Experimental - public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig, PushHandler pushHandler) { - this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, (Cache) null, pushHandler); + this(hostAndPort, clientConfig, null); } @Experimental public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig, Cache csCache) { - this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, csCache, null); - } - - @Experimental - public ConnectionFactory(final HostAndPort hostAndPort, final JedisClientConfig clientConfig, - Cache csCache, PushHandler pushHandler) { - this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, csCache, pushHandler); + this(new DefaultJedisSocketFactory(hostAndPort, clientConfig), clientConfig, csCache); } public ConnectionFactory(final JedisSocketFactory jedisSocketFactory, final JedisClientConfig clientConfig) { - this(jedisSocketFactory, clientConfig, null, null); + this(jedisSocketFactory, clientConfig, null); } private ConnectionFactory(final JedisSocketFactory jedisSocketFactory, - final JedisClientConfig clientConfig, Cache csCache, PushHandler pushHandler) { + final JedisClientConfig clientConfig, Cache csCache) { this.jedisSocketFactory = jedisSocketFactory; this.clientSideCache = csCache; this.clientConfig = clientConfig; - this.pushHandler = pushHandler; AuthXManager authXManager = clientConfig.getAuthXManager(); if (authXManager == null) { @@ -82,9 +69,8 @@ private ConnectionFactory(final JedisSocketFactory jedisSocketFactory, } private Supplier connectionSupplier() { - - return clientSideCache == null ? () -> new Connection(jedisSocketFactory, clientConfig, pushHandler) - : () -> new CacheConnection(jedisSocketFactory, clientConfig, clientSideCache, pushHandler); + return clientSideCache == null ? () -> new Connection(jedisSocketFactory, clientConfig) + : () -> new CacheConnection(jedisSocketFactory, clientConfig, clientSideCache); } @Override diff --git a/src/main/java/redis/clients/jedis/ConnectionPool.java b/src/main/java/redis/clients/jedis/ConnectionPool.java index 5a6ebc0266..2ae1401081 100644 --- a/src/main/java/redis/clients/jedis/ConnectionPool.java +++ b/src/main/java/redis/clients/jedis/ConnectionPool.java @@ -18,12 +18,6 @@ public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig) { attachAuthenticationListener(clientConfig.getAuthXManager()); } - @Experimental - public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig, PushHandler pushHandler) { - this(new ConnectionFactory(hostAndPort, clientConfig, pushHandler)); - attachAuthenticationListener(clientConfig.getAuthXManager()); - } - @Experimental public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig, Cache clientSideCache) { diff --git a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java index dc294d4bcc..6e9bfa19f4 100644 --- a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java @@ -37,6 +37,8 @@ public final class DefaultJedisClientConfig implements JedisClientConfig { private final boolean proactiveRebindEnabled; + private final PushHandler pushHandler; + private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.redisProtocol = builder.redisProtocol; this.connectionTimeoutMillis = builder.connectionTimeoutMillis; @@ -56,6 +58,7 @@ private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.authXManager = builder.authXManager; this.timeoutOptions = builder.timeoutOptions; this.proactiveRebindEnabled = builder.proactiveRebindEnabled; + this.pushHandler = builder.pushHandler; } @Override @@ -159,6 +162,11 @@ public boolean isProactiveRebindEnabled() { return proactiveRebindEnabled; } + @Override + public PushHandler getPushHandler() { + return pushHandler; + } + public static Builder builder() { return new Builder(); } @@ -195,6 +203,8 @@ public static class Builder { private boolean proactiveRebindEnabled = false; + private PushHandler pushHandler = null; + private Builder() { } @@ -327,6 +337,11 @@ public Builder proactiveRebindEnabled(boolean proactiveRebindEnabled) { return this; } + public Builder pushHandler(PushHandler pushHandler) { + this.pushHandler = pushHandler; + return this; + } + public Builder from(JedisClientConfig instance) { this.redisProtocol = instance.getRedisProtocol(); this.connectionTimeoutMillis = instance.getConnectionTimeoutMillis(); diff --git a/src/main/java/redis/clients/jedis/Jedis.java b/src/main/java/redis/clients/jedis/Jedis.java index 7eaec5c2cd..0deda621df 100644 --- a/src/main/java/redis/clients/jedis/Jedis.java +++ b/src/main/java/redis/clients/jedis/Jedis.java @@ -40,7 +40,6 @@ public class Jedis implements ServerCommands, DatabaseCommands, JedisCommands, J protected final Connection connection; private final CommandObjects commandObjects = new CommandObjects(); - private final PushHandler pushHandler = new PushHandlerImpl(); private int db = 0; private Transaction transaction = null; private boolean isInMulti = false; @@ -52,7 +51,6 @@ public class Jedis implements ServerCommands, DatabaseCommands, JedisCommands, J public Jedis() { connection = new Connection(); - initializePushHandler(); } /** @@ -66,12 +64,10 @@ public Jedis(final String url) { public Jedis(final HostAndPort hp) { connection = new Connection(hp); - initializePushHandler(); } public Jedis(final String host, final int port) { connection = new Connection(host, port); - initializePushHandler(); } public Jedis(final String host, final int port, final JedisClientConfig config) { @@ -82,7 +78,6 @@ public Jedis(final HostAndPort hostPort, final JedisClientConfig config) { connection = new Connection(hostPort, config); RedisProtocol proto = config.getRedisProtocol(); if (proto != null) commandObjects.setProtocol(proto); - initializePushHandler(); } public Jedis(final String host, final int port, final boolean ssl) { @@ -161,7 +156,6 @@ public Jedis(URI uri) { .password(JedisURIHelper.getPassword(uri)).database(JedisURIHelper.getDBIndex(uri)) .protocol(JedisURIHelper.getRedisProtocol(uri)) .ssl(JedisURIHelper.isRedisSSLScheme(uri)).build()); - initializePushHandler(); } public Jedis(URI uri, final SSLSocketFactory sslSocketFactory, @@ -231,24 +225,20 @@ public Jedis(final URI uri, JedisClientConfig config) { .build()); RedisProtocol proto = config.getRedisProtocol(); if (proto != null) commandObjects.setProtocol(proto); - initializePushHandler(); } public Jedis(final JedisSocketFactory jedisSocketFactory) { connection = new Connection(jedisSocketFactory); - initializePushHandler(); } public Jedis(final JedisSocketFactory jedisSocketFactory, final JedisClientConfig clientConfig) { connection = new Connection(jedisSocketFactory, clientConfig); RedisProtocol proto = clientConfig.getRedisProtocol(); if (proto != null) commandObjects.setProtocol(proto); - initializePushHandler(); } public Jedis(final Connection connection) { this.connection = connection; - initializePushHandler(); } @Override @@ -354,49 +344,9 @@ public int getDB() { return this.db; } - /** - * Initialize the push handler with the connection's push consumer system. - * This method sets up the listener notification consumer to handle push messages. - */ - private void initializePushHandler() { - if (connection != null && pushHandler != null) { - connection.getPushConsumer().add(new ListenerNotificationConsumer(pushHandler)); - } - } - - /** - * Add a new {@link PushListener listener} to receive push messages. - * Requires Redis 6+ using RESP3 protocol. - * - * @param listener the listener, must not be {@code null}. - */ - @Experimental - public void addListener(PushListener listener) { - pushHandler.addListener(listener); - } - - /** - * Remove an existing {@link PushListener listener}. - * - * @param listener the listener, must not be {@code null}. - */ - @Experimental - public void removeListener(PushListener listener) { - pushHandler.removeListener(listener); - } /** - * Returns a collection of {@link PushListener}. - * - * @return the collection of listeners. - */ - @Experimental - public Collection getPushListeners() { - return pushHandler.getPushListeners(); - } - - /** - * @return PONG + * @return PONG { - try { - pushListener.onPush(pushMessage); - } catch (Exception e) { - // Log individual listener failures but don't propagate - } - }); - } catch (Exception e) { - // Log notification failures but don't propagate - } - } - } - } diff --git a/src/main/java/redis/clients/jedis/JedisClientConfig.java b/src/main/java/redis/clients/jedis/JedisClientConfig.java index 59ddcc9565..a34522425c 100644 --- a/src/main/java/redis/clients/jedis/JedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/JedisClientConfig.java @@ -7,6 +7,8 @@ import redis.clients.jedis.authentication.AuthXManager; +import static redis.clients.jedis.PushHandler.NOOP; + public interface JedisClientConfig { default RedisProtocol getRedisProtocol() { @@ -69,6 +71,10 @@ default AuthXManager getAuthXManager() { return null; } + default PushHandler getPushHandler() { + return null; + } + default int getDatabase() { return Protocol.DEFAULT_DATABASE; } diff --git a/src/main/java/redis/clients/jedis/PushHandelrImpl.java b/src/main/java/redis/clients/jedis/PushHandelrImpl.java index d3e02f2e15..d7595c1111 100644 --- a/src/main/java/redis/clients/jedis/PushHandelrImpl.java +++ b/src/main/java/redis/clients/jedis/PushHandelrImpl.java @@ -17,6 +17,11 @@ public void removeListener(PushListener listener) { pushListeners.remove(listener); } + @Override + public void removeAllListeners() { + pushListeners.clear(); + } + @Override public Collection getPushListeners() { return pushListeners; diff --git a/src/main/java/redis/clients/jedis/PushHandler.java b/src/main/java/redis/clients/jedis/PushHandler.java index 724c7e471b..72330db757 100644 --- a/src/main/java/redis/clients/jedis/PushHandler.java +++ b/src/main/java/redis/clients/jedis/PushHandler.java @@ -4,7 +4,7 @@ import java.util.Collections; /** - * A handler object that provides access to {@link PushListener}. + * A handler object that provides access to {@link PushListener}s. * * @author Ivo Gaydajiev * @since 6.1 @@ -25,6 +25,11 @@ public interface PushHandler { */ void removeListener(PushListener listener); + /** + * Remove all existing {@link PushListener listeners}. + */ + void removeAllListeners(); + /** * Returns a collection of {@link PushListener}. * @@ -35,17 +40,13 @@ public interface PushHandler { /** * A no-operation implementation of PushHandler that doesn't maintain any listeners. * All operations are no-ops and getPushListeners() returns an empty list. - * Implemented as a singleton to avoid unnecessary object creation. */ PushHandler NOOP = new NoOpPushHandler(); + } -/** - * Singleton implementation of a no-operation PushHandler. - */ final class NoOpPushHandler implements PushHandler { - // Package-private constructor to prevent external instantiation NoOpPushHandler() {} @Override @@ -58,6 +59,11 @@ public void removeListener(PushListener listener) { // No-op } + @Override + public void removeAllListeners() { + // No-op + } + @Override public Collection getPushListeners() { return Collections.emptyList(); diff --git a/src/main/java/redis/clients/jedis/UnifiedJedis.java b/src/main/java/redis/clients/jedis/UnifiedJedis.java index d1edc2c7ef..e3960862fa 100644 --- a/src/main/java/redis/clients/jedis/UnifiedJedis.java +++ b/src/main/java/redis/clients/jedis/UnifiedJedis.java @@ -2,7 +2,6 @@ import java.net.URI; import java.time.Duration; -import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; @@ -57,7 +56,6 @@ public class UnifiedJedis implements JedisCommands, JedisBinaryCommands, protected final CommandObjects commandObjects; private JedisBroadcastAndRoundRobinConfig broadcastAndRoundRobinConfig = null; private final Cache cache; - private final PushHandler pushHandler; public UnifiedJedis() { this(new HostAndPort(Protocol.DEFAULT_HOST, Protocol.DEFAULT_PORT)); @@ -103,11 +101,7 @@ public UnifiedJedis(final URI uri, JedisClientConfig config) { } public UnifiedJedis(HostAndPort hostAndPort, JedisClientConfig clientConfig) { - this(hostAndPort, clientConfig, new PushHandlerImpl()); - } - - private UnifiedJedis(HostAndPort hostAndPort, JedisClientConfig clientConfig, PushHandler pushHandler) { - this(new PooledConnectionProvider(hostAndPort, clientConfig, pushHandler), clientConfig.getRedisProtocol(), pushHandler); + this(new PooledConnectionProvider(hostAndPort, clientConfig), clientConfig.getRedisProtocol()); } @Experimental @@ -128,10 +122,6 @@ protected UnifiedJedis(ConnectionProvider provider, RedisProtocol protocol) { this(new DefaultCommandExecutor(provider), provider, new CommandObjects(), protocol); } - private UnifiedJedis(ConnectionProvider provider, RedisProtocol protocol, PushHandler pushHandler) { - this(new DefaultCommandExecutor(provider), provider, new CommandObjects(), protocol, null, pushHandler); - } - @Experimental protected UnifiedJedis(ConnectionProvider provider, RedisProtocol protocol, Cache cache) { this(new DefaultCommandExecutor(provider), provider, new CommandObjects(), protocol, cache); @@ -167,7 +157,6 @@ public UnifiedJedis(Connection connection) { this.provider = null; this.executor = new SimpleCommandExecutor(connection); this.commandObjects = new CommandObjects(); - this.pushHandler = null; RedisProtocol proto = connection.getRedisProtocol(); if (proto != null) { this.commandObjects.setProtocol(proto); @@ -291,12 +280,6 @@ private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, Comm @Experimental private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, CommandObjects commandObjects, RedisProtocol protocol, Cache cache) { - this(executor, provider, commandObjects, protocol, cache, null); - } - - @Experimental - private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, CommandObjects commandObjects, - RedisProtocol protocol, Cache cache, PushHandler pushHandler) { if (cache != null && protocol != RedisProtocol.RESP3) { throw new IllegalArgumentException("Client-side caching is only supported with RESP3."); @@ -311,7 +294,6 @@ private UnifiedJedis(CommandExecutor executor, ConnectionProvider provider, Comm } this.cache = cache; - this.pushHandler = pushHandler; } @Override @@ -325,21 +307,6 @@ protected final void setProtocol(RedisProtocol protocol) { this.commandObjects.setProtocol(this.protocol); } - @Experimental - public void addListener(PushListener listener) { - pushHandler.addListener(listener); - } - - @Experimental - public void removeListener(PushListener listener) { - pushHandler.removeListener(listener); - } - - @Experimental - public Collection getPushListeners() { - return pushHandler.getPushListeners(); - } - public final T executeCommand(CommandObject commandObject) { return executor.executeCommand(commandObject); } diff --git a/src/main/java/redis/clients/jedis/csc/CacheConnection.java b/src/main/java/redis/clients/jedis/csc/CacheConnection.java index 27a67d4881..3d1cea908d 100644 --- a/src/main/java/redis/clients/jedis/csc/CacheConnection.java +++ b/src/main/java/redis/clients/jedis/csc/CacheConnection.java @@ -57,28 +57,9 @@ public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig initializeClientSideCache(); } - public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig clientConfig, Cache cache, PushHandler pushHandler) { - super(socketFactory, clientConfig, pushHandler); - - if (protocol != RedisProtocol.RESP3) { - throw new JedisException("Client side caching is only supported with RESP3."); - } - if (!cache.compatibilityMode()) { - RedisVersion current = new RedisVersion(version); - RedisVersion required = new RedisVersion(MIN_REDIS_VERSION); - if (!REDIS.equals(server) || current.compareTo(required) < 0) { - throw new JedisException(String.format("Client side caching is only supported with 'Redis %s' or later.", MIN_REDIS_VERSION)); - } - } - this.cache = Objects.requireNonNull(cache); - - - initializeClientSideCache(); - } - @Override - protected void initPushConsumers(PushHandler pushHandler, JedisClientConfig config) { - super.initPushConsumers(pushHandler, config); + protected void initPushConsumers( JedisClientConfig config) { + super.initPushConsumers(config); this.pushConsumer.add(new PushInvalidateConsumer(cache)); } diff --git a/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java b/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java index 6dc5361eee..ddbd768f9b 100644 --- a/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java +++ b/src/main/java/redis/clients/jedis/providers/PooledConnectionProvider.java @@ -11,7 +11,6 @@ import redis.clients.jedis.ConnectionPool; import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisClientConfig; -import redis.clients.jedis.PushHandler; import redis.clients.jedis.annots.Experimental; import redis.clients.jedis.csc.Cache; import redis.clients.jedis.util.Pool; @@ -31,12 +30,6 @@ public PooledConnectionProvider(HostAndPort hostAndPort, JedisClientConfig clien this.connectionMapKey = hostAndPort; } - @Experimental - public PooledConnectionProvider(HostAndPort hostAndPort, JedisClientConfig clientConfig, PushHandler pushHandler) { - this(new ConnectionPool(hostAndPort, clientConfig, pushHandler)); - this.connectionMapKey = hostAndPort; - } - @Experimental public PooledConnectionProvider(HostAndPort hostAndPort, JedisClientConfig clientConfig, Cache clientSideCache) { this(new ConnectionPool(hostAndPort, clientConfig, clientSideCache)); diff --git a/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java index e231590754..d324fa8bc8 100644 --- a/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java +++ b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java @@ -139,11 +139,16 @@ public void testUnifiedJedisResp3PushNotifications() { @Test public void testUnifiedJedisCustomPushListener() { - unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), - endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); - List receivedMessages = new ArrayList<>(); - unifiedJedis.addListener(receivedMessages::add); + PushHandlerImpl pushHandler = new PushHandlerImpl(); + pushHandler.addListener(receivedMessages::add); + + DefaultJedisClientConfig clientConfig = endpoint.getClientConfigBuilder() + .pushHandler(pushHandler) + .protocol(RedisProtocol.RESP3).build(); + + unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), clientConfig); + // Enable client tracking unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); @@ -167,11 +172,15 @@ public void testUnifiedJedisCustomPushListener() { @Test public void testJedisCustomPushListener() { - Jedis jedis = new Jedis(endpoint.getHostAndPort(), - endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); - List receivedMessages = new ArrayList<>(); - jedis.addListener(receivedMessages::add); + PushHandlerImpl pushHandler = new PushHandlerImpl(); + pushHandler.addListener(receivedMessages::add); + + DefaultJedisClientConfig clientConfig = endpoint.getClientConfigBuilder() + .pushHandler(pushHandler) + .protocol(RedisProtocol.RESP3).build(); + + Jedis jedis = new Jedis(endpoint.getHostAndPort(), clientConfig); // Enable client tracking jedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); From 4bd38da9116798609e7c68b97a30bfb8a7687752 Mon Sep 17 00:00:00 2001 From: ggivo Date: Fri, 4 Jul 2025 16:58:47 +0300 Subject: [PATCH 07/23] Fix NPE in CacheConnection Register PushInvalidateConsumer after cache is initialised --- src/main/java/redis/clients/jedis/csc/CacheConnection.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/java/redis/clients/jedis/csc/CacheConnection.java b/src/main/java/redis/clients/jedis/csc/CacheConnection.java index 3d1cea908d..62870905a8 100644 --- a/src/main/java/redis/clients/jedis/csc/CacheConnection.java +++ b/src/main/java/redis/clients/jedis/csc/CacheConnection.java @@ -57,12 +57,6 @@ public CacheConnection(final JedisSocketFactory socketFactory, JedisClientConfig initializeClientSideCache(); } - @Override - protected void initPushConsumers( JedisClientConfig config) { - super.initPushConsumers(config); - this.pushConsumer.add(new PushInvalidateConsumer(cache)); - } - @Override protected void initializeFromClientConfig(JedisClientConfig config) { lock = new ReentrantLock(); @@ -130,6 +124,7 @@ public Cache getCache() { } private void initializeClientSideCache() { + this.pushConsumer.add(new PushInvalidateConsumer(cache)); sendCommand(Protocol.Command.CLIENT, "TRACKING", "ON"); String reply = getStatusCodeReply(); if (!"OK".equals(reply)) { From 4ad3fce612d77c9bb50776320b4af761b7324847 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 7 Jul 2025 12:14:11 +0300 Subject: [PATCH 08/23] [cleanup] Use weak reference in AdaptiveTimeoutHandler to avoid memory leak --- .../clients/jedis/AdaptiveTimeoutHandler.java | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java b/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java index 9385321140..5e56ce06f4 100644 --- a/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java +++ b/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java @@ -1,21 +1,24 @@ package redis.clients.jedis; +import java.lang.ref.WeakReference; +import redis.clients.jedis.annots.Experimental; + /** - * Implementation of MaintenanceListener that manages connection timeout relaxation + * Implementation of {@link PushConsumer } that manages connection timeout relaxation * during Redis server maintenance events like migration and failover. */ +@Experimental public class AdaptiveTimeoutHandler implements PushConsumer { - Connection connection; - + private final WeakReference connectionRef; /** * Creates a new maintenance listener for the specified connection. - * + * * @param connection The connection to manage timeouts for */ public AdaptiveTimeoutHandler(Connection connection) { - this.connection = connection; + this.connectionRef = new WeakReference<>(connection); } @Override @@ -39,18 +42,30 @@ public void accept(PushConsumerContext context) { } private void onMigrating() { - connection.relaxTimeouts(); + Connection connection = connectionRef.get(); + if (connection != null) { + connection.relaxTimeouts(); + } } private void onMigrated() { - connection.disableRelaxedTimeout(); + Connection connection = connectionRef.get(); + if (connection != null) { + connection.disableRelaxedTimeout(); + } } private void onFailOver() { - connection.relaxTimeouts(); + Connection connection = connectionRef.get(); + if (connection != null) { + connection.relaxTimeouts(); + } } private void onFailedOver() { - connection.disableRelaxedTimeout(); + Connection connection = connectionRef.get(); + if (connection != null) { + connection.disableRelaxedTimeout(); + } } } \ No newline at end of file From f4751773eeaf9b5b2403e2a8d89700974a0173a9 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 7 Jul 2025 13:33:33 +0300 Subject: [PATCH 09/23] [cleanup] Fix javadoc errors --- src/main/java/redis/clients/jedis/Jedis.java | 2 +- src/main/java/redis/clients/jedis/TimeoutOptions.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/redis/clients/jedis/Jedis.java b/src/main/java/redis/clients/jedis/Jedis.java index 0deda621df..5707192277 100644 --- a/src/main/java/redis/clients/jedis/Jedis.java +++ b/src/main/java/redis/clients/jedis/Jedis.java @@ -346,7 +346,7 @@ public int getDB() { /** - * @return PONGPONG */ @Override public String ping() { diff --git a/src/main/java/redis/clients/jedis/TimeoutOptions.java b/src/main/java/redis/clients/jedis/TimeoutOptions.java index b6cad7a9a6..ba192f66ea 100644 --- a/src/main/java/redis/clients/jedis/TimeoutOptions.java +++ b/src/main/java/redis/clients/jedis/TimeoutOptions.java @@ -54,13 +54,13 @@ public static class Builder { /** * Enable proactive timeout relaxing. Disabled by default, see {@link #DEFAULT_RELAXED_TIMEOUT}. - *

+ *

* If the Redis server supports this, and the client is set up to use it * , the client would listen to notifications that the current * endpoint is about to go down (as part of some maintenance activity, for example). In such cases, the driver could * extend the existing timeout settings for newly issued commands, or such that are in flight, to make sure they do not * time out during this process. - * + *

* @param duration {@link Duration} to relax timeouts proactively, must not be {@code null}. * @return {@code this} */ From 56cd4090a5c7412b84a250cdd5c5fdedad845378 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 7 Jul 2025 14:08:10 +0300 Subject: [PATCH 10/23] [cleanup] Fix TransactionCommandsTest mocked test --- .../jedis/commands/jedis/TransactionCommandsTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/redis/clients/jedis/commands/jedis/TransactionCommandsTest.java b/src/test/java/redis/clients/jedis/commands/jedis/TransactionCommandsTest.java index 7ab7589fb7..09fdd7e7d5 100644 --- a/src/test/java/redis/clients/jedis/commands/jedis/TransactionCommandsTest.java +++ b/src/test/java/redis/clients/jedis/commands/jedis/TransactionCommandsTest.java @@ -30,11 +30,13 @@ import redis.clients.jedis.Jedis; import redis.clients.jedis.Protocol; +import redis.clients.jedis.PushConsumer; import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.Response; import redis.clients.jedis.Transaction; import redis.clients.jedis.exceptions.JedisConnectionException; import redis.clients.jedis.exceptions.JedisDataException; +import redis.clients.jedis.util.RedisInputStream; import redis.clients.jedis.util.SafeEncoder; @ParameterizedClass @@ -176,7 +178,7 @@ public void discardFail() { trans.set("b", "b"); try (MockedStatic protocol = Mockito.mockStatic(Protocol.class)) { - protocol.when(() -> Protocol.read(any())).thenThrow(JedisConnectionException.class); + protocol.when(() -> Protocol.read(any(RedisInputStream.class), any(PushConsumer.class))).thenThrow(JedisConnectionException.class); trans.discard(); fail("Should get mocked JedisConnectionException."); @@ -196,7 +198,7 @@ public void execFail() { trans.set("b", "b"); try (MockedStatic protocol = Mockito.mockStatic(Protocol.class)) { - protocol.when(() -> Protocol.read(any())).thenThrow(JedisConnectionException.class); + protocol.when(() -> Protocol.read(any(RedisInputStream.class), any(PushConsumer.class))).thenThrow(JedisConnectionException.class); trans.exec(); fail("Should get mocked JedisConnectionException."); From 175b773c9ffe18b956e676baddab51b6ea61516e Mon Sep 17 00:00:00 2001 From: ggivo Date: Wed, 9 Jul 2025 09:14:49 +0300 Subject: [PATCH 11/23] Moving/Rebind initial support --- .../clients/jedis/AdaptiveTimeoutHandler.java | 71 ------- .../java/redis/clients/jedis/Connection.java | 177 ++++++++++++++++-- .../clients/jedis/ConnectionFactory.java | 16 +- .../redis/clients/jedis/ConnectionPool.java | 35 ++++ .../jedis/DefaultJedisClientConfig.java | 23 +++ .../clients/jedis/JedisClientConfig.java | 46 +++-- .../jedis/MaintenanceEventHandler.java | 18 ++ .../jedis/MaintenanceEventHandlerImpl.java | 29 +++ .../jedis/MaintenanceEventListener.java | 15 ++ .../java/redis/clients/jedis/RebindAware.java | 28 +++ 10 files changed, 354 insertions(+), 104 deletions(-) delete mode 100644 src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java create mode 100644 src/main/java/redis/clients/jedis/MaintenanceEventHandler.java create mode 100644 src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java create mode 100644 src/main/java/redis/clients/jedis/MaintenanceEventListener.java create mode 100644 src/main/java/redis/clients/jedis/RebindAware.java diff --git a/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java b/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java deleted file mode 100644 index 5e56ce06f4..0000000000 --- a/src/main/java/redis/clients/jedis/AdaptiveTimeoutHandler.java +++ /dev/null @@ -1,71 +0,0 @@ -package redis.clients.jedis; - -import java.lang.ref.WeakReference; -import redis.clients.jedis.annots.Experimental; - -/** - * Implementation of {@link PushConsumer } that manages connection timeout relaxation - * during Redis server maintenance events like migration and failover. - */ -@Experimental -public class AdaptiveTimeoutHandler implements PushConsumer { - - private final WeakReference connectionRef; - - /** - * Creates a new maintenance listener for the specified connection. - * - * @param connection The connection to manage timeouts for - */ - public AdaptiveTimeoutHandler(Connection connection) { - this.connectionRef = new WeakReference<>(connection); - } - - @Override - public void accept(PushConsumerContext context) { - String type = context.getMessage().getType(); - - switch (type) { - case "MIGRATING": - onMigrating(); - break; - case "MIGRATED": - onMigrated();; - break; - case "FAILING_OVER": - onFailOver(); - break; - case "FAILED_OVER": - onFailedOver(); - break; - } - } - - private void onMigrating() { - Connection connection = connectionRef.get(); - if (connection != null) { - connection.relaxTimeouts(); - } - } - - private void onMigrated() { - Connection connection = connectionRef.get(); - if (connection != null) { - connection.disableRelaxedTimeout(); - } - } - - private void onFailOver() { - Connection connection = connectionRef.get(); - if (connection != null) { - connection.relaxTimeouts(); - } - } - - private void onFailedOver() { - Connection connection = connectionRef.get(); - if (connection != null) { - connection.disableRelaxedTimeout(); - } - } -} \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index 0c3be5dec7..9a48b7a44b 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -5,6 +5,7 @@ import java.io.Closeable; import java.io.IOException; +import java.lang.ref.WeakReference; import java.net.Socket; import java.net.SocketAddress; import java.net.SocketException; @@ -34,6 +35,7 @@ import redis.clients.jedis.util.IOUtils; import redis.clients.jedis.util.RedisInputStream; import redis.clients.jedis.util.RedisOutputStream; +import redis.clients.jedis.util.SafeEncoder; public class Connection implements Closeable { public static Logger logger = LoggerFactory.getLogger(Connection.class); @@ -55,11 +57,9 @@ public class Connection implements Closeable { private AtomicReference currentCredentials = new AtomicReference<>(null); private AuthXManager authXManager; private boolean relaxedTimeoutActive = false; - + private boolean rebindRequested = false; protected PushConsumerChain pushConsumer; - private PushHandler pushHandler; - public Connection() { this(Protocol.DEFAULT_HOST, Protocol.DEFAULT_PORT); } @@ -106,21 +106,29 @@ protected void initPushConsumers(JedisClientConfig config) { ); if (config != null) { + /* - * If the user has enabled relaxed timeouts, add consumer to handle push messages - * related to server maintenance events. + * Add consumer to handle server maintenance events. + * Maintenance events are propagated to the registered {@link MaintenanceEventListener}s. */ - if (TimeoutOptions.isRelaxedTimeoutEnabled(config.getTimeoutOptions().getRelaxedTimeout())) { - PushConsumer maintenanceHandler = new AdaptiveTimeoutHandler(Connection.this); - this.pushConsumer.add(maintenanceHandler); + MaintenanceEventHandler maintenanceEventHandler = config.getMaintenanceEventHandler(); + if (maintenanceEventHandler != null) { + this.pushConsumer.add(new MaintenanceEventConsumer(maintenanceEventHandler)); + + if (config.isProactiveRebindEnabled()) { + maintenanceEventHandler.addListener(new ConnectionRebindHandler()); + } + + if (TimeoutOptions.isRelaxedTimeoutEnabled(config.getTimeoutOptions().getRelaxedTimeout())) { + maintenanceEventHandler.addListener(new AdaptiveTimeoutHandler(Connection.this)); + } } /* - * If the user has provided a {@link PushHandler}, - * add consumer to notify {@link PushListener}s, without changing the processed flag. + * Add consumer to notify registered {@link PushListener}s. */ - pushHandler = config.getPushHandler(); - if (this.pushHandler != null) { + PushHandler pushHandler = config.getPushHandler(); + if (pushHandler != null) { this.pushConsumer.add(new ListenerNotificationConsumer(pushHandler)); } } @@ -310,7 +318,7 @@ public void close() { if (this.memberOf != null) { ConnectionPool pool = this.memberOf; this.memberOf = null; - if (isBroken()) { + if (isBroken() || isRebindRequested()) { pool.returnBrokenResource(this); } else { pool.returnResource(this); @@ -320,6 +328,10 @@ public void close() { } } + private boolean isRebindRequested() { + return rebindRequested; + } + /** * Close the socket and disconnect the server. */ @@ -740,7 +752,7 @@ private void notifyListeners(PushMessage pushMessage) { try { pushListener.onPush(pushMessage); } catch (Exception e) { - // Log individual listener failures + // ignore } }); } catch (Exception e) { @@ -748,4 +760,141 @@ private void notifyListeners(PushMessage pushMessage) { } } } + + /** + * Push consumer that delegates to a {@link PushHandler} for listener notification. + */ + private static class MaintenanceEventConsumer implements PushConsumer { + private final MaintenanceEventHandler eventHandler; + + public MaintenanceEventConsumer(MaintenanceEventHandler eventHandler) { + this.eventHandler = eventHandler; + } + + @Override + public void accept(PushConsumerContext context) { + PushMessage message = context.getMessage(); + + switch ( message.getType()) { + case "MOVING": + onMoving(message); + break; + case "MIGRATING": + onMigrating(); + break; + case "MIGRATED": + onMigrated(); + break; + case "FAILING_OVER": + onFailOver(); + break; + case "FAILED_OVER": + onFailedOver(); + break; + } + } + private void onMoving(PushMessage message) { + HostAndPort rebindTarget = getRebindTarget(message); + eventHandler.getListeners().forEach(listener -> listener.onRebind(rebindTarget)); + } + + private void onMigrating() { + eventHandler.getListeners().forEach(MaintenanceEventListener::onMigrating); + } + + private void onMigrated() { + eventHandler.getListeners().forEach(MaintenanceEventListener::onMigrated); + } + + private void onFailOver() { + eventHandler.getListeners().forEach(MaintenanceEventListener::onFailOver); + } + + private void onFailedOver() { + eventHandler.getListeners().forEach(MaintenanceEventListener::onFailedOver); + } + + private HostAndPort getRebindTarget(PushMessage message) { + // Extract domain/ip and port from the message + // MOVING push message format: ["MOVING", slot, "host:port"] + List content = message.getContent(); + + if (content.size() < 3) { + logger.warn("MOVING push message is malformed: {}", message); + return null; + } + + Object addressObject = content.get(2); // Get the 3rd element (index 2) + if (!(addressObject instanceof byte[])) { + logger.warn("Invalid re-bind message format, expected 3rd element to be a byte[], got {}", + addressObject.getClass()); + return null; + } + + try { + String addressAndPort = SafeEncoder.encode((byte[]) addressObject); + String[] parts = addressAndPort.split(":"); + if (parts.length != 2) { + logger.warn("Invalid re-bind message format, expected 'host:port', got {}", + addressAndPort); + return null; + } + + String address = parts[0]; + int port = Integer.parseInt(parts[1]); + return new HostAndPort(address, port); + } catch (Exception e) { + logger.warn("Error parsing re-bind target from message: {}", message, e); + return null; + } + } + } + + private class ConnectionRebindHandler implements MaintenanceEventListener { + public void onRebind(HostAndPort target) { + rebindRequested = true; + } + } + + private static class AdaptiveTimeoutHandler implements MaintenanceEventListener { + + private final WeakReference connectionRef; + + /** + * Creates a new maintenance listener for the specified connection. + * + * @param connection The connection to manage timeouts for + */ + public AdaptiveTimeoutHandler(Connection connection) { + this.connectionRef = new WeakReference<>(connection); + } + + public void onMigrating() { + Connection connection = connectionRef.get(); + if (connection != null) { + connection.relaxTimeouts(); + } + } + + public void onMigrated() { + Connection connection = connectionRef.get(); + if (connection != null) { + connection.disableRelaxedTimeout(); + } + } + + public void onFailOver() { + Connection connection = connectionRef.get(); + if (connection != null) { + connection.relaxTimeouts(); + } + } + + public void onFailedOver() { + Connection connection = connectionRef.get(); + if (connection != null) { + connection.disableRelaxedTimeout(); + } + } + } } diff --git a/src/main/java/redis/clients/jedis/ConnectionFactory.java b/src/main/java/redis/clients/jedis/ConnectionFactory.java index 7440417152..d24139c79b 100644 --- a/src/main/java/redis/clients/jedis/ConnectionFactory.java +++ b/src/main/java/redis/clients/jedis/ConnectionFactory.java @@ -19,7 +19,7 @@ /** * PoolableObjectFactory custom impl. */ -public class ConnectionFactory implements PooledObjectFactory { +public class ConnectionFactory implements PooledObjectFactory , RebindAware { private static final Logger logger = LoggerFactory.getLogger(ConnectionFactory.class); @@ -140,4 +140,18 @@ private void reAuthenticate(Connection jedis) throws Exception { throw e; } } + + + @Override + public void rebind(HostAndPort newHostAndPort) { + // TODO : extract interface from DefaultJedisSocketFactory so that we can support custom socket factories + if (!(jedisSocketFactory instanceof DefaultJedisSocketFactory)) { + throw new IllegalStateException("Rebind not supported for custom JedisSocketFactory implementations"); + } + + DefaultJedisSocketFactory factory = (DefaultJedisSocketFactory) jedisSocketFactory; + logger.debug("Rebinding to {}", newHostAndPort); + factory.updateHostAndPort(newHostAndPort); + } + } diff --git a/src/main/java/redis/clients/jedis/ConnectionPool.java b/src/main/java/redis/clients/jedis/ConnectionPool.java index 2ae1401081..3a5e74b0cc 100644 --- a/src/main/java/redis/clients/jedis/ConnectionPool.java +++ b/src/main/java/redis/clients/jedis/ConnectionPool.java @@ -3,12 +3,16 @@ import org.apache.commons.pool2.PooledObjectFactory; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import redis.clients.jedis.annots.Experimental; import redis.clients.jedis.authentication.AuthXManager; import redis.clients.jedis.csc.Cache; import redis.clients.jedis.exceptions.JedisException; import redis.clients.jedis.util.Pool; +import java.util.concurrent.atomic.AtomicReference; + public class ConnectionPool extends Pool { private AuthXManager authXManager; @@ -16,6 +20,7 @@ public class ConnectionPool extends Pool { public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig) { this(new ConnectionFactory(hostAndPort, clientConfig)); attachAuthenticationListener(clientConfig.getAuthXManager()); + attachRebindHandler(clientConfig, (ConnectionFactory) this.getFactory()); } @Experimental @@ -23,6 +28,7 @@ public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig, Cache clientSideCache) { this(new ConnectionFactory(hostAndPort, clientConfig, clientSideCache)); attachAuthenticationListener(clientConfig.getAuthXManager()); + attachRebindHandler(clientConfig, (ConnectionFactory) this.getFactory()); } public ConnectionPool(PooledObjectFactory factory) { @@ -33,6 +39,7 @@ public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig, GenericObjectPoolConfig poolConfig) { this(new ConnectionFactory(hostAndPort, clientConfig), poolConfig); attachAuthenticationListener(clientConfig.getAuthXManager()); + attachRebindHandler(clientConfig, (ConnectionFactory) this.getFactory()); } @Experimental @@ -40,6 +47,7 @@ public ConnectionPool(HostAndPort hostAndPort, JedisClientConfig clientConfig, Cache clientSideCache, GenericObjectPoolConfig poolConfig) { this(new ConnectionFactory(hostAndPort, clientConfig, clientSideCache), poolConfig); attachAuthenticationListener(clientConfig.getAuthXManager()); + attachRebindHandler(clientConfig, (ConnectionFactory) this.getFactory()); } public ConnectionPool(PooledObjectFactory factory, @@ -78,4 +86,31 @@ private void attachAuthenticationListener(AuthXManager authXManager) { }); } } + + private void attachRebindHandler(JedisClientConfig clientConfig, ConnectionFactory factory) { + if (clientConfig.isProactiveRebindEnabled()) { + RebindHandler rebindHandler = new RebindHandler(this, factory); + clientConfig.getMaintenanceEventHandler().addListener(rebindHandler); + } + } + + private static class RebindHandler implements MaintenanceEventListener { + private final ConnectionPool pool; + private final ConnectionFactory factory; + private final AtomicReference rebindTarget = new AtomicReference<>(); + + public RebindHandler(ConnectionPool pool, ConnectionFactory factory) { + this.pool = pool; + this.factory = factory; + } + + @Override + public void onRebind(HostAndPort target) { + HostAndPort previous = rebindTarget.getAndSet(target); + if (previous != target) { + this.pool.clear(); + this.factory.rebind(target); + } + } + } } diff --git a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java index 6e9bfa19f4..42c0de5e00 100644 --- a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java @@ -39,6 +39,8 @@ public final class DefaultJedisClientConfig implements JedisClientConfig { private final PushHandler pushHandler; + private final MaintenanceEventHandler maintenanceEventHandler; + private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.redisProtocol = builder.redisProtocol; this.connectionTimeoutMillis = builder.connectionTimeoutMillis; @@ -59,6 +61,12 @@ private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.timeoutOptions = builder.timeoutOptions; this.proactiveRebindEnabled = builder.proactiveRebindEnabled; this.pushHandler = builder.pushHandler; + + if (builder.proactiveRebindEnabled && builder.maintenanceEventHandler == null) { + this.maintenanceEventHandler = new MaintenanceEventHandlerImpl(); + } else { + this.maintenanceEventHandler = builder.maintenanceEventHandler; + } } @Override @@ -167,6 +175,12 @@ public PushHandler getPushHandler() { return pushHandler; } + + @Override + public MaintenanceEventHandler getMaintenanceEventHandler() { + return maintenanceEventHandler; + } + public static Builder builder() { return new Builder(); } @@ -205,6 +219,8 @@ public static class Builder { private PushHandler pushHandler = null; + private MaintenanceEventHandler maintenanceEventHandler = null; + private Builder() { } @@ -342,6 +358,11 @@ public Builder pushHandler(PushHandler pushHandler) { return this; } + public Builder maintenanceEventHandler(MaintenanceEventHandler maintenanceEventHandler) { + this.maintenanceEventHandler = maintenanceEventHandler; + return this; + } + public Builder from(JedisClientConfig instance) { this.redisProtocol = instance.getRedisProtocol(); this.connectionTimeoutMillis = instance.getConnectionTimeoutMillis(); @@ -361,6 +382,8 @@ public Builder from(JedisClientConfig instance) { this.authXManager = instance.getAuthXManager(); this.timeoutOptions = instance.getTimeoutOptions(); this.proactiveRebindEnabled = instance.isProactiveRebindEnabled(); + this.pushHandler = instance.getPushHandler(); + this.maintenanceEventHandler = instance.getMaintenanceEventHandler(); return this; } } diff --git a/src/main/java/redis/clients/jedis/JedisClientConfig.java b/src/main/java/redis/clients/jedis/JedisClientConfig.java index a34522425c..8ea4e250d2 100644 --- a/src/main/java/redis/clients/jedis/JedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/JedisClientConfig.java @@ -7,7 +7,6 @@ import redis.clients.jedis.authentication.AuthXManager; -import static redis.clients.jedis.PushHandler.NOOP; public interface JedisClientConfig { @@ -37,19 +36,6 @@ default int getBlockingSocketTimeoutMillis() { return 0; } - default TimeoutOptions getTimeoutOptions() { - return TimeoutOptions.create(); - } - - /** - * Configure whether the driver should listen for server events that indicate the current endpoint is being re-bound. - * When enabled, the proactive re-bind will help with the connection handover and reduce the number of failed commands. - * This feature requires the server to support proactive re-binds. Defaults to {@code false}. - */ - default boolean isProactiveRebindEnabled() { - return false; - } - /** * @return Redis ACL user */ @@ -71,10 +57,6 @@ default AuthXManager getAuthXManager() { return null; } - default PushHandler getPushHandler() { - return null; - } - default int getDatabase() { return Protocol.DEFAULT_DATABASE; } @@ -134,4 +116,32 @@ default boolean isReadOnlyForRedisClusterReplicas() { default ClientSetInfoConfig getClientSetInfoConfig() { return ClientSetInfoConfig.DEFAULT; } + + default TimeoutOptions getTimeoutOptions() { + return TimeoutOptions.create(); + } + + /** + * Configure whether the driver should listen for server events that indicate the current endpoint is being re-bound. + * When enabled, the proactive re-bind will help with the connection handover and reduce the number of failed commands. + * This feature requires the server to support proactive re-binds. + * Enabling this feature requires also setting a {@link #getMaintenanceEventHandler() maintenance event handler} + * + * Defaults to {@code false}. + */ + default boolean isProactiveRebindEnabled() { + return false; + } + + default PushHandler getPushHandler() { + return null; + } + + /** + * @return The event handler to use for server maintenance events. + */ + default MaintenanceEventHandler getMaintenanceEventHandler(){ + return null; + } + } diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java b/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java new file mode 100644 index 0000000000..3e921c7d57 --- /dev/null +++ b/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java @@ -0,0 +1,18 @@ +package redis.clients.jedis; + +import java.util.Collection; + +public interface MaintenanceEventHandler { + + + void addListener(MaintenanceEventListener listener); + + + void removeListener(MaintenanceEventListener listener); + + + void removeAllListeners(); + + + Collection getListeners(); +} \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java b/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java new file mode 100644 index 0000000000..cff9cbdda2 --- /dev/null +++ b/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java @@ -0,0 +1,29 @@ +package redis.clients.jedis; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +class MaintenanceEventHandlerImpl implements MaintenanceEventHandler { + private final List listeners = new CopyOnWriteArrayList<>(); + + @Override + public void addListener(MaintenanceEventListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(MaintenanceEventListener listener) { + listeners.remove(listener); + } + + @Override + public void removeAllListeners() { + listeners.clear(); + } + + @Override + public Collection getListeners() { + return listeners; + } +} diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventListener.java b/src/main/java/redis/clients/jedis/MaintenanceEventListener.java new file mode 100644 index 0000000000..fba8d24507 --- /dev/null +++ b/src/main/java/redis/clients/jedis/MaintenanceEventListener.java @@ -0,0 +1,15 @@ +package redis.clients.jedis; + + +public interface MaintenanceEventListener { + + default void onMigrating(){}; + + default void onMigrated(){}; + + default void onFailOver(){}; + + default void onFailedOver(){}; + + default void onRebind( HostAndPort target){}; +} diff --git a/src/main/java/redis/clients/jedis/RebindAware.java b/src/main/java/redis/clients/jedis/RebindAware.java new file mode 100644 index 0000000000..a76d7aa2e9 --- /dev/null +++ b/src/main/java/redis/clients/jedis/RebindAware.java @@ -0,0 +1,28 @@ +package redis.clients.jedis; + +import redis.clients.jedis.annots.Experimental; + +/** + * Interface for components that support rebinding to a new host and port. + * Implementations of this interface can be notified when a Redis server sends + * a MOVING notification during maintenance events. + * + * This interface can be implemented by various components such as: + * - Connection pools + * - Socket factories + * - Connection providers + * - Any component that manages connections to Redis servers + */ +@Experimental +public interface RebindAware { + + /** + * Notifies the component that a re-bind to a new host and port is scheduled. This is called when + * a MOVING notification is received. Components that implement this interface should update their + * internal state to reflect the new host and port, and return true if the re-bind was accepted. + * Components might decide to reject the re-bind request if they are not in a state to support + * it. + * @param newHostAndPort The new host and port to use for new connections + */ + void rebind(HostAndPort newHostAndPort); +} \ No newline at end of file From c19d8a5138a1538dd6c868dd7fae5996236a9f3b Mon Sep 17 00:00:00 2001 From: ggivo Date: Wed, 9 Jul 2025 18:35:44 +0300 Subject: [PATCH 12/23] Mocked relaxed timeout test --- .../java/redis/clients/jedis/Connection.java | 28 +- .../jedis/DefaultJedisClientConfig.java | 4 +- .../jedis/MaintenanceEventHandlerImpl.java | 2 +- .../redis/clients/jedis/TimeoutOptions.java | 8 +- .../jedis/util/ReflectionTestUtils.java | 44 +++ .../redis/clients/jedis/ProtocolTest.java | 1 - .../ConnectionAdaptiveTimeoutTest.java | 271 +++++++++++++++++ .../jedis/util/server/TcpMockServer.java | 284 ++++++++++++++++++ 8 files changed, 630 insertions(+), 12 deletions(-) create mode 100644 src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java create mode 100644 src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java create mode 100644 src/test/java/redis/clients/jedis/util/server/TcpMockServer.java diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index 9a48b7a44b..eaa1f2a0b0 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -11,7 +11,6 @@ import java.net.SocketException; import java.nio.ByteBuffer; import java.nio.CharBuffer; -import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -24,6 +23,7 @@ import redis.clients.jedis.Protocol.Command; import redis.clients.jedis.Protocol.Keyword; import redis.clients.jedis.annots.Experimental; +import redis.clients.jedis.annots.VisibleForTesting; import redis.clients.jedis.args.ClientAttributeOption; import redis.clients.jedis.args.Rawable; import redis.clients.jedis.authentication.AuthXManager; @@ -47,7 +47,7 @@ public class Connection implements Closeable { private RedisOutputStream outputStream; private RedisInputStream inputStream; private int soTimeout = 0; - private Duration relaxedTimeout = TimeoutOptions.DISABLED_TIMEOUT; + private int relaxedTimeout = safeToInt(TimeoutOptions.DISABLED_TIMEOUT.toMillis()); private int infiniteSoTimeout = 0; private boolean broken = false; private boolean strValActive; @@ -87,7 +87,7 @@ public Connection(final JedisSocketFactory socketFactory, JedisClientConfig clie this.socketFactory = socketFactory; this.soTimeout = clientConfig.getSocketTimeoutMillis(); this.infiniteSoTimeout = clientConfig.getBlockingSocketTimeoutMillis(); - this.relaxedTimeout = clientConfig.getTimeoutOptions().getRelaxedTimeout(); + this.relaxedTimeout = safeToInt(clientConfig.getTimeoutOptions().getRelaxedTimeout().toMillis()); initPushConsumers(clientConfig); initializeFromClientConfig(clientConfig); @@ -209,7 +209,8 @@ public void setTimeoutInfinite() { public void rollbackTimeout() { try { - socket.setSoTimeout(this.soTimeout); + int timeout = relaxedTimeoutActive? this.relaxedTimeout : this.soTimeout; + socket.setSoTimeout(timeout); } catch (SocketException ex) { setBroken(); throw new JedisConnectionException(ex); @@ -695,17 +696,23 @@ protected AuthXManager getAuthXManager() { } @Experimental + @VisibleForTesting PushConsumerChain getPushConsumer() { return this.pushConsumer; } + @Experimental + public boolean isRelaxedTimeoutActive() { + return relaxedTimeoutActive; + } + @Experimental public void relaxTimeouts() { if (!relaxedTimeoutActive && !TimeoutOptions.isRelaxedTimeoutDisabled(relaxedTimeout)) { relaxedTimeoutActive = true; try { if (isConnected()) { - socket.setSoTimeout((int) relaxedTimeout.toMillis()); + socket.setSoTimeout(relaxedTimeout); } } catch (SocketException ex) { setBroken(); @@ -729,6 +736,13 @@ public void disableRelaxedTimeout() { } } + private static int safeToInt(long millis) { + if (millis > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + + return (int) millis; + } /** * Push consumer that delegates to a {@link PushHandler} for listener notification. */ @@ -761,9 +775,7 @@ private void notifyListeners(PushMessage pushMessage) { } } - /** - * Push consumer that delegates to a {@link PushHandler} for listener notification. - */ + private static class MaintenanceEventConsumer implements PushConsumer { private final MaintenanceEventHandler eventHandler; diff --git a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java index 42c0de5e00..83cdf1c5fb 100644 --- a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java @@ -62,7 +62,9 @@ private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.proactiveRebindEnabled = builder.proactiveRebindEnabled; this.pushHandler = builder.pushHandler; - if (builder.proactiveRebindEnabled && builder.maintenanceEventHandler == null) { + if ((builder.proactiveRebindEnabled || TimeoutOptions.isRelaxedTimeoutEnabled(builder.timeoutOptions.getRelaxedTimeout())) + && builder.maintenanceEventHandler == null) { + // Proactive rebind or relaxed timeouts require a maintenance event handler this.maintenanceEventHandler = new MaintenanceEventHandlerImpl(); } else { this.maintenanceEventHandler = builder.maintenanceEventHandler; diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java b/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java index cff9cbdda2..7ad983a762 100644 --- a/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java +++ b/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; -class MaintenanceEventHandlerImpl implements MaintenanceEventHandler { +public class MaintenanceEventHandlerImpl implements MaintenanceEventHandler { private final List listeners = new CopyOnWriteArrayList<>(); @Override diff --git a/src/main/java/redis/clients/jedis/TimeoutOptions.java b/src/main/java/redis/clients/jedis/TimeoutOptions.java index ba192f66ea..81eb48ed08 100644 --- a/src/main/java/redis/clients/jedis/TimeoutOptions.java +++ b/src/main/java/redis/clients/jedis/TimeoutOptions.java @@ -6,7 +6,9 @@ public class TimeoutOptions { - public static final Duration DISABLED_TIMEOUT = Duration.ZERO.minusSeconds(1); + private static final int DISABLED_TIMEOUT_MS = -1; + + public static final Duration DISABLED_TIMEOUT = Duration.ofMillis(DISABLED_TIMEOUT_MS); public static final Duration DEFAULT_RELAXED_TIMEOUT = DISABLED_TIMEOUT; @@ -24,6 +26,10 @@ public static boolean isRelaxedTimeoutDisabled(Duration relaxedTimeout) { return relaxedTimeout == null || relaxedTimeout.equals(DISABLED_TIMEOUT); } + public static boolean isRelaxedTimeoutDisabled(int relaxedTimeout) { + return relaxedTimeout == DISABLED_TIMEOUT_MS; + } + /** * @return the {@link Duration} to relax timeouts proactively, {@link #DISABLED_TIMEOUT} if disabled. */ diff --git a/src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java b/src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java new file mode 100644 index 0000000000..02734ea543 --- /dev/null +++ b/src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java @@ -0,0 +1,44 @@ +package redis.clients.jedis.util; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; + +public class ReflectionTestUtils { + + public static T getField(Object targetObject, String name) { + + Class targetClass = targetObject.getClass(); + + Field field = findField(targetClass, name); + + makeAccessible(field); + + try { + return (T) field.get(targetObject); + } catch (IllegalAccessException ex) { + throw new IllegalStateException( + "Unexpected reflection exception - " + ex.getClass().getName() + ": " + ex.getMessage()); + } + } + + public static Field findField(Class clazz, String name) { + Class searchType = clazz; + while (Object.class != searchType && searchType != null) { + Field[] fields = searchType.getDeclaredFields(); + for (Field field : fields) { + if (name.equals(field.getName())) { + return field; + } + } + searchType = searchType.getSuperclass(); + } + return null; + } + + private static void makeAccessible(Field field) { + if ((!Modifier.isPublic(field.getModifiers()) || !Modifier.isPublic(field.getDeclaringClass().getModifiers()) + || Modifier.isFinal(field.getModifiers())) && !field.isAccessible()) { + field.setAccessible(true); + } + } +} diff --git a/src/test/java/redis/clients/jedis/ProtocolTest.java b/src/test/java/redis/clients/jedis/ProtocolTest.java index 68515d6f04..8e1f142f90 100644 --- a/src/test/java/redis/clients/jedis/ProtocolTest.java +++ b/src/test/java/redis/clients/jedis/ProtocolTest.java @@ -7,7 +7,6 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static redis.clients.jedis.util.AssertUtil.assertByteArrayListEquals; diff --git a/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java new file mode 100644 index 0000000000..cff7a84c08 --- /dev/null +++ b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java @@ -0,0 +1,271 @@ +package redis.clients.jedis.upgrade; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.net.Socket; +import java.net.SocketException; +import java.time.Duration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import redis.clients.jedis.Connection; +import redis.clients.jedis.DefaultJedisClientConfig; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.MaintenanceEventHandler; +import redis.clients.jedis.MaintenanceEventHandlerImpl; +import redis.clients.jedis.MaintenanceEventListener; +import redis.clients.jedis.RedisProtocol; +import redis.clients.jedis.TimeoutOptions; +import redis.clients.jedis.util.ReflectionTestUtils; +import redis.clients.jedis.util.server.TcpMockServer; + +/** + * Test that connection adaptive timeout works as expected. + * Usses a mock TCP server to send Maintenance push messages to the client in controllable manner. + */ +@Tag("upgrade") +public class ConnectionAdaptiveTimeoutTest { + + private TcpMockServer mockServer; + private Connection connection; + private final int originalTimeoutMs = 2000; + private final Duration relaxedTimeout = Duration.ofSeconds(10); + + @BeforeEach + public void setUp() throws IOException { + // Start the mock TCP server + mockServer = new TcpMockServer(); + mockServer.start(); + + // Create client configuration with relaxed timeout and maintenance event handler + TimeoutOptions timeoutOptions = TimeoutOptions.builder() + .proactiveTimeoutsRelaxing(relaxedTimeout) + .build(); + + MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); + + MaintenanceEventListener testListener = new MaintenanceEventListener() { + @Override + public void onMigrating() { + System.out.println("MIGRATING"); + } + }; + maintenanceEventHandler.addListener(testListener); + + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(originalTimeoutMs) + .timeoutOptions(timeoutOptions) + .maintenanceEventHandler(maintenanceEventHandler) + .protocol(RedisProtocol.RESP3) + .build(); + + // Create connection to the mock server + HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); + connection = new Connection(hostAndPort, clientConfig); + } + + @AfterEach + public void tearDown() throws IOException { + if (connection != null && connection.isConnected()) { + connection.close(); + } + if (mockServer != null) { + mockServer.stop(); + } + } + + @Test + public void testMigratingPushMessage() throws SocketException { + Socket socket = ReflectionTestUtils.getField(connection, "socket"); + + assertTrue(connection.isConnected()); + assertEquals(originalTimeoutMs, connection.getSoTimeout()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + + // First send MIGRATING to activate relaxed timeout + mockServer.sendMigratingPushToAll(); + assertTrue(connection.ping()); + assertTrue(connection.isRelaxedTimeoutActive()); + assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout()); + + mockServer.sendMigratedPushToAll(); + assertTrue(connection.ping()); + assertFalse(connection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + } + + @Test + public void testFailoverPushMessage() throws SocketException { + Socket socket = ReflectionTestUtils.getField(connection, "socket"); + + assertTrue(connection.isConnected()); + assertEquals(originalTimeoutMs, connection.getSoTimeout()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + + // First send MIGRATING to activate relaxed timeout + mockServer.sendFailingOverPushToAll(); + assertTrue(connection.ping()); + assertTrue(connection.isRelaxedTimeoutActive()); + assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout()); + + mockServer.sendFailedOverPushToAll(); + assertTrue(connection.ping()); + assertFalse(connection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + } + + @Test + public void testDisabledTimeoutRelaxationDoesNotApplyRelaxedTimeout() throws Exception { + // Create a connection with disabled timeout relaxation + Connection disabledConnection = createConnectionWithDisabledTimeoutRelaxation(); + Socket disabledSocket = ReflectionTestUtils.getField(disabledConnection, "socket"); + + try { + assertTrue(disabledConnection.isConnected()); + assertEquals(originalTimeoutMs, disabledConnection.getSoTimeout()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Verify that relaxed timeout is disabled + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + + // Send MIGRATING push message - should NOT activate relaxed timeout + mockServer.sendMigratingPushToAll(); + + assertTrue(disabledConnection.ping()); + + // Verify that relaxed timeout was NOT activated + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Send FAILING_OVER push message - should also NOT activate relaxed timeout + mockServer.sendFailingOverPushToAll(); + + assertTrue(disabledConnection.ping()); + + // Verify that relaxed timeout is still NOT activated + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Send MIGRATED and FAILED_OVER messages - timeout should remain unchanged + mockServer.sendMigratedPushToAll(); + mockServer.sendFailedOverPushToAll(); + + assertTrue(disabledConnection.ping()); + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + } finally { + if (disabledConnection.isConnected()) { + disabledConnection.close(); + } + } + } + + @Test + public void testManualRelaxTimeoutsCallWithDisabledTimeoutRelaxation() throws Exception { + // Create a connection with disabled timeout relaxation + Connection disabledConnection = createConnectionWithDisabledTimeoutRelaxation(); + Socket disabledSocket = ReflectionTestUtils.getField(disabledConnection, "socket"); + + try { + assertTrue(disabledConnection.isConnected()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Manually call relaxTimeouts() - should have no effect when disabled + disabledConnection.relaxTimeouts(); + + // Verify that timeout was not changed + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Verify connection still works + assertTrue(disabledConnection.ping()); + + } finally { + if (disabledConnection.isConnected()) { + disabledConnection.close(); + } + } + } + + @Test + public void testNullTimeoutOptionsDisablesRelaxedTimeout() throws Exception { + // Create a connection with null timeout options + Connection nullTimeoutConnection = createConnectionWithNullTimeoutOptions(); + Socket nullTimeoutSocket = ReflectionTestUtils.getField(nullTimeoutConnection, "socket"); + + try { + assertTrue(nullTimeoutConnection.isConnected()); + assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); + + // Verify that relaxed timeout is disabled + assertFalse(nullTimeoutConnection.isRelaxedTimeoutActive()); + + // Send maintenance push messages - should NOT activate relaxed timeout + mockServer.sendMigratingPushToAll(); + + assertTrue(nullTimeoutConnection.ping()); + assertFalse(nullTimeoutConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); + + // Manual call should also have no effect + nullTimeoutConnection.relaxTimeouts(); + assertFalse(nullTimeoutConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); + + } finally { + if (nullTimeoutConnection.isConnected()) { + nullTimeoutConnection.close(); + } + } + } + + /** + * Helper method to create a connection with disabled timeout relaxation. + */ + private Connection createConnectionWithDisabledTimeoutRelaxation() throws IOException { + // Create configuration with disabled timeout relaxation + TimeoutOptions disabledTimeoutOptions = TimeoutOptions.create(); // Uses default disabled timeout + + MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); + + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(originalTimeoutMs) + .timeoutOptions(disabledTimeoutOptions) + .maintenanceEventHandler(maintenanceEventHandler) + .protocol(RedisProtocol.RESP3) + .build(); + + // Create connection to the mock server + HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); + Connection disabledConnection = new Connection(hostAndPort, clientConfig); + disabledConnection.connect(); + + return disabledConnection; + } + + /** + * Helper method to create a connection with null timeout options. + */ + private Connection createConnectionWithNullTimeoutOptions() throws IOException { + MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); + + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(originalTimeoutMs) + // Note: not setting timeoutOptions, so it will be null + .maintenanceEventHandler(maintenanceEventHandler) + .protocol(RedisProtocol.RESP3) + .build(); + + // Create connection to the mock server + HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); + Connection nullTimeoutConnection = new Connection(hostAndPort, clientConfig); + nullTimeoutConnection.connect(); + + return nullTimeoutConnection; + } +} diff --git a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java new file mode 100644 index 0000000000..1412d9f5de --- /dev/null +++ b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java @@ -0,0 +1,284 @@ +package redis.clients.jedis.util.server; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.Protocol; +import redis.clients.jedis.util.RedisInputStream; +import redis.clients.jedis.util.RedisOutputStream; +import redis.clients.jedis.util.SafeEncoder; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Map; + +/** + * A simple TCP mock server for testing Redis push notifications and timeout behavior. + * This server can accept connections and send predefined responses including push messages. + */ +public class TcpMockServer { + Logger logger = LoggerFactory.getLogger(TcpMockServer.class); + + private ServerSocket serverSocket; + private final AtomicBoolean running = new AtomicBoolean(false); + private final ExecutorService executor = Executors.newCachedThreadPool(); + private int port; + private final Map connectedClients = new ConcurrentHashMap<>(); + + /** + * Start the server on an available port + */ + public void start() throws IOException { + start(0); // Use any available port + } + + /** + * Start the server on a specific port + */ + public void start(int port) throws IOException { + serverSocket = new ServerSocket(port); + this.port = serverSocket.getLocalPort(); + running.set(true); + + executor.submit(() -> { + while (running.get() && !serverSocket.isClosed()) { + try { + Socket clientSocket = serverSocket.accept(); + executor.submit(new ClientHandler(clientSocket)); + } catch (IOException e) { + if (running.get()) { + logger.error("Error accepting client connection: " + e.getMessage()); + } + } + } + }); + } + + /** + * Stop the server + */ + public void stop() throws IOException { + running.set(false); + if (serverSocket != null && !serverSocket.isClosed()) { + serverSocket.close(); + } + executor.shutdownNow(); + } + + /** + * Get the port the server is running on + */ + public int getPort() { + return port; + } + + /** + * Check if the server is running + */ + public boolean isRunning() { + return running.get() && serverSocket != null && !serverSocket.isClosed(); + } + + /** + * Get the number of connected clients + */ + public int getConnectedClientCount() { + return connectedClients.size(); + } + + /** + * Generic method to send a push message to all connected clients. + * + * @param pushType the type of push message (e.g., "MIGRATING", "MIGRATED") + * @param args optional arguments for the push message + */ + public void sendPushMessageToAll(String pushType, String... args) { + connectedClients.values().forEach(client -> client.sendPushMessage(pushType, args)); + } + + /** + * Send a MIGRATING push message to all connected clients + */ + public void sendMigratingPushToAll() { + sendPushMessageToAll("MIGRATING", "30"); // Default slot 30 + } + + /** + * Send a MIGRATED push message to all connected clients + */ + public void sendMigratedPushToAll() { + sendPushMessageToAll("MIGRATED"); + } + + /** + * Send a FAILING_OVER push message to all connected clients + */ + public void sendFailingOverPushToAll() { + sendPushMessageToAll("FAILING_OVER", "30"); // Default slot 30 + } + + /** + * Send a FAILED_OVER push message to all connected clients + */ + public void sendFailedOverPushToAll() { + sendPushMessageToAll("FAILED_OVER"); + } + + public void sendMovingPushToAll(String targetHost) { + sendPushMessageToAll("MOVING", "30", targetHost); + } + + /** + * Send a custom push message to all connected clients + */ + public void sendCustomPushToAll(String pushType, String... args) { + sendPushMessageToAll(pushType, args); + } + + /** + * Client handler for each connection + */ + private class ClientHandler implements Runnable { + private final Socket clientSocket; + private final String clientId; + private RedisOutputStream outputStream; + private volatile boolean connected = true; + + public ClientHandler(Socket clientSocket) { + this.clientSocket = clientSocket; + this.clientId = clientSocket.getRemoteSocketAddress().toString(); + } + + @Override + public void run() { + try (RedisInputStream rin = new RedisInputStream(clientSocket.getInputStream()); + RedisOutputStream out = new RedisOutputStream(clientSocket.getOutputStream())) { + + this.outputStream = out; + connectedClients.put(clientId, this); + + Object input; + while (connected && !clientSocket.isClosed()) { + try { + input = Protocol.read(rin); + if (input == null) { + // Client closed connection + break; + } + + List cmdArgs = (List) input; + String cmd = SafeEncoder.encode((byte[]) cmdArgs.get(0)); + + // Handle different commands + if (cmd.equalsIgnoreCase("HELLO")) { + sendHelloResponse(out); + } else if (cmd.contains("PING")) { + sendPongResponse(out); + } else if (cmd.contains("CLIENT")) { + sendOkResponse(out); + } else { + throw new RuntimeException("Unknown command: " + cmd); + } + } catch (IOException e) { + // Client disconnected or connection error + logger.error("Client " + clientId + " disconnected (IOException): " + e.getMessage()); + break; + } catch (Exception e) { + // Other errors (like connection reset, socket closed, etc.) + logger.error("Client " + clientId + " connection error: " + e.getMessage()); + break; + } + } + } catch (IOException e) { + logger.error("Error handling client: " + e.getMessage()); + } finally { + cleanup(); + } + } + + private void sendHelloResponse(OutputStream out) throws IOException { + // RESP3 HELLO response + String response = "%7\r\n" + + "$6\r\nserver\r\n$5\r\nredis\r\n" + + "$7\r\nversion\r\n$5\r\n7.0.0\r\n" + + "$5\r\nproto\r\n:3\r\n" + + "$2\r\nid\r\n:1\r\n" + + "$4\r\nmode\r\n$10\r\nstandalone\r\n" + + "$4\r\nrole\r\n$6\r\nmaster\r\n" + + "$7\r\nmodules\r\n*0\r\n"; + out.write(response.getBytes()); + out.flush(); + } + + private void sendPongResponse(OutputStream out) throws IOException { + String response = "+PONG\r\n"; + out.write(response.getBytes()); + out.flush(); + } + + private void sendOkResponse(OutputStream out) throws IOException { + String response = "+OK\r\n"; + out.write(response.getBytes()); + out.flush(); + } + + /** + * Clean up client resources and remove from connected clients map + */ + private void cleanup() { + connected = false; + connectedClients.remove(clientId); + outputStream = null; + + try { + if (clientSocket != null && !clientSocket.isClosed()) { + clientSocket.close(); + } + } catch (IOException e) { + logger.error("Error closing client socket during cleanup: " + e.getMessage()); + } + } + + + /** + * Generic method to send a push message to this client. + * + * @param pushType the type of push message (e.g., "MIGRATING", "MIGRATED") + * @param args optional arguments for the push message + */ + public void sendPushMessage(String pushType, String... args) { + try { + StringBuilder pushMessage = new StringBuilder(); + + // Calculate total number of elements (push type + arguments) + int elementCount = 1 + args.length; + pushMessage.append(">").append(elementCount).append("\r\n"); + + // Add push type + pushMessage.append("$").append(pushType.length()).append("\r\n") + .append(pushType).append("\r\n"); + + // Add arguments + for (String arg : args) { + pushMessage.append("$").append(arg.length()).append("\r\n") + .append(arg).append("\r\n"); + } + + outputStream.write(pushMessage.toString().getBytes()); + outputStream.flush(); + + + } catch (IOException e) { + logger.error("Error sending " + pushType + " push to " + clientId + " (client disconnected): " + e.getMessage()); + cleanup(); + } + } + + } +} From 4ee525dc5d05ab38b45206445c18eb2af33cc868 Mon Sep 17 00:00:00 2001 From: ggivo Date: Wed, 9 Jul 2025 20:41:50 +0300 Subject: [PATCH 13/23] Mocked rebind test --- .../UnifiedJedisProactiveRebindTest.java | 112 ++++++++++++++++++ .../jedis/util/server/TcpMockServer.java | 46 ++++++- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java diff --git a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java new file mode 100644 index 0000000000..1b7459bd80 --- /dev/null +++ b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java @@ -0,0 +1,112 @@ +package redis.clients.jedis.upgrade; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import redis.clients.jedis.ConnectionPoolConfig; +import redis.clients.jedis.DefaultJedisClientConfig; +import redis.clients.jedis.HostAndPort; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.RedisProtocol; +import redis.clients.jedis.UnifiedJedis; +import redis.clients.jedis.util.server.TcpMockServer; + +/** + * Test that UnifiedJedis proactively rebinds to new target when receiving MOVING notifications. + * Uses mock TCP servers to simulate Redis cluster slot migration scenarios. + */ +@Tag("upgrade") +public class UnifiedJedisProactiveRebindTest { + + private TcpMockServer mockServer1; + private TcpMockServer mockServer2; + private JedisPooled unifiedJedis; + private final int socketTimeoutMs = 5000; + + @BeforeEach + public void setUp() throws IOException { + // Start tcpmockedserver1 + mockServer1 = new TcpMockServer(); + mockServer1.start(); + + // Start tcpmockedserver2 + mockServer2 = new TcpMockServer(); + mockServer2.start(); + + System.out.println("MockServer1 started on port: " + mockServer1.getPort()); + System.out.println("MockServer2 started on port: " + mockServer2.getPort()); + } + + @AfterEach + public void tearDown() throws IOException { + if (unifiedJedis != null) { + unifiedJedis.close(); + } + if (mockServer1 != null) { + mockServer1.stop(); + } + if (mockServer2 != null) { + mockServer2.stop(); + } + } + + @Test + public void testProactiveRebindOnMovingNotification() throws Exception { + // 1. Create UnifiedJedis client and connect it to mockedserver1 + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(socketTimeoutMs) + .protocol(RedisProtocol.RESP3) + .proactiveRebindEnabled(true) // Enable proactive rebinding + .build(); + + HostAndPort server1Address = new HostAndPort("localhost", mockServer1.getPort()); + ConnectionPoolConfig connectionPoolConfig = new ConnectionPoolConfig(); + connectionPoolConfig.setMaxTotal(1); + connectionPoolConfig.setMinIdle(1); + unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig); + + // 1. Perform a PING command to initiate a connection + String response1 = unifiedJedis.ping(); + assertEquals("PONG", response1); + + // Verify initial connection to server1 + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification to mockedserver2 + // MOVING format: ['MOVING', slot, 'host:port'] + String server2Address = "localhost:" + mockServer2.getPort(); + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address); + + // 3. Perform PING command + // This should trigger read of the MOVING notification and rebind to server2 + // the ping command itself should be executed against server1 + // the used connection should be closed after the ping command is executed + String response2 = unifiedJedis.ping(); + assertEquals("PONG", response2); + + // drop connection to server1 + mockServer1.stop(); + + // Verify initial connection to server1 + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 4. Perform PING command + // Folowup ping command should be executed against server2 + + String response3 = unifiedJedis.ping(); + assertEquals("PONG", response3); + + // Verify that connection has moved to server2 + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(1, mockServer2.getConnectedClientCount()); + } + +} diff --git a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java index 1412d9f5de..c2c4d1ea81 100644 --- a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java +++ b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java @@ -61,10 +61,15 @@ public void start(int port) throws IOException { } /** - * Stop the server + * Stop the server and close all active connections */ public void stop() throws IOException { running.set(false); + + // Close all active client connections first + closeAllActiveConnections(); + + // Close the server socket if (serverSocket != null && !serverSocket.isClosed()) { serverSocket.close(); } @@ -141,6 +146,26 @@ public void sendCustomPushToAll(String pushType, String... args) { sendPushMessageToAll(pushType, args); } + + /** + * Close all active client connections + */ + private void closeAllActiveConnections() { + // Create a copy of the values to avoid ConcurrentModificationException + java.util.List clientsToClose = new java.util.ArrayList<>(connectedClients.values()); + + for (ClientHandler client : clientsToClose) { + try { + client.forceClose(); + } catch (Exception e) { + logger.error("Error closing client connection: " + e.getMessage()); + } + } + + // Clear the map + connectedClients.clear(); + } + /** * Client handler for each connection */ @@ -280,5 +305,24 @@ public void sendPushMessage(String pushType, String... args) { } } + /** + * Force close this client connection (used when server is shutting down) + */ + public void forceClose() { + connected = false; + + try { + if (clientSocket != null && !clientSocket.isClosed()) { + clientSocket.close(); + } + } catch (IOException e) { + logger.error("Error force closing client socket: " + e.getMessage()); + } + + // Remove from connected clients map + connectedClients.remove(clientId); + outputStream = null; + } + } } From b940ad705e363760d7c7f317cdebd07461c74839 Mon Sep 17 00:00:00 2001 From: ggivo Date: Thu, 10 Jul 2025 13:42:35 +0300 Subject: [PATCH 14/23] Fix : wrong order connection.rebind pool.clear ConenctionFactory should be rebound before triggering the disposal of Idle connection, so that any newly creaetd are using the proper hostname --- src/main/java/redis/clients/jedis/ConnectionPool.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/redis/clients/jedis/ConnectionPool.java b/src/main/java/redis/clients/jedis/ConnectionPool.java index 3a5e74b0cc..5a71b02841 100644 --- a/src/main/java/redis/clients/jedis/ConnectionPool.java +++ b/src/main/java/redis/clients/jedis/ConnectionPool.java @@ -108,8 +108,8 @@ public RebindHandler(ConnectionPool pool, ConnectionFactory factory) { public void onRebind(HostAndPort target) { HostAndPort previous = rebindTarget.getAndSet(target); if (previous != target) { - this.pool.clear(); this.factory.rebind(target); + this.pool.clear(); } } } From 7efbe72d24f80cc76c29acc5af95ae194da6bcf2 Mon Sep 17 00:00:00 2001 From: ggivo Date: Thu, 10 Jul 2025 19:23:17 +0300 Subject: [PATCH 15/23] [clean up] Address review comments from a-TODO-rov --- src/main/java/redis/clients/jedis/Jedis.java | 3 --- src/main/java/redis/clients/jedis/JedisClientConfig.java | 1 - src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java | 2 -- src/main/java/redis/clients/jedis/Protocol.java | 2 +- src/main/java/redis/clients/jedis/csc/CacheConnection.java | 1 - src/main/java/redis/clients/jedis/util/JedisAsserts.java | 2 -- 6 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/main/java/redis/clients/jedis/Jedis.java b/src/main/java/redis/clients/jedis/Jedis.java index 5707192277..e19a4fa619 100644 --- a/src/main/java/redis/clients/jedis/Jedis.java +++ b/src/main/java/redis/clients/jedis/Jedis.java @@ -8,7 +8,6 @@ import java.io.Closeable; import java.net.URI; -import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -22,7 +21,6 @@ import javax.net.ssl.SSLSocketFactory; import redis.clients.jedis.Protocol.*; -import redis.clients.jedis.annots.Experimental; import redis.clients.jedis.args.*; import redis.clients.jedis.commands.*; import redis.clients.jedis.exceptions.InvalidURIException; @@ -344,7 +342,6 @@ public int getDB() { return this.db; } - /** * @return PONG */ diff --git a/src/main/java/redis/clients/jedis/JedisClientConfig.java b/src/main/java/redis/clients/jedis/JedisClientConfig.java index 8ea4e250d2..ab3039bc00 100644 --- a/src/main/java/redis/clients/jedis/JedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/JedisClientConfig.java @@ -7,7 +7,6 @@ import redis.clients.jedis.authentication.AuthXManager; - public interface JedisClientConfig { default RedisProtocol getRedisProtocol() { diff --git a/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java b/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java index 3a81bde3f7..9020693929 100644 --- a/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java +++ b/src/main/java/redis/clients/jedis/JedisShardedPubSubBase.java @@ -3,9 +3,7 @@ import static redis.clients.jedis.Protocol.ResponseKeyword.*; import java.util.Arrays; -import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.function.Consumer; import redis.clients.jedis.Protocol.Command; diff --git a/src/main/java/redis/clients/jedis/Protocol.java b/src/main/java/redis/clients/jedis/Protocol.java index 88258d71e4..2968ca0277 100644 --- a/src/main/java/redis/clients/jedis/Protocol.java +++ b/src/main/java/redis/clients/jedis/Protocol.java @@ -131,7 +131,7 @@ private static String[] parseTargetHostAndSlot(String clusterRedirectResponse) { private static Object process(final RedisInputStream is, PushConsumer pushConsumer) { final byte b = is.readByte(); - //System.out.println("BYTE: " + (char) b); + // System.out.println("BYTE: " + (char) b); switch (b) { case PLUS_BYTE: return is.readLineBytes(); diff --git a/src/main/java/redis/clients/jedis/csc/CacheConnection.java b/src/main/java/redis/clients/jedis/csc/CacheConnection.java index 62870905a8..fdeefdd9c3 100644 --- a/src/main/java/redis/clients/jedis/csc/CacheConnection.java +++ b/src/main/java/redis/clients/jedis/csc/CacheConnection.java @@ -11,7 +11,6 @@ import redis.clients.jedis.Protocol; import redis.clients.jedis.PushConsumer; import redis.clients.jedis.PushConsumerContext; -import redis.clients.jedis.PushHandler; import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.exceptions.JedisException; import redis.clients.jedis.util.RedisInputStream; diff --git a/src/main/java/redis/clients/jedis/util/JedisAsserts.java b/src/main/java/redis/clients/jedis/util/JedisAsserts.java index 1abd890bb7..70b2f1b527 100644 --- a/src/main/java/redis/clients/jedis/util/JedisAsserts.java +++ b/src/main/java/redis/clients/jedis/util/JedisAsserts.java @@ -1,7 +1,5 @@ package redis.clients.jedis.util; -import java.time.Duration; - /** * Assertion utility class that assists in validating arguments. This class is part of the internal API and may change without * further notice. From b0987e0e9ac432dd6327171f751d284d0142759c Mon Sep 17 00:00:00 2001 From: ggivo Date: Fri, 11 Jul 2025 16:50:07 +0300 Subject: [PATCH 16/23] add more rebind tests --- .../clients/jedis/ConnectionTestHelper.java | 8 + .../UnifiedJedisProactiveRebindTest.java | 258 ++++++++++++++---- .../jedis/util/server/TcpMockServer.java | 10 +- 3 files changed, 218 insertions(+), 58 deletions(-) create mode 100644 src/test/java/redis/clients/jedis/ConnectionTestHelper.java diff --git a/src/test/java/redis/clients/jedis/ConnectionTestHelper.java b/src/test/java/redis/clients/jedis/ConnectionTestHelper.java new file mode 100644 index 0000000000..672788843f --- /dev/null +++ b/src/test/java/redis/clients/jedis/ConnectionTestHelper.java @@ -0,0 +1,8 @@ +package redis.clients.jedis; + +public class ConnectionTestHelper +{ + public static HostAndPort getHostAndPort(Connection connection) { + return connection.getHostAndPort(); + } +} diff --git a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java index 1b7459bd80..217cdda825 100644 --- a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java +++ b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java @@ -1,20 +1,25 @@ package redis.clients.jedis.upgrade; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; +import java.time.Duration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import redis.clients.jedis.Connection; import redis.clients.jedis.ConnectionPoolConfig; +import redis.clients.jedis.ConnectionTestHelper; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisPooled; import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.UnifiedJedis; +import redis.clients.jedis.util.Pool; import redis.clients.jedis.util.server.TcpMockServer; /** @@ -26,9 +31,20 @@ public class UnifiedJedisProactiveRebindTest { private TcpMockServer mockServer1; private TcpMockServer mockServer2; - private JedisPooled unifiedJedis; + private final int socketTimeoutMs = 5000; + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(socketTimeoutMs) + .protocol(RedisProtocol.RESP3) + .proactiveRebindEnabled(true) // Enable proactive rebinding + .build(); + + HostAndPort server1Address; + HostAndPort server2Address; + + ConnectionPoolConfig connectionPoolConfig; + @BeforeEach public void setUp() throws IOException { // Start tcpmockedserver1 @@ -39,15 +55,18 @@ public void setUp() throws IOException { mockServer2 = new TcpMockServer(); mockServer2.start(); + server1Address = new HostAndPort("localhost", mockServer1.getPort()); + server2Address = new HostAndPort("localhost", mockServer2.getPort()); + + connectionPoolConfig = new ConnectionPoolConfig(); + System.out.println("MockServer1 started on port: " + mockServer1.getPort()); System.out.println("MockServer2 started on port: " + mockServer2.getPort()); } @AfterEach public void tearDown() throws IOException { - if (unifiedJedis != null) { - unifiedJedis.close(); - } + if (mockServer1 != null) { mockServer1.stop(); } @@ -57,56 +76,189 @@ public void tearDown() throws IOException { } @Test - public void testProactiveRebindOnMovingNotification() throws Exception { + public void testProactiveRebind() throws Exception { + // 1. Create UnifiedJedis client and connect it to mockedserver1 + try(JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig);) { + + // 1. Perform a PING command to initiate a connection + String response1 = unifiedJedis.ping(); + assertEquals("PONG", response1); + + // Verify initial connection to server1 + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + + // 3. Perform PING command + // This should trigger read of the MOVING notification and rebind to server2 + // the ping command itself should be executed against server1 + // the used connection should be closed after the ping command is executed + String response2 = unifiedJedis.ping(); + assertEquals("PONG", response2); + + // drop connection to server1 + mockServer1.stop(); + + // Verify initial connection to server1 + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 4. Perform PING command + // Folowup ping command should be executed against server2 + + String response3 = unifiedJedis.ping(); + assertEquals("PONG", response3); + + // Verify that connection has moved to server2 + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(1, mockServer2.getConnectedClientCount()); + } + } + + @Test + public void testActiveConnectionShouldBeDisposedOnRebind() throws Exception { // 1. Create UnifiedJedis client and connect it to mockedserver1 - DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() - .socketTimeoutMillis(socketTimeoutMs) - .protocol(RedisProtocol.RESP3) - .proactiveRebindEnabled(true) // Enable proactive rebinding - .build(); - - HostAndPort server1Address = new HostAndPort("localhost", mockServer1.getPort()); - ConnectionPoolConfig connectionPoolConfig = new ConnectionPoolConfig(); - connectionPoolConfig.setMaxTotal(1); - connectionPoolConfig.setMinIdle(1); - unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig); - - // 1. Perform a PING command to initiate a connection - String response1 = unifiedJedis.ping(); - assertEquals("PONG", response1); - - // Verify initial connection to server1 - assertEquals(1, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - - // 2. Send MOVING notification to mockedserver2 - // MOVING format: ['MOVING', slot, 'host:port'] - String server2Address = "localhost:" + mockServer2.getPort(); - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address); - - // 3. Perform PING command - // This should trigger read of the MOVING notification and rebind to server2 - // the ping command itself should be executed against server1 - // the used connection should be closed after the ping command is executed - String response2 = unifiedJedis.ping(); - assertEquals("PONG", response2); - - // drop connection to server1 - mockServer1.stop(); - - // Verify initial connection to server1 - assertEquals(0, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - - // 4. Perform PING command - // Folowup ping command should be executed against server2 - - String response3 = unifiedJedis.ping(); - assertEquals("PONG", response3); - - // Verify that connection has moved to server2 - assertEquals(0, mockServer1.getConnectedClientCount()); - assertEquals(1, mockServer2.getConnectedClientCount()); + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig);) { + Pool pool = unifiedJedis.getPool(); + + // 1. Test setup - 1 active connection, 0 idle connection + Connection activeConnection = unifiedJedis.getPool().getResource(); + assertEquals(1, pool.getNumActive()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(0, pool.getNumIdle()); + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + + // 3. Active connection should be still usable until closed and returned to the pools + assertTrue(activeConnection.ping()); + + // 4. When closed connection should be disposed and not returned to the pool + activeConnection.close(); + assertEquals(1, pool.getDestroyedCount()); + assertEquals(0, pool.getNumActive()); + + // 5. Wait for connection to be closed on server1 + await().pollDelay(Duration.ofMillis(1)).timeout(Duration.ofMillis(10)) + .until(() -> mockServer1.getConnectedClientCount() == 0); + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 6. Next command should create a new connection to server2 + String response2 = unifiedJedis.ping(); + assertEquals("PONG", response2); + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(1, mockServer2.getConnectedClientCount()); + } + } + + @Test + public void testIdleConnectionShouldBeDisposedOnRebind() throws Exception { + + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig);) { + Pool pool = unifiedJedis.getPool(); + + // 1. Test setup - 1 active connection, 1 idle connection + Connection activeConnection = unifiedJedis.getPool().getResource(); + Connection idleConnection = unifiedJedis.getPool().getResource(); + idleConnection.close(); + + assertEquals(1, pool.getNumActive()); + assertEquals(1, pool.getNumIdle()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(2, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + String server2Address = "localhost:" + mockServer2.getPort(); + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address); + + // 3. perform a command on active connection to trigger rebind + assertTrue(activeConnection.ping()); + + // 4. All IDLE connection's should be closed & disposed + assertEquals(0, pool.getNumIdle()); + assertEquals(1, pool.getNumActive()); + + // 5. Wait for connection to be closed on server1 + await().pollDelay(Duration.ofMillis(1)).timeout(Duration.ofMillis(10)) + .until(() -> mockServer1.getConnectedClientCount() == 1); + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 6. Next command should create a new connection to server2 + String response2 = unifiedJedis.ping(); + assertEquals("PONG", response2); + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(1, mockServer2.getConnectedClientCount()); + } + } + + @Test + public void testNewPoolConnectionsCreatedAgainstMovingTarget() throws Exception { + // Create UnifiedJedis with connection pooling enabled + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)){ + + // 1. Test setup - 1 active connection + Connection activeConnection = unifiedJedis.getPool().getResource(); + + // Verify initial connection to server1 + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + + // 3. perform a command on active connection to trigger rebind + assertTrue(activeConnection.ping()); + + // 4. Initiate a new connection from the pool + Connection newConnection = unifiedJedis.getPool().getResource(); + assertTrue(newConnection.ping()); + + // Verify that new connections are being created against server2 + assertEquals(server2Address, ConnectionTestHelper.getHostAndPort(newConnection)); + assertEquals(1, mockServer2.getConnectedClientCount()); + } + } + + @Test + public void testPoolConnectionsWithProactiveRebindDisabled() throws Exception { + // Verify that with proactive rebind disabled, connections stay on original server + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder().from(this.clientConfig).proactiveRebindEnabled(false).build(); + try(JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { + Pool pool = unifiedJedis.getPool(); + + // 1. Test setup - 1 active connection, 1 idle connection + Connection activeConnection = unifiedJedis.getPool().getResource(); + Connection idleConnection = unifiedJedis.getPool().getResource(); + idleConnection.close(); + + // Verify initial connection to server1 + assertEquals(1, pool.getNumActive()); + assertEquals(1, pool.getNumIdle()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(2, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + + // 3. Perform PING command + // This should trigger read of the MOVING notification processing + assertTrue(activeConnection.ping()); + + // Verify initial connection to server1 + assertEquals(1, pool.getNumActive()); + assertEquals(1, pool.getNumIdle()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(2, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + } } } diff --git a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java index c2c4d1ea81..1287f65fca 100644 --- a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java +++ b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java @@ -193,7 +193,7 @@ public void run() { try { input = Protocol.read(rin); if (input == null) { - // Client closed connection + connected = false; break; } @@ -211,12 +211,12 @@ public void run() { throw new RuntimeException("Unknown command: " + cmd); } } catch (IOException e) { - // Client disconnected or connection error - logger.error("Client " + clientId + " disconnected (IOException): " + e.getMessage()); + logger.debug("Client " + clientId + " disconnected: " + e.getMessage()); + connected = false; break; } catch (Exception e) { - // Other errors (like connection reset, socket closed, etc.) - logger.error("Client " + clientId + " connection error: " + e.getMessage()); + logger.debug("Client " + clientId + " connection error: " + e.getMessage()); + connected = false; break; } } From 7e11737a8ce1ac49b286edd7234cf248bea3ddce Mon Sep 17 00:00:00 2001 From: ggivo Date: Fri, 11 Jul 2025 16:53:11 +0300 Subject: [PATCH 17/23] clean up --- .../upgrade/UnifiedJedisProactiveRebindTest.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java index 217cdda825..2964b56302 100644 --- a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java +++ b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java @@ -18,7 +18,6 @@ import redis.clients.jedis.HostAndPort; import redis.clients.jedis.JedisPooled; import redis.clients.jedis.RedisProtocol; -import redis.clients.jedis.UnifiedJedis; import redis.clients.jedis.util.Pool; import redis.clients.jedis.util.server.TcpMockServer; @@ -78,7 +77,7 @@ public void tearDown() throws IOException { @Test public void testProactiveRebind() throws Exception { // 1. Create UnifiedJedis client and connect it to mockedserver1 - try(JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig);) { + try(JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { // 1. Perform a PING command to initiate a connection String response1 = unifiedJedis.ping(); @@ -118,9 +117,9 @@ public void testProactiveRebind() throws Exception { } @Test - public void testActiveConnectionShouldBeDisposedOnRebind() throws Exception { + public void testActiveConnectionShouldBeDisposedOnRebind() { // 1. Create UnifiedJedis client and connect it to mockedserver1 - try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig);) { + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { Pool pool = unifiedJedis.getPool(); // 1. Test setup - 1 active connection, 0 idle connection @@ -157,9 +156,9 @@ public void testActiveConnectionShouldBeDisposedOnRebind() throws Exception { } @Test - public void testIdleConnectionShouldBeDisposedOnRebind() throws Exception { + public void testIdleConnectionShouldBeDisposedOnRebind() { - try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig);) { + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { Pool pool = unifiedJedis.getPool(); // 1. Test setup - 1 active connection, 1 idle connection @@ -199,7 +198,7 @@ public void testIdleConnectionShouldBeDisposedOnRebind() throws Exception { } @Test - public void testNewPoolConnectionsCreatedAgainstMovingTarget() throws Exception { + public void testNewPoolConnectionsCreatedAgainstMovingTarget() { // Create UnifiedJedis with connection pooling enabled try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)){ @@ -227,7 +226,7 @@ public void testNewPoolConnectionsCreatedAgainstMovingTarget() throws Exception } @Test - public void testPoolConnectionsWithProactiveRebindDisabled() throws Exception { + public void testPoolConnectionsWithProactiveRebindDisabled() { // Verify that with proactive rebind disabled, connections stay on original server DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder().from(this.clientConfig).proactiveRebindEnabled(false).build(); try(JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { From b769492dd48b418ffc672bc62f132f7bedd754f5 Mon Sep 17 00:00:00 2001 From: ggivo Date: Fri, 11 Jul 2025 16:54:34 +0300 Subject: [PATCH 18/23] clean up remove unused test method --- .../redis/clients/jedis/util/server/TcpMockServer.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java index 1287f65fca..3e9c77b46a 100644 --- a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java +++ b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java @@ -139,14 +139,6 @@ public void sendMovingPushToAll(String targetHost) { sendPushMessageToAll("MOVING", "30", targetHost); } - /** - * Send a custom push message to all connected clients - */ - public void sendCustomPushToAll(String pushType, String... args) { - sendPushMessageToAll(pushType, args); - } - - /** * Close all active client connections */ From e7ffd1d9fa1f98031666a81a4157a9b2162cc6c8 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 21 Jul 2025 18:45:03 +0300 Subject: [PATCH 19/23] fix relaxed timeout on blocking command Issue : If Maintenace notifications are received during blocking command, relaxTimeout is enforced instead of infinit timeout. Fix: Introduce dedicated relax timeout setting for blocking commands. It will fall back to infinit timeout if not set --- .../java/redis/clients/jedis/Connection.java | 62 +++++-- .../jedis/DefaultJedisClientConfig.java | 4 +- .../jedis/MaintenanceEventHandler.java | 3 - .../jedis/MaintenanceEventHandlerImpl.java | 5 - .../redis/clients/jedis/TimeoutOptions.java | 39 +++- .../jedis/util/ReflectionTestUtils.java | 16 ++ .../ConnectionAdaptiveTimeoutTest.java | 88 +++++++-- .../jedis/util/server/CommandHandler.java | 20 ++ .../jedis/util/server/RespResponse.java | 171 ++++++++++++++++++ .../jedis/util/server/TcpMockServer.java | 65 ++++++- 10 files changed, 427 insertions(+), 46 deletions(-) create mode 100644 src/test/java/redis/clients/jedis/util/server/CommandHandler.java create mode 100644 src/test/java/redis/clients/jedis/util/server/RespResponse.java diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index eaa1f2a0b0..834981abeb 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -46,8 +46,10 @@ public class Connection implements Closeable { private Socket socket; private RedisOutputStream outputStream; private RedisInputStream inputStream; - private int soTimeout = 0; + private boolean relaxedTimeoutEnabled = false; private int relaxedTimeout = safeToInt(TimeoutOptions.DISABLED_TIMEOUT.toMillis()); + private int relaxedBlockingTimeout = safeToInt(TimeoutOptions.DISABLED_TIMEOUT.toMillis()); + private int soTimeout = 0; private int infiniteSoTimeout = 0; private boolean broken = false; private boolean strValActive; @@ -56,7 +58,8 @@ public class Connection implements Closeable { protected String version; private AtomicReference currentCredentials = new AtomicReference<>(null); private AuthXManager authXManager; - private boolean relaxedTimeoutActive = false; + private boolean isBlocking = false; + private boolean isRelaxed = false; private boolean rebindRequested = false; protected PushConsumerChain pushConsumer; @@ -88,7 +91,9 @@ public Connection(final JedisSocketFactory socketFactory, JedisClientConfig clie this.soTimeout = clientConfig.getSocketTimeoutMillis(); this.infiniteSoTimeout = clientConfig.getBlockingSocketTimeoutMillis(); this.relaxedTimeout = safeToInt(clientConfig.getTimeoutOptions().getRelaxedTimeout().toMillis()); - + this.relaxedBlockingTimeout = safeToInt(clientConfig.getTimeoutOptions().getRelaxedBlockingTimeout().toMillis()); + this.relaxedTimeoutEnabled = TimeoutOptions.isRelaxedTimeoutEnabled(relaxedTimeout) || + TimeoutOptions.isRelaxedTimeoutEnabled(relaxedBlockingTimeout); initPushConsumers(clientConfig); initializeFromClientConfig(clientConfig); } @@ -209,7 +214,7 @@ public void setTimeoutInfinite() { public void rollbackTimeout() { try { - int timeout = relaxedTimeoutActive? this.relaxedTimeout : this.soTimeout; + int timeout = getDesiredTimeout(); socket.setSoTimeout(timeout); } catch (SocketException ex) { setBroken(); @@ -233,9 +238,11 @@ public T executeCommand(final CommandObject commandObject) { return commandObject.getBuilder().build(getOne()); } else { try { + isBlocking = true; setTimeoutInfinite(); return commandObject.getBuilder().build(getOne()); } finally { + isBlocking = false; rollbackTimeout(); } } @@ -703,16 +710,42 @@ PushConsumerChain getPushConsumer() { @Experimental public boolean isRelaxedTimeoutActive() { - return relaxedTimeoutActive; + return isRelaxed; + } + + /** + * Calculate the desired timeout based on current state (blocking/non-blocking and relaxed/normal). + * When relaxed timeouts are enabled, use configured relaxed timeout if available, otherwise fallback to default timeout. + */ + private int getDesiredTimeout() { + if (!isRelaxed) { + if (!isBlocking) { + return soTimeout; + } else { + return infiniteSoTimeout; + } + } else { + if (!isBlocking) { + // Use relaxed timeout if configured, otherwise fallback to normal timeout + return TimeoutOptions.isRelaxedTimeoutDisabled(relaxedTimeout) ? soTimeout : relaxedTimeout; + } else { + // Use relaxed blocking timeout if configured, otherwise fallback to infinite timeout + return TimeoutOptions.isRelaxedTimeoutDisabled(relaxedBlockingTimeout) ? infiniteSoTimeout : relaxedBlockingTimeout; + } + } } @Experimental public void relaxTimeouts() { - if (!relaxedTimeoutActive && !TimeoutOptions.isRelaxedTimeoutDisabled(relaxedTimeout)) { - relaxedTimeoutActive = true; + if (!relaxedTimeoutEnabled) { + return; + } + + if (!isRelaxed) { + isRelaxed = true; try { if (isConnected()) { - socket.setSoTimeout(relaxedTimeout); + socket.setSoTimeout(getDesiredTimeout()); } } catch (SocketException ex) { setBroken(); @@ -723,11 +756,11 @@ public void relaxTimeouts() { @Experimental public void disableRelaxedTimeout() { - if (relaxedTimeoutActive) { - relaxedTimeoutActive = false; + if (isRelaxed) { + isRelaxed = false; try { if (isConnected()) { - socket.setSoTimeout(soTimeout); + socket.setSoTimeout(getDesiredTimeout()); } } catch (SocketException ex) { setBroken(); @@ -908,5 +941,12 @@ public void onFailedOver() { connection.disableRelaxedTimeout(); } } + + public void onRebind(HostAndPort target) { + Connection connection = connectionRef.get(); + if (connection != null) { + connection.relaxTimeouts(); + } + } } } diff --git a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java index 83cdf1c5fb..0c39aa67df 100644 --- a/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java +++ b/src/main/java/redis/clients/jedis/DefaultJedisClientConfig.java @@ -62,7 +62,9 @@ private DefaultJedisClientConfig(DefaultJedisClientConfig.Builder builder) { this.proactiveRebindEnabled = builder.proactiveRebindEnabled; this.pushHandler = builder.pushHandler; - if ((builder.proactiveRebindEnabled || TimeoutOptions.isRelaxedTimeoutEnabled(builder.timeoutOptions.getRelaxedTimeout())) + if ((builder.proactiveRebindEnabled + || TimeoutOptions.isRelaxedTimeoutEnabled(builder.timeoutOptions.getRelaxedTimeout()) + || TimeoutOptions.isRelaxedTimeoutEnabled(builder.timeoutOptions.getRelaxedBlockingTimeout())) && builder.maintenanceEventHandler == null) { // Proactive rebind or relaxed timeouts require a maintenance event handler this.maintenanceEventHandler = new MaintenanceEventHandlerImpl(); diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java b/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java index 3e921c7d57..0e2ea3cf01 100644 --- a/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java +++ b/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java @@ -11,8 +11,5 @@ public interface MaintenanceEventHandler { void removeListener(MaintenanceEventListener listener); - void removeAllListeners(); - - Collection getListeners(); } \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java b/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java index 7ad983a762..c6d687493c 100644 --- a/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java +++ b/src/main/java/redis/clients/jedis/MaintenanceEventHandlerImpl.java @@ -17,11 +17,6 @@ public void removeListener(MaintenanceEventListener listener) { listeners.remove(listener); } - @Override - public void removeAllListeners() { - listeners.clear(); - } - @Override public Collection getListeners() { return listeners; diff --git a/src/main/java/redis/clients/jedis/TimeoutOptions.java b/src/main/java/redis/clients/jedis/TimeoutOptions.java index 81eb48ed08..70ff188fa4 100644 --- a/src/main/java/redis/clients/jedis/TimeoutOptions.java +++ b/src/main/java/redis/clients/jedis/TimeoutOptions.java @@ -12,10 +12,15 @@ public class TimeoutOptions { public static final Duration DEFAULT_RELAXED_TIMEOUT = DISABLED_TIMEOUT; + public static final Duration DEFAULT_RELAXED_BLOCKING_TIMEOUT = DISABLED_TIMEOUT; + private final Duration relaxedTimeout; - private TimeoutOptions(Duration relaxedTimeout) { + private final Duration relaxedBlockingTimeout; + + private TimeoutOptions(Duration relaxedTimeout, Duration relaxedBlockingTimeout) { this.relaxedTimeout = relaxedTimeout; + this.relaxedBlockingTimeout = relaxedBlockingTimeout; } public static boolean isRelaxedTimeoutEnabled(Duration relaxedTimeout) { @@ -26,6 +31,10 @@ public static boolean isRelaxedTimeoutDisabled(Duration relaxedTimeout) { return relaxedTimeout == null || relaxedTimeout.equals(DISABLED_TIMEOUT); } + public static boolean isRelaxedTimeoutEnabled(int relaxedTimeout) { + return relaxedTimeout != DISABLED_TIMEOUT_MS; + } + public static boolean isRelaxedTimeoutDisabled(int relaxedTimeout) { return relaxedTimeout == DISABLED_TIMEOUT_MS; } @@ -37,6 +46,13 @@ public Duration getRelaxedTimeout() { return relaxedTimeout; } + /** + * @return the {@link Duration} to relax timeouts proactively for blocking commands, {@link #DISABLED_TIMEOUT} if disabled. + */ + public Duration getRelaxedBlockingTimeout() { + return relaxedBlockingTimeout; + } + /** * Returns a new {@link TimeoutOptions.Builder} to construct {@link TimeoutOptions}. * @@ -57,6 +73,7 @@ public static TimeoutOptions create() { public static class Builder { private Duration relaxedTimeout = DEFAULT_RELAXED_TIMEOUT; + private Duration relaxedBlockingTimeout = DEFAULT_RELAXED_BLOCKING_TIMEOUT; /** * Enable proactive timeout relaxing. Disabled by default, see {@link #DEFAULT_RELAXED_TIMEOUT}. @@ -77,8 +94,26 @@ public Builder proactiveTimeoutsRelaxing(Duration duration) { return this; } + /** + * Enable proactive timeout relaxing for blocking commands. Disabled by default, see {@link #DEFAULT_RELAXED_BLOCKING_TIMEOUT}. + *

+ * If the Redis server supports this, and the client is set up to use it, the client would listen to notifications that the current + * endpoint is about to go down (as part of some maintenance activity, for example). In such cases, the driver could + * extend the existing timeout settings for blocking commands that are in flight, to make sure they do not + * time out during this process. If not configured, the infinite timeout for blocking commands will be preserved. + *

+ * @param duration {@link Duration} to relax timeouts proactively for blocking commands, must not be {@code null}. + * @return {@code this} + */ + public Builder proactiveBlockingTimeoutsRelaxing(Duration duration) { + JedisAsserts.notNull(duration, "Duration must not be null"); + + this.relaxedBlockingTimeout = duration; + return this; + } + public TimeoutOptions build() { - return new TimeoutOptions(relaxedTimeout); + return new TimeoutOptions(relaxedTimeout, relaxedBlockingTimeout); } } diff --git a/src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java b/src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java index 02734ea543..c4a1790543 100644 --- a/src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java +++ b/src/main/java/redis/clients/jedis/util/ReflectionTestUtils.java @@ -21,6 +21,22 @@ public static T getField(Object targetObject, String name) { } } + public static void setField(Object targetObject, String name, Object value) { + + Class targetClass = targetObject.getClass(); + + Field field = findField(targetClass, name); + + makeAccessible(field); + + try { + field.set(targetObject, value); + } catch (IllegalAccessException ex) { + throw new IllegalStateException( + "Unexpected reflection exception - " + ex.getClass().getName() + ": " + ex.getMessage()); + } + } + public static Field findField(Class clazz, String name) { Class searchType = clazz; while (Object.class != searchType && searchType != null) { diff --git a/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java index cff7a84c08..c29a687734 100644 --- a/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java +++ b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java @@ -1,17 +1,20 @@ package redis.clients.jedis.upgrade; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.net.Socket; import java.net.SocketException; import java.time.Duration; +import java.util.concurrent.CountDownLatch; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import redis.clients.jedis.CommandObjects; import redis.clients.jedis.Connection; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.HostAndPort; @@ -21,7 +24,13 @@ import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.TimeoutOptions; import redis.clients.jedis.util.ReflectionTestUtils; +import redis.clients.jedis.util.server.RespResponse; import redis.clients.jedis.util.server.TcpMockServer; +import redis.clients.jedis.util.server.CommandHandler; +import org.mockito.Mockito; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; /** * Test that connection adaptive timeout works as expected. @@ -34,16 +43,21 @@ public class ConnectionAdaptiveTimeoutTest { private Connection connection; private final int originalTimeoutMs = 2000; private final Duration relaxedTimeout = Duration.ofSeconds(10); + private final Duration relaxedBlockingTimeout = Duration.ofSeconds(15); + private final CommandObjects commandObjects = new CommandObjects(); + private final CommandHandler mockHandler = Mockito.mock(CommandHandler.class); @BeforeEach public void setUp() throws IOException { // Start the mock TCP server mockServer = new TcpMockServer(); + mockServer.setCommandHandler(mockHandler); mockServer.start(); // Create client configuration with relaxed timeout and maintenance event handler TimeoutOptions timeoutOptions = TimeoutOptions.builder() .proactiveTimeoutsRelaxing(relaxedTimeout) + .proactiveBlockingTimeoutsRelaxing(relaxedBlockingTimeout) .build(); MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); @@ -178,7 +192,7 @@ public void testManualRelaxTimeoutsCallWithDisabledTimeoutRelaxation() throws Ex // Manually call relaxTimeouts() - should have no effect when disabled disabledConnection.relaxTimeouts(); - // Verify that timeout was not changed + // Relaxed timeout should fallback to original timeout, if relaxed timeout is disabled assertFalse(disabledConnection.isRelaxedTimeoutActive()); assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); @@ -193,43 +207,89 @@ public void testManualRelaxTimeoutsCallWithDisabledTimeoutRelaxation() throws Ex } @Test - public void testNullTimeoutOptionsDisablesRelaxedTimeout() throws Exception { + public void testDefaultTimeoutOptionsDisablesRelaxedTimeout() throws Exception { // Create a connection with null timeout options - Connection nullTimeoutConnection = createConnectionWithNullTimeoutOptions(); - Socket nullTimeoutSocket = ReflectionTestUtils.getField(nullTimeoutConnection, "socket"); + Connection defaultTimeoutConnection = createConnectionWithDefaultTimeoutOptions(); + Socket nullTimeoutSocket = ReflectionTestUtils.getField(defaultTimeoutConnection, "socket"); try { - assertTrue(nullTimeoutConnection.isConnected()); + assertTrue(defaultTimeoutConnection.isConnected()); assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); // Verify that relaxed timeout is disabled - assertFalse(nullTimeoutConnection.isRelaxedTimeoutActive()); + assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); // Send maintenance push messages - should NOT activate relaxed timeout mockServer.sendMigratingPushToAll(); - assertTrue(nullTimeoutConnection.ping()); - assertFalse(nullTimeoutConnection.isRelaxedTimeoutActive()); + assertTrue(defaultTimeoutConnection.ping()); + + // Relaxed timeout's are disabled by default + assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); // Manual call should also have no effect - nullTimeoutConnection.relaxTimeouts(); - assertFalse(nullTimeoutConnection.isRelaxedTimeoutActive()); + defaultTimeoutConnection.relaxTimeouts(); + assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); } finally { - if (nullTimeoutConnection.isConnected()) { - nullTimeoutConnection.close(); + if (defaultTimeoutConnection.isConnected()) { + defaultTimeoutConnection.close(); } } } + @Test + public void testRelaxedBlockingTimeoutAppliedDuringBlockingCommand() + throws IOException, InterruptedException { + + // Verify initial timeout + Socket socket = ReflectionTestUtils.getField(connection, "socket"); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + + CountDownLatch blpopLatch = new CountDownLatch(1); + CountDownLatch blpopLatchAfter = new CountDownLatch(1); + doAnswer(invocation -> { + blpopLatch.countDown(); + return RespResponse.arrayOfBulkStrings("popped-item"); + }).when(mockHandler).handleCommand(eq("BLPOP"), anyList(), anyString()); + + // Send MIGRATING push notification which should trigger relaxTimeouts() + mockServer.sendMigratingPushToAll(); + + Thread t1 = new Thread(() -> { + connection.executeCommand(commandObjects.blpop(5, "test:blpop:key")); + blpopLatchAfter.countDown(); + }); + t1.start(); + + // Verify that relaxed blocking timeout was applied + blpopLatch.await(); + assertTrue(connection.isRelaxedTimeoutActive(), "Relaxed timeout should be active during blocking command"); + assertEquals((int) relaxedBlockingTimeout.toMillis(), socket.getSoTimeout(), "Socket timeout should be relaxed blocking timeout during blocking command"); + + blpopLatchAfter.await(); + assertTrue(connection.isRelaxedTimeoutActive(), "Relaxed timeout should be still active after blocking command"); + assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout(), "Socket timeout should be restored to relaxed timeout for non blocking command"); + + // Send MIGRATED push notification to disable relaxed timeout + mockServer.sendMigratedPushToAll(); + connection.executeCommand(commandObjects.ping()); + + assertFalse(connection.isRelaxedTimeoutActive(), "Relaxed timeout should be disabled after MIGRATED"); + assertEquals(originalTimeoutMs, socket.getSoTimeout(), "Socket timeout should be restored to original timeout"); + } + /** * Helper method to create a connection with disabled timeout relaxation. */ private Connection createConnectionWithDisabledTimeoutRelaxation() throws IOException { // Create configuration with disabled timeout relaxation - TimeoutOptions disabledTimeoutOptions = TimeoutOptions.create(); // Uses default disabled timeout + TimeoutOptions disabledTimeoutOptions = TimeoutOptions.builder() + .proactiveTimeoutsRelaxing(TimeoutOptions.DISABLED_TIMEOUT) + .proactiveBlockingTimeoutsRelaxing(TimeoutOptions.DISABLED_TIMEOUT) + .build(); MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); @@ -251,7 +311,7 @@ private Connection createConnectionWithDisabledTimeoutRelaxation() throws IOExce /** * Helper method to create a connection with null timeout options. */ - private Connection createConnectionWithNullTimeoutOptions() throws IOException { + private Connection createConnectionWithDefaultTimeoutOptions() throws IOException { MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() diff --git a/src/test/java/redis/clients/jedis/util/server/CommandHandler.java b/src/test/java/redis/clients/jedis/util/server/CommandHandler.java new file mode 100644 index 0000000000..16caa828a0 --- /dev/null +++ b/src/test/java/redis/clients/jedis/util/server/CommandHandler.java @@ -0,0 +1,20 @@ +package redis.clients.jedis.util.server; + +import java.util.List; + +/** + * Interface for handling custom Redis commands in TcpMockServer. + * This can be easily mocked with Mockito for testing purposes. + */ +public interface CommandHandler { + + /** + * Handle a Redis command and return a response. + * + * @param command The Redis command (case-insensitive) + * @param args The command arguments (excluding the command name) + * @param clientId The client identifier + * @return A RESP response string, or null to use default handling + */ + String handleCommand(String command, List args, String clientId); +} diff --git a/src/test/java/redis/clients/jedis/util/server/RespResponse.java b/src/test/java/redis/clients/jedis/util/server/RespResponse.java new file mode 100644 index 0000000000..0029330949 --- /dev/null +++ b/src/test/java/redis/clients/jedis/util/server/RespResponse.java @@ -0,0 +1,171 @@ +package redis.clients.jedis.util.server; + +import java.util.List; + +/** + * Utility class for building RESP (Redis Serialization Protocol) responses. + * This makes it easier to construct proper Redis protocol responses for testing. + */ +public class RespResponse { + + /** + * Create a simple string response (+OK, +PONG, etc.). + * + * @param value The string value + * @return A RESP simple string response + */ + public static String simpleString(String value) { + return "+" + value + "\r\n"; + } + + /** + * Create an error response (-ERR message). + * + * @param message The error message + * @return A RESP error response + */ + public static String error(String message) { + return "-" + message + "\r\n"; + } + + /** + * Create an integer response (:123). + * + * @param value The integer value + * @return A RESP integer response + */ + public static String integer(long value) { + return ":" + value + "\r\n"; + } + + /** + * Create a bulk string response ($5\r\nhello\r\n). + * + * @param value The string value (null for null bulk string) + * @return A RESP bulk string response + */ + public static String bulkString(String value) { + if (value == null) { + return "$-1\r\n"; + } + byte[] bytes = value.getBytes(); + return "$" + bytes.length + "\r\n" + value + "\r\n"; + } + + /** + * Create a bulk string response from byte array. + * + * @param bytes The byte array (null for null bulk string) + * @return A RESP bulk string response + */ + public static String bulkString(byte[] bytes) { + if (bytes == null) { + return "$-1\r\n"; + } + return "$" + bytes.length + "\r\n" + new String(bytes) + "\r\n"; + } + + /** + * Create an array response (*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n). + * + * @param elements The array elements as RESP strings + * @return A RESP array response + */ + public static String array(String... elements) { + if (elements == null) { + return "*-1\r\n"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("*").append(elements.length).append("\r\n"); + + for (String element : elements) { + sb.append(element); + } + + return sb.toString(); + } + + /** + * Create an array response from a list of strings as bulk strings. + * + * @param strings The list of strings + * @return A RESP array response with bulk string elements + */ + public static String arrayOfBulkStrings(List strings) { + if (strings == null) { + return "*-1\r\n"; + } + + String[] elements = new String[strings.size()]; + for (int i = 0; i < strings.size(); i++) { + elements[i] = bulkString(strings.get(i)); + } + + return array(elements); + } + + /** + * Create an array response from string values (automatically converts to bulk strings). + * + * @param values The string values + * @return A RESP array response with bulk string elements + */ + public static String arrayOfBulkStrings(String... values) { + if (values == null) { + return "*-1\r\n"; + } + + String[] elements = new String[values.length]; + for (int i = 0; i < values.length; i++) { + elements[i] = bulkString(values[i]); + } + + return array(elements); + } + + /** + * Create an empty array response (*0\r\n). + * + * @return A RESP empty array response + */ + public static String emptyArray() { + return "*0\r\n"; + } + + /** + * Create a null array response (*-1\r\n). + * + * @return A RESP null array response + */ + public static String nullArray() { + return "*-1\r\n"; + } + + /** + * Create a null bulk string response ($-1\r\n). + * + * @return A RESP null bulk string response + */ + public static String nullBulkString() { + return "$-1\r\n"; + } + + /** + * Create an OK response (+OK\r\n). + * + * @return A RESP OK response + */ + public static String ok() { + return simpleString("OK"); + } + + /** + * Create a PONG response (+PONG\r\n). + * + * @return A RESP PONG response + */ + public static String pong() { + return simpleString("PONG"); + } +} diff --git a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java index 3e9c77b46a..6910f634dc 100644 --- a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java +++ b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java @@ -3,6 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import redis.clients.jedis.Protocol; + import redis.clients.jedis.util.RedisInputStream; import redis.clients.jedis.util.RedisOutputStream; import redis.clients.jedis.util.SafeEncoder; @@ -30,6 +31,8 @@ public class TcpMockServer { private final ExecutorService executor = Executors.newCachedThreadPool(); private int port; private final Map connectedClients = new ConcurrentHashMap<>(); + private CommandHandler commandHandler; + /** * Start the server on an available port @@ -139,6 +142,24 @@ public void sendMovingPushToAll(String targetHost) { sendPushMessageToAll("MOVING", "30", targetHost); } + /** + * Set a custom command handler for processing Redis commands. + * + * @param commandHandler The command handler to use, or null to use only built-in handlers + */ + public void setCommandHandler(CommandHandler commandHandler) { + this.commandHandler = commandHandler; + } + + /** + * Get the current command handler. + * + * @return The current command handler, or null if none is set + */ + public CommandHandler getCommandHandler() { + return commandHandler; + } + /** * Close all active client connections */ @@ -190,17 +211,26 @@ public void run() { } List cmdArgs = (List) input; - String cmd = SafeEncoder.encode((byte[]) cmdArgs.get(0)); - - // Handle different commands - if (cmd.equalsIgnoreCase("HELLO")) { - sendHelloResponse(out); - } else if (cmd.contains("PING")) { - sendPongResponse(out); - } else if (cmd.contains("CLIENT")) { - sendOkResponse(out); + String cmdString = SafeEncoder.encode((byte[]) cmdArgs.get(0)); + + // Convert arguments to strings (excluding command name) + List args = new java.util.ArrayList<>(); + for (int i = 1; i < cmdArgs.size(); i++) { + args.add(SafeEncoder.encode((byte[]) cmdArgs.get(i))); + } + + // Try custom handler first + String customResponse = null; + if (commandHandler != null) { + customResponse = commandHandler.handleCommand(cmdString, args, clientId); + } + + if (customResponse != null) { + out.write(customResponse.getBytes()); + out.flush(); } else { - throw new RuntimeException("Unknown command: " + cmd); + // Handle with default built-in handlers + handleBuiltinCommand(cmdString, out); } } catch (IOException e) { logger.debug("Client " + clientId + " disconnected: " + e.getMessage()); @@ -245,6 +275,21 @@ private void sendOkResponse(OutputStream out) throws IOException { out.flush(); } + /** + * Handle a command with built-in handlers. + */ + private void handleBuiltinCommand(String cmdString, OutputStream out) throws IOException { + if (cmdString.equalsIgnoreCase("HELLO")) { + sendHelloResponse(out); + } else if (cmdString.contains("PING")) { + sendPongResponse(out); + } else if (cmdString.contains("CLIENT")) { + sendOkResponse(out); + } else { + throw new RuntimeException("Unknown command: " + cmdString); + } + } + /** * Clean up client resources and remove from connected clients map */ From a67b5cbeedff77e70f0a8c0eaaa6890dedb06e49 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 21 Jul 2025 18:50:39 +0300 Subject: [PATCH 20/23] format --- .../redis/clients/jedis/TimeoutOptions.java | 4 - .../ConnectionAdaptiveTimeoutTest.java | 568 +++++++++-------- .../jedis/util/server/CommandHandler.java | 24 +- .../jedis/util/server/RespResponse.java | 298 +++++---- .../jedis/util/server/TcpMockServer.java | 593 +++++++++--------- 5 files changed, 730 insertions(+), 757 deletions(-) diff --git a/src/main/java/redis/clients/jedis/TimeoutOptions.java b/src/main/java/redis/clients/jedis/TimeoutOptions.java index 70ff188fa4..4eedfdfd45 100644 --- a/src/main/java/redis/clients/jedis/TimeoutOptions.java +++ b/src/main/java/redis/clients/jedis/TimeoutOptions.java @@ -27,10 +27,6 @@ public static boolean isRelaxedTimeoutEnabled(Duration relaxedTimeout) { return relaxedTimeout != null && !relaxedTimeout.equals(DISABLED_TIMEOUT); } - public static boolean isRelaxedTimeoutDisabled(Duration relaxedTimeout) { - return relaxedTimeout == null || relaxedTimeout.equals(DISABLED_TIMEOUT); - } - public static boolean isRelaxedTimeoutEnabled(int relaxedTimeout) { return relaxedTimeout != DISABLED_TIMEOUT_MS; } diff --git a/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java index c29a687734..a2106a4a69 100644 --- a/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java +++ b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java @@ -1,19 +1,10 @@ package redis.clients.jedis.upgrade; -import static org.awaitility.Awaitility.await; -import static org.junit.jupiter.api.Assertions.*; - -import java.io.IOException; -import java.net.Socket; -import java.net.SocketException; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; - +import org.mockito.Mockito; import redis.clients.jedis.CommandObjects; import redis.clients.jedis.Connection; import redis.clients.jedis.DefaultJedisClientConfig; @@ -24,308 +15,315 @@ import redis.clients.jedis.RedisProtocol; import redis.clients.jedis.TimeoutOptions; import redis.clients.jedis.util.ReflectionTestUtils; +import redis.clients.jedis.util.server.CommandHandler; import redis.clients.jedis.util.server.RespResponse; import redis.clients.jedis.util.server.TcpMockServer; -import redis.clients.jedis.util.server.CommandHandler; -import org.mockito.Mockito; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import java.io.IOException; +import java.net.Socket; +import java.net.SocketException; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; /** - * Test that connection adaptive timeout works as expected. - * Usses a mock TCP server to send Maintenance push messages to the client in controllable manner. + * Test that connection adaptive timeout works as expected. Usses a mock TCP server to send + * Maintenance push messages to the client in controllable manner. */ @Tag("upgrade") public class ConnectionAdaptiveTimeoutTest { - private TcpMockServer mockServer; - private Connection connection; - private final int originalTimeoutMs = 2000; - private final Duration relaxedTimeout = Duration.ofSeconds(10); - private final Duration relaxedBlockingTimeout = Duration.ofSeconds(15); - private final CommandObjects commandObjects = new CommandObjects(); - private final CommandHandler mockHandler = Mockito.mock(CommandHandler.class); - - @BeforeEach - public void setUp() throws IOException { - // Start the mock TCP server - mockServer = new TcpMockServer(); - mockServer.setCommandHandler(mockHandler); - mockServer.start(); - - // Create client configuration with relaxed timeout and maintenance event handler - TimeoutOptions timeoutOptions = TimeoutOptions.builder() - .proactiveTimeoutsRelaxing(relaxedTimeout) - .proactiveBlockingTimeoutsRelaxing(relaxedBlockingTimeout) - .build(); - - MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); - - MaintenanceEventListener testListener = new MaintenanceEventListener() { - @Override - public void onMigrating() { - System.out.println("MIGRATING"); - } - }; - maintenanceEventHandler.addListener(testListener); - - DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() - .socketTimeoutMillis(originalTimeoutMs) - .timeoutOptions(timeoutOptions) - .maintenanceEventHandler(maintenanceEventHandler) - .protocol(RedisProtocol.RESP3) - .build(); - - // Create connection to the mock server - HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); - connection = new Connection(hostAndPort, clientConfig); - } - - @AfterEach - public void tearDown() throws IOException { - if (connection != null && connection.isConnected()) { - connection.close(); - } - if (mockServer != null) { - mockServer.stop(); - } - } - - @Test - public void testMigratingPushMessage() throws SocketException { - Socket socket = ReflectionTestUtils.getField(connection, "socket"); - - assertTrue(connection.isConnected()); - assertEquals(originalTimeoutMs, connection.getSoTimeout()); - assertEquals(originalTimeoutMs, socket.getSoTimeout()); - - // First send MIGRATING to activate relaxed timeout - mockServer.sendMigratingPushToAll(); - assertTrue(connection.ping()); - assertTrue(connection.isRelaxedTimeoutActive()); - assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout()); - - mockServer.sendMigratedPushToAll(); - assertTrue(connection.ping()); - assertFalse(connection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, socket.getSoTimeout()); + private final int originalTimeoutMs = 2000; + private final Duration relaxedTimeout = Duration.ofSeconds(10); + private final Duration relaxedBlockingTimeout = Duration.ofSeconds(15); + private final CommandObjects commandObjects = new CommandObjects(); + private final CommandHandler mockHandler = Mockito.mock(CommandHandler.class); + private TcpMockServer mockServer; + private Connection connection; + + @BeforeEach + public void setUp() throws IOException { + // Start the mock TCP server + mockServer = new TcpMockServer(); + mockServer.setCommandHandler(mockHandler); + mockServer.start(); + + // Create client configuration with relaxed timeout and maintenance event handler + TimeoutOptions timeoutOptions = TimeoutOptions.builder() + .proactiveTimeoutsRelaxing(relaxedTimeout) + .proactiveBlockingTimeoutsRelaxing(relaxedBlockingTimeout).build(); + + MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); + + MaintenanceEventListener testListener = new MaintenanceEventListener() { + @Override + public void onMigrating() { + System.out.println("MIGRATING"); + } + }; + maintenanceEventHandler.addListener(testListener); + + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(originalTimeoutMs).timeoutOptions(timeoutOptions) + .maintenanceEventHandler(maintenanceEventHandler).protocol(RedisProtocol.RESP3).build(); + + // Create connection to the mock server + HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); + connection = new Connection(hostAndPort, clientConfig); + } + + @AfterEach + public void tearDown() throws IOException { + if (connection != null && connection.isConnected()) { + connection.close(); } - - @Test - public void testFailoverPushMessage() throws SocketException { - Socket socket = ReflectionTestUtils.getField(connection, "socket"); - - assertTrue(connection.isConnected()); - assertEquals(originalTimeoutMs, connection.getSoTimeout()); - assertEquals(originalTimeoutMs, socket.getSoTimeout()); - - // First send MIGRATING to activate relaxed timeout - mockServer.sendFailingOverPushToAll(); - assertTrue(connection.ping()); - assertTrue(connection.isRelaxedTimeoutActive()); - assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout()); - - mockServer.sendFailedOverPushToAll(); - assertTrue(connection.ping()); - assertFalse(connection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, socket.getSoTimeout()); + if (mockServer != null) { + mockServer.stop(); } - - @Test - public void testDisabledTimeoutRelaxationDoesNotApplyRelaxedTimeout() throws Exception { - // Create a connection with disabled timeout relaxation - Connection disabledConnection = createConnectionWithDisabledTimeoutRelaxation(); - Socket disabledSocket = ReflectionTestUtils.getField(disabledConnection, "socket"); - - try { - assertTrue(disabledConnection.isConnected()); - assertEquals(originalTimeoutMs, disabledConnection.getSoTimeout()); - assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); - - // Verify that relaxed timeout is disabled - assertFalse(disabledConnection.isRelaxedTimeoutActive()); - - // Send MIGRATING push message - should NOT activate relaxed timeout - mockServer.sendMigratingPushToAll(); - - assertTrue(disabledConnection.ping()); - - // Verify that relaxed timeout was NOT activated - assertFalse(disabledConnection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); - - // Send FAILING_OVER push message - should also NOT activate relaxed timeout - mockServer.sendFailingOverPushToAll(); - - assertTrue(disabledConnection.ping()); - - // Verify that relaxed timeout is still NOT activated - assertFalse(disabledConnection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); - - // Send MIGRATED and FAILED_OVER messages - timeout should remain unchanged - mockServer.sendMigratedPushToAll(); - mockServer.sendFailedOverPushToAll(); - - assertTrue(disabledConnection.ping()); - assertFalse(disabledConnection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); - - } finally { - if (disabledConnection.isConnected()) { - disabledConnection.close(); - } - } + } + + @Test + public void testMigratingPushMessage() throws SocketException { + Socket socket = ReflectionTestUtils.getField(connection, "socket"); + + assertTrue(connection.isConnected()); + assertEquals(originalTimeoutMs, connection.getSoTimeout()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + + // First send MIGRATING to activate relaxed timeout + mockServer.sendMigratingPushToAll(); + assertTrue(connection.ping()); + assertTrue(connection.isRelaxedTimeoutActive()); + assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout()); + + mockServer.sendMigratedPushToAll(); + assertTrue(connection.ping()); + assertFalse(connection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + } + + @Test + public void testFailoverPushMessage() throws SocketException { + Socket socket = ReflectionTestUtils.getField(connection, "socket"); + + assertTrue(connection.isConnected()); + assertEquals(originalTimeoutMs, connection.getSoTimeout()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + + // First send MIGRATING to activate relaxed timeout + mockServer.sendFailingOverPushToAll(); + assertTrue(connection.ping()); + assertTrue(connection.isRelaxedTimeoutActive()); + assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout()); + + mockServer.sendFailedOverPushToAll(); + assertTrue(connection.ping()); + assertFalse(connection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + } + + @Test + public void testDisabledTimeoutRelaxationDoesNotApplyRelaxedTimeout() throws Exception { + // Create a connection with disabled timeout relaxation + Connection disabledConnection = createConnectionWithDisabledTimeoutRelaxation(); + Socket disabledSocket = ReflectionTestUtils.getField(disabledConnection, "socket"); + + try { + assertTrue(disabledConnection.isConnected()); + assertEquals(originalTimeoutMs, disabledConnection.getSoTimeout()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Verify that relaxed timeout is disabled + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + + // Send MIGRATING push message - should NOT activate relaxed timeout + mockServer.sendMigratingPushToAll(); + + assertTrue(disabledConnection.ping()); + + // Verify that relaxed timeout was NOT activated + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Send FAILING_OVER push message - should also NOT activate relaxed timeout + mockServer.sendFailingOverPushToAll(); + + assertTrue(disabledConnection.ping()); + + // Verify that relaxed timeout is still NOT activated + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + // Send MIGRATED and FAILED_OVER messages - timeout should remain unchanged + mockServer.sendMigratedPushToAll(); + mockServer.sendFailedOverPushToAll(); + + assertTrue(disabledConnection.ping()); + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + + } finally { + if (disabledConnection.isConnected()) { + disabledConnection.close(); + } } + } - @Test - public void testManualRelaxTimeoutsCallWithDisabledTimeoutRelaxation() throws Exception { - // Create a connection with disabled timeout relaxation - Connection disabledConnection = createConnectionWithDisabledTimeoutRelaxation(); - Socket disabledSocket = ReflectionTestUtils.getField(disabledConnection, "socket"); + @Test + public void testManualRelaxTimeoutsCallWithDisabledTimeoutRelaxation() throws Exception { + // Create a connection with disabled timeout relaxation + Connection disabledConnection = createConnectionWithDisabledTimeoutRelaxation(); + Socket disabledSocket = ReflectionTestUtils.getField(disabledConnection, "socket"); - try { - assertTrue(disabledConnection.isConnected()); - assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + try { + assertTrue(disabledConnection.isConnected()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); - // Manually call relaxTimeouts() - should have no effect when disabled - disabledConnection.relaxTimeouts(); + // Manually call relaxTimeouts() - should have no effect when disabled + disabledConnection.relaxTimeouts(); - // Relaxed timeout should fallback to original timeout, if relaxed timeout is disabled - assertFalse(disabledConnection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); + // Relaxed timeout should fallback to original timeout, if relaxed timeout is disabled + assertFalse(disabledConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, disabledSocket.getSoTimeout()); - // Verify connection still works - assertTrue(disabledConnection.ping()); + // Verify connection still works + assertTrue(disabledConnection.ping()); - } finally { - if (disabledConnection.isConnected()) { - disabledConnection.close(); - } - } + } finally { + if (disabledConnection.isConnected()) { + disabledConnection.close(); + } } + } - @Test - public void testDefaultTimeoutOptionsDisablesRelaxedTimeout() throws Exception { - // Create a connection with null timeout options - Connection defaultTimeoutConnection = createConnectionWithDefaultTimeoutOptions(); - Socket nullTimeoutSocket = ReflectionTestUtils.getField(defaultTimeoutConnection, "socket"); + @Test + public void testDefaultTimeoutOptionsDisablesRelaxedTimeout() throws Exception { + // Create a connection with null timeout options + Connection defaultTimeoutConnection = createConnectionWithDefaultTimeoutOptions(); + Socket nullTimeoutSocket = ReflectionTestUtils.getField(defaultTimeoutConnection, "socket"); - try { - assertTrue(defaultTimeoutConnection.isConnected()); - assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); + try { + assertTrue(defaultTimeoutConnection.isConnected()); + assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); - // Verify that relaxed timeout is disabled - assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); + // Verify that relaxed timeout is disabled + assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); - // Send maintenance push messages - should NOT activate relaxed timeout - mockServer.sendMigratingPushToAll(); + // Send maintenance push messages - should NOT activate relaxed timeout + mockServer.sendMigratingPushToAll(); - assertTrue(defaultTimeoutConnection.ping()); + assertTrue(defaultTimeoutConnection.ping()); - // Relaxed timeout's are disabled by default - assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); + // Relaxed timeout's are disabled by default + assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); - // Manual call should also have no effect - defaultTimeoutConnection.relaxTimeouts(); - assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); - assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); + // Manual call should also have no effect + defaultTimeoutConnection.relaxTimeouts(); + assertFalse(defaultTimeoutConnection.isRelaxedTimeoutActive()); + assertEquals(originalTimeoutMs, nullTimeoutSocket.getSoTimeout()); - } finally { - if (defaultTimeoutConnection.isConnected()) { - defaultTimeoutConnection.close(); - } - } + } finally { + if (defaultTimeoutConnection.isConnected()) { + defaultTimeoutConnection.close(); + } } + } + + @Test + public void testRelaxedBlockingTimeoutAppliedDuringBlockingCommand() + throws IOException, InterruptedException { + + // Verify initial timeout + Socket socket = ReflectionTestUtils.getField(connection, "socket"); + assertEquals(originalTimeoutMs, socket.getSoTimeout()); + + CountDownLatch blpopLatch = new CountDownLatch(1); + CountDownLatch blpopLatchAfter = new CountDownLatch(1); + doAnswer(invocation -> { + blpopLatch.countDown(); + return RespResponse.arrayOfBulkStrings("popped-item"); + }).when(mockHandler).handleCommand(eq("BLPOP"), anyList(), anyString()); + + // Send MIGRATING push notification which should trigger relaxTimeouts() + mockServer.sendMigratingPushToAll(); + + Thread t1 = new Thread(() -> { + connection.executeCommand(commandObjects.blpop(5, "test:blpop:key")); + blpopLatchAfter.countDown(); + }); + t1.start(); + + // Verify that relaxed blocking timeout was applied + blpopLatch.await(); + assertTrue(connection.isRelaxedTimeoutActive(), + "Relaxed timeout should be active during blocking command"); + assertEquals((int) relaxedBlockingTimeout.toMillis(), socket.getSoTimeout(), + "Socket timeout should be relaxed blocking timeout during blocking command"); + + blpopLatchAfter.await(); + assertTrue(connection.isRelaxedTimeoutActive(), + "Relaxed timeout should be still active after blocking command"); + assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout(), + "Socket timeout should be restored to relaxed timeout for non blocking command"); + + // Send MIGRATED push notification to disable relaxed timeout + mockServer.sendMigratedPushToAll(); + connection.executeCommand(commandObjects.ping()); + + assertFalse(connection.isRelaxedTimeoutActive(), + "Relaxed timeout should be disabled after MIGRATED"); + assertEquals(originalTimeoutMs, socket.getSoTimeout(), + "Socket timeout should be restored to original timeout"); + } + + /** + * Helper method to create a connection with disabled timeout relaxation. + */ + private Connection createConnectionWithDisabledTimeoutRelaxation() { + // Create configuration with disabled timeout relaxation + TimeoutOptions disabledTimeoutOptions = TimeoutOptions.builder() + .proactiveTimeoutsRelaxing(TimeoutOptions.DISABLED_TIMEOUT) + .proactiveBlockingTimeoutsRelaxing(TimeoutOptions.DISABLED_TIMEOUT).build(); + + MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); + + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(originalTimeoutMs).timeoutOptions(disabledTimeoutOptions) + .maintenanceEventHandler(maintenanceEventHandler).protocol(RedisProtocol.RESP3).build(); + + // Create connection to the mock server + HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); + Connection disabledConnection = new Connection(hostAndPort, clientConfig); + disabledConnection.connect(); + + return disabledConnection; + } + + /** + * Helper method to create a connection with null timeout options. + */ + private Connection createConnectionWithDefaultTimeoutOptions() { + MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); + + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(originalTimeoutMs) + // Note: not setting timeoutOptions, so it will be null + .maintenanceEventHandler(maintenanceEventHandler).protocol(RedisProtocol.RESP3).build(); + + // Create connection to the mock server + HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); + Connection nullTimeoutConnection = new Connection(hostAndPort, clientConfig); + nullTimeoutConnection.connect(); + + return nullTimeoutConnection; + } - @Test - public void testRelaxedBlockingTimeoutAppliedDuringBlockingCommand() - throws IOException, InterruptedException { - - // Verify initial timeout - Socket socket = ReflectionTestUtils.getField(connection, "socket"); - assertEquals(originalTimeoutMs, socket.getSoTimeout()); - - CountDownLatch blpopLatch = new CountDownLatch(1); - CountDownLatch blpopLatchAfter = new CountDownLatch(1); - doAnswer(invocation -> { - blpopLatch.countDown(); - return RespResponse.arrayOfBulkStrings("popped-item"); - }).when(mockHandler).handleCommand(eq("BLPOP"), anyList(), anyString()); - - // Send MIGRATING push notification which should trigger relaxTimeouts() - mockServer.sendMigratingPushToAll(); - - Thread t1 = new Thread(() -> { - connection.executeCommand(commandObjects.blpop(5, "test:blpop:key")); - blpopLatchAfter.countDown(); - }); - t1.start(); - - // Verify that relaxed blocking timeout was applied - blpopLatch.await(); - assertTrue(connection.isRelaxedTimeoutActive(), "Relaxed timeout should be active during blocking command"); - assertEquals((int) relaxedBlockingTimeout.toMillis(), socket.getSoTimeout(), "Socket timeout should be relaxed blocking timeout during blocking command"); - - blpopLatchAfter.await(); - assertTrue(connection.isRelaxedTimeoutActive(), "Relaxed timeout should be still active after blocking command"); - assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout(), "Socket timeout should be restored to relaxed timeout for non blocking command"); - - // Send MIGRATED push notification to disable relaxed timeout - mockServer.sendMigratedPushToAll(); - connection.executeCommand(commandObjects.ping()); - - assertFalse(connection.isRelaxedTimeoutActive(), "Relaxed timeout should be disabled after MIGRATED"); - assertEquals(originalTimeoutMs, socket.getSoTimeout(), "Socket timeout should be restored to original timeout"); - } - - /** - * Helper method to create a connection with disabled timeout relaxation. - */ - private Connection createConnectionWithDisabledTimeoutRelaxation() throws IOException { - // Create configuration with disabled timeout relaxation - TimeoutOptions disabledTimeoutOptions = TimeoutOptions.builder() - .proactiveTimeoutsRelaxing(TimeoutOptions.DISABLED_TIMEOUT) - .proactiveBlockingTimeoutsRelaxing(TimeoutOptions.DISABLED_TIMEOUT) - .build(); - - MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); - - DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() - .socketTimeoutMillis(originalTimeoutMs) - .timeoutOptions(disabledTimeoutOptions) - .maintenanceEventHandler(maintenanceEventHandler) - .protocol(RedisProtocol.RESP3) - .build(); - - // Create connection to the mock server - HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); - Connection disabledConnection = new Connection(hostAndPort, clientConfig); - disabledConnection.connect(); - - return disabledConnection; - } - - /** - * Helper method to create a connection with null timeout options. - */ - private Connection createConnectionWithDefaultTimeoutOptions() throws IOException { - MaintenanceEventHandler maintenanceEventHandler = new MaintenanceEventHandlerImpl(); - - DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() - .socketTimeoutMillis(originalTimeoutMs) - // Note: not setting timeoutOptions, so it will be null - .maintenanceEventHandler(maintenanceEventHandler) - .protocol(RedisProtocol.RESP3) - .build(); - - // Create connection to the mock server - HostAndPort hostAndPort = new HostAndPort("localhost", mockServer.getPort()); - Connection nullTimeoutConnection = new Connection(hostAndPort, clientConfig); - nullTimeoutConnection.connect(); - - return nullTimeoutConnection; - } } diff --git a/src/test/java/redis/clients/jedis/util/server/CommandHandler.java b/src/test/java/redis/clients/jedis/util/server/CommandHandler.java index 16caa828a0..2070a02134 100644 --- a/src/test/java/redis/clients/jedis/util/server/CommandHandler.java +++ b/src/test/java/redis/clients/jedis/util/server/CommandHandler.java @@ -3,18 +3,18 @@ import java.util.List; /** - * Interface for handling custom Redis commands in TcpMockServer. - * This can be easily mocked with Mockito for testing purposes. + * Interface for handling custom Redis commands in TcpMockServer. This can be easily mocked with + * Mockito for testing purposes. */ public interface CommandHandler { - - /** - * Handle a Redis command and return a response. - * - * @param command The Redis command (case-insensitive) - * @param args The command arguments (excluding the command name) - * @param clientId The client identifier - * @return A RESP response string, or null to use default handling - */ - String handleCommand(String command, List args, String clientId); + + /** + * Handle a Redis command and return a response. + * @param command The Redis command (case-insensitive) + * @param args The command arguments (excluding the command name) + * @param clientId The client identifier + * @return A RESP response string, or null to use default handling + */ + String handleCommand(String command, List args, String clientId); + } diff --git a/src/test/java/redis/clients/jedis/util/server/RespResponse.java b/src/test/java/redis/clients/jedis/util/server/RespResponse.java index 0029330949..c1bf5e8e65 100644 --- a/src/test/java/redis/clients/jedis/util/server/RespResponse.java +++ b/src/test/java/redis/clients/jedis/util/server/RespResponse.java @@ -3,169 +3,157 @@ import java.util.List; /** - * Utility class for building RESP (Redis Serialization Protocol) responses. - * This makes it easier to construct proper Redis protocol responses for testing. + * Utility class for building RESP (Redis Serialization Protocol) responses. This makes it easier to + * construct proper Redis protocol responses for testing. */ public class RespResponse { - - /** - * Create a simple string response (+OK, +PONG, etc.). - * - * @param value The string value - * @return A RESP simple string response - */ - public static String simpleString(String value) { - return "+" + value + "\r\n"; - } - - /** - * Create an error response (-ERR message). - * - * @param message The error message - * @return A RESP error response - */ - public static String error(String message) { - return "-" + message + "\r\n"; - } - - /** - * Create an integer response (:123). - * - * @param value The integer value - * @return A RESP integer response - */ - public static String integer(long value) { - return ":" + value + "\r\n"; - } - - /** - * Create a bulk string response ($5\r\nhello\r\n). - * - * @param value The string value (null for null bulk string) - * @return A RESP bulk string response - */ - public static String bulkString(String value) { - if (value == null) { - return "$-1\r\n"; - } - byte[] bytes = value.getBytes(); - return "$" + bytes.length + "\r\n" + value + "\r\n"; - } - - /** - * Create a bulk string response from byte array. - * - * @param bytes The byte array (null for null bulk string) - * @return A RESP bulk string response - */ - public static String bulkString(byte[] bytes) { - if (bytes == null) { - return "$-1\r\n"; - } - return "$" + bytes.length + "\r\n" + new String(bytes) + "\r\n"; - } - - /** - * Create an array response (*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n). - * - * @param elements The array elements as RESP strings - * @return A RESP array response - */ - public static String array(String... elements) { - if (elements == null) { - return "*-1\r\n"; - } - - StringBuilder sb = new StringBuilder(); - sb.append("*").append(elements.length).append("\r\n"); - - for (String element : elements) { - sb.append(element); - } - - return sb.toString(); + + /** + * Create a simple string response (+OK, +PONG, etc.). + * @param value The string value + * @return A RESP simple string response + */ + public static String simpleString(String value) { + return "+" + value + "\r\n"; + } + + /** + * Create an error response (-ERR message). + * @param message The error message + * @return A RESP error response + */ + public static String error(String message) { + return "-" + message + "\r\n"; + } + + /** + * Create an integer response (:123). + * @param value The integer value + * @return A RESP integer response + */ + public static String integer(long value) { + return ":" + value + "\r\n"; + } + + /** + * Create a bulk string response ($5\r\nhello\r\n). + * @param value The string value (null for null bulk string) + * @return A RESP bulk string response + */ + public static String bulkString(String value) { + if (value == null) { + return "$-1\r\n"; } - - /** - * Create an array response from a list of strings as bulk strings. - * - * @param strings The list of strings - * @return A RESP array response with bulk string elements - */ - public static String arrayOfBulkStrings(List strings) { - if (strings == null) { - return "*-1\r\n"; - } - - String[] elements = new String[strings.size()]; - for (int i = 0; i < strings.size(); i++) { - elements[i] = bulkString(strings.get(i)); - } - - return array(elements); + byte[] bytes = value.getBytes(); + return "$" + bytes.length + "\r\n" + value + "\r\n"; + } + + /** + * Create a bulk string response from byte array. + * @param bytes The byte array (null for null bulk string) + * @return A RESP bulk string response + */ + public static String bulkString(byte[] bytes) { + if (bytes == null) { + return "$-1\r\n"; } - - /** - * Create an array response from string values (automatically converts to bulk strings). - * - * @param values The string values - * @return A RESP array response with bulk string elements - */ - public static String arrayOfBulkStrings(String... values) { - if (values == null) { - return "*-1\r\n"; - } - - String[] elements = new String[values.length]; - for (int i = 0; i < values.length; i++) { - elements[i] = bulkString(values[i]); - } - - return array(elements); + return "$" + bytes.length + "\r\n" + new String(bytes) + "\r\n"; + } + + /** + * Create an array response (*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n). + * @param elements The array elements as RESP strings + * @return A RESP array response + */ + public static String array(String... elements) { + if (elements == null) { + return "*-1\r\n"; } - - /** - * Create an empty array response (*0\r\n). - * - * @return A RESP empty array response - */ - public static String emptyArray() { - return "*0\r\n"; + + StringBuilder sb = new StringBuilder(); + sb.append("*").append(elements.length).append("\r\n"); + + for (String element : elements) { + sb.append(element); } - - /** - * Create a null array response (*-1\r\n). - * - * @return A RESP null array response - */ - public static String nullArray() { - return "*-1\r\n"; + + return sb.toString(); + } + + /** + * Create an array response from a list of strings as bulk strings. + * @param strings The list of strings + * @return A RESP array response with bulk string elements + */ + public static String arrayOfBulkStrings(List strings) { + if (strings == null) { + return "*-1\r\n"; } - - /** - * Create a null bulk string response ($-1\r\n). - * - * @return A RESP null bulk string response - */ - public static String nullBulkString() { - return "$-1\r\n"; + + String[] elements = new String[strings.size()]; + for (int i = 0; i < strings.size(); i++) { + elements[i] = bulkString(strings.get(i)); } - - /** - * Create an OK response (+OK\r\n). - * - * @return A RESP OK response - */ - public static String ok() { - return simpleString("OK"); + + return array(elements); + } + + /** + * Create an array response from string values (automatically converts to bulk strings). + * @param values The string values + * @return A RESP array response with bulk string elements + */ + public static String arrayOfBulkStrings(String... values) { + if (values == null) { + return "*-1\r\n"; } - - /** - * Create a PONG response (+PONG\r\n). - * - * @return A RESP PONG response - */ - public static String pong() { - return simpleString("PONG"); + + String[] elements = new String[values.length]; + for (int i = 0; i < values.length; i++) { + elements[i] = bulkString(values[i]); } + + return array(elements); + } + + /** + * Create an empty array response (*0\r\n). + * @return A RESP empty array response + */ + public static String emptyArray() { + return "*0\r\n"; + } + + /** + * Create a null array response (*-1\r\n). + * @return A RESP null array response + */ + public static String nullArray() { + return "*-1\r\n"; + } + + /** + * Create a null bulk string response ($-1\r\n). + * @return A RESP null bulk string response + */ + public static String nullBulkString() { + return "$-1\r\n"; + } + + /** + * Create an OK response (+OK\r\n). + * @return A RESP OK response + */ + public static String ok() { + return simpleString("OK"); + } + + /** + * Create a PONG response (+PONG\r\n). + * @return A RESP PONG response + */ + public static String pong() { + return simpleString("PONG"); + } + } diff --git a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java index 6910f634dc..d381c82ddd 100644 --- a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java +++ b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java @@ -3,7 +3,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import redis.clients.jedis.Protocol; - import redis.clients.jedis.util.RedisInputStream; import redis.clients.jedis.util.RedisOutputStream; import redis.clients.jedis.util.SafeEncoder; @@ -13,353 +12,345 @@ import java.net.ServerSocket; import java.net.Socket; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.Map; /** - * A simple TCP mock server for testing Redis push notifications and timeout behavior. - * This server can accept connections and send predefined responses including push messages. + * A simple TCP mock server for testing Redis push notifications and timeout behavior. This server + * can accept connections and send predefined responses including push messages. */ public class TcpMockServer { - Logger logger = LoggerFactory.getLogger(TcpMockServer.class); - - private ServerSocket serverSocket; - private final AtomicBoolean running = new AtomicBoolean(false); - private final ExecutorService executor = Executors.newCachedThreadPool(); - private int port; - private final Map connectedClients = new ConcurrentHashMap<>(); - private CommandHandler commandHandler; - - - /** - * Start the server on an available port - */ - public void start() throws IOException { - start(0); // Use any available port - } - - /** - * Start the server on a specific port - */ - public void start(int port) throws IOException { - serverSocket = new ServerSocket(port); - this.port = serverSocket.getLocalPort(); - running.set(true); - - executor.submit(() -> { - while (running.get() && !serverSocket.isClosed()) { - try { - Socket clientSocket = serverSocket.accept(); - executor.submit(new ClientHandler(clientSocket)); - } catch (IOException e) { - if (running.get()) { - logger.error("Error accepting client connection: " + e.getMessage()); - } - } - } - }); - } - - /** - * Stop the server and close all active connections - */ - public void stop() throws IOException { - running.set(false); - - // Close all active client connections first - closeAllActiveConnections(); - - // Close the server socket - if (serverSocket != null && !serverSocket.isClosed()) { - serverSocket.close(); + private final AtomicBoolean running = new AtomicBoolean(false); + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final Map connectedClients = new ConcurrentHashMap<>(); + Logger logger = LoggerFactory.getLogger(TcpMockServer.class); + private ServerSocket serverSocket; + private int port; + private CommandHandler commandHandler; + + /** + * Start the server on an available port + */ + public void start() throws IOException { + start(0); // Use any available port + } + + /** + * Start the server on a specific port + */ + public void start(int port) throws IOException { + serverSocket = new ServerSocket(port); + this.port = serverSocket.getLocalPort(); + running.set(true); + + executor.submit(() -> { + while (running.get() && !serverSocket.isClosed()) { + try { + Socket clientSocket = serverSocket.accept(); + executor.submit(new ClientHandler(clientSocket)); + } catch (IOException e) { + if (running.get()) { + logger.error("Error accepting client connection: " + e.getMessage()); + } } - executor.shutdownNow(); + } + }); + } + + /** + * Stop the server and close all active connections + */ + public void stop() throws IOException { + running.set(false); + + // Close all active client connections first + closeAllActiveConnections(); + + // Close the server socket + if (serverSocket != null && !serverSocket.isClosed()) { + serverSocket.close(); } - - /** - * Get the port the server is running on - */ - public int getPort() { - return port; + executor.shutdownNow(); + } + + /** + * Get the port the server is running on + */ + public int getPort() { + return port; + } + + /** + * Check if the server is running + */ + public boolean isRunning() { + return running.get() && serverSocket != null && !serverSocket.isClosed(); + } + + /** + * Get the number of connected clients + */ + public int getConnectedClientCount() { + return connectedClients.size(); + } + + /** + * Generic method to send a push message to all connected clients. + * @param pushType the type of push message (e.g., "MIGRATING", "MIGRATED") + * @param args optional arguments for the push message + */ + public void sendPushMessageToAll(String pushType, String... args) { + connectedClients.values().forEach(client -> client.sendPushMessage(pushType, args)); + } + + /** + * Send a MIGRATING push message to all connected clients + */ + public void sendMigratingPushToAll() { + sendPushMessageToAll("MIGRATING", "30"); // Default slot 30 + } + + /** + * Send a MIGRATED push message to all connected clients + */ + public void sendMigratedPushToAll() { + sendPushMessageToAll("MIGRATED"); + } + + /** + * Send a FAILING_OVER push message to all connected clients + */ + public void sendFailingOverPushToAll() { + sendPushMessageToAll("FAILING_OVER", "30"); // Default slot 30 + } + + /** + * Send a FAILED_OVER push message to all connected clients + */ + public void sendFailedOverPushToAll() { + sendPushMessageToAll("FAILED_OVER"); + } + + public void sendMovingPushToAll(String targetHost) { + sendPushMessageToAll("MOVING", "30", targetHost); + } + + /** + * Get the current command handler. + * @return The current command handler, or null if none is set + */ + public CommandHandler getCommandHandler() { + return commandHandler; + } + + /** + * Set a custom command handler for processing Redis commands. + * @param commandHandler The command handler to use, or null to use only built-in handlers + */ + public void setCommandHandler(CommandHandler commandHandler) { + this.commandHandler = commandHandler; + } + + /** + * Close all active client connections + */ + private void closeAllActiveConnections() { + // Create a copy of the values to avoid ConcurrentModificationException + java.util.List clientsToClose = new java.util.ArrayList<>( + connectedClients.values()); + + for (ClientHandler client : clientsToClose) { + try { + client.forceClose(); + } catch (Exception e) { + logger.error("Error closing client connection: " + e.getMessage()); + } } - /** - * Check if the server is running - */ - public boolean isRunning() { - return running.get() && serverSocket != null && !serverSocket.isClosed(); + // Clear the map + connectedClients.clear(); + } + + /** + * Client handler for each connection + */ + private class ClientHandler implements Runnable { + private final Socket clientSocket; + private final String clientId; + private RedisOutputStream outputStream; + private volatile boolean connected = true; + + public ClientHandler(Socket clientSocket) { + this.clientSocket = clientSocket; + this.clientId = clientSocket.getRemoteSocketAddress().toString(); } - /** - * Get the number of connected clients - */ - public int getConnectedClientCount() { - return connectedClients.size(); - } + @Override + public void run() { + try (RedisInputStream rin = new RedisInputStream(clientSocket.getInputStream()); + RedisOutputStream out = new RedisOutputStream(clientSocket.getOutputStream())) { + + this.outputStream = out; + connectedClients.put(clientId, this); + + Object input; + while (connected && !clientSocket.isClosed()) { + try { + input = Protocol.read(rin); + if (input == null) { + connected = false; + break; + } - /** - * Generic method to send a push message to all connected clients. - * - * @param pushType the type of push message (e.g., "MIGRATING", "MIGRATED") - * @param args optional arguments for the push message - */ - public void sendPushMessageToAll(String pushType, String... args) { - connectedClients.values().forEach(client -> client.sendPushMessage(pushType, args)); - } + List cmdArgs = (List) input; + String cmdString = SafeEncoder.encode((byte[]) cmdArgs.get(0)); - /** - * Send a MIGRATING push message to all connected clients - */ - public void sendMigratingPushToAll() { - sendPushMessageToAll("MIGRATING", "30"); // Default slot 30 - } + // Convert arguments to strings (excluding command name) + List args = new java.util.ArrayList<>(); + for (int i = 1; i < cmdArgs.size(); i++) { + args.add(SafeEncoder.encode((byte[]) cmdArgs.get(i))); + } - /** - * Send a MIGRATED push message to all connected clients - */ - public void sendMigratedPushToAll() { - sendPushMessageToAll("MIGRATED"); - } + // Try custom handler first + String customResponse = null; + if (commandHandler != null) { + customResponse = commandHandler.handleCommand(cmdString, args, clientId); + } - /** - * Send a FAILING_OVER push message to all connected clients - */ - public void sendFailingOverPushToAll() { - sendPushMessageToAll("FAILING_OVER", "30"); // Default slot 30 + if (customResponse != null) { + out.write(customResponse.getBytes()); + out.flush(); + } else { + // Handle with default built-in handlers + handleBuiltinCommand(cmdString, out); + } + } catch (IOException e) { + logger.debug("Client " + clientId + " disconnected: " + e.getMessage()); + connected = false; + break; + } catch (Exception e) { + logger.debug("Client " + clientId + " connection error: " + e.getMessage()); + connected = false; + break; + } + } + } catch (IOException e) { + logger.error("Error handling client: " + e.getMessage()); + } finally { + cleanup(); + } } - /** - * Send a FAILED_OVER push message to all connected clients - */ - public void sendFailedOverPushToAll() { - sendPushMessageToAll("FAILED_OVER"); + private void sendHelloResponse(OutputStream out) throws IOException { + // RESP3 HELLO response + String response = + "%7\r\n" + "$6\r\nserver\r\n$5\r\nredis\r\n" + "$7\r\nversion\r\n$5\r\n7.0.0\r\n" + + "$5\r\nproto\r\n:3\r\n" + "$2\r\nid\r\n:1\r\n" + + "$4\r\nmode\r\n$10\r\nstandalone\r\n" + "$4\r\nrole\r\n$6\r\nmaster\r\n" + + "$7\r\nmodules\r\n*0\r\n"; + out.write(response.getBytes()); + out.flush(); } - public void sendMovingPushToAll(String targetHost) { - sendPushMessageToAll("MOVING", "30", targetHost); + private void sendPongResponse(OutputStream out) throws IOException { + String response = "+PONG\r\n"; + out.write(response.getBytes()); + out.flush(); } - /** - * Set a custom command handler for processing Redis commands. - * - * @param commandHandler The command handler to use, or null to use only built-in handlers - */ - public void setCommandHandler(CommandHandler commandHandler) { - this.commandHandler = commandHandler; + private void sendOkResponse(OutputStream out) throws IOException { + String response = "+OK\r\n"; + out.write(response.getBytes()); + out.flush(); } /** - * Get the current command handler. - * - * @return The current command handler, or null if none is set + * Handle a command with built-in handlers. */ - public CommandHandler getCommandHandler() { - return commandHandler; + private void handleBuiltinCommand(String cmdString, OutputStream out) throws IOException { + if (cmdString.equalsIgnoreCase("HELLO")) { + sendHelloResponse(out); + } else if (cmdString.contains("PING")) { + sendPongResponse(out); + } else if (cmdString.contains("CLIENT")) { + sendOkResponse(out); + } else { + throw new RuntimeException("Unknown command: " + cmdString); + } } /** - * Close all active client connections + * Clean up client resources and remove from connected clients map */ - private void closeAllActiveConnections() { - // Create a copy of the values to avoid ConcurrentModificationException - java.util.List clientsToClose = new java.util.ArrayList<>(connectedClients.values()); - - for (ClientHandler client : clientsToClose) { - try { - client.forceClose(); - } catch (Exception e) { - logger.error("Error closing client connection: " + e.getMessage()); - } + private void cleanup() { + connected = false; + connectedClients.remove(clientId); + outputStream = null; + + try { + if (clientSocket != null && !clientSocket.isClosed()) { + clientSocket.close(); } - - // Clear the map - connectedClients.clear(); + } catch (IOException e) { + logger.error("Error closing client socket during cleanup: " + e.getMessage()); + } } /** - * Client handler for each connection + * Generic method to send a push message to this client. + * @param pushType the type of push message (e.g., "MIGRATING", "MIGRATED") + * @param args optional arguments for the push message */ - private class ClientHandler implements Runnable { - private final Socket clientSocket; - private final String clientId; - private RedisOutputStream outputStream; - private volatile boolean connected = true; - - public ClientHandler(Socket clientSocket) { - this.clientSocket = clientSocket; - this.clientId = clientSocket.getRemoteSocketAddress().toString(); - } - - @Override - public void run() { - try (RedisInputStream rin = new RedisInputStream(clientSocket.getInputStream()); - RedisOutputStream out = new RedisOutputStream(clientSocket.getOutputStream())) { - - this.outputStream = out; - connectedClients.put(clientId, this); - - Object input; - while (connected && !clientSocket.isClosed()) { - try { - input = Protocol.read(rin); - if (input == null) { - connected = false; - break; - } - - List cmdArgs = (List) input; - String cmdString = SafeEncoder.encode((byte[]) cmdArgs.get(0)); - - // Convert arguments to strings (excluding command name) - List args = new java.util.ArrayList<>(); - for (int i = 1; i < cmdArgs.size(); i++) { - args.add(SafeEncoder.encode((byte[]) cmdArgs.get(i))); - } - - // Try custom handler first - String customResponse = null; - if (commandHandler != null) { - customResponse = commandHandler.handleCommand(cmdString, args, clientId); - } - - if (customResponse != null) { - out.write(customResponse.getBytes()); - out.flush(); - } else { - // Handle with default built-in handlers - handleBuiltinCommand(cmdString, out); - } - } catch (IOException e) { - logger.debug("Client " + clientId + " disconnected: " + e.getMessage()); - connected = false; - break; - } catch (Exception e) { - logger.debug("Client " + clientId + " connection error: " + e.getMessage()); - connected = false; - break; - } - } - } catch (IOException e) { - logger.error("Error handling client: " + e.getMessage()); - } finally { - cleanup(); - } - } - - private void sendHelloResponse(OutputStream out) throws IOException { - // RESP3 HELLO response - String response = "%7\r\n" + - "$6\r\nserver\r\n$5\r\nredis\r\n" + - "$7\r\nversion\r\n$5\r\n7.0.0\r\n" + - "$5\r\nproto\r\n:3\r\n" + - "$2\r\nid\r\n:1\r\n" + - "$4\r\nmode\r\n$10\r\nstandalone\r\n" + - "$4\r\nrole\r\n$6\r\nmaster\r\n" + - "$7\r\nmodules\r\n*0\r\n"; - out.write(response.getBytes()); - out.flush(); - } - - private void sendPongResponse(OutputStream out) throws IOException { - String response = "+PONG\r\n"; - out.write(response.getBytes()); - out.flush(); - } + public void sendPushMessage(String pushType, String... args) { + try { + StringBuilder pushMessage = new StringBuilder(); - private void sendOkResponse(OutputStream out) throws IOException { - String response = "+OK\r\n"; - out.write(response.getBytes()); - out.flush(); - } + // Calculate total number of elements (push type + arguments) + int elementCount = 1 + args.length; + pushMessage.append(">").append(elementCount).append("\r\n"); - /** - * Handle a command with built-in handlers. - */ - private void handleBuiltinCommand(String cmdString, OutputStream out) throws IOException { - if (cmdString.equalsIgnoreCase("HELLO")) { - sendHelloResponse(out); - } else if (cmdString.contains("PING")) { - sendPongResponse(out); - } else if (cmdString.contains("CLIENT")) { - sendOkResponse(out); - } else { - throw new RuntimeException("Unknown command: " + cmdString); - } - } + // Add push type + pushMessage.append("$").append(pushType.length()).append("\r\n").append(pushType) + .append("\r\n"); - /** - * Clean up client resources and remove from connected clients map - */ - private void cleanup() { - connected = false; - connectedClients.remove(clientId); - outputStream = null; - - try { - if (clientSocket != null && !clientSocket.isClosed()) { - clientSocket.close(); - } - } catch (IOException e) { - logger.error("Error closing client socket during cleanup: " + e.getMessage()); - } + // Add arguments + for (String arg : args) { + pushMessage.append("$").append(arg.length()).append("\r\n").append(arg).append("\r\n"); } + outputStream.write(pushMessage.toString().getBytes()); + outputStream.flush(); - /** - * Generic method to send a push message to this client. - * - * @param pushType the type of push message (e.g., "MIGRATING", "MIGRATED") - * @param args optional arguments for the push message - */ - public void sendPushMessage(String pushType, String... args) { - try { - StringBuilder pushMessage = new StringBuilder(); - - // Calculate total number of elements (push type + arguments) - int elementCount = 1 + args.length; - pushMessage.append(">").append(elementCount).append("\r\n"); - - // Add push type - pushMessage.append("$").append(pushType.length()).append("\r\n") - .append(pushType).append("\r\n"); - - // Add arguments - for (String arg : args) { - pushMessage.append("$").append(arg.length()).append("\r\n") - .append(arg).append("\r\n"); - } - - outputStream.write(pushMessage.toString().getBytes()); - outputStream.flush(); + } catch (IOException e) { + logger.error( + "Error sending " + pushType + " push to " + clientId + " (client disconnected): " + + e.getMessage()); + cleanup(); + } + } + /** + * Force close this client connection (used when server is shutting down) + */ + public void forceClose() { + connected = false; - } catch (IOException e) { - logger.error("Error sending " + pushType + " push to " + clientId + " (client disconnected): " + e.getMessage()); - cleanup(); - } + try { + if (clientSocket != null && !clientSocket.isClosed()) { + clientSocket.close(); } + } catch (IOException e) { + logger.error("Error force closing client socket: " + e.getMessage()); + } - /** - * Force close this client connection (used when server is shutting down) - */ - public void forceClose() { - connected = false; - - try { - if (clientSocket != null && !clientSocket.isClosed()) { - clientSocket.close(); - } - } catch (IOException e) { - logger.error("Error force closing client socket: " + e.getMessage()); - } + // Remove from connected clients map + connectedClients.remove(clientId); + outputStream = null; + } - // Remove from connected clients map - connectedClients.remove(clientId); - outputStream = null; - } + } - } } From 5a59b8503ac7943db6d049f83dc0d9b07da9a126 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 28 Jul 2025 13:00:35 +0300 Subject: [PATCH 21/23] enforce code formating for new classes --- pom.xml | 5 + .../jedis/MaintenanceEventHandler.java | 3 - .../jedis/MaintenanceEventListener.java | 16 +- .../redis/clients/jedis/PushConsumer.java | 8 +- .../clients/jedis/PushConsumerChain.java | 22 +- .../clients/jedis/PushConsumerContext.java | 28 +- .../java/redis/clients/jedis/PushHandler.java | 12 +- .../redis/clients/jedis/PushListener.java | 5 +- .../java/redis/clients/jedis/PushMessage.java | 6 +- .../java/redis/clients/jedis/RebindAware.java | 22 +- .../jedis/PushMessageNotificationTest.java | 108 ++--- .../ConnectionAdaptiveTimeoutTest.java | 12 +- .../UnifiedJedisProactiveRebindTest.java | 399 +++++++++--------- .../jedis/util/server/TcpMockServer.java | 14 +- 14 files changed, 332 insertions(+), 328 deletions(-) diff --git a/pom.xml b/pom.xml index d6b5bc6d6e..810da48202 100644 --- a/pom.xml +++ b/pom.xml @@ -348,6 +348,11 @@ src/test/java/redis/clients/jedis/commands/jedis/ClusterStreamsCommandsTest.java src/test/java/redis/clients/jedis/commands/jedis/PooledStreamsCommandsTest.java src/test/java/redis/clients/jedis/resps/StreamEntryDeletionResultTest.java + **/Maintenance*.java + **/Push*.java + **/Rebind*.java + src/test/java/redis/clients/jedis/upgrade/*.java + src/test/java/redis/clients/jedis/util/server/*.java diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java b/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java index 0e2ea3cf01..01786e3105 100644 --- a/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java +++ b/src/main/java/redis/clients/jedis/MaintenanceEventHandler.java @@ -4,12 +4,9 @@ public interface MaintenanceEventHandler { - void addListener(MaintenanceEventListener listener); - void removeListener(MaintenanceEventListener listener); - Collection getListeners(); } \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/MaintenanceEventListener.java b/src/main/java/redis/clients/jedis/MaintenanceEventListener.java index fba8d24507..8b917b65bb 100644 --- a/src/main/java/redis/clients/jedis/MaintenanceEventListener.java +++ b/src/main/java/redis/clients/jedis/MaintenanceEventListener.java @@ -1,15 +1,19 @@ package redis.clients.jedis; - public interface MaintenanceEventListener { - default void onMigrating(){}; + default void onMigrating() { + }; - default void onMigrated(){}; + default void onMigrated() { + }; - default void onFailOver(){}; + default void onFailOver() { + }; - default void onFailedOver(){}; + default void onFailedOver() { + }; - default void onRebind( HostAndPort target){}; + default void onRebind(HostAndPort target) { + }; } diff --git a/src/main/java/redis/clients/jedis/PushConsumer.java b/src/main/java/redis/clients/jedis/PushConsumer.java index 73ff3c04c4..a666652d04 100644 --- a/src/main/java/redis/clients/jedis/PushConsumer.java +++ b/src/main/java/redis/clients/jedis/PushConsumer.java @@ -8,10 +8,10 @@ public interface PushConsumer { /** * Handle a push message. - * - * Messages are not processed by default. Handlers should update the context's processed flag to true if they - * have processed the message. - * + *

+ * Messages are not processed by default. Handlers should update the + * context's processed flag to true if they have processed the message. + *

* @param context The context of the message to respond to. */ void accept(PushConsumerContext context); diff --git a/src/main/java/redis/clients/jedis/PushConsumerChain.java b/src/main/java/redis/clients/jedis/PushConsumerChain.java index 12e5c1507a..94cbfcdf59 100644 --- a/src/main/java/redis/clients/jedis/PushConsumerChain.java +++ b/src/main/java/redis/clients/jedis/PushConsumerChain.java @@ -12,7 +12,9 @@ /** * A chain of PushHandlers that processes events in order. + *

* Uses a context object for tracking the processed state. + *

*/ @Internal public final class PushConsumerChain implements PushConsumer { @@ -37,13 +39,14 @@ public final class PushConsumerChain implements PushConsumer { context.setProcessed(false); }; - /** - * Handler that allows only pub/sub related events to be propagated to the client. + * Handler that allows only pub/sub related events to be propagated to the client + *

* Marks non-pub/sub events as processed, preventing their propagation. + *

*/ - public static final PushConsumer PUBSUB_ONLY_HANDLER = new PushConsumer(){ - final Set pubSubCommands = new HashSet<>(); + public static final PushConsumer PUBSUB_ONLY_HANDLER = new PushConsumer() { + final Set pubSubCommands = new HashSet<>(); { pubSubCommands.add("message"); pubSubCommands.add("pmessage"); @@ -69,7 +72,6 @@ private boolean isPubSubType(String type) { } }; - /** * Create a new empty handler chain. */ @@ -79,7 +81,6 @@ public PushConsumerChain() { /** * Create a chain with the specified handlers. - * * @param consumers The handlers to add to the chain */ public PushConsumerChain(PushConsumer... consumers) { @@ -88,7 +89,6 @@ public PushConsumerChain(PushConsumer... consumers) { /** * Create a chain with a single handler. - * * @param handler The handler to include in the chain * @return A new handler chain with the specified handler */ @@ -98,7 +98,6 @@ public static PushConsumerChain of(PushConsumer handler) { /** * Create a chain with the specified handlers. - * * @param handlers The handlers to add to the chain * @return A new handler chain with the specified handlers */ @@ -108,7 +107,6 @@ public static PushConsumerChain of(PushConsumer... handlers) { /** * Add a handler to be executed after this chain. - * * @param handler The handler to add * @return A new chain with the handler added */ @@ -126,7 +124,6 @@ public PushConsumerChain then(PushConsumer handler) { /** * Add a handler to the end of the chain. - * * @param handler The handler to add * @return this chain for method chaining */ @@ -139,7 +136,6 @@ public PushConsumerChain add(PushConsumer handler) { /** * Insert a handler at the specified position. - * * @param index The position to insert at (0-based) * @param handler The handler to insert * @return this chain for method chaining @@ -153,7 +149,6 @@ public PushConsumerChain insert(int index, PushConsumer handler) { /** * Remove a handler from the chain. - * * @param handler The handler to remove * @return true if the handler was removed */ @@ -163,7 +158,6 @@ public boolean remove(PushConsumer handler) { /** * Remove the handler at the specified position. - * * @param index The position to remove from (0-based) * @return The removed handler */ @@ -173,7 +167,6 @@ public PushConsumer removeAt(int index) { /** * Get the handler at the specified position. - * * @param index The position to get (0-based) * @return The handler at that position */ @@ -183,7 +176,6 @@ public PushConsumer get(int index) { /** * Get the number of handlers in the chain. - * * @return The number of handlers */ public int size() { diff --git a/src/main/java/redis/clients/jedis/PushConsumerContext.java b/src/main/java/redis/clients/jedis/PushConsumerContext.java index 65977c4865..18723f2bc3 100644 --- a/src/main/java/redis/clients/jedis/PushConsumerContext.java +++ b/src/main/java/redis/clients/jedis/PushConsumerContext.java @@ -4,23 +4,23 @@ @Internal public class PushConsumerContext { - private final PushMessage message; - private boolean processed = false; + private final PushMessage message; + private boolean processed = false; - public PushConsumerContext(PushMessage message) { - this.message = message; - } + public PushConsumerContext(PushMessage message) { + this.message = message; + } - public PushMessage getMessage() { - return message; - } + public PushMessage getMessage() { + return message; + } - public boolean isProcessed() { - return processed; - } + public boolean isProcessed() { + return processed; + } - public void setProcessed(boolean processed) { - this.processed = processed; - } + public void setProcessed(boolean processed) { + this.processed = processed; + } } diff --git a/src/main/java/redis/clients/jedis/PushHandler.java b/src/main/java/redis/clients/jedis/PushHandler.java index 72330db757..ca457513f7 100644 --- a/src/main/java/redis/clients/jedis/PushHandler.java +++ b/src/main/java/redis/clients/jedis/PushHandler.java @@ -5,7 +5,6 @@ /** * A handler object that provides access to {@link PushListener}s. - * * @author Ivo Gaydajiev * @since 6.1 */ @@ -13,14 +12,12 @@ public interface PushHandler { /** * Add a new {@link PushListener listener}. - * * @param listener the listener, must not be {@code null}. */ void addListener(PushListener listener); /** * Remove an existing {@link PushListener listener}. - * * @param listener the listener, must not be {@code null}. */ void removeListener(PushListener listener); @@ -32,14 +29,16 @@ public interface PushHandler { /** * Returns a collection of {@link PushListener}. - * * @return the collection of listeners. */ Collection getPushListeners(); /** - * A no-operation implementation of PushHandler that doesn't maintain any listeners. + * A no-operation implementation of PushHandler that doesn't maintain any listeners + * + *

* All operations are no-ops and getPushListeners() returns an empty list. + *

*/ PushHandler NOOP = new NoOpPushHandler(); @@ -47,7 +46,8 @@ public interface PushHandler { final class NoOpPushHandler implements PushHandler { - NoOpPushHandler() {} + NoOpPushHandler() { + } @Override public void addListener(PushListener listener) { diff --git a/src/main/java/redis/clients/jedis/PushListener.java b/src/main/java/redis/clients/jedis/PushListener.java index 345f102c83..26ca46b6bd 100644 --- a/src/main/java/redis/clients/jedis/PushListener.java +++ b/src/main/java/redis/clients/jedis/PushListener.java @@ -4,9 +4,8 @@ public interface PushListener { /** - * Interface to be implemented by push message listeners that are interested in listening to {@link PushMessage}. Requires Redis - * 6+ using RESP3. - * + * Interface to be implemented by push message listeners that are interested in listening to + * {@link PushMessage}. Requires Redis 6+ using RESP3. * @author Ivo Gaydajiev * @since 6.1 * @see PushMessage diff --git a/src/main/java/redis/clients/jedis/PushMessage.java b/src/main/java/redis/clients/jedis/PushMessage.java index ffb3bba516..48c0364c41 100644 --- a/src/main/java/redis/clients/jedis/PushMessage.java +++ b/src/main/java/redis/clients/jedis/PushMessage.java @@ -15,11 +15,11 @@ public PushMessage(List content) { } } - public String getType(){ - return type; + public String getType() { + return type; } - public List getContent(){ + public List getContent() { return content; } } \ No newline at end of file diff --git a/src/main/java/redis/clients/jedis/RebindAware.java b/src/main/java/redis/clients/jedis/RebindAware.java index a76d7aa2e9..a3aaf62ba1 100644 --- a/src/main/java/redis/clients/jedis/RebindAware.java +++ b/src/main/java/redis/clients/jedis/RebindAware.java @@ -4,24 +4,24 @@ /** * Interface for components that support rebinding to a new host and port. - * Implementations of this interface can be notified when a Redis server sends - * a MOVING notification during maintenance events. - * - * This interface can be implemented by various components such as: - * - Connection pools - * - Socket factories - * - Connection providers - * - Any component that manages connections to Redis servers + *

+ * Implementations of this + * interface can be notified when a Redis server sends a MOVING notification during maintenance + * events. This interface can be implemented by various components such as: - Connection pools - + * Socket factories - Connection providers - Any component that manages connections to Redis servers + *

*/ @Experimental public interface RebindAware { /** - * Notifies the component that a re-bind to a new host and port is scheduled. This is called when + * Notifies the component that a re-bind to a new host and port is scheduled. + *

+ * This is called when * a MOVING notification is received. Components that implement this interface should update their * internal state to reflect the new host and port, and return true if the re-bind was accepted. - * Components might decide to reject the re-bind request if they are not in a state to support - * it. + * Components might decide to reject the re-bind request if they are not in a state to support it. + *

* @param newHostAndPort The new host and port to use for new connections */ void rebind(HostAndPort newHostAndPort); diff --git a/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java index d324fa8bc8..ff3204738d 100644 --- a/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java +++ b/src/test/java/redis/clients/jedis/PushMessageNotificationTest.java @@ -49,7 +49,7 @@ public void tearDown() { connection.close(); connection = null; } - + if (unifiedJedis != null) { try { unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "OFF"); @@ -60,10 +60,9 @@ public void tearDown() { unifiedJedis = null; } } - + /** * Helper method to modify a key using a separate connection to trigger invalidation. - * * @param key The key to modify * @param value The new value to set */ @@ -73,10 +72,9 @@ private void triggerKeyInvalidation(String key, String value) { modifierClient.set(key, value); } } - + /** * Helper method to enable client tracking on a connection. - * * @param connection The connection on which to enable tracking */ private void enableClientTracking(Connection connection) { @@ -95,12 +93,14 @@ public void testConnectionResp3PushNotifications() { // Set initial value CommandArguments comArgs = new CommandArguments(Command.SET); - CommandObject set = new CommandObject<>(comArgs.key(testKey).add(initialValue), BuilderFactory.STRING); + CommandObject set = new CommandObject<>(comArgs.key(testKey).add(initialValue), + BuilderFactory.STRING); String setResult = connection.executeCommand(set); assertEquals("OK", setResult); // Get the key to track it - CommandObject get = new CommandObject<>(new CommandArguments(Command.GET).key(testKey), BuilderFactory.STRING); + CommandObject get = new CommandObject<>(new CommandArguments(Command.GET).key(testKey), + BuilderFactory.STRING); String getResponse = connection.executeCommand(get); assertEquals(initialValue, getResponse); @@ -108,7 +108,8 @@ public void testConnectionResp3PushNotifications() { triggerKeyInvalidation(testKey, modifiedValue); // Send PING and expect to receive invalidation message first, then PONG - CommandObject ping = new CommandObject<>(new CommandArguments(Command.PING), BuilderFactory.STRING); + CommandObject ping = new CommandObject<>(new CommandArguments(Command.PING), + BuilderFactory.STRING); String pingResponse = connection.executeCommand(ping); assertEquals("PONG", pingResponse); } @@ -117,17 +118,17 @@ public void testConnectionResp3PushNotifications() { public void testUnifiedJedisResp3PushNotifications() { unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); - + // Enable client tracking unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); - + // Set initial value unifiedJedis.set(testKey, initialValue); - + // Get the key to track it String getResponse = unifiedJedis.get(testKey); assertEquals(initialValue, getResponse); - + // Modify the key from another connection to trigger invalidation triggerKeyInvalidation(testKey, modifiedValue); @@ -144,12 +145,10 @@ public void testUnifiedJedisCustomPushListener() { pushHandler.addListener(receivedMessages::add); DefaultJedisClientConfig clientConfig = endpoint.getClientConfigBuilder() - .pushHandler(pushHandler) - .protocol(RedisProtocol.RESP3).build(); + .pushHandler(pushHandler).protocol(RedisProtocol.RESP3).build(); unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), clientConfig); - // Enable client tracking unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); @@ -177,8 +176,7 @@ public void testJedisCustomPushListener() { pushHandler.addListener(receivedMessages::add); DefaultJedisClientConfig clientConfig = endpoint.getClientConfigBuilder() - .pushHandler(pushHandler) - .protocol(RedisProtocol.RESP3).build(); + .pushHandler(pushHandler).protocol(RedisProtocol.RESP3).build(); Jedis jedis = new Jedis(endpoint.getHostAndPort(), clientConfig); @@ -204,45 +202,50 @@ public void testJedisCustomPushListener() { // Clean up jedis.close(); } - + @Test public void testConnectionResp3PushNotificationsWithCustomListener() { // Create a list to store received push messages List receivedMessages = new ArrayList<>(); - + // Create a custom push listener - PushConsumer listener = pushContext -> { receivedMessages.add(pushContext.getMessage());}; + PushConsumer listener = pushContext -> { + receivedMessages.add(pushContext.getMessage()); + }; // Create connection with RESP3 protocol connection = new Connection(endpoint.getHostAndPort(), endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build()); connection.connect(); - + // Set the push listener connection.getPushConsumer().add(listener); - + // Enable client tracking enableClientTracking(connection); - + // Set and get a key to track it CommandArguments setArgs = new CommandArguments(Command.SET); - CommandObject setCmd = new CommandObject<>(setArgs.key(testKey).add(initialValue), BuilderFactory.STRING); + CommandObject setCmd = new CommandObject<>(setArgs.key(testKey).add(initialValue), + BuilderFactory.STRING); connection.executeCommand(setCmd); - - CommandObject getCmd = new CommandObject<>(new CommandArguments(Command.GET).key(testKey), BuilderFactory.STRING); + + CommandObject getCmd = new CommandObject<>( + new CommandArguments(Command.GET).key(testKey), BuilderFactory.STRING); connection.executeCommand(getCmd); - + // Modify the key from another connection to trigger invalidation triggerKeyInvalidation(testKey, modifiedValue); - + // Send a command to trigger processing of any pending push messages - CommandObject pingCmd = new CommandObject<>(new CommandArguments(Command.PING), BuilderFactory.STRING); + CommandObject pingCmd = new CommandObject<>(new CommandArguments(Command.PING), + BuilderFactory.STRING); String pingResponse = connection.executeCommand(pingCmd); assertEquals("PONG", pingResponse); - + // Verify we received at least one push message assertTrue(!receivedMessages.isEmpty(), "Should have received at least one push message"); - + // Verify the message is an invalidation message PushMessage pushMessage = receivedMessages.get(0); assertNotNull(pushMessage); @@ -251,37 +254,38 @@ public void testConnectionResp3PushNotificationsWithCustomListener() { @ParameterizedTest @MethodSource("redis.clients.jedis.commands.CommandsTestsParameters#respVersions") - public void testUnifiedJedisPubSubWithResp3PushNotifications(RedisProtocol protocol) throws InterruptedException { + public void testUnifiedJedisPubSubWithResp3PushNotifications(RedisProtocol protocol) + throws InterruptedException { // Create a UnifiedJedis instance with RESP3 protocol for subscribing unifiedJedis = new UnifiedJedis(endpoint.getHostAndPort(), endpoint.getClientConfigBuilder().protocol(protocol).build()); - + // Enable client tracking to generate push notifications unifiedJedis.sendCommand(Command.CLIENT, "TRACKING", "ON"); - + // Set initial value to track unifiedJedis.set(testKey, initialValue); - + // Get the key to track it String getResponse = unifiedJedis.get(testKey); assertEquals(initialValue, getResponse); - + // Create a list to store received pub/sub messages final List receivedMessages = new ArrayList<>(); - + // Create an atomic counter to track received messages final AtomicInteger messageCounter = new AtomicInteger(0); - + // Create a latch to signal when subscription is ready final CountDownLatch subscriptionLatch = new CountDownLatch(1); - + // Create a JedisPubSub instance to handle pub/sub messages JedisPubSub pubSub = new JedisPubSub() { @Override public void onMessage(String channel, String message) { System.out.println("onMessage from " + channel + " : " + message); receivedMessages.add(message); - + // If we've received both messages, unsubscribe if (messageCounter.incrementAndGet() == 2) { this.unsubscribe("test-channel"); @@ -300,20 +304,20 @@ public void onSubscribe(String channel, int subscribedChannels) { subscriptionLatch.countDown(); } }; - + // Start a thread to handle the subscription Thread subscriberThread = new Thread(() -> { unifiedJedis.subscribe(pubSub, "test-channel"); }); - + // Start the subscriber thread subscriberThread.start(); - + // Start a thread to publish messages and trigger key invalidation Thread publisherThread = new Thread(() -> { try (UnifiedJedis publisher = new UnifiedJedis(endpoint.getHostAndPort(), endpoint.getClientConfigBuilder().protocol(RedisProtocol.RESP3).build())) { - + // Wait for subscription to be ready try { if (!subscriptionLatch.await(5, TimeUnit.SECONDS)) { @@ -324,34 +328,34 @@ public void onSubscribe(String channel, int subscribedChannels) { Thread.currentThread().interrupt(); return; } - + // Publish a message publisher.publish("test-channel", "test-message-1"); - + // Trigger key invalidation to generate a push notification triggerKeyInvalidation(testKey, modifiedValue); - + // Publish another message publisher.publish("test-channel", "test-message-2"); } catch (Exception e) { e.printStackTrace(); } }); - + // Start the publisher thread publisherThread.start(); - + // Wait for the subscriber thread to complete (it will complete when unsubscribe is called) subscriberThread.join(); - + // Wait for the publisher thread to complete publisherThread.join(); - + // Verify that we received both pub/sub messages assertEquals(2, receivedMessages.size(), "Should have received both pub/sub messages"); assertEquals("test-message-1", receivedMessages.get(0)); assertEquals("test-message-2", receivedMessages.get(1)); - + // Send a PING command to process any pending push messages String pingResponse = unifiedJedis.ping(); assertEquals("PONG", pingResponse); diff --git a/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java index a2106a4a69..06dbf0f430 100644 --- a/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java +++ b/src/test/java/redis/clients/jedis/upgrade/ConnectionAdaptiveTimeoutTest.java @@ -264,24 +264,24 @@ public void testRelaxedBlockingTimeoutAppliedDuringBlockingCommand() // Verify that relaxed blocking timeout was applied blpopLatch.await(); assertTrue(connection.isRelaxedTimeoutActive(), - "Relaxed timeout should be active during blocking command"); + "Relaxed timeout should be active during blocking command"); assertEquals((int) relaxedBlockingTimeout.toMillis(), socket.getSoTimeout(), - "Socket timeout should be relaxed blocking timeout during blocking command"); + "Socket timeout should be relaxed blocking timeout during blocking command"); blpopLatchAfter.await(); assertTrue(connection.isRelaxedTimeoutActive(), - "Relaxed timeout should be still active after blocking command"); + "Relaxed timeout should be still active after blocking command"); assertEquals(relaxedTimeout.toMillis(), socket.getSoTimeout(), - "Socket timeout should be restored to relaxed timeout for non blocking command"); + "Socket timeout should be restored to relaxed timeout for non blocking command"); // Send MIGRATED push notification to disable relaxed timeout mockServer.sendMigratedPushToAll(); connection.executeCommand(commandObjects.ping()); assertFalse(connection.isRelaxedTimeoutActive(), - "Relaxed timeout should be disabled after MIGRATED"); + "Relaxed timeout should be disabled after MIGRATED"); assertEquals(originalTimeoutMs, socket.getSoTimeout(), - "Socket timeout should be restored to original timeout"); + "Socket timeout should be restored to original timeout"); } /** diff --git a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java index 2964b56302..3c0ec7277b 100644 --- a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java +++ b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java @@ -28,236 +28,241 @@ @Tag("upgrade") public class UnifiedJedisProactiveRebindTest { - private TcpMockServer mockServer1; - private TcpMockServer mockServer2; + private TcpMockServer mockServer1; + private TcpMockServer mockServer2; - private final int socketTimeoutMs = 5000; + private final int socketTimeoutMs = 5000; - DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() - .socketTimeoutMillis(socketTimeoutMs) - .protocol(RedisProtocol.RESP3) - .proactiveRebindEnabled(true) // Enable proactive rebinding - .build(); + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .socketTimeoutMillis(socketTimeoutMs).protocol(RedisProtocol.RESP3) + .proactiveRebindEnabled(true) // Enable proactive rebinding + .build(); - HostAndPort server1Address; - HostAndPort server2Address; + HostAndPort server1Address; + HostAndPort server2Address; - ConnectionPoolConfig connectionPoolConfig; + ConnectionPoolConfig connectionPoolConfig; - @BeforeEach - public void setUp() throws IOException { - // Start tcpmockedserver1 - mockServer1 = new TcpMockServer(); - mockServer1.start(); - - // Start tcpmockedserver2 - mockServer2 = new TcpMockServer(); - mockServer2.start(); + @BeforeEach + public void setUp() throws IOException { + // Start tcpmockedserver1 + mockServer1 = new TcpMockServer(); + mockServer1.start(); - server1Address = new HostAndPort("localhost", mockServer1.getPort()); - server2Address = new HostAndPort("localhost", mockServer2.getPort()); + // Start tcpmockedserver2 + mockServer2 = new TcpMockServer(); + mockServer2.start(); - connectionPoolConfig = new ConnectionPoolConfig(); + server1Address = new HostAndPort("localhost", mockServer1.getPort()); + server2Address = new HostAndPort("localhost", mockServer2.getPort()); - System.out.println("MockServer1 started on port: " + mockServer1.getPort()); - System.out.println("MockServer2 started on port: " + mockServer2.getPort()); - } + connectionPoolConfig = new ConnectionPoolConfig(); - @AfterEach - public void tearDown() throws IOException { + System.out.println("MockServer1 started on port: " + mockServer1.getPort()); + System.out.println("MockServer2 started on port: " + mockServer2.getPort()); + } - if (mockServer1 != null) { - mockServer1.stop(); - } - if (mockServer2 != null) { - mockServer2.stop(); - } + @AfterEach + public void tearDown() throws IOException { + + if (mockServer1 != null) { + mockServer1.stop(); + } + if (mockServer2 != null) { + mockServer2.stop(); } + } - @Test - public void testProactiveRebind() throws Exception { - // 1. Create UnifiedJedis client and connect it to mockedserver1 - try(JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { + @Test + public void testProactiveRebind() throws Exception { + // 1. Create UnifiedJedis client and connect it to mockedserver1 + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, + clientConfig)) { - // 1. Perform a PING command to initiate a connection - String response1 = unifiedJedis.ping(); - assertEquals("PONG", response1); + // 1. Perform a PING command to initiate a connection + String response1 = unifiedJedis.ping(); + assertEquals("PONG", response1); - // Verify initial connection to server1 - assertEquals(1, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); + // Verify initial connection to server1 + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); - // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); - // 3. Perform PING command - // This should trigger read of the MOVING notification and rebind to server2 - // the ping command itself should be executed against server1 - // the used connection should be closed after the ping command is executed - String response2 = unifiedJedis.ping(); - assertEquals("PONG", response2); + // 3. Perform PING command + // This should trigger read of the MOVING notification and rebind to server2 + // the ping command itself should be executed against server1 + // the used connection should be closed after the ping command is executed + String response2 = unifiedJedis.ping(); + assertEquals("PONG", response2); - // drop connection to server1 - mockServer1.stop(); + // drop connection to server1 + mockServer1.stop(); - // Verify initial connection to server1 - assertEquals(0, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); + // Verify initial connection to server1 + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); - // 4. Perform PING command - // Folowup ping command should be executed against server2 + // 4. Perform PING command + // Folowup ping command should be executed against server2 - String response3 = unifiedJedis.ping(); - assertEquals("PONG", response3); + String response3 = unifiedJedis.ping(); + assertEquals("PONG", response3); - // Verify that connection has moved to server2 - assertEquals(0, mockServer1.getConnectedClientCount()); - assertEquals(1, mockServer2.getConnectedClientCount()); - } + // Verify that connection has moved to server2 + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(1, mockServer2.getConnectedClientCount()); } - - @Test - public void testActiveConnectionShouldBeDisposedOnRebind() { - // 1. Create UnifiedJedis client and connect it to mockedserver1 - try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { - Pool pool = unifiedJedis.getPool(); - - // 1. Test setup - 1 active connection, 0 idle connection - Connection activeConnection = unifiedJedis.getPool().getResource(); - assertEquals(1, pool.getNumActive()); - assertEquals(0, pool.getDestroyedCount()); - assertEquals(0, pool.getNumIdle()); - assertEquals(1, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - - // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); - - // 3. Active connection should be still usable until closed and returned to the pools - assertTrue(activeConnection.ping()); - - // 4. When closed connection should be disposed and not returned to the pool - activeConnection.close(); - assertEquals(1, pool.getDestroyedCount()); - assertEquals(0, pool.getNumActive()); - - // 5. Wait for connection to be closed on server1 - await().pollDelay(Duration.ofMillis(1)).timeout(Duration.ofMillis(10)) - .until(() -> mockServer1.getConnectedClientCount() == 0); - assertEquals(0, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - - // 6. Next command should create a new connection to server2 - String response2 = unifiedJedis.ping(); - assertEquals("PONG", response2); - assertEquals(0, mockServer1.getConnectedClientCount()); - assertEquals(1, mockServer2.getConnectedClientCount()); - } + } + + @Test + public void testActiveConnectionShouldBeDisposedOnRebind() { + // 1. Create UnifiedJedis client and connect it to mockedserver1 + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, + clientConfig)) { + Pool pool = unifiedJedis.getPool(); + + // 1. Test setup - 1 active connection, 0 idle connection + Connection activeConnection = unifiedJedis.getPool().getResource(); + assertEquals(1, pool.getNumActive()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(0, pool.getNumIdle()); + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + + // 3. Active connection should be still usable until closed and returned to the pools + assertTrue(activeConnection.ping()); + + // 4. When closed connection should be disposed and not returned to the pool + activeConnection.close(); + assertEquals(1, pool.getDestroyedCount()); + assertEquals(0, pool.getNumActive()); + + // 5. Wait for connection to be closed on server1 + await().pollDelay(Duration.ofMillis(1)).timeout(Duration.ofMillis(10)) + .until(() -> mockServer1.getConnectedClientCount() == 0); + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 6. Next command should create a new connection to server2 + String response2 = unifiedJedis.ping(); + assertEquals("PONG", response2); + assertEquals(0, mockServer1.getConnectedClientCount()); + assertEquals(1, mockServer2.getConnectedClientCount()); } - - @Test - public void testIdleConnectionShouldBeDisposedOnRebind() { - - try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { - Pool pool = unifiedJedis.getPool(); - - // 1. Test setup - 1 active connection, 1 idle connection - Connection activeConnection = unifiedJedis.getPool().getResource(); - Connection idleConnection = unifiedJedis.getPool().getResource(); - idleConnection.close(); - - assertEquals(1, pool.getNumActive()); - assertEquals(1, pool.getNumIdle()); - assertEquals(0, pool.getDestroyedCount()); - assertEquals(2, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - - // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - String server2Address = "localhost:" + mockServer2.getPort(); - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address); - - // 3. perform a command on active connection to trigger rebind - assertTrue(activeConnection.ping()); - - // 4. All IDLE connection's should be closed & disposed - assertEquals(0, pool.getNumIdle()); - assertEquals(1, pool.getNumActive()); - - // 5. Wait for connection to be closed on server1 - await().pollDelay(Duration.ofMillis(1)).timeout(Duration.ofMillis(10)) - .until(() -> mockServer1.getConnectedClientCount() == 1); - assertEquals(1, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - - // 6. Next command should create a new connection to server2 - String response2 = unifiedJedis.ping(); - assertEquals("PONG", response2); - assertEquals(1, mockServer1.getConnectedClientCount()); - assertEquals(1, mockServer2.getConnectedClientCount()); - } + } + + @Test + public void testIdleConnectionShouldBeDisposedOnRebind() { + + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, + clientConfig)) { + Pool pool = unifiedJedis.getPool(); + + // 1. Test setup - 1 active connection, 1 idle connection + Connection activeConnection = unifiedJedis.getPool().getResource(); + Connection idleConnection = unifiedJedis.getPool().getResource(); + idleConnection.close(); + + assertEquals(1, pool.getNumActive()); + assertEquals(1, pool.getNumIdle()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(2, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + String server2Address = "localhost:" + mockServer2.getPort(); + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address); + + // 3. perform a command on active connection to trigger rebind + assertTrue(activeConnection.ping()); + + // 4. All IDLE connection's should be closed & disposed + assertEquals(0, pool.getNumIdle()); + assertEquals(1, pool.getNumActive()); + + // 5. Wait for connection to be closed on server1 + await().pollDelay(Duration.ofMillis(1)).timeout(Duration.ofMillis(10)) + .until(() -> mockServer1.getConnectedClientCount() == 1); + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 6. Next command should create a new connection to server2 + String response2 = unifiedJedis.ping(); + assertEquals("PONG", response2); + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(1, mockServer2.getConnectedClientCount()); } + } - @Test - public void testNewPoolConnectionsCreatedAgainstMovingTarget() { - // Create UnifiedJedis with connection pooling enabled - try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)){ + @Test + public void testNewPoolConnectionsCreatedAgainstMovingTarget() { + // Create UnifiedJedis with connection pooling enabled + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, + clientConfig)) { - // 1. Test setup - 1 active connection - Connection activeConnection = unifiedJedis.getPool().getResource(); + // 1. Test setup - 1 active connection + Connection activeConnection = unifiedJedis.getPool().getResource(); - // Verify initial connection to server1 - assertEquals(1, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); + // Verify initial connection to server1 + assertEquals(1, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); - // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); - // 3. perform a command on active connection to trigger rebind - assertTrue(activeConnection.ping()); + // 3. perform a command on active connection to trigger rebind + assertTrue(activeConnection.ping()); - // 4. Initiate a new connection from the pool - Connection newConnection = unifiedJedis.getPool().getResource(); - assertTrue(newConnection.ping()); + // 4. Initiate a new connection from the pool + Connection newConnection = unifiedJedis.getPool().getResource(); + assertTrue(newConnection.ping()); - // Verify that new connections are being created against server2 - assertEquals(server2Address, ConnectionTestHelper.getHostAndPort(newConnection)); - assertEquals(1, mockServer2.getConnectedClientCount()); - } + // Verify that new connections are being created against server2 + assertEquals(server2Address, ConnectionTestHelper.getHostAndPort(newConnection)); + assertEquals(1, mockServer2.getConnectedClientCount()); } + } - @Test - public void testPoolConnectionsWithProactiveRebindDisabled() { - // Verify that with proactive rebind disabled, connections stay on original server - DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder().from(this.clientConfig).proactiveRebindEnabled(false).build(); - try(JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, clientConfig)) { - Pool pool = unifiedJedis.getPool(); - - // 1. Test setup - 1 active connection, 1 idle connection - Connection activeConnection = unifiedJedis.getPool().getResource(); - Connection idleConnection = unifiedJedis.getPool().getResource(); - idleConnection.close(); - - // Verify initial connection to server1 - assertEquals(1, pool.getNumActive()); - assertEquals(1, pool.getNumIdle()); - assertEquals(0, pool.getDestroyedCount()); - assertEquals(2, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - - // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); - - // 3. Perform PING command - // This should trigger read of the MOVING notification processing - assertTrue(activeConnection.ping()); - - // Verify initial connection to server1 - assertEquals(1, pool.getNumActive()); - assertEquals(1, pool.getNumIdle()); - assertEquals(0, pool.getDestroyedCount()); - assertEquals(2, mockServer1.getConnectedClientCount()); - assertEquals(0, mockServer2.getConnectedClientCount()); - } + @Test + public void testPoolConnectionsWithProactiveRebindDisabled() { + // Verify that with proactive rebind disabled, connections stay on original server + DefaultJedisClientConfig clientConfig = DefaultJedisClientConfig.builder() + .from(this.clientConfig).proactiveRebindEnabled(false).build(); + try (JedisPooled unifiedJedis = new JedisPooled(connectionPoolConfig, server1Address, + clientConfig)) { + Pool pool = unifiedJedis.getPool(); + + // 1. Test setup - 1 active connection, 1 idle connection + Connection activeConnection = unifiedJedis.getPool().getResource(); + Connection idleConnection = unifiedJedis.getPool().getResource(); + idleConnection.close(); + + // Verify initial connection to server1 + assertEquals(1, pool.getNumActive()); + assertEquals(1, pool.getNumIdle()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(2, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); + + // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 + mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + + // 3. Perform PING command + // This should trigger read of the MOVING notification processing + assertTrue(activeConnection.ping()); + + // Verify initial connection to server1 + assertEquals(1, pool.getNumActive()); + assertEquals(1, pool.getNumIdle()); + assertEquals(0, pool.getDestroyedCount()); + assertEquals(2, mockServer1.getConnectedClientCount()); + assertEquals(0, mockServer2.getConnectedClientCount()); } + } } diff --git a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java index d381c82ddd..30a4224764 100644 --- a/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java +++ b/src/test/java/redis/clients/jedis/util/server/TcpMockServer.java @@ -246,11 +246,10 @@ public void run() { private void sendHelloResponse(OutputStream out) throws IOException { // RESP3 HELLO response - String response = - "%7\r\n" + "$6\r\nserver\r\n$5\r\nredis\r\n" + "$7\r\nversion\r\n$5\r\n7.0.0\r\n" - + "$5\r\nproto\r\n:3\r\n" + "$2\r\nid\r\n:1\r\n" - + "$4\r\nmode\r\n$10\r\nstandalone\r\n" + "$4\r\nrole\r\n$6\r\nmaster\r\n" - + "$7\r\nmodules\r\n*0\r\n"; + String response = "%7\r\n" + "$6\r\nserver\r\n$5\r\nredis\r\n" + + "$7\r\nversion\r\n$5\r\n7.0.0\r\n" + "$5\r\nproto\r\n:3\r\n" + "$2\r\nid\r\n:1\r\n" + + "$4\r\nmode\r\n$10\r\nstandalone\r\n" + "$4\r\nrole\r\n$6\r\nmaster\r\n" + + "$7\r\nmodules\r\n*0\r\n"; out.write(response.getBytes()); out.flush(); } @@ -325,9 +324,8 @@ public void sendPushMessage(String pushType, String... args) { outputStream.flush(); } catch (IOException e) { - logger.error( - "Error sending " + pushType + " push to " + clientId + " (client disconnected): " - + e.getMessage()); + logger.error("Error sending " + pushType + " push to " + clientId + + " (client disconnected): " + e.getMessage()); cleanup(); } } From b81a79efe8dd87eb253f62ba524b6e087b2a9654 Mon Sep 17 00:00:00 2001 From: ggivo Date: Mon, 28 Jul 2025 14:57:25 +0300 Subject: [PATCH 22/23] sendPushMessageToAll -> sendMovingPushToAll --- .../jedis/upgrade/UnifiedJedisProactiveRebindTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java index 3c0ec7277b..eb524200bd 100644 --- a/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java +++ b/src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java @@ -88,7 +88,7 @@ public void testProactiveRebind() throws Exception { assertEquals(0, mockServer2.getConnectedClientCount()); // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + mockServer1.sendMovingPushToAll( 30L, server2Address.toString()); // 3. Perform PING command // This should trigger read of the MOVING notification and rebind to server2 @@ -132,7 +132,7 @@ public void testActiveConnectionShouldBeDisposedOnRebind() { assertEquals(0, mockServer2.getConnectedClientCount()); // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + mockServer1.sendMovingPushToAll( 30L, server2Address.toString()); // 3. Active connection should be still usable until closed and returned to the pools assertTrue(activeConnection.ping()); @@ -176,7 +176,7 @@ public void testIdleConnectionShouldBeDisposedOnRebind() { // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 String server2Address = "localhost:" + mockServer2.getPort(); - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address); + mockServer1.sendMovingPushToAll( 30L, server2Address); // 3. perform a command on active connection to trigger rebind assertTrue(activeConnection.ping()); @@ -213,7 +213,7 @@ public void testNewPoolConnectionsCreatedAgainstMovingTarget() { assertEquals(0, mockServer2.getConnectedClientCount()); // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + mockServer1.sendMovingPushToAll( 30L, server2Address.toString()); // 3. perform a command on active connection to trigger rebind assertTrue(activeConnection.ping()); @@ -250,7 +250,7 @@ public void testPoolConnectionsWithProactiveRebindDisabled() { assertEquals(0, mockServer2.getConnectedClientCount()); // 2. Send MOVING notification on server1 -> MOVING 30 localhost:port2 - mockServer1.sendPushMessageToAll("MOVING", "30", server2Address.toString()); + mockServer1.sendMovingPushToAll( 30L, server2Address.toString()); // 3. Perform PING command // This should trigger read of the MOVING notification processing From c9c514948bd5dcbdff5194f03935a2191a808e95 Mon Sep 17 00:00:00 2001 From: ggivo Date: Wed, 23 Jul 2025 15:34:18 +0300 Subject: [PATCH 23/23] Revert rebind address to initial one after delay # Conflicts: # src/main/java/redis/clients/jedis/MaintenanceEventListener.java # src/main/java/redis/clients/jedis/RebindAware.java # src/test/java/redis/clients/jedis/upgrade/UnifiedJedisProactiveRebindTest.java --- .../java/redis/clients/jedis/Connection.java | 42 ++++++-- .../clients/jedis/ConnectionFactory.java | 59 +++++++++-- .../redis/clients/jedis/ConnectionPool.java | 12 +-- .../jedis/DefaultJedisSocketFactory.java | 47 ++++++++- .../jedis/MaintenanceEventListener.java | 6 +- .../java/redis/clients/jedis/RebindAware.java | 5 +- .../redis/clients/jedis/util/Expirable.java | 79 +++++++++++++++ .../clients/jedis/ConnectionTestHelper.java | 36 ++++++- .../UnifiedJedisProactiveRebindTest.java | 38 +++++++ .../clients/jedis/util/ExpirableTest.java | 99 +++++++++++++++++++ .../jedis/util/server/TcpMockServer.java | 42 +++++--- 11 files changed, 422 insertions(+), 43 deletions(-) create mode 100644 src/main/java/redis/clients/jedis/util/Expirable.java create mode 100644 src/test/java/redis/clients/jedis/util/ExpirableTest.java diff --git a/src/main/java/redis/clients/jedis/Connection.java b/src/main/java/redis/clients/jedis/Connection.java index 834981abeb..3104c06226 100644 --- a/src/main/java/redis/clients/jedis/Connection.java +++ b/src/main/java/redis/clients/jedis/Connection.java @@ -11,6 +11,7 @@ import java.net.SocketException; import java.nio.ByteBuffer; import java.nio.CharBuffer; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -839,8 +840,11 @@ public void accept(PushConsumerContext context) { } } private void onMoving(PushMessage message) { - HostAndPort rebindTarget = getRebindTarget(message); - eventHandler.getListeners().forEach(listener -> listener.onRebind(rebindTarget)); + RebindEvent rebindEvent = getRebindTarget(message); + if (rebindEvent == null) { + return; + } + eventHandler.getListeners().forEach(listener -> listener.onRebind(rebindEvent.target,rebindEvent.rebindTimeout)); } private void onMigrating() { @@ -859,7 +863,7 @@ private void onFailedOver() { eventHandler.getListeners().forEach(MaintenanceEventListener::onFailedOver); } - private HostAndPort getRebindTarget(PushMessage message) { + private RebindEvent getRebindTarget(PushMessage message) { // Extract domain/ip and port from the message // MOVING push message format: ["MOVING", slot, "host:port"] List content = message.getContent(); @@ -869,6 +873,14 @@ private HostAndPort getRebindTarget(PushMessage message) { return null; } + Object timeObject = content.get(1); // Get the 3rd element (index 2) + if (!(timeObject instanceof Long)) { + logger.warn("Invalid re-bind message format, expected 2rd element to be a