connectPubSub();
+ /**
+ * Open asynchronously a new multi database connection to a Redis server. "Use the default {@link RedisCodec codec}
+ * StringCodec.UTF8 to encode/decode keys and values.
+ *
+ * The returned {@link MultiDbConnectionFuture} ensures that all callbacks (thenApply, thenAccept, etc.) execute on a
+ * separate thread pool rather than on Netty event loop threads, preventing deadlocks when calling blocking sync operations
+ * inside callbacks.
+ *
+ * @return a {@link MultiDbConnectionFuture} that is notified with the connection progress.
+ * @since 7.4
+ */
+ public MultiDbConnectionFuture connectAsync();
+
+ /**
+ * Open asynchronously a new multi database connection to a Redis server. Use the supplied {@link RedisCodec codec} to
+ * encode/decode keys and values.
+ *
+ * The returned {@link MultiDbConnectionFuture} ensures that all callbacks (thenApply, thenAccept, etc.) execute on a
+ * separate thread pool rather than on Netty event loop threads, preventing deadlocks when calling blocking sync operations
+ * inside callbacks.
+ *
+ * @param codec Use this codec to encode/decode keys and values, must not be {@code null}
+ * @param Key type
+ * @param Value type
+ * @return a {@link MultiDbConnectionFuture} that is notified with the connection progress.
+ * @since 7.4
+ */
+ public MultiDbConnectionFuture connectAsync(RedisCodec codec);
+
}
diff --git a/src/main/java/io/lettuce/core/failover/MultiDbClientImpl.java b/src/main/java/io/lettuce/core/failover/MultiDbClientImpl.java
index 4d5c14dad..89a1cfa8b 100644
--- a/src/main/java/io/lettuce/core/failover/MultiDbClientImpl.java
+++ b/src/main/java/io/lettuce/core/failover/MultiDbClientImpl.java
@@ -4,6 +4,7 @@
import java.util.Comparator;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@@ -105,6 +106,17 @@ void resetOptions() {
this.localClientOptions.remove();
}
+ /**
+ * Open a new connection to a Redis server. Use the supplied {@link RedisCodec codec} to encode/decode keys and values. This
+ * method is synchronous and will block until all database connections are established. It also waits for the initial health
+ * checks to complete starting from most weighted database, ensuring that at least one database is healthy before returning
+ * to use in the order of their weights.
+ *
+ * @param codec Use this codec to encode/decode keys and values, must not be {@code null}
+ * @param Key type
+ * @param Value type
+ * @return A new stateful Redis connection
+ */
public StatefulRedisMultiDbConnection connect(RedisCodec codec) {
if (codec == null) {
@@ -127,13 +139,12 @@ public StatefulRedisMultiDbConnection connect(RedisCodec code
databases.put(uri, database);
}
- StatusTracker statusTracker = new StatusTracker(healthStatusManager);
+ StatusTracker statusTracker = new StatusTracker(healthStatusManager, getResources());
// Wait for health checks to complete if configured
waitForInitialHealthyDatabase(statusTracker, databases);
// Provide a connection factory for dynamic database addition
- return new StatefulRedisMultiDbConnectionImpl, K, V>(databases, getResources(), codec,
- this::createRedisDatabase, healthStatusManager);
+ return createMultiDbConnection(databases, codec, healthStatusManager);
}
protected HealthStatusManager createHealthStatusManager() {
@@ -169,6 +180,82 @@ private RedisDatabaseImpl> createRedisDatab
}
}
+ // ASYNC CONNECT
+ /**
+ * Asynchronously open a new connection to a Redis server. Use the supplied {@link RedisCodec codec} to encode/decode keys
+ * and values. This method is asynchronous and returns a {@link MultiDbConnectionFuture} that completes when all database
+ * connections are established and initial health checks (if configured) have completed.
+ *
+ * The returned {@link MultiDbConnectionFuture} ensures that all callbacks (thenApply, thenAccept, etc.) execute on a
+ * separate thread pool rather than on Netty event loop threads, preventing deadlocks when calling blocking sync operations
+ * inside callbacks.
+ *
+ * @param codec Use this codec to encode/decode keys and values, must not be {@code null}
+ * @param Key type
+ * @param Value type
+ * @return A new stateful Redis connection future
+ */
+ @Override
+ public MultiDbConnectionFuture connectAsync() {
+ HealthStatusManager healthStatusManager = createHealthStatusManager();
+ MultiDbAsyncConnectionBuilder builder = new MultiDbAsyncConnectionBuilder<>(healthStatusManager,
+ getResources(), this);
+
+ CompletableFuture> future = builder.connectAsync(databaseConfigs,
+ newStringStringCodec(), this::createMultiDbConnection);
+
+ return MultiDbConnectionFuture.from(future, getResources().eventExecutorGroup());
+ }
+
+ /**
+ * Asynchronously open a new connection to a Redis server. Use the supplied {@link RedisCodec codec} to encode/decode keys
+ * and values. This method is asynchronous and returns a {@link MultiDbConnectionFuture} that completes when all database
+ * connections are established and initial health checks (if configured) have completed.
+ *
+ * The returned {@link MultiDbConnectionFuture} ensures that all callbacks (thenApply, thenAccept, etc.) execute on a
+ * separate thread pool rather than on Netty event loop threads, preventing deadlocks when calling blocking sync operations
+ * inside callbacks.
+ *
+ * @param codec Use this codec to encode/decode keys and values, must not be {@code null}
+ * @param Key type
+ * @param Value type
+ * @return A new stateful Redis connection future
+ */
+ @Override
+ public MultiDbConnectionFuture connectAsync(RedisCodec codec) {
+ if (codec == null) {
+ throw new IllegalArgumentException("codec must not be null");
+ }
+
+ HealthStatusManager healthStatusManager = createHealthStatusManager();
+ MultiDbAsyncConnectionBuilder builder = new MultiDbAsyncConnectionBuilder<>(healthStatusManager, getResources(),
+ this);
+
+ CompletableFuture> future = builder.connectAsync(databaseConfigs, codec,
+ this::createMultiDbConnection);
+
+ return MultiDbConnectionFuture.from(future, getResources().eventExecutorGroup());
+ }
+
+ /**
+ * Creates a new {@link StatefulRedisMultiDbConnection} instance with the provided healthy database map.
+ *
+ * @param healthyDatabaseMap the map of healthy databases
+ * @param codec the Redis codec
+ * @param healthStatusManager the health status manager
+ * @param Key type
+ * @param Value type
+ * @return a new multi-database connection
+ */
+ protected StatefulRedisMultiDbConnection createMultiDbConnection(
+ Map>> healthyDatabaseMap, RedisCodec codec,
+ HealthStatusManager healthStatusManager) {
+
+ return new StatefulRedisMultiDbConnectionImpl, K, V>(healthyDatabaseMap, getResources(),
+ codec, this::createRedisDatabase, healthStatusManager);
+ }
+ // END OF ASYNC CONNECT
+
/**
* Open a new connection to a Redis server that treats keys and values as UTF-8 strings.
*
@@ -197,7 +284,7 @@ public StatefulRedisMultiDbPubSubConnection connectPubSub(RedisCode
databases.put(uri, database);
}
- StatusTracker statusTracker = new StatusTracker(healthStatusManager);
+ StatusTracker statusTracker = new StatusTracker(healthStatusManager, getResources());
// Wait for health checks to complete if configured
waitForInitialHealthyDatabase(statusTracker, databases);
diff --git a/src/main/java/io/lettuce/core/failover/MultiDbConnectionFuture.java b/src/main/java/io/lettuce/core/failover/MultiDbConnectionFuture.java
new file mode 100644
index 000000000..dc4788d4a
--- /dev/null
+++ b/src/main/java/io/lettuce/core/failover/MultiDbConnectionFuture.java
@@ -0,0 +1,85 @@
+package io.lettuce.core.failover;
+
+import java.util.concurrent.*;
+
+import io.lettuce.core.BaseConnectionFuture;
+import io.lettuce.core.failover.api.StatefulRedisMultiDbConnection;
+
+/**
+ * A {@code MultiDbConnectionFuture} represents the result of an asynchronous multi-database connection initialization.
+ *
+ * This future wrapper ensures that all callbacks (thenApply, thenAccept, etc.) execute on a separate thread pool rather than on
+ * Netty event loop threads. This prevents deadlocks when users call blocking sync operations inside callbacks.
+ *
+ * Example of the problem this solves:
+ *
+ *
+ * {@code
+ * // DANGEROUS with plain CompletableFuture - can deadlock!
+ * future.thenApply(conn -> conn.sync().ping());
+ *
+ * // SAFE with MultiDbConnectionFuture - always runs on separate thread
+ * future.thenApply(conn -> conn.sync().ping());
+ * }
+ *
+ *
+ * @param Key type
+ * @param Value type
+ * @author Ali Takavci
+ * @since 7.4
+ */
+public class MultiDbConnectionFuture extends BaseConnectionFuture> {
+
+ /**
+ * Create a new {@link MultiDbConnectionFuture} wrapping the given delegate future.
+ *
+ * @param delegate the underlying CompletableFuture
+ */
+ public MultiDbConnectionFuture(CompletableFuture> delegate) {
+ super(delegate);
+ }
+
+ /**
+ * Create a new {@link MultiDbConnectionFuture} wrapping the given delegate future with a custom executor.
+ *
+ * @param delegate the underlying CompletableFuture
+ * @param defaultExecutor the executor to use for async callbacks
+ */
+ public MultiDbConnectionFuture(CompletableFuture> delegate, Executor defaultExecutor) {
+ super(delegate, defaultExecutor);
+ }
+
+ /**
+ * Create a {@link MultiDbConnectionFuture} from a {@link CompletableFuture}.
+ *
+ * @param future the CompletableFuture to wrap
+ * @param Key type
+ * @param Value type
+ * @return the wrapped future
+ */
+ public static MultiDbConnectionFuture from(CompletableFuture> future) {
+ return new MultiDbConnectionFuture<>(future);
+ }
+
+ /**
+ * Create a {@link MultiDbConnectionFuture} from a {@link CompletableFuture} with a custom executor.
+ *
+ * @param future the CompletableFuture to wrap
+ * @param executor the executor to use for async callbacks
+ * @param Key type
+ * @param Value type
+ * @return the wrapped future
+ */
+ public static MultiDbConnectionFuture from(CompletableFuture> future,
+ Executor executor) {
+ return new MultiDbConnectionFuture<>(future, executor);
+ }
+
+ @Override
+ protected CompletionStage wrap(CompletableFuture future) {
+ // We can't preserve K,V type parameters when wrapping arbitrary U types,
+ // so we just return the CompletableFuture directly
+ return future;
+ }
+
+}
diff --git a/src/main/java/io/lettuce/core/failover/RedisDatabaseImpl.java b/src/main/java/io/lettuce/core/failover/RedisDatabaseImpl.java
index e24827b77..64cdc565f 100644
--- a/src/main/java/io/lettuce/core/failover/RedisDatabaseImpl.java
+++ b/src/main/java/io/lettuce/core/failover/RedisDatabaseImpl.java
@@ -61,7 +61,7 @@ public float getWeight() {
return weight;
}
- public C getConnection() {
+ C getConnection() {
return connection;
}
diff --git a/src/main/java/io/lettuce/core/failover/StatusTracker.java b/src/main/java/io/lettuce/core/failover/StatusTracker.java
index 933932723..bd09e0000 100644
--- a/src/main/java/io/lettuce/core/failover/StatusTracker.java
+++ b/src/main/java/io/lettuce/core/failover/StatusTracker.java
@@ -6,9 +6,13 @@
import io.lettuce.core.failover.health.HealthStatusChangeEvent;
import io.lettuce.core.failover.health.HealthStatusListener;
import io.lettuce.core.failover.health.HealthStatusManager;
+import io.lettuce.core.resource.ClientResources;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
@@ -21,14 +25,17 @@ class StatusTracker {
private final HealthStatusManager healthStatusManager;
- public StatusTracker(HealthStatusManager healthStatusManager) {
+ private final ScheduledExecutorService scheduler;
+
+ public StatusTracker(HealthStatusManager healthStatusManager, ClientResources clientResources) {
this.healthStatusManager = healthStatusManager;
+ this.scheduler = clientResources.eventExecutorGroup();
}
/**
* Waits for a specific endpoint's health status to be determined (not UNKNOWN). Uses event-driven approach with
* CountDownLatch to avoid polling.
- *
+ *
* @param endpoint the endpoint to wait for
* @return the determined health status (HEALTHY or UNHEALTHY)
* @throws RedisConnectionException if interrupted while waiting or if a timeout occurs
@@ -84,4 +91,80 @@ public void onStatusChange(HealthStatusChangeEvent event) {
}
}
+ /**
+ * Asynchronously waits for a specific endpoint's health status to be determined (not UNKNOWN). Uses event-driven approach
+ * with CompletableFuture to avoid blocking.
+ *
+ * @param endpoint the endpoint to wait for
+ * @return CompletableFuture that completes with the determined health status (HEALTHY or UNHEALTHY)
+ */
+ public CompletableFuture waitForHealthStatusAsync(RedisURI endpoint) {
+ // First check if status is already determined
+ HealthStatus currentStatus = healthStatusManager.getHealthStatus(endpoint);
+ if (currentStatus != HealthStatus.UNKNOWN) {
+ return CompletableFuture.completedFuture(currentStatus);
+ }
+
+ // Create a CompletableFuture to return
+ CompletableFuture future = new CompletableFuture<>();
+ AtomicBoolean listenerRemoved = new AtomicBoolean(false);
+
+ // Create a temporary listener for this specific endpoint
+ HealthStatusListener tempListener = new HealthStatusListener() {
+
+ @Override
+ public void onStatusChange(HealthStatusChangeEvent event) {
+ if (event.getEndpoint().equals(endpoint) && event.getNewStatus() != HealthStatus.UNKNOWN) {
+ // Complete the future with the new status
+ if (future.complete(event.getNewStatus())) {
+ // Successfully completed, clean up listener
+ if (listenerRemoved.compareAndSet(false, true)) {
+ healthStatusManager.unregisterListener(endpoint, this);
+ }
+ }
+ }
+ }
+
+ };
+
+ // Register the temporary listener
+ healthStatusManager.registerListener(endpoint, tempListener);
+
+ // Double-check status after registering listener (race condition protection)
+ currentStatus = healthStatusManager.getHealthStatus(endpoint);
+ if (currentStatus != HealthStatus.UNKNOWN) {
+ // Status already determined, complete immediately
+ if (listenerRemoved.compareAndSet(false, true)) {
+ healthStatusManager.unregisterListener(endpoint, tempListener);
+ }
+ future.complete(currentStatus);
+ return future;
+ }
+
+ // Set up timeout manually
+ long timeoutMs = healthStatusManager.getMaxWaitFor(endpoint);
+
+ scheduler.schedule(() -> {
+ // Try to complete exceptionally with timeout
+ if (future.completeExceptionally(
+ new RedisConnectionException("Timeout while waiting for health check result for " + endpoint))) {
+ // Successfully completed with timeout, clean up listener
+ if (listenerRemoved.compareAndSet(false, true)) {
+ healthStatusManager.unregisterListener(endpoint, tempListener);
+ }
+ }
+ scheduler.shutdown();
+ }, timeoutMs, TimeUnit.MILLISECONDS);
+
+ // Clean up scheduler when future completes (either successfully or exceptionally)
+ future.whenComplete((status, throwable) -> {
+ // Ensure listener is removed
+ if (listenerRemoved.compareAndSet(false, true)) {
+ healthStatusManager.unregisterListener(endpoint, tempListener);
+ }
+ });
+
+ return future;
+ }
+
}
diff --git a/src/test/java/io/lettuce/core/failover/CircuitBreakerMetricsIntegrationTests.java b/src/test/java/io/lettuce/core/failover/CircuitBreakerMetricsIntegrationTests.java
index 3c5416c47..1ae044c1b 100644
--- a/src/test/java/io/lettuce/core/failover/CircuitBreakerMetricsIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/failover/CircuitBreakerMetricsIntegrationTests.java
@@ -1,9 +1,9 @@
package io.lettuce.core.failover;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static io.lettuce.TestTags.INTEGRATION_TEST;
import java.util.List;
import java.util.stream.Collectors;
@@ -19,8 +19,6 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import com.google.gson.internal.reflect.ReflectionHelper;
-
import io.lettuce.core.RedisURI;
import io.lettuce.core.failover.api.StatefulRedisMultiDbConnection;
import io.lettuce.test.LettuceExtension;
@@ -33,7 +31,7 @@
* @since 7.1
*/
@ExtendWith(LettuceExtension.class)
-@Tag("integration")
+@Tag(INTEGRATION_TEST)
class CircuitBreakerMetricsIntegrationTests extends MultiDbTestSupport {
@Inject
diff --git a/src/test/java/io/lettuce/core/failover/DatabaseCommandTrackerUnitTests.java b/src/test/java/io/lettuce/core/failover/DatabaseCommandTrackerUnitTests.java
index 28118bdc7..5e1279063 100644
--- a/src/test/java/io/lettuce/core/failover/DatabaseCommandTrackerUnitTests.java
+++ b/src/test/java/io/lettuce/core/failover/DatabaseCommandTrackerUnitTests.java
@@ -24,6 +24,7 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
+import static io.lettuce.TestTags.UNIT_TEST;
import java.util.ArrayList;
import java.util.Collection;
@@ -55,7 +56,7 @@
* @author Ali Takavci
* @since 7.4
*/
-@Tag("unit")
+@Tag(UNIT_TEST)
class DatabaseCommandTrackerUnitTests {
private DatabaseCommandTracker.CommandWriter mockWriter;
@@ -84,7 +85,7 @@ private CircuitBreakerConfig getCBConfig(float failureRateThreshold, int minimum
@Nested
@DisplayName("Write Delegation Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class WriteDelegationTests {
@Test
@@ -161,7 +162,7 @@ void shouldDelegateBatchCommandWriteWhenCircuitBreakerIsClosed() {
@Nested
@DisplayName("Circuit Breaker Open State Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class CircuitBreakerOpenStateTests {
@Test
@@ -229,7 +230,7 @@ void shouldCompleteAllCommandsExceptionallyWhenCircuitBreakerIsOpenForBatchWrite
@Nested
@DisplayName("Timeout Exception Tracking Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class TimeoutExceptionTrackingTests {
@Test
@@ -304,7 +305,7 @@ void shouldNotRecordNonTimeoutExceptionsViaOnCompleteCallback() {
@Nested
@DisplayName("Channel Registration Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class ChannelRegistrationTests {
@Test
@@ -359,7 +360,7 @@ void shouldRemoveHandlerFromPipelineWhenChannelIsReset() {
@Nested
@DisplayName("Exception Handling Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class ExceptionHandlingTests {
@Test
@@ -409,7 +410,7 @@ void shouldRecordExceptionThrownDuringBatchWrite() {
@Nested
@DisplayName("Success Tracking Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class SuccessTrackingTests {
@Test
@@ -440,7 +441,7 @@ void shouldNotRecordSuccessViaOnCompleteCallback() {
@Nested
@DisplayName("Callback Attachment Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class CallbackAttachmentTests {
@Test
diff --git a/src/test/java/io/lettuce/core/failover/DatabaseEndpointCallbackTests.java b/src/test/java/io/lettuce/core/failover/DatabaseEndpointCallbackTests.java
index f4d858579..b0914647e 100644
--- a/src/test/java/io/lettuce/core/failover/DatabaseEndpointCallbackTests.java
+++ b/src/test/java/io/lettuce/core/failover/DatabaseEndpointCallbackTests.java
@@ -3,6 +3,8 @@
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.awaitility.Awaitility.await;
+import static io.lettuce.TestTags.UNIT_TEST;
+
import java.io.IOException;
import java.nio.channels.ClosedChannelException;
import java.time.Duration;
@@ -39,7 +41,7 @@
*
* @author Ali Takavci
*/
-@Tag("unit")
+@Tag(UNIT_TEST)
class DatabaseEndpointCallbackTests {
private ClientResources clientResources;
@@ -73,7 +75,7 @@ private CircuitBreakerConfig getCBConfig(float failureRateThreshold, int minimum
@Nested
@DisplayName("Timeout Exception Tracking Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class TimeoutExceptionTrackingTests {
@Test
@@ -160,7 +162,7 @@ void shouldNotTrackSuccessViaCallback() {
@Nested
@DisplayName("Failover Behavior and Generation Tracking Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class FailoverBehaviorTests {
@Test
@@ -251,7 +253,7 @@ void shouldHandleMultipleTimeoutExceptionsCompletingAtDifferentTimes() {
@Nested
@DisplayName("Failover Behavior and Generation Tracking Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class FailoverBehaviorAndGenerationTrackingTests {
@Test
@@ -427,7 +429,7 @@ void shouldHandleConcurrentTimeoutExceptionsDuringFailover() throws Exception {
@Nested
@DisplayName("Concurrent Timeout Exception Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class ConcurrentTimeoutExceptionTests {
@Test
@@ -522,7 +524,7 @@ void shouldHandleRaceBetweenCompletionAndStateChange() throws Exception {
@Nested
@DisplayName("Edge Cases and Special Scenarios")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class EdgeCaseTests {
@Test
diff --git a/src/test/java/io/lettuce/core/failover/DatabasePubSubEndpointTrackerTests.java b/src/test/java/io/lettuce/core/failover/DatabasePubSubEndpointTrackerTests.java
index 079fc6c95..aeb365c97 100644
--- a/src/test/java/io/lettuce/core/failover/DatabasePubSubEndpointTrackerTests.java
+++ b/src/test/java/io/lettuce/core/failover/DatabasePubSubEndpointTrackerTests.java
@@ -23,6 +23,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
+import static io.lettuce.TestTags.UNIT_TEST;
import java.util.ArrayList;
import java.util.Collection;
@@ -56,7 +57,7 @@
* @author Ali Takavci
* @since 7.4
*/
-@Tag("unit")
+@Tag(UNIT_TEST)
class DatabasePubSubEndpointTrackerTests {
private ClientResources clientResources;
@@ -90,7 +91,7 @@ private CircuitBreakerConfig getCBConfig(float failureRateThreshold, int minimum
@Nested
@DisplayName("DatabaseCommandTracker Integration Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class DatabaseCommandTrackerIntegrationTests {
@Test
@@ -159,7 +160,7 @@ public void onComplete(java.util.function.BiConsumer super String, Throwable>
@Nested
@DisplayName("Circuit Breaker Integration Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class CircuitBreakerIntegrationTests {
@Test
@@ -257,7 +258,7 @@ void shouldCompleteAllPubSubCommandsInBatchExceptionallyWhenCircuitBreakerIsOpen
@Nested
@DisplayName("Channel Lifecycle Tests")
- @Tag("unit")
+ @Tag(UNIT_TEST)
class ChannelLifecycleTests {
@Test
diff --git a/src/test/java/io/lettuce/core/failover/HealthCheckIntegrationTests.java b/src/test/java/io/lettuce/core/failover/HealthCheckIntegrationTests.java
index 839116e46..43ca78d95 100644
--- a/src/test/java/io/lettuce/core/failover/HealthCheckIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/failover/HealthCheckIntegrationTests.java
@@ -33,6 +33,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.awaitility.Awaitility.with;
+import static io.lettuce.TestTags.INTEGRATION_TEST;;
/**
* Integration tests for health check functionality in MultiDbClient.
@@ -41,7 +42,7 @@
* @since 7.1
*/
@ExtendWith(LettuceExtension.class)
-@Tag("integration")
+@Tag(INTEGRATION_TEST)
@DisplayName("HealthCheck Integration Tests")
public class HealthCheckIntegrationTests extends MultiDbTestSupport {
@@ -85,7 +86,7 @@ void extractRunIds() {
@Nested
@DisplayName("Health Check Configuration")
- @Tag("integration")
+ @Tag(INTEGRATION_TEST)
class HealthCheckConfigurationIntegrationTests {
@Test
@@ -305,7 +306,7 @@ void shouldConfigureHealthCheckIntervalAndTimeout() {
@Nested
@DisplayName("Health Check Lifecycle")
- @Tag("integration")
+ @Tag(INTEGRATION_TEST)
class HealthCheckLifecycleIntegrationTests {
@Test
@@ -450,7 +451,7 @@ void shouldTransitionFromUnknownToHealthy() {
@Nested
@DisplayName("Failover Integration")
- @Tag("integration")
+ @Tag(INTEGRATION_TEST)
class FailoverIntegrationTests {
@Test
diff --git a/src/test/java/io/lettuce/core/failover/MultiDbAsyncConnectionBuilderUnitTests.java b/src/test/java/io/lettuce/core/failover/MultiDbAsyncConnectionBuilderUnitTests.java
new file mode 100644
index 000000000..3921962ef
--- /dev/null
+++ b/src/test/java/io/lettuce/core/failover/MultiDbAsyncConnectionBuilderUnitTests.java
@@ -0,0 +1,284 @@
+package io.lettuce.core.failover;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static io.lettuce.TestTags.UNIT_TEST;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import io.lettuce.core.ClientOptions;
+import io.lettuce.core.RedisConnectionException;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.failover.health.HealthCheck;
+import io.lettuce.core.failover.health.HealthStatus;
+import io.lettuce.core.failover.health.HealthStatusManager;
+import io.lettuce.core.resource.ClientResources;
+import io.lettuce.core.resource.DefaultClientResources;
+
+/**
+ * Unit tests for {@link MultiDbAsyncConnectionBuilder}.
+ *
+ * @author Ali Takavci
+ * @since 7.4
+ */
+@Tag(UNIT_TEST)
+class MultiDbAsyncConnectionBuilderUnitTests {
+
+ @Mock
+ private HealthStatusManager healthStatusManager;
+
+ @Mock
+ private MultiDbClientImpl client;
+
+ @Mock
+ private StatefulRedisConnection mockConnection1;
+
+ @Mock
+ private StatefulRedisConnection mockConnection2;
+
+ @Mock
+ private HealthCheck healthCheck;
+
+ private ClientResources resources;
+
+ private MultiDbAsyncConnectionBuilder builder;
+
+ private RedisURI uri1;
+
+ private RedisURI uri2;
+
+ private DatabaseConfig config1;
+
+ private DatabaseConfig config2;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ resources = DefaultClientResources.create();
+
+ uri1 = RedisURI.create("redis://localhost:6379");
+ uri2 = RedisURI.create("redis://localhost:6380");
+
+ config1 = DatabaseConfig.builder(uri1).weight(1.0f).clientOptions(ClientOptions.create()).build();
+
+ config2 = DatabaseConfig.builder(uri2).weight(0.5f).clientOptions(ClientOptions.create()).build();
+
+ builder = new MultiDbAsyncConnectionBuilder<>(healthStatusManager, resources, client);
+ }
+
+ @Test
+ void constructorShouldInitializeFields() {
+ assertThat(builder).isNotNull();
+ }
+
+ @Test
+ void collectDatabasesWithEstablishedConnectionsShouldReturnSuccessfulConnections() {
+ // Given
+ Map>>> databaseFutures = new ConcurrentHashMap<>();
+
+ RedisDatabaseImpl> db1 = createMockDatabase(config1, mockConnection1);
+ RedisDatabaseImpl> db2 = createMockDatabase(config2, mockConnection2);
+
+ databaseFutures.put(uri1, CompletableFuture.completedFuture(db1));
+ databaseFutures.put(uri2, CompletableFuture.completedFuture(db2));
+
+ // When
+ Map>> result = builder
+ .collectDatabasesWithEstablishedConnections(databaseFutures);
+
+ // Then
+ assertThat(result).hasSize(2);
+ assertThat(result).containsKeys(uri1, uri2);
+ assertThat(result.get(uri1)).isEqualTo(db1);
+ assertThat(result.get(uri2)).isEqualTo(db2);
+ }
+
+ @Test
+ void collectDatabasesWithEstablishedConnectionsShouldHandlePartialFailure() {
+ // Given
+ Map>>> databaseFutures = new ConcurrentHashMap<>();
+
+ RedisDatabaseImpl> db1 = createMockDatabase(config1, mockConnection1);
+
+ CompletableFuture>> failedFuture = new CompletableFuture<>();
+ failedFuture.completeExceptionally(new RedisConnectionException("Connection failed"));
+
+ databaseFutures.put(uri1, CompletableFuture.completedFuture(db1));
+ databaseFutures.put(uri2, failedFuture);
+
+ // When
+ Map>> result = builder
+ .collectDatabasesWithEstablishedConnections(databaseFutures);
+
+ // Then
+ assertThat(result).hasSize(1);
+ assertThat(result).containsKey(uri1);
+ assertThat(result.get(uri1)).isEqualTo(db1);
+ }
+
+ @Test
+ void collectDatabasesWithEstablishedConnectionsShouldThrowWhenAllFail() {
+ // Given
+ Map>>> databaseFutures = new ConcurrentHashMap<>();
+
+ CompletableFuture>> failedFuture1 = new CompletableFuture<>();
+ failedFuture1.completeExceptionally(new RedisConnectionException("Connection 1 failed"));
+
+ CompletableFuture>> failedFuture2 = new CompletableFuture<>();
+ failedFuture2.completeExceptionally(new RedisConnectionException("Connection 2 failed"));
+
+ databaseFutures.put(uri1, failedFuture1);
+ databaseFutures.put(uri2, failedFuture2);
+
+ // When/Then
+ assertThatThrownBy(() -> builder.collectDatabasesWithEstablishedConnections(databaseFutures))
+ .isInstanceOf(RedisConnectionException.class).hasMessageContaining("Failed to connect to any database")
+ .hasMessageContaining("2 connection(s) failed");
+ }
+
+ @Test
+ void collectHealthStatusesShouldWaitForAllHealthChecks() throws Exception {
+ // Given
+ RedisDatabaseImpl> db1 = createMockDatabaseWithHealthCheck(config1,
+ mockConnection1);
+ RedisDatabaseImpl> db2 = createMockDatabaseWithHealthCheck(config2,
+ mockConnection2);
+
+ Map>> databases = new HashMap<>();
+ databases.put(uri1, db1);
+ databases.put(uri2, db2);
+
+ // Mock health status manager to return health statuses
+ when(healthStatusManager.getHealthStatus(uri1)).thenReturn(HealthStatus.HEALTHY);
+ when(healthStatusManager.getHealthStatus(uri2)).thenReturn(HealthStatus.HEALTHY);
+
+ // When
+ CompletableFuture