Repository: redis/lettuce (java)

Issue:
Json commands not exposed in AsyncCluster 
## Bug Report
 I am using the latest release **6.5.0** which has the recently added redisJson commands (Thank you and Great Job!).

 Using the ```StatefulRedisClusterConnection``` , I can access either ```RedisAdvancedClusterCommands``` which gives me access to ```RedisJsonCommands``` through the simple inheritance model.
However when using the async member ```RedisAdvancedClusterAsyncCommands``` , it would appear that the ```RedisJsonAsyncCommands``` was left out of ```RedisClusterAsyncCommands``` [here](https://github.com/redis/lettuce/blob/39682dc931ec5de954e442f44f2bb162efdbcbf7/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java#L40) 

If this is intentional , what would the proper path forward be to access redisJson async commands for a cluster?



Pull request:
Json commands not exposed in AsyncCluster #3048
Closes #3048

Added missing ASYNC and REACTIVE interfaces for the cluster commands
Added tests to verify the change works
Changed the way the testcontainers are being started

<!--
Thank you for proposing a pull request. This template will guide you through the essential steps necessary for a pull request.
-->
Make sure that:

- [x] You have read the [contribution guidelines](https://github.com/lettuce-io/lettuce-core/blob/main/.github/CONTRIBUTING.md).
- [x] You have created a feature request first to discuss your contribution intent. Please reference the feature request ticket number in the pull request.
- [x] You applied code formatting rules using the `mvn formatter:format` target. Don’t submit any formatting related changes.
- [x] You submit test cases (unit or integration tests) that back your changes.

 <!--
Great! Live long and prosper.
-->


Patch:
diff --git a/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java
index 9fb75c8b4..0d05c83da 100644
--- a/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java
@@ -37,11 +37,12 @@ import io.lettuce.core.json.JsonParser;
  * @author dengliming
  * @since 4.0
  */
-public interface RedisClusterAsyncCommands<K, V> extends BaseRedisAsyncCommands<K, V>, RedisAclAsyncCommands<K, V>,
-        RedisFunctionAsyncCommands<K, V>, RedisGeoAsyncCommands<K, V>, RedisHashAsyncCommands<K, V>,
-        RedisHLLAsyncCommands<K, V>, RedisKeyAsyncCommands<K, V>, RedisListAsyncCommands<K, V>,
-        RedisScriptingAsyncCommands<K, V>, RedisServerAsyncCommands<K, V>, RedisSetAsyncCommands<K, V>,
-        RedisSortedSetAsyncCommands<K, V>, RedisStreamAsyncCommands<K, V>, RedisStringAsyncCommands<K, V> {
+public interface RedisClusterAsyncCommands<K, V>
+        extends BaseRedisAsyncCommands<K, V>, RedisAclAsyncCommands<K, V>, RedisFunctionAsyncCommands<K, V>,
+        RedisGeoAsyncCommands<K, V>, RedisHashAsyncCommands<K, V>, RedisHLLAsyncCommands<K, V>, RedisKeyAsyncCommands<K, V>,
+        RedisListAsyncCommands<K, V>, RedisScriptingAsyncCommands<K, V>, RedisServerAsyncCommands<K, V>,
+        RedisSetAsyncCommands<K, V>, RedisSortedSetAsyncCommands<K, V>, RedisStreamAsyncCommands<K, V>,
+        RedisStringAsyncCommands<K, V>, RedisJsonAsyncCommands<K, V> {
 
     /**
      * Set the default timeout for operations. A zero timeout value indicates to not time out.
diff --git a/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java b/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java
index 2416b380e..fa34e9a27 100644
--- a/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java
@@ -37,11 +37,12 @@ import reactor.core.publisher.Mono;
  * @author dengliming
  * @since 5.0
  */
-public interface RedisClusterReactiveCommands<K, V> extends BaseRedisReactiveCommands<K, V>, RedisAclReactiveCommands<K, V>,
-        RedisFunctionReactiveCommands<K, V>, RedisGeoReactiveCommands<K, V>, RedisHashReactiveCommands<K, V>,
-        RedisHLLReactiveCommands<K, V>, RedisKeyReactiveCommands<K, V>, RedisListReactiveCommands<K, V>,
-        RedisScriptingReactiveCommands<K, V>, RedisServerReactiveCommands<K, V>, RedisSetReactiveCommands<K, V>,
-        RedisSortedSetReactiveCommands<K, V>, RedisStreamReactiveCommands<K, V>, RedisStringReactiveCommands<K, V> {
+public interface RedisClusterReactiveCommands<K, V>
+        extends BaseRedisReactiveCommands<K, V>, RedisAclReactiveCommands<K, V>, RedisFunctionReactiveCommands<K, V>,
+        RedisGeoReactiveCommands<K, V>, RedisHashReactiveCommands<K, V>, RedisHLLReactiveCommands<K, V>,
+        RedisKeyReactiveCommands<K, V>, RedisListReactiveCommands<K, V>, RedisScriptingReactiveCommands<K, V>,
+        RedisServerReactiveCommands<K, V>, RedisSetReactiveCommands<K, V>, RedisSortedSetReactiveCommands<K, V>,
+        RedisStreamReactiveCommands<K, V>, RedisStringReactiveCommands<K, V>, RedisJsonReactiveCommands<K, V> {
 
     /**
      * Set the default timeout for operations. A zero timeout value indicates to not time out.


Test patch:
diff --git a/src/test/java/io/lettuce/core/RedisContainerIntegrationTests.java b/src/test/java/io/lettuce/core/RedisContainerIntegrationTests.java
index d50988e81..c42a90dc3 100644
--- a/src/test/java/io/lettuce/core/RedisContainerIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/RedisContainerIntegrationTests.java
@@ -16,7 +16,6 @@ import org.testcontainers.containers.wait.strategy.Wait;
 import org.testcontainers.junit.jupiter.Testcontainers;
 
 import java.io.File;
-import java.io.IOException;
 
 @Testcontainers
 public class RedisContainerIntegrationTests {
@@ -27,6 +26,8 @@ public class RedisContainerIntegrationTests {
 
     private static final String REDIS_STACK_CLUSTER = "clustered-stack";
 
+    private static Exception initializationException;
+
     public static ComposeContainer CLUSTERED_STACK = new ComposeContainer(
             new File("src/test/resources/docker/docker-compose.yml")).withExposedService(REDIS_STACK_CLUSTER, 36379)
                     .withExposedService(REDIS_STACK_CLUSTER, 36380).withExposedService(REDIS_STACK_CLUSTER, 36381)
@@ -34,15 +35,32 @@ public class RedisContainerIntegrationTests {
                     .withExposedService(REDIS_STACK_CLUSTER, 36384).withExposedService(REDIS_STACK_STANDALONE, 6379)
                     .withLocalCompose(true);
 
-    @BeforeAll
-    public static void setup() throws IOException, InterruptedException {
+    // Singleton container pattern - start the containers only once
+    // See https://java.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers
+    static {
+        int attempts = 0;
+
         // In case you need to debug the container uncomment these lines to redirect the output
         CLUSTERED_STACK.withLogConsumer(REDIS_STACK_CLUSTER, (OutputFrame frame) -> LOGGER.debug(frame.getUtf8String()));
         CLUSTERED_STACK.withLogConsumer(REDIS_STACK_STANDALONE, (OutputFrame frame) -> LOGGER.debug(frame.getUtf8String()));
 
         CLUSTERED_STACK.waitingFor(REDIS_STACK_CLUSTER,
                 Wait.forLogMessage(".*Background RDB transfer terminated with success.*", 1));
-        CLUSTERED_STACK.start();
+        do {
+            try {
+                CLUSTERED_STACK.start();
+            } catch (Exception e) {
+                initializationException = e;
+            }
+            // Attempt to stabilize the pipeline - sometime the `docker compose up` fails randomly
+        } while (initializationException != null && attempts++ < 3);
+    }
+
+    @BeforeAll
+    public static void checkContainerInitialization() {
+        if (initializationException != null) {
+            throw new IllegalStateException("Failed to initialize containers", initializationException);
+        }
     }
 
 }
diff --git a/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java b/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java
index 5bcbfd537..5a3eefeeb 100644
--- a/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java
@@ -10,6 +10,8 @@ package io.lettuce.core.json;
 import io.lettuce.core.RedisContainerIntegrationTests;
 import io.lettuce.core.RedisURI;
 import io.lettuce.core.cluster.RedisClusterClient;
+import io.lettuce.core.cluster.api.async.RedisClusterAsyncCommands;
+import io.lettuce.core.cluster.api.reactive.RedisClusterReactiveCommands;
 import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
 import io.lettuce.core.json.arguments.JsonGetArgs;
 import io.lettuce.core.json.arguments.JsonMsetArgs;
@@ -21,6 +23,7 @@ import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import reactor.test.StepVerifier;
 
 import java.io.IOException;
 import java.nio.file.Files;
@@ -30,6 +33,7 @@ import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.ExecutionException;
 
 import static io.lettuce.TestTags.INTEGRATION_TEST;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -114,6 +118,22 @@ public class RedisJsonClusterIntegrationTests extends RedisContainerIntegrationT
         assertThat(arrayIndex.get(0).longValue()).isEqualTo(3L);
     }
 
+    @Test
+    void jsonArrLenAsyncAndReactive() throws ExecutionException, InterruptedException {
+        RedisClusterAsyncCommands<String, String> asyncCommands = client.connect().async();
+        RedisClusterReactiveCommands<String, String> reactiveCommands = client.connect().reactive();
+
+        JsonPath myPath = JsonPath.of(MOUNTAIN_BIKES_V1);
+
+        List<Long> poppedJson = asyncCommands.jsonArrlen(BIKES_INVENTORY, myPath).get();
+        assertThat(poppedJson).hasSize(1);
+        assertThat(poppedJson.get(0).longValue()).isEqualTo(3);
+
+        StepVerifier.create(reactiveCommands.jsonArrlen(BIKES_INVENTORY, myPath)).consumeNextWith(actual -> {
+            assertThat(actual).isEqualTo(3);
+        }).verifyComplete();
+    }
+
     @ParameterizedTest(name = "With {0} as path")
     @ValueSource(strings = { MOUNTAIN_BIKES_V1, MOUNTAIN_BIKES_V2 })
     void jsonArrlen(String path) {
diff --git a/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java b/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java
index d8473ee96..76fdd0661 100644
--- a/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java
@@ -14,6 +14,7 @@ import io.lettuce.core.RedisFuture;
 import io.lettuce.core.RedisURI;
 import io.lettuce.core.api.StatefulRedisConnection;
 import io.lettuce.core.api.async.RedisAsyncCommands;
+import io.lettuce.core.api.reactive.RedisReactiveCommands;
 import io.lettuce.core.api.sync.RedisCommands;
 import io.lettuce.core.codec.ByteArrayCodec;
 import io.lettuce.core.codec.StringCodec;
@@ -26,6 +27,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
 
 import java.io.IOException;
 import java.nio.ByteBuffer;
@@ -130,6 +132,22 @@ public class RedisJsonIntegrationTests extends RedisContainerIntegrationTests {
         assertThat(poppedJson.get(0).longValue()).isEqualTo(3);
     }
 
+    @Test
+    void jsonArrLenAsyncAndReactive() throws ExecutionException, InterruptedException {
+        RedisAsyncCommands<String, String> asyncCommands = client.connect().async();
+        RedisReactiveCommands<String, String> reactiveCommands = client.connect().reactive();
+
+        JsonPath myPath = JsonPath.of(MOUNTAIN_BIKES_V1);
+
+        List<Long> poppedJson = asyncCommands.jsonArrlen(BIKES_INVENTORY, myPath).get();
+        assertThat(poppedJson).hasSize(1);
+        assertThat(poppedJson.get(0).longValue()).isEqualTo(3);
+
+        StepVerifier.create(reactiveCommands.jsonArrlen(BIKES_INVENTORY, myPath)).consumeNextWith(actual -> {
+            assertThat(actual).isEqualTo(3);
+        }).verifyComplete();
+    }
+
     @ParameterizedTest(name = "With {0} as path")
     @ValueSource(strings = { MOUNTAIN_BIKES_V1, MOUNTAIN_BIKES_V2 })
     void jsonArrpop(String path) {