Repository: redis/lettuce
Pull request #3185 (2025-02-18 16:16:15)

DOC-4757 list examples using join()
<!--
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 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.

[DOC-4757](https://redislabs.atlassian.net/browse/DOC-4757)

Note: the example for `rule_1.1` uses `handle()` to deal with the intentional exception caused by using `lpush` on a string key. Is this the best way to do it? 


[DOC-4757]: https://redislabs.atlassian.net/browse/DOC-4757?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Patch:
diff --git a/.github/wordlist.txt b/.github/wordlist.txt
index b4dbd628a..3b2d8e7e7 100644
--- a/.github/wordlist.txt
+++ b/.github/wordlist.txt
@@ -277,4 +277,48 @@ ACR
 AMR
 Entra
 authx
-entraid
\ No newline at end of file
+entraid
+autoReconnect
+ggivo
+tishun
+Dltmd
+LCS
+STRALGO
+ArrayDeque
+HashIndexedQueue
+cxf
+okg
+CommandKeyword
+hardcoded
+minwoo
+Bitfield
+psw
+sazzad
+arturaz
+ListSubscriber
+UnsupportedOperationException
+hrandfieldWithvalues
+CommonsPool
+ConfigConverterUnitTests
+Rian
+jsonArrpop
+arrpop
+thachlp
+enableAdaptiveRefreshTrigger
+ymiliaresis
+SNI
+kiryazovi
+uglide
+andy
+danicheg
+binbin
+mget
+ori
+lilb
+libs
+dependabot
+jcl
+slf
+testcontainers
+Readme
+DefaultAzureCredential
\ No newline at end of file
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
index 19b38e891..f71826201 100644
--- a/.github/workflows/benchmarks.yml
+++ b/.github/workflows/benchmarks.yml
@@ -44,7 +44,7 @@ jobs:
       - name: Set up Docker Compose environment
         run: |
           mkdir -m 777 $REDIS_ENV_WORK_DIR
-          make docker-start
+          make start
         env:
           REDIS_ENV_WORK_DIR: ${{ github.workspace }}/work
       - name: Maven offline
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index f4ebe1dc9..53e38ee9e 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -19,7 +19,7 @@ on:
 jobs:
   build:
     name: Build and Test
-    runs-on: ubuntu-24.04
+    runs-on: ubuntu-latest
     strategy:
       fail-fast: false
       matrix:
@@ -31,20 +31,6 @@ jobs:
       REDIS_ENV_WORK_DIR: ${{ github.workspace }}/work
 
     steps:
-      - name: Test Redis Server Version
-        id: map-tags
-        run: |
-          # Map requested version to github or  tag
-          case "${{ matrix.redis_version }}" in
-            "8.0") redis_branch="8.0" stack_version="8.0-M04-pre" redis_test_version="8.0-M04-pre";;
-            "7.4") redis_branch="7.4" stack_version="rs-7.4.0-v2" redis_test_version="7.4.2";;
-            "7.2") redis_branch="7.2" stack_version="rs-7.2.0-v14" redis_test_version="7.2.7";;
-            *) echo "Unsupported version: ${{ matrix.redis_version }}" && exit 1 ;;
-          esac
-          # Save them as outputs for later use
-          echo "redis_branch=$redis_branch" >> $GITHUB_OUTPUT
-          echo "redis_stack_version=$stack_version" >> $GITHUB_OUTPUT
-          echo "redis_test_version=$redis_test_version" >> $GITHUB_OUTPUT
       - name: Checkout project
         uses: actions/checkout@v4
       - name: Set Java up in the runner
@@ -63,8 +49,7 @@ jobs:
       - name: Set up Docker Compose environment
         run: |
           mkdir -m 777 $REDIS_ENV_WORK_DIR
-          export REDIS_VERSION="${{ steps.map-tags.outputs.redis_test_version }}"
-          make docker-start version=$REDIS_VERSION
+          make start version=${{ matrix.redis_version }}
       - name: Maven offline
         run: |
           mvn -q dependency:go-offline
@@ -76,7 +61,6 @@ jobs:
           ls -la $TEST_WORK_FOLDER
           make test-coverage
         env:
-          REDIS_STACK_VERSION: ${{ steps.map-tags.outputs.redis_stack_version }}
           JVM_OPTS: -Xmx3200m
           TERM: dumb
       - name: Tear down Docker Compose environment
diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml
index de20973f4..16605d6b0 100644
--- a/.github/workflows/stale-issues.yml
+++ b/.github/workflows/stale-issues.yml
@@ -15,7 +15,7 @@ jobs:
       - uses: actions/stale@v9
         with:
           repo-token: ${{ secrets.GITHUB_TOKEN }}
-          stale-issue-message: 'If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 30 days this issue will be closed.'
+          stale-issue-message: 'If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 2 weeks this issue will be closed.'
           stale-pr-message: 'Closing due to lack of requested feedback. If you would like us to look at this issue, please provide the requested information and we will re-open the issue.'
           days-before-stale: 30
           days-before-close: 14
diff --git a/Makefile b/Makefile
index ec7f08840..6df7a01fe 100644
--- a/Makefile
+++ b/Makefile
@@ -2,11 +2,11 @@ SHELL := /bin/bash
 PATH := ./work/redis-git/src:${PATH}
 ROOT_DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
 PROFILE ?= ci
-SUPPORTED_TEST_ENV_VERSIONS := 8.0-M04-pre 7.4.2 7.2.7
-DEFAULT_TEST_ENV_VERSION := 8.0-M04-pre
+SUPPORTED_TEST_ENV_VERSIONS := 8.0 7.4 7.2
+DEFAULT_TEST_ENV_VERSION := 8.0
 REDIS_ENV_WORK_DIR := $(or ${REDIS_ENV_WORK_DIR},$(ROOT_DIR)/work)
 
-docker-start:
+start:
 	@if [ -z "$(version)" ]; then \
 		version=$(arg); \
 		if [ -z "$$version" ]; then \
@@ -27,18 +27,17 @@ docker-start:
 	echo "Environment work directory: $(REDIS_ENV_WORK_DIR)"; \
 	rm -rf "$(REDIS_ENV_WORK_DIR)"; \
 	mkdir -p "$(REDIS_ENV_WORK_DIR)"; \
-	export REDIS_VERSION=$$version && \
 	docker compose $$env_files -f src/test/resources/docker-env/docker-compose.yml up -d; \
 	echo "Started test environment with Redis version $$version."
 
 
-docker-test:
+test:
 	mvn -DskipITs=false clean compile verify -P$(PROFILE)
 
 test-coverage:
 	mvn -DskipITs=false clean compile verify jacoco:report -P$(PROFILE)
 
-docker-stop:
+stop:
 	docker compose --env-file src/test/resources/docker-env/.env -f src/test/resources/docker-env/docker-compose.yml down; \
 	rm -rf "$(REDIS_ENV_WORK_DIR)"
 
diff --git a/README.md b/README.md
index d06e87be9..374cc9893 100644
--- a/README.md
+++ b/README.md
@@ -162,12 +162,12 @@ To build:
 ```
 $ git clone https://github.com/redis/lettuce.git
 $ cd lettuce/
-$ make docker-start
+$ make start
 ```
 
-* Run the build: ```make docker-test```
-* Start Redis (manually): ```make docker-start```
-* Stop Redis (manually): ```make docker-stop```
+* Run the build: ```make test```
+* Start Redis (manually): ```make start```
+* Stop Redis (manually): ```make stop```
 * Clean up: ```make clean```
 
 Bugs and Feedback
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index b59b795e4..02f1e9dac 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -1,10 +1,7 @@
-Lettuce 6.7.0 RELEASE NOTES
+Lettuce 6.6.0 BETA4 NOTES
 ==============================
 
-The Redis team is delighted to announce the general availability of Lettuce 6.7.
-
-Great news, everyone! Lettuce 6.7.0 comes with RedisJSON support enabled.
-For more on that, please consult with the [RedisJSON documentation](https://redis.io/docs/latest/develop/data-types/json/) and the [Lettuce guide on RedisJSON](https://redis.github.io/lettuce/user-guide/redis-json/). 
+The Redis team is delighted to announce the BETA4 pre-release of Lettuce 6.6.0
 
 Lettuce 6 supports Redis 2.6+ up to Redis 7.x. In terms of Java runtime, Lettuce requires
 at least Java 8 and works with Java 21.
@@ -17,25 +14,69 @@ If you need any support, meet Lettuce at
 * Stack Overflow (Questions): https://stackoverflow.com/questions/tagged/lettuce
 * Join the chat at https://discord.gg/redis and look for the "Help:Tools Lettuce" channel 
 * GitHub Issues (Bug reports, feature requests): https://github.com/lettuce-io/lettuce-core/issues
-* Documentation: https://lettuce.io/core/6.7.0.RELEASE/reference/
-* Javadoc: https://lettuce.io/core/6.7.0.RELEASE/api/
+* Documentation: https://lettuce.io/core/6.6.0.BETA4/reference/
+* Javadoc: https://lettuce.io/core/6.6.0.BETA4/api/
 
 Commands
 --------
-* Add `CLUSTER MYSHARDID` in #2920 and `CLUSTER LINKS` in #2986
-
+* Hash Field Expiration (part II) (#3195) by @ggivo in https://github.com/redis/lettuce/pull/3204
 
 Enhancements
 ------------
-* Default ClientOptions.timeoutOptions to TimeoutOptions.enabled() (#2927)
-
+* Introduce command replay filter to avoid command replaying after reconnect #1310 by @tishun in https://github.com/redis/lettuce/pull/3118
+* Deprecate the STRALGO command and implement the LCS in its place by @Dltmd202 in https://github.com/redis/lettuce/pull/3037
+* fix: prevent blocking event loop thread by replacing ArrayDeque with HashIndexedQueue by @okg-cxf in https://github.com/redis/lettuce/pull/2953
+* Token based authentication integration with core extension by @ggivo in https://github.com/redis/lettuce/pull/3063
+* Support for DefaultAzureCredential by @ggivo in https://github.com/redis/lettuce/pull/3230
+* replace hardcoded GT and LT with CommandKeyword enum by @minwoo1999 in https://github.com/redis/lettuce/pull/3079
+* Add support up to max unsigned integer in Bitfield offset (#2964) by @psw0946 in https://github.com/redis/lettuce/pull/3099
+* Improve code by adding some null checks by @tishun in https://github.com/redis/lettuce/pull/3115
+* Introduce test matrix based on Redis server versions by @ggivo in https://github.com/redis/lettuce/pull/3145
+* Add modules ACL support by @sazzad16 in https://github.com/redis/lettuce/pull/3102
+* Test modules CONFIG support by @sazzad16 in https://github.com/redis/lettuce/pull/3103
+* report block error when use with reactor mode #3168 by @tishun in https://github.com/redis/lettuce/pull/3169
+* Include command type in the timeout message by @arturaz in https://github.com/redis/lettuce/pull/3167
 
 Fixes
 -----
-* fix(2971): spublish typo fix (#2972)
-
+* WATCH during MULTI shouldn't fail transaction #3009 by @tishun in https://github.com/redis/lettuce/pull/3027
+* Fix: make sure FIFO order between write and notify channel active by @okg-cxf in https://github.com/redis/lettuce/pull/2597
+* UnsupportedOperationException from ListSubscriber during hrandfieldWithvalues #3122 by @tishun in https://github.com/redis/lettuce/pull/3123
+* Update CommonsPool2ConfigConverterUnitTests.java by @Rian-Ismael in https://github.com/redis/lettuce/pull/3147
+* Fix typo & add withSsl() in  connecting to Entra ID enabled Redis doc by @ggivo in https://github.com/redis/lettuce/pull/3191
+* Fix SimpleBatcher apparent deadlock #2196 by @ggivo in https://github.com/redis/lettuce/pull/3148
+* jsonArrpop fails with null return value (#3196) by @tishun in https://github.com/redis/lettuce/pull/3206
+* json.arrpop forces index=-1 with root path (#3214) by @thachlp in https://github.com/redis/lettuce/pull/3217
+* Updates enableAdaptiveRefreshTrigger trigger assertion message by @ymiliaresis in https://github.com/redis/lettuce/pull/3216
 
 Other
 -----
-* Add badges to the README.md file (#2939)
-
+* Add example configuration using SNI enabled TLS connection by @ggivo in https://github.com/redis/lettuce/pull/3045
+* Disable docker image being used to call compose when running tests by @tishun in https://github.com/redis/lettuce/pull/3046
+* Workflow for running benchmarks weekly by @tishun in https://github.com/redis/lettuce/pull/3052
+* Fixing benchmark flow by @tishun in https://github.com/redis/lettuce/pull/3056
+* Test failures not reported because step is skipped by @tishun in https://github.com/redis/lettuce/pull/3067
+* Stale issues action bump by @tishun in https://github.com/redis/lettuce/pull/3182
+* Migrate Lettuce test setup to use client-lib-test by @kiryazovi-redis in https://github.com/redis/lettuce/pull/3158
+* JSON integration tests now do not use the test-containers framework by @tishun in https://github.com/redis/lettuce/pull/3203
+* Test with 8.0-M05-pre by @ggivo in https://github.com/redis/lettuce/pull/3219
+* Add sample examples to test redis.io build by @uglide in https://github.com/redis/lettuce/pull/3051
+* DOC-4528 async hash examples by @andy-stark-redis in https://github.com/redis/lettuce/pull/3069
+* DOC-4531 set data type examples by @andy-stark-redis in https://github.com/redis/lettuce/pull/3076
+* DOC-4802 fix string example concurrency by @andy-stark-redis in https://github.com/redis/lettuce/pull/3156
+* Fix several typos on the advanced-usage page by @danicheg in https://github.com/redis/lettuce/pull/3174
+* docs: update Limitations section to reflect shaded JAR deprecation by @minwoo1999 in https://github.com/redis/lettuce/pull/3095
+* Remove extra spaces in words in docs by @enjoy-binbin in https://github.com/redis/lettuce/pull/3120
+* Bump to v4 of checkout by @tishun in https://github.com/redis/lettuce/pull/3152
+* Fix 'make test' test failures by @ggivo in https://github.com/redis/lettuce/pull/3157
+* Readme doc on how to connect to Azure Managed Redis with Entra ID authentication by @ggivo in https://github.com/redis/lettuce/pull/3166
+* refactor mget method improved readability and efficiency by @ori0o0p in https://github.com/redis/lettuce/pull/3061
+* Migrate JSON tests infra to use client-lilb-test  by @ggivo in https://github.com/redis/lettuce/pull/3128
+* Update the base project URLs in pom.xml by @danicheg in https://github.com/redis/lettuce/pull/3151
+* Update publish docs action to use latest versions of actions by @tishun in https://github.com/redis/lettuce/pull/3154
+* Bump default client-libs-test container version by @ggivo in https://github.com/redis/lettuce/pull/3165
+* Bump org.slf4j:jcl-over-slf4j from 1.7.25 to 2.0.16 by @dependabot in https://github.com/redis/lettuce/pull/2959
+* Bump org.testcontainers:testcontainers from 1.20.1 to 1.20.4 by @dependabot in https://github.com/redis/lettuce/pull/3082
+* Bump io.micrometer:micrometer-bom from 1.12.4 to 1.14.2 by @dependabot in https://github.com/redis/lettuce/pull/3096
+* Bump io.netty.incubator:netty-incubator-transport-native-io_uring from 0.0.25.Final to 0.0.26.Final by @dependabot in https://github.com/redis/lettuce/pull/3106
+* Bump netty.version to 4.1.118.Final #3187 by @tishun in https://github.com/redis/lettuce/pull/3189
diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md
index 74f154dee..6b95f7d9a 100644
--- a/docs/advanced-usage.md
+++ b/docs/advanced-usage.md
@@ -327,7 +327,7 @@ client.setOptions(ClientOptions.builder()
 <tbody>
 <tr>
 <td>PING before activating connection</td>
-<td><code>pingBefor eActivateConnection</code></td>
+<td><code>pingBeforeActivateConnection</code></td>
 <td><code>true</code></td>
 </tr>
 <tr>
@@ -362,8 +362,21 @@ queued commands.</p>
 refuse commands and cancel these with an exception.</p></td>
 </tr>
 <tr>
+<td>Replay filter</td>
+<td><code>replayFilter</code></td>
+<td><code>(cmd) -> false</code></td>
+</tr>
+<tr>
+<td colspan="3"><p>Since: 6.6</p>
+<p>Controls which commands are to be filtered out in case the driver
+attempts to reconnect to the server. Returning <code>false</code> means
+that the command would not be filtered out.</p>
+<p>This flag has no effect in case the autoReconnect feature is not
+enabled.</p></td>
+</tr>
+<tr>
 <td>Cancel commands on reconnect failure</td>
-<td><code>cancelCommand sOnReconnectFailure</code></td>
+<td><code>cancelCommandsOnReconnectFailure</code></td>
 <td><code>false</code></td>
 </tr>
 <tr>
@@ -486,7 +499,7 @@ store/trust store.</p></td>
 <tr>
 <td>Timeout Options</td>
 <td><code>timeoutOptions</code></td>
-<td><code>Do n ot timeout commands.</code></td>
+<td><code>Do not timeout commands.</code></td>
 </tr>
 <tr>
 <td colspan="3"><p>Since: 5.1</p>
@@ -550,7 +563,7 @@ client.setOptions(ClusterClientOptions.builder()
 <tbody>
 <tr>
 <td>Periodic cluster topology refresh</td>
-<td><code>en ablePeriodicRefresh</code></td>
+<td><code>enablePeriodicRefresh</code></td>
 <td><code>false</code></td>
 </tr>
 <tr>
@@ -909,6 +922,12 @@ Lettuce because of two reasons:
 2.  Support for using epoll/kqueue with shaded netty requires netty 4.1
     and all parts of netty to be shaded.
 
+!!! NOTE
+    The limitations regarding shaded JAR files are applicable only to 
+    Lettuce versions prior to 5.0.1. Since version 5.0.1, Lettuce no
+    longer releases shaded JAR files, and these limitations are no
+    longer relevant for newer versions.
+
 See also Netty [documentation on native
 transports](http://netty.io/wiki/native-transports.html).
 
@@ -2399,14 +2418,14 @@ independent connections to Redis.
 Lettuce provides two levels of consistency; these are the rules for
 Redis command sends:
 
-Depending on the chosen consistency level:
+#### Depending on the chosen consistency level
 
-- **at-most-once execution**, i. e. no guaranteed execution
+- **at-most-once execution**, i.e. no guaranteed execution
 
-- **at-least-once execution**, i. e. guaranteed execution (with [some
+- **at-least-once execution**, i.e. guaranteed execution (with [some
   exceptions](#exceptions-to-at-least-once))
 
-Always:
+#### Always
 
 - command ordering in the order of invocations
 
@@ -2602,9 +2621,44 @@ re-established, queued commands are re-sent for execution. While a
 connection failure persists, issued commands are buffered.
 
 To change into *at-most-once* consistency level, disable auto-reconnect
-mode. Connections cannot be longer reconnected and thus no retries are
-issued. Not successfully commands are canceled. New commands are
-rejected.
+mode. Connections can no longer be reconnected and thus no retries are
+issued. Unsuccessful commands are canceled. New commands are rejected.
+
+#### Controlling replay of commands in *at-lease-once* mode
+
+!!! NOTE
+    This feature is only available since Lettuce 6.6
+
+One can achieve a more fine-grained control over the commands that are
+replayed after a reconnection by using the option to specify a filter
+predicate. This option is part of the ClientOptions configuration. See 
+[Client Options](advanced-usage.md#client-options) for further reference.
+
+``` java
+Predicate<RedisCommand<?, ?, ?> > filter = cmd -> 
+    cmd.getType().toString().equalsIgnoreCase("DECR");
+
+client.setOptions(ClientOptions.builder()
+    .autoReconnect(true)
+    .replayFilter(filter)
+    .build());
+```
+
+The code above would filter out all `DECR` commands from being replayed
+after a reconnection. Another, perhaps more popular example, would be:
+
+``` java
+Predicate<RedisCommand<?, ?, ?> > filter = cmd -> true;
+
+client.setOptions(ClientOptions.builder()
+    .autoReconnect(true)
+    .replayFilter(filter)
+    .build());
+```
+
+... which disables any command replay, but still allows the driver to
+re-connect, basically providing a way to have auto-reconnect without
+auto-replay of commands.
 
 ### Clustered operations
 
diff --git a/docs/getting-started.md b/docs/getting-started.md
index a9bdc97fd..d2937081c 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -12,7 +12,7 @@ Add these lines to file pom.xml:
 <dependency>
     <groupId>io.lettuce</groupId>
     <artifactId>lettuce-core</artifactId>
-    <version>6.5.0.RELEASE</version>
+    <version>6.6.0.RELEASE</version>
 </dependency>
 ```
 
@@ -23,7 +23,7 @@ Add these lines to file ivy.xml:
 ``` xml
 <ivy-module>
   <dependencies>
-    <dependency org="io.lettuce" name="lettuce-core" rev="6.5.0.RELEASE"/>
+    <dependency org="io.lettuce" name="lettuce-core" rev="6.6.0.RELEASE"/>
   </dependencies>
 </ivy-module>
 ```
@@ -34,7 +34,7 @@ Add these lines to file build.gradle:
 
 ``` groovy
 dependencies {
-  implementation 'io.lettuce:lettuce-core:6.5.0.RELEASE'
+  implementation 'io.lettuce:lettuce-core:6.6.0.RELEASE'
 }
 ```
 
diff --git a/docs/user-guide/connecting-redis.md b/docs/user-guide/connecting-redis.md
index 6de6e5534..9e2d2eed5 100644
--- a/docs/user-guide/connecting-redis.md
+++ b/docs/user-guide/connecting-redis.md
@@ -377,10 +377,11 @@ Microsoft Entra ID tokens have a limited lifetime. Lettuce provides a mechanism
   RedisURI redisURI = RedisURI.builder()
       .withHost(HOST)
       .withPort(PORT)
+      .withSsl(true)
       .withAuthentication(credentialsSP).build();
 
   // RedisClient
-  RedisClient redisClient = RedisClient.create(redisURI1);
+  RedisClient redisClient = RedisClient.create(redisURI);
   redisClient.setOptions(clientOptions);
   
   try {
@@ -394,4 +395,4 @@ Microsoft Entra ID tokens have a limited lifetime. Lettuce provides a mechanism
       redisClient.shutdown();  // Shutdown Redis client and close connections
       credentialsSP.close(); // Shutdown Entra ID Credentials provider
   }
-```
\ No newline at end of file
+```
diff --git a/pom.xml b/pom.xml
index 068f30e9f..182132f7a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -34,6 +34,10 @@
     </issueManagement>
 
     <developers>
+        <developer>
+            <id>tishun</id>
+            <name>Tihomir Mateev</name>
+        </developer>
         <developer>
             <id>mp911de</id>
             <name>Mark Paluch</name>
@@ -64,7 +68,7 @@
         <micrometer.version>1.14.2</micrometer.version>
         <micrometer-tracing.version>1.2.4</micrometer-tracing.version>
         <mockito.version>4.9.0</mockito.version>
-        <netty.version>4.1.115.Final</netty.version>
+        <netty.version>4.1.118.Final</netty.version>
         <openwebbeans.version>2.0.27</openwebbeans.version>
         <reactor.version>3.6.6</reactor.version>
         <rxjava.version>1.3.8</rxjava.version>
@@ -181,12 +185,12 @@
         <dependency>
             <groupId>redis.clients.authentication</groupId>
             <artifactId>redis-authx-core</artifactId>
-            <version>0.1.1-beta1</version>
+            <version>0.1.1-beta2</version>
         </dependency>
         <dependency>
             <groupId>redis.clients.authentication</groupId>
             <artifactId>redis-authx-entraid</artifactId>
-            <version>0.1.1-beta1</version>
+            <version>0.1.1-beta2</version>
             <scope>test</scope>
         </dependency>
         <!-- Start of core dependencies -->
diff --git a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java
index fd1ad55eb..08159e25f 100644
--- a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java
@@ -1429,6 +1429,41 @@ public abstract class AbstractRedisAsyncCommands<K, V> implements RedisAclAsyncC
         return dispatch(commandBuilder.hset(key, map));
     }
 
+    @Override
+    public RedisFuture<Long> hsetex(K key, Map<K, V> map) {
+        return dispatch(commandBuilder.hsetex(key, map));
+    }
+
+    @Override
+    public RedisFuture<Long> hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map) {
+        return dispatch(commandBuilder.hsetex(key, hSetExArgs, map));
+    }
+
+    @Override
+    public RedisFuture<List<KeyValue<K, V>>> hgetex(K key, K... fields) {
+        return dispatch(commandBuilder.hgetex(key, fields));
+    }
+
+    @Override
+    public RedisFuture<List<KeyValue<K, V>>> hgetex(K key, HGetExArgs hGetExArgs, K... fields) {
+        return dispatch(commandBuilder.hgetex(key, hGetExArgs, fields));
+    }
+
+    @Override
+    public RedisFuture<Long> hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields) {
+        return dispatch(commandBuilder.hgetex(channel, key, hGetExArgs, fields));
+    }
+
+    @Override
+    public RedisFuture<List<KeyValue<K, V>>> hgetdel(K key, K... fields) {
+        return dispatch(commandBuilder.hgetdel(key, fields));
+    }
+
+    @Override
+    public RedisFuture<Long> hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields) {
+        return dispatch(commandBuilder.hgetdel(channel, key, fields));
+    }
+
     @Override
     public RedisFuture<Boolean> hsetnx(K key, K field, V value) {
         return dispatch(commandBuilder.hsetnx(key, field, value));
diff --git a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java
index e1651e562..a920d3a2d 100644
--- a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java
+++ b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java
@@ -1494,6 +1494,41 @@ public abstract class AbstractRedisReactiveCommands<K, V>
         return createMono(() -> commandBuilder.hset(key, map));
     }
 
+    @Override
+    public Mono<Long> hsetex(K key, Map<K, V> map) {
+        return createMono(() -> commandBuilder.hsetex(key, map));
+    }
+
+    @Override
+    public Mono<Long> hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map) {
+        return createMono(() -> commandBuilder.hsetex(key, hSetExArgs, map));
+    }
+
+    @Override
+    public Flux<KeyValue<K, V>> hgetex(K key, K... fields) {
+        return createDissolvingFlux(() -> commandBuilder.hgetex(key, fields));
+    }
+
+    @Override
+    public Flux<KeyValue<K, V>> hgetex(K key, HGetExArgs hGetExArgs, K... fields) {
+        return createDissolvingFlux(() -> commandBuilder.hgetex(key, hGetExArgs, fields));
+    }
+
+    @Override
+    public Mono<Long> hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields) {
+        return createMono(() -> commandBuilder.hgetex(channel, key, hGetExArgs, fields));
+    }
+
+    @Override
+    public Flux<KeyValue<K, V>> hgetdel(K key, K... fields) {
+        return createDissolvingFlux(() -> commandBuilder.hgetdel(key, fields));
+    }
+
+    @Override
+    public Mono<Long> hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields) {
+        return createMono(() -> commandBuilder.hgetdel(channel, key, fields));
+    }
+
     @Override
     public Mono<Boolean> hsetnx(K key, K field, V value) {
         return createMono(() -> commandBuilder.hsetnx(key, field, value));
diff --git a/src/main/java/io/lettuce/core/ClientOptions.java b/src/main/java/io/lettuce/core/ClientOptions.java
index 5c02c3b18..a16845b00 100644
--- a/src/main/java/io/lettuce/core/ClientOptions.java
+++ b/src/main/java/io/lettuce/core/ClientOptions.java
@@ -25,6 +25,7 @@ import java.nio.charset.StandardCharsets;
 import java.util.Iterator;
 import java.util.ServiceConfigurationError;
 import java.util.ServiceLoader;
+import java.util.function.Predicate;
 import java.util.function.Supplier;
 
 import io.lettuce.core.api.StatefulConnection;
@@ -35,6 +36,7 @@ import io.lettuce.core.protocol.DecodeBufferPolicies;
 import io.lettuce.core.protocol.DecodeBufferPolicy;
 import io.lettuce.core.protocol.ProtocolVersion;
 import io.lettuce.core.protocol.ReadOnlyCommands;
+import io.lettuce.core.protocol.RedisCommand;
 import io.lettuce.core.resource.ClientResources;
 import reactor.core.publisher.Mono;
 
@@ -50,6 +52,8 @@ public class ClientOptions implements Serializable {
 
     public static final boolean DEFAULT_AUTO_RECONNECT = true;
 
+    public static final Predicate<RedisCommand<?, ?, ?>> DEFAULT_REPLAY_FILTER = (cmd) -> false;
+
     public static final int DEFAULT_BUFFER_USAGE_RATIO = 3;
 
     public static final boolean DEFAULT_CANCEL_CMD_RECONNECT_FAIL = false;
@@ -92,6 +96,8 @@ public class ClientOptions implements Serializable {
 
     private final boolean autoReconnect;
 
+    private final Predicate<RedisCommand<?, ?, ?>> replayFilter;
+
     private final boolean cancelCommandsOnReconnectFailure;
 
     private final DecodeBufferPolicy decodeBufferPolicy;
@@ -126,6 +132,7 @@ public class ClientOptions implements Serializable {
 
     protected ClientOptions(Builder builder) {
         this.autoReconnect = builder.autoReconnect;
+        this.replayFilter = builder.replayFilter;
         this.cancelCommandsOnReconnectFailure = builder.cancelCommandsOnReconnectFailure;
         this.decodeBufferPolicy = builder.decodeBufferPolicy;
         this.disconnectedBehavior = builder.disconnectedBehavior;
@@ -146,6 +153,7 @@ public class ClientOptions implements Serializable {
 
     protected ClientOptions(ClientOptions original) {
         this.autoReconnect = original.isAutoReconnect();
+        this.replayFilter = original.getReplayFilter();
         this.cancelCommandsOnReconnectFailure = original.isCancelCommandsOnReconnectFailure();
         this.decodeBufferPolicy = original.getDecodeBufferPolicy();
         this.disconnectedBehavior = original.getDisconnectedBehavior();
@@ -199,6 +207,8 @@ public class ClientOptions implements Serializable {
 
         private boolean autoReconnect = DEFAULT_AUTO_RECONNECT;
 
+        private Predicate<RedisCommand<?, ?, ?>> replayFilter = DEFAULT_REPLAY_FILTER;
+
         private boolean cancelCommandsOnReconnectFailure = DEFAULT_CANCEL_CMD_RECONNECT_FAIL;
 
         private DecodeBufferPolicy decodeBufferPolicy = DecodeBufferPolicies.ratio(DEFAULT_BUFFER_USAGE_RATIO);
@@ -246,6 +256,21 @@ public class ClientOptions implements Serializable {
             return this;
         }
 
+        /**
+         * When {@link #autoReconnect(boolean)} is set to true, this {@link Predicate} is used to filter commands to replay when
+         * the connection is reestablished after a disconnect. Returning <code>false</code> means the command will not be
+         * filtered out and will be replayed. Defaults to replaying all queued commands.
+         *
+         * @param replayFilter a {@link Predicate} to filter commands to replay. Must not be {@code null}.
+         * @see #DEFAULT_REPLAY_FILTER
+         * @return {@code this}
+         * @since 6.6
+         */
+        public Builder replayFilter(Predicate<RedisCommand<?, ?, ?>> replayFilter) {
+            this.replayFilter = replayFilter;
+            return this;
+        }
+
         /**
          * Allows cancelling queued commands in case a reconnect fails.Defaults to {@code false}. See
          * {@link #DEFAULT_CANCEL_CMD_RECONNECT_FAIL}. <b>This flag is deprecated and should not be used as it can lead to race
@@ -527,13 +552,13 @@ public class ClientOptions implements Serializable {
         Builder builder = new Builder();
 
         builder.autoReconnect(isAutoReconnect()).cancelCommandsOnReconnectFailure(isCancelCommandsOnReconnectFailure())
-                .decodeBufferPolicy(getDecodeBufferPolicy()).disconnectedBehavior(getDisconnectedBehavior())
-                .reauthenticateBehavior(getReauthenticateBehaviour()).readOnlyCommands(getReadOnlyCommands())
-                .publishOnScheduler(isPublishOnScheduler()).pingBeforeActivateConnection(isPingBeforeActivateConnection())
-                .protocolVersion(getConfiguredProtocolVersion()).requestQueueSize(getRequestQueueSize())
-                .scriptCharset(getScriptCharset()).jsonParser(getJsonParser()).socketOptions(getSocketOptions())
-                .sslOptions(getSslOptions()).suspendReconnectOnProtocolFailure(isSuspendReconnectOnProtocolFailure())
-                .timeoutOptions(getTimeoutOptions());
+                .replayFilter(getReplayFilter()).decodeBufferPolicy(getDecodeBufferPolicy())
+                .disconnectedBehavior(getDisconnectedBehavior()).reauthenticateBehavior(getReauthenticateBehaviour())
+                .readOnlyCommands(getReadOnlyCommands()).publishOnScheduler(isPublishOnScheduler())
+                .pingBeforeActivateConnection(isPingBeforeActivateConnection()).protocolVersion(getConfiguredProtocolVersion())
+                .requestQueueSize(getRequestQueueSize()).scriptCharset(getScriptCharset()).jsonParser(getJsonParser())
+                .socketOptions(getSocketOptions()).sslOptions(getSslOptions())
+                .suspendReconnectOnProtocolFailure(isSuspendReconnectOnProtocolFailure()).timeoutOptions(getTimeoutOptions());
 
         return builder;
     }
@@ -551,6 +576,16 @@ public class ClientOptions implements Serializable {
         return autoReconnect;
     }
 
+    /**
+     * Controls which {@link RedisCommand} will be replayed after a re-connect. The {@link Predicate} returns <code>true</code>
+     * if command should be filtered out and not replayed. Defaults to {@link #DEFAULT_REPLAY_FILTER}.
+     *
+     * @return the currently set {@link Predicate} used to filter out commands to replay
+     */
+    public Predicate<RedisCommand<?, ?, ?>> getReplayFilter() {
+        return replayFilter;
+    }
+
     /**
      * If this flag is {@code true} any queued commands will be canceled when a reconnect fails within the activation sequence.
      * Default is {@code false}.
diff --git a/src/main/java/io/lettuce/core/HGetExArgs.java b/src/main/java/io/lettuce/core/HGetExArgs.java
new file mode 100644
index 000000000..8037ff4d3
--- /dev/null
+++ b/src/main/java/io/lettuce/core/HGetExArgs.java
@@ -0,0 +1,196 @@
+package io.lettuce.core;
+
+import io.lettuce.core.internal.LettuceAssert;
+import io.lettuce.core.protocol.CommandArgs;
+import io.lettuce.core.protocol.CommandType;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+
+/**
+ * Argument list builder for the Redis <a href="https://redis.io/commands/getex">HGETEX</a> command starting from Redis 8.0.
+ * Static import the methods from {@link Builder} and chain the method calls: {@code ex(10).nx()}.
+ * <p>
+ * {@link HGetExArgs} is a mutable object and instances should be used only once to avoid shared mutable state.
+ *
+ * @author Ivo Gaydajiev
+ * @since 6.6
+ */
+public class HGetExArgs implements CompositeArgument {
+
+    private Long ex;
+
+    private Long exAt;
+
+    private Long px;
+
+    private Long pxAt;
+
+    private boolean persist = false;
+
+    /**
+     * Builder entry points for {@link HGetExArgs}.
+     */
+    public static class Builder {
+
+        /**
+         * Utility constructor.
+         */
+        private Builder() {
+        }
+
+        /**
+         * Creates new {@link HGetExArgs} and enable {@literal EX}.
+         *
+         * @param timeout expire time in seconds.
+         * @return new {@link HGetExArgs} with {@literal EX} enabled.
+         * @see HGetExArgs#ex(long)
+         * @since 6.6
+         */
+        public static HGetExArgs ex(Duration timeout) {
+            return new HGetExArgs().ex(timeout);
+        }
+
+        /**
+         * Creates new {@link HGetExArgs} and enable {@literal EXAT}.
+         *
+         * @param timestamp the timestamp type: posix time in seconds.
+         * @return new {@link HGetExArgs} with {@literal EXAT} enabled.
+         * @see HGetExArgs#exAt(Instant)
+         * @since 6.6
+         */
+        public static HGetExArgs exAt(Instant timestamp) {
+            return new HGetExArgs().exAt(timestamp);
+        }
+
+        /**
+         * Creates new {@link HGetExArgs} and enable {@literal PX}.
+         *
+         * @param timeout expire time in milliseconds.
+         * @return new {@link HGetExArgs} with {@literal PX} enabled.
+         * @see HGetExArgs#px(long)
+         * @since 6.6
+         */
+        public static HGetExArgs px(Duration timeout) {
+            return new HGetExArgs().px(timeout);
+        }
+
+        /**
+         * Creates new {@link HGetExArgs} and enable {@literal PXAT}.
+         *
+         * @param timestamp the timestamp type: posix time.
+         * @return new {@link HGetExArgs} with {@literal PXAT} enabled.
+         * @see HGetExArgs#pxAt(Instant)
+         * @since 6.6
+         */
+        public static HGetExArgs pxAt(Instant timestamp) {
+            return new HGetExArgs().pxAt(timestamp);
+        }
+
+        /**
+         * Creates new {@link HGetExArgs} and enable {@literal PERSIST}.
+         *
+         * @return new {@link HGetExArgs} with {@literal PERSIST} enabled.
+         * @see HGetExArgs#persist()
+         */
+        public static HGetExArgs persist() {
+            return new HGetExArgs().persist();
+        }
+
+    }
+
+    /**
+     * Set the specified expire time, in seconds.
+     *
+     * @param timeout expire time in seconds.
+     * @return {@code this} {@link HGetExArgs}.
+     * @since 6.6
+     */
+    public HGetExArgs ex(Duration timeout) {
+
+        LettuceAssert.notNull(timeout, "Timeout must not be null");
+
+        this.ex = timeout.getSeconds();
+        return this;
+    }
+
+    /**
+     * Set the specified expire at time using a posix {@code timestamp}.
+     *
+     * @param timestamp the timestamp type: posix time in seconds.
+     * @return {@code this} {@link HGetExArgs}.
+     * @since 6.6
+     */
+    public HGetExArgs exAt(Instant timestamp) {
+
+        LettuceAssert.notNull(timestamp, "Timestamp must not be null");
+        this.exAt = timestamp.getEpochSecond();
+        return this;
+    }
+
+    /**
+     * Set the specified expire time, in milliseconds.
+     *
+     * @param timeout expire time in milliseconds.
+     * @return {@code this} {@link HGetExArgs}.
+     */
+    public HGetExArgs px(Duration timeout) {
+
+        LettuceAssert.notNull(timeout, "Timeout must not be null");
+
+        this.px = timeout.toMillis();
+        return this;
+    }
+
+    /**
+     * Set the specified expire at time using a posix {@code timestamp}.
+     *
+     * @param timestamp the timestamp type: posix time in milliseconds.
+     * @return {@code this} {@link HGetExArgs}.
+     * @since 6.6
+     */
+    public HGetExArgs pxAt(Instant timestamp) {
+
+        LettuceAssert.notNull(timestamp, "Timestamp must not be null");
+
+        this.pxAt = timestamp.toEpochMilli();
+        return this;
+    }
+
+    /**
+     * Remove the time to live associated with the key.
+     *
+     * @return {@code this} {@link HGetExArgs}.
+     */
+    public HGetExArgs persist() {
+
+        this.persist = true;
+        return this;
+    }
+
+    @Override
+    public <K, V> void build(CommandArgs<K, V> args) {
+
+        if (ex != null) {
+            args.add("EX").add(ex);
+        }
+
+        if (exAt != null) {
+            args.add("EXAT").add(exAt);
+        }
+
+        if (px != null) {
+            args.add("PX").add(px);
+        }
+
+        if (pxAt != null) {
+            args.add("PXAT").add(pxAt);
+        }
+
+        if (persist) {
+            args.add(CommandType.PERSIST);
+        }
+    }
+
+}
diff --git a/src/main/java/io/lettuce/core/HSetExArgs.java b/src/main/java/io/lettuce/core/HSetExArgs.java
new file mode 100644
index 000000000..40070e8bd
--- /dev/null
+++ b/src/main/java/io/lettuce/core/HSetExArgs.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2011-Present, Redis Ltd. and Contributors
+ * All rights reserved.
+ *
+ * Licensed under the MIT License.
+ *
+ * This file contains contributions from third-party contributors
+ * licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.lettuce.core;
+
+import io.lettuce.core.internal.LettuceAssert;
+import io.lettuce.core.protocol.CommandArgs;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+
+import static io.lettuce.core.protocol.CommandKeyword.FNX;
+import static io.lettuce.core.protocol.CommandKeyword.FXX;
+
+/**
+ * Argument list builder for the Redis <a href="https://redis.io/commands/set">HSETEX</a> command starting from Redis 8.0.
+ * Static import the methods from {@link Builder} and chain the method calls: {@code ex(10).nx()}.
+ * <p>
+ * {@link HSetExArgs} is a mutable object and instances should be used only once to avoid shared mutable state.
+ *
+ * @author Ivo Gaydajiev
+ * @since 6.6
+ */
+public class HSetExArgs implements CompositeArgument {
+
+    private Long ex;
+
+    private Long exAt;
+
+    private Long px;
+
+    private Long pxAt;
+
+    private boolean fnx = false;
+
+    private boolean fxx = false;
+
+    private boolean keepttl = false;
+
+    /**
+     * Builder entry points for {@link HSetExArgs}.
+     */
+    public static class Builder {
+
+        /**
+         * Utility constructor.
+         */
+        private Builder() {
+        }
+
+        /**
+         * Creates new {@link HSetExArgs} and enable {@literal EX}.
+         *
+         * @param timeout expire time as duration.
+         * @return new {@link HSetExArgs} with {@literal EX} enabled.
+         * @see HSetExArgs#ex(long)
+         * @since 6.6
+         */
+        public static HSetExArgs ex(Duration timeout) {
+            return new HSetExArgs().ex(timeout);
+        }
+
+        /**
+         * Creates new {@link HSetExArgs} and enable {@literal EXAT}.
+         *
+         * @param timestamp the timestamp type: posix time in seconds.
+         * @return new {@link HSetExArgs} with {@literal EXAT} enabled.
+         * @see HSetExArgs#exAt(Instant)
+         * @since 6.6
+         */
+        public static HSetExArgs exAt(Instant timestamp) {
+            return new HSetExArgs().exAt(timestamp);
+        }
+
+        /**
+         * Creates new {@link HSetExArgs} and enable {@literal PX}.
+         *
+         * @param timeout expire time in milliseconds.
+         * @return new {@link HSetExArgs} with {@literal PX} enabled.
+         * @see HSetExArgs#px(long)
+         * @since 6.6
+         */
+        public static HSetExArgs px(Duration timeout) {
+            return new HSetExArgs().px(timeout);
+        }
+
+        /**
+         * Creates new {@link HSetExArgs} and enable {@literal PXAT}.
+         *
+         * @param timestamp the timestamp type: posix time.
+         * @return new {@link HSetExArgs} with {@literal PXAT} enabled.
+         * @see HSetExArgs#pxAt(Instant)
+         * @since 6.6
+         */
+        public static HSetExArgs pxAt(Instant timestamp) {
+            return new HSetExArgs().pxAt(timestamp);
+        }
+
+        /**
+         * Creates new {@link HSetExArgs} and enable {@literal NX}.
+         *
+         * @return new {@link HSetExArgs} with {@literal NX} enabled.
+         * @see HSetExArgs#fnx()
+         */
+        public static HSetExArgs fnx() {
+            return new HSetExArgs().fnx();
+        }
+
+        /**
+         * Creates new {@link HSetExArgs} and enable {@literal XX}.
+         *
+         * @return new {@link HSetExArgs} with {@literal XX} enabled.
+         * @see HSetExArgs#fxx()
+         */
+        public static HSetExArgs xx() {
+            return new HSetExArgs().fxx();
+        }
+
+        /**
+         * Creates new {@link HSetExArgs} and enable {@literal KEEPTTL}.
+         *
+         * @return new {@link HSetExArgs} with {@literal KEEPTTL} enabled.
+         * @see HSetExArgs#keepttl()
+         * @since 6.6
+         */
+        public static HSetExArgs keepttl() {
+            return new HSetExArgs().keepttl();
+        }
+
+    }
+
+    /**
+     * Set the specified expire time, in seconds.
+     *
+     * @param timeout expire time in seconds.
+     * @return {@code this} {@link HSetExArgs}.
+     * @since 6.6
+     */
+    public HSetExArgs ex(Duration timeout) {
+
+        LettuceAssert.notNull(timeout, "Timeout must not be null");
+
+        this.ex = timeout.getSeconds();
+        return this;
+    }
+
+    /**
+     * Set the specified expire at time using a posix {@code timestamp}.
+     *
+     * @param timestamp the timestamp type: posix time in seconds.
+     * @return {@code this} {@link HSetExArgs}.
+     * @since 6.6
+     */
+    public HSetExArgs exAt(Instant timestamp) {
+
+        LettuceAssert.notNull(timestamp, "Timestamp must not be null");
+
+        this.exAt = timestamp.getEpochSecond();
+        return this;
+    }
+
+    /**
+     * Set the specified expire time, in milliseconds.
+     *
+     * @param timeout expire time in milliseconds.
+     * @return {@code this} {@link HSetExArgs}.
+     */
+    public HSetExArgs px(Duration timeout) {
+
+        LettuceAssert.notNull(timeout, "Timeout must not be null");
+
+        this.px = timeout.toMillis();
+        return this;
+    }
+
+    /**
+     * Set the specified expire at time using a posix {@code timestamp}.
+     *
+     * @param timestamp the timestamp type: posix time in milliseconds.
+     * @return {@code this} {@link HSetExArgs}.
+     * @since 6.6
+     */
+    public HSetExArgs pxAt(Instant timestamp) {
+
+        LettuceAssert.notNull(timestamp, "Timestamp must not be null");
+
+        this.pxAt = timestamp.toEpochMilli();
+        return this;
+    }
+
+    /**
+     * Only set the key if it does not already exist.
+     *
+     * @return {@code this} {@link HSetExArgs}.
+     */
+    public HSetExArgs fnx() {
+
+        this.fnx = true;
+        return this;
+    }
+
+    /**
+     * Set the value and retain the existing TTL.
+     *
+     * @return {@code this} {@link HSetExArgs}.
+     * @since 6.6
+     */
+    public HSetExArgs keepttl() {
+
+        this.keepttl = true;
+        return this;
+    }
+
+    /**
+     * Only set the key if it already exists.
+     *
+     * @return {@code this} {@link HSetExArgs}.
+     */
+    public HSetExArgs fxx() {
+
+        this.fxx = true;
+        return this;
+    }
+
+    @Override
+    public <K, V> void build(CommandArgs<K, V> args) {
+
+        if (ex != null) {
+            args.add("EX").add(ex);
+        }
+
+        if (exAt != null) {
+            args.add("EXAT").add(exAt);
+        }
+
+        if (px != null) {
+            args.add("PX").add(px);
+        }
+
+        if (pxAt != null) {
+            args.add("PXAT").add(pxAt);
+        }
+
+        if (fnx) {
+            args.add(FNX);
+        }
+
+        if (fxx) {
+            args.add(FXX);
+        }
+
+        if (keepttl) {
+            args.add("KEEPTTL");
+        }
+    }
+
+}
diff --git a/src/main/java/io/lettuce/core/RedisCommandBuilder.java b/src/main/java/io/lettuce/core/RedisCommandBuilder.java
index aa1ce4435..6bcf4fa1b 100644
--- a/src/main/java/io/lettuce/core/RedisCommandBuilder.java
+++ b/src/main/java/io/lettuce/core/RedisCommandBuilder.java
@@ -1767,6 +1767,79 @@ class RedisCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return createCommand(HSET, new IntegerOutput<>(codec), args);
     }
 
+    Command<K, V, Long> hsetex(K key, Map<K, V> map) {
+        notNullKey(key);
+        LettuceAssert.notNull(map, "Map " + MUST_NOT_BE_NULL);
+        LettuceAssert.isTrue(!map.isEmpty(), "Map " + MUST_NOT_BE_EMPTY);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
+        args.add(FIELDS).add(map.size()).add(map);
+
+        return createCommand(HSETEX, new IntegerOutput<>(codec), args);
+    }
+
+    Command<K, V, Long> hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map) {
+        notNullKey(key);
+        LettuceAssert.notNull(hSetExArgs, "HSetExArgs " + MUST_NOT_BE_NULL);
+        LettuceAssert.notNull(map, "Map " + MUST_NOT_BE_NULL);
+        LettuceAssert.isTrue(!map.isEmpty(), "Map " + MUST_NOT_BE_EMPTY);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
+        hSetExArgs.build(args);
+        args.add(FIELDS).add(map.size()).add(map);
+
+        return createCommand(HSETEX, new IntegerOutput<>(codec), args);
+    }
+
+    Command<K, V, List<KeyValue<K, V>>> hgetex(K key, K... fields) {
+        keyAndFieldsProvided(key, fields);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
+        args.add(FIELDS).add(fields.length).addKeys(fields);
+
+        return createCommand(HGETEX, new KeyValueListOutput<>(codec, Arrays.asList(fields)), args);
+    }
+
+    Command<K, V, List<KeyValue<K, V>>> hgetex(K key, HGetExArgs hGetExArgs, K... fields) {
+        keyAndFieldsProvided(key, fields);
+        LettuceAssert.notNull(hGetExArgs, "HGetExArgs " + MUST_NOT_BE_NULL);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
+        hGetExArgs.build(args);
+        args.add(FIELDS).add(fields.length).addKeys(fields);
+
+        return createCommand(HGETEX, new KeyValueListOutput<>(codec, Arrays.asList(fields)), args);
+    }
+
+    Command<K, V, Long> hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields) {
+        keyAndFieldsProvided(key, fields);
+        LettuceAssert.notNull(hGetExArgs, "HGetExArgs " + MUST_NOT_BE_NULL);
+        notNull(channel);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
+        hGetExArgs.build(args);
+        args.add(FIELDS).add(fields.length).addKeys(fields);
+
+        return createCommand(HGETEX, new KeyValueStreamingOutput<>(codec, channel, Arrays.asList(fields)), args);
+    }
+
+    Command<K, V, List<KeyValue<K, V>>> hgetdel(K key, K... fields) {
+        keyAndFieldsProvided(key, fields);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key).add(FIELDS).add(fields.length).addKeys(fields);
+
+        return createCommand(HGETDEL, new KeyValueListOutput<>(codec, Arrays.asList(fields)), args);
+    }
+
+    Command<K, V, Long> hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields) {
+        keyAndFieldsProvided(key, fields);
+
+        notNull(channel);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key).add(FIELDS).add(fields.length).addKeys(fields);
+        return createCommand(HGETDEL, new KeyValueStreamingOutput<>(codec, channel, Arrays.asList(fields)), args);
+    }
+
     Command<K, V, Boolean> hsetnx(K key, K field, V value) {
         notNullKey(key);
         LettuceAssert.notNull(field, "Field " + MUST_NOT_BE_NULL);
diff --git a/src/main/java/io/lettuce/core/RedisJsonCommandBuilder.java b/src/main/java/io/lettuce/core/RedisJsonCommandBuilder.java
index 9aff2e594..42e8e40cf 100644
--- a/src/main/java/io/lettuce/core/RedisJsonCommandBuilder.java
+++ b/src/main/java/io/lettuce/core/RedisJsonCommandBuilder.java
@@ -47,6 +47,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
 
@@ -62,10 +63,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
 
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
-        if (jsonPath != null && !jsonPath.isRootPath()) {
-            args.add(jsonPath.toString());
-        }
-
+        args.add(jsonPath.toString());
         args.add(value.asByteBuffer().array());
 
         if (range != null) {
@@ -81,10 +79,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
 
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
-        if (jsonPath != null && !jsonPath.isRootPath()) {
-            args.add(jsonPath.toString());
-        }
-
+        args.add(jsonPath.toString());
         args.add(index);
 
         for (JsonValue value : values) {
@@ -100,6 +95,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
         return createCommand(JSON_ARRLEN, (CommandOutput) new ArrayOutput<>(codec), args);
@@ -111,10 +107,13 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null) {
-            args.add(jsonPath.toString());
-
             if (index != -1) {
+                // OPTIONAL as per API
+                args.add(jsonPath.toString());
                 args.add(index);
+            } else if (!jsonPath.isRootPath()) {
+                // OPTIONAL as per API
+                args.add(jsonPath.toString());
             }
         }
 
@@ -126,10 +125,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         notNullKey(key);
 
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
-
-        if (jsonPath != null && !jsonPath.isRootPath()) {
-            args.add(jsonPath.toString());
-        }
+        args.add(jsonPath.toString());
 
         if (range != null) {
             range.build(args);
@@ -144,6 +140,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
 
@@ -156,10 +153,12 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (options != null) {
+            // OPTIONAL as per API
             options.build(args);
         }
 
         if (jsonPaths != null) {
+            // OPTIONAL as per API
             for (JsonPath jsonPath : jsonPaths) {
                 if (jsonPath != null) {
                     args.add(jsonPath.toString());
@@ -175,11 +174,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         notNullKey(key);
 
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
-
-        if (jsonPath != null && !jsonPath.isRootPath()) {
-            args.add(jsonPath.toString());
-        }
-
+        args.add(jsonPath.toString());
         args.add(value.asByteBuffer().array());
 
         return createCommand(JSON_MERGE, new StatusOutput<>(codec), args);
@@ -189,10 +184,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         notEmpty(keys);
 
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKeys(keys);
-
-        if (jsonPath != null) {
-            args.add(jsonPath.toString());
-        }
+        args.add(jsonPath.toString());
 
         return createCommand(JSON_MGET, new JsonValueListOutput<>(codec, parser.get()), args);
     }
@@ -214,11 +206,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         notNullKey(key);
 
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
-
-        if (jsonPath != null && !jsonPath.isRootPath()) {
-            args.add(jsonPath.toString());
-        }
-
+        args.add(jsonPath.toString());
         args.add(number.toString());
 
         return createCommand(JSON_NUMINCRBY, new NumberListOutput<>(codec), args);
@@ -230,6 +218,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
 
@@ -243,6 +232,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
 
@@ -259,6 +249,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         args.add(value.asByteBuffer().array());
 
         if (options != null) {
+            // OPTIONAL as per API
             options.build(args);
         }
 
@@ -271,6 +262,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
 
@@ -286,6 +278,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
 
@@ -296,10 +289,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         notNullKey(key);
 
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
-
-        if (jsonPath != null && !jsonPath.isRootPath()) {
-            args.add(jsonPath.toString());
-        }
+        args.add(jsonPath.toString());
 
         return createCommand(JSON_TOGGLE, (CommandOutput) new ArrayOutput<>(codec), args);
     }
@@ -310,6 +300,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
         return createCommand(JSON_DEL, new IntegerOutput<>(codec), args);
@@ -321,6 +312,7 @@ class RedisJsonCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         CommandArgs<K, V> args = new CommandArgs<>(codec).addKey(key);
 
         if (jsonPath != null && !jsonPath.isRootPath()) {
+            // OPTIONAL as per API
             args.add(jsonPath.toString());
         }
 
diff --git a/src/main/java/io/lettuce/core/api/async/RedisHashAsyncCommands.java b/src/main/java/io/lettuce/core/api/async/RedisHashAsyncCommands.java
index ee0b8eed1..f1fa1d9b8 100644
--- a/src/main/java/io/lettuce/core/api/async/RedisHashAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/api/async/RedisHashAsyncCommands.java
@@ -20,6 +20,8 @@
 package io.lettuce.core.api.async;
 
 import io.lettuce.core.ExpireArgs;
+import io.lettuce.core.HGetExArgs;
+import io.lettuce.core.HSetExArgs;
 import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
@@ -396,6 +398,78 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<Long> hset(K key, Map<K, V> map);
 
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    RedisFuture<Long> hsetex(K key, Map<K, V> map);
+
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hSetExArgs hsetex arguments.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    RedisFuture<Long> hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    RedisFuture<List<KeyValue<K, V>>> hgetex(K key, K... fields);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    RedisFuture<List<KeyValue<K, V>>> hgetex(K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    RedisFuture<Long> hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Get and delete one or more hash fields.
+     *
+     * @param key the hash key.
+     * @param fields fields to retrieve and delete.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     */
+    RedisFuture<List<KeyValue<K, V>>> hgetdel(K key, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param fields fields to retrieve and delete.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    RedisFuture<Long> hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields);
+
     /**
      * Set the value of a hash field, only if the field does not exist.
      *
diff --git a/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java b/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java
index 99d60e705..d7b610593 100644
--- a/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java
+++ b/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java
@@ -20,6 +20,8 @@
 package io.lettuce.core.api.reactive;
 
 import io.lettuce.core.ExpireArgs;
+import io.lettuce.core.HGetExArgs;
+import io.lettuce.core.HSetExArgs;
 import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
@@ -423,6 +425,78 @@ public interface RedisHashReactiveCommands<K, V> {
      */
     Mono<Long> hset(K key, Map<K, V> map);
 
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Mono<Long> hsetex(K key, Map<K, V> map);
+
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hSetExArgs hsetex arguments.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Mono<Long> hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param fields fields to retrieve.
+     * @return KeyValue&lt;K, V&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    Flux<KeyValue<K, V>> hgetex(K key, K... fields);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return KeyValue&lt;K, V&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    Flux<KeyValue<K, V>> hgetex(K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Mono<Long> hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Get and delete one or more hash fields.
+     *
+     * @param key the hash key.
+     * @param fields fields to retrieve and delete.
+     * @return KeyValue&lt;K, V&gt; array-reply list of fields and their values.
+     */
+    Flux<KeyValue<K, V>> hgetdel(K key, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param fields fields to retrieve and delete.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Mono<Long> hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields);
+
     /**
      * Set the value of a hash field, only if the field does not exist.
      *
diff --git a/src/main/java/io/lettuce/core/api/sync/RedisHashCommands.java b/src/main/java/io/lettuce/core/api/sync/RedisHashCommands.java
index 2d9f3ac31..a0a4927e0 100644
--- a/src/main/java/io/lettuce/core/api/sync/RedisHashCommands.java
+++ b/src/main/java/io/lettuce/core/api/sync/RedisHashCommands.java
@@ -20,6 +20,8 @@
 package io.lettuce.core.api.sync;
 
 import io.lettuce.core.ExpireArgs;
+import io.lettuce.core.HGetExArgs;
+import io.lettuce.core.HSetExArgs;
 import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
@@ -393,6 +395,78 @@ public interface RedisHashCommands<K, V> {
      */
     Long hset(K key, Map<K, V> map);
 
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Long hsetex(K key, Map<K, V> map);
+
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hSetExArgs hsetex arguments.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Long hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    List<KeyValue<K, V>> hgetex(K key, K... fields);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    List<KeyValue<K, V>> hgetex(K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Long hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Get and delete one or more hash fields.
+     *
+     * @param key the hash key.
+     * @param fields fields to retrieve and delete.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     */
+    List<KeyValue<K, V>> hgetdel(K key, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param fields fields to retrieve and delete.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Long hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields);
+
     /**
      * Set the value of a hash field, only if the field does not exist.
      *
diff --git a/src/main/java/io/lettuce/core/cluster/ClusterTopologyRefreshOptions.java b/src/main/java/io/lettuce/core/cluster/ClusterTopologyRefreshOptions.java
index ddf33481a..882e4718f 100644
--- a/src/main/java/io/lettuce/core/cluster/ClusterTopologyRefreshOptions.java
+++ b/src/main/java/io/lettuce/core/cluster/ClusterTopologyRefreshOptions.java
@@ -169,7 +169,7 @@ public class ClusterTopologyRefreshOptions {
 
             LettuceAssert.notNull(refreshTrigger, "RefreshTriggers must not be null");
             LettuceAssert.noNullElements(refreshTrigger, "RefreshTriggers must not contain null elements");
-            LettuceAssert.notEmpty(refreshTrigger, "RefreshTriggers must not contain at least one element");
+            LettuceAssert.notEmpty(refreshTrigger, "RefreshTriggers must contain at least one element");
 
             adaptiveRefreshTriggers.addAll(Arrays.asList(refreshTrigger));
             return this;
diff --git a/src/main/java/io/lettuce/core/cluster/NodeSelectionInvocationHandler.java b/src/main/java/io/lettuce/core/cluster/NodeSelectionInvocationHandler.java
index eb8aa67ec..ec620024f 100644
--- a/src/main/java/io/lettuce/core/cluster/NodeSelectionInvocationHandler.java
+++ b/src/main/java/io/lettuce/core/cluster/NodeSelectionInvocationHandler.java
@@ -264,7 +264,7 @@ class NodeSelectionInvocationHandler extends AbstractInvocationHandler {
     }
 
     private String getNodeDescription(List<RedisClusterNode> notFinished) {
-        return String.join(", ", notFinished.stream().map(this::getDescriptor).collect(Collectors.toList()));
+        return notFinished.stream().map(this::getDescriptor).collect(Collectors.joining(", "));
     }
 
     private String getDescriptor(RedisClusterNode redisClusterNode) {
diff --git a/src/main/java/io/lettuce/core/cluster/PooledClusterConnectionProvider.java b/src/main/java/io/lettuce/core/cluster/PooledClusterConnectionProvider.java
index 56ece5d73..a82dee16c 100644
--- a/src/main/java/io/lettuce/core/cluster/PooledClusterConnectionProvider.java
+++ b/src/main/java/io/lettuce/core/cluster/PooledClusterConnectionProvider.java
@@ -74,7 +74,6 @@ class PooledClusterConnectionProvider<K, V>
 
     private static final InternalLogger logger = InternalLoggerFactory.getInstance(PooledClusterConnectionProvider.class);
 
-    // Contains NodeId-identified and HostAndPort-identified connections.
     private final Lock stateLock = new ReentrantLock();
 
     private final boolean debugEnabled = logger.isDebugEnabled();
@@ -158,46 +157,38 @@ class PooledClusterConnectionProvider<K, V>
 
     private CompletableFuture<StatefulRedisConnection<K, V>> getWriteConnection(int slot) {
 
-        CompletableFuture<StatefulRedisConnection<K, V>> writer;// avoid races when reconfiguring partitions.
-
-        stateLock.lock();
-        try {
-            writer = writers[slot];
-        } finally {
-            stateLock.unlock();
+        CompletableFuture<StatefulRedisConnection<K, V>> writer = writers[slot];
+        if (writer != null) {
+            return writer;
         }
 
-        if (writer == null) {
-            RedisClusterNode master = partitions.getMasterBySlot(slot);
-            if (master == null) {
-                clusterEventListener.onUncoveredSlot(slot);
-                return Futures.failed(new PartitionSelectorException("Cannot determine a partition for slot " + slot + ".",
-                        partitions.clone()));
-            }
+        RedisClusterNode master = partitions.getMasterBySlot(slot);
+        if (master == null) {
+            clusterEventListener.onUncoveredSlot(slot);
+            return Futures.failed(
+                    new PartitionSelectorException("Cannot determine a partition for slot " + slot + ".", partitions.clone()));
+        }
 
-            // Use always host and port for slot-oriented operations. We don't want to get reconnected on a different
-            // host because the nodeId can be handled by a different host.
-            RedisURI uri = master.getUri();
-            ConnectionKey key = new ConnectionKey(ConnectionIntent.WRITE, uri.getHost(), uri.getPort());
+        // Use always host and port for slot-oriented operations. We don't want to get reconnected on a different
+        // host because the nodeId can be handled by a different host.
+        RedisURI uri = master.getUri();
+        ConnectionKey key = new ConnectionKey(ConnectionIntent.WRITE, uri.getHost(), uri.getPort());
 
-            ConnectionFuture<StatefulRedisConnection<K, V>> future = getConnectionAsync(key);
+        ConnectionFuture<StatefulRedisConnection<K, V>> future = getConnectionAsync(key);
 
-            return future.thenApply(connection -> {
+        return future.thenApply(connection -> {
 
-                stateLock.lock();
-                try {
-                    if (writers[slot] == null) {
-                        writers[slot] = CompletableFuture.completedFuture(connection);
-                    }
-                } finally {
-                    stateLock.unlock();
+            stateLock.lock();
+            try {
+                if (writers[slot] == null) {
+                    writers[slot] = CompletableFuture.completedFuture(connection);
                 }
+            } finally {
+                stateLock.unlock();
+            }
 
-                return connection;
-            }).toCompletableFuture();
-        }
-
-        return writer;
+            return connection;
+        }).toCompletableFuture();
     }
 
     private CompletableFuture<StatefulRedisConnection<K, V>> getReadConnection(int slot) {
@@ -654,7 +645,6 @@ class PooledClusterConnectionProvider<K, V>
     }
 
     /**
-     *
      * @return number of connections.
      */
     long getConnectionCount() {
diff --git a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionHashAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionHashAsyncCommands.java
index f6e733034..c9e13ba1d 100644
--- a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionHashAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionHashAsyncCommands.java
@@ -20,6 +20,8 @@
 package io.lettuce.core.cluster.api.async;
 
 import io.lettuce.core.ExpireArgs;
+import io.lettuce.core.HGetExArgs;
+import io.lettuce.core.HSetExArgs;
 import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
@@ -395,6 +397,78 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<Long> hset(K key, Map<K, V> map);
 
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    AsyncExecutions<Long> hsetex(K key, Map<K, V> map);
+
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hSetExArgs hsetex arguments.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    AsyncExecutions<Long> hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    AsyncExecutions<List<KeyValue<K, V>>> hgetex(K key, K... fields);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    AsyncExecutions<List<KeyValue<K, V>>> hgetex(K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    AsyncExecutions<Long> hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Get and delete one or more hash fields.
+     *
+     * @param key the hash key.
+     * @param fields fields to retrieve and delete.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     */
+    AsyncExecutions<List<KeyValue<K, V>>> hgetdel(K key, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param fields fields to retrieve and delete.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    AsyncExecutions<Long> hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields);
+
     /**
      * Set the value of a hash field, only if the field does not exist.
      *
diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionHashCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionHashCommands.java
index 05cf792d9..13dcd1d92 100644
--- a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionHashCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionHashCommands.java
@@ -20,6 +20,8 @@
 package io.lettuce.core.cluster.api.sync;
 
 import io.lettuce.core.ExpireArgs;
+import io.lettuce.core.HGetExArgs;
+import io.lettuce.core.HSetExArgs;
 import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
@@ -393,6 +395,78 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<Long> hset(K key, Map<K, V> map);
 
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Executions<Long> hsetex(K key, Map<K, V> map);
+
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hSetExArgs hsetex arguments.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Executions<Long> hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    Executions<List<KeyValue<K, V>>> hgetex(K key, K... fields);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    Executions<List<KeyValue<K, V>>> hgetex(K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Executions<Long> hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Get and delete one or more hash fields.
+     *
+     * @param key the hash key.
+     * @param fields fields to retrieve and delete.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     */
+    Executions<List<KeyValue<K, V>>> hgetdel(K key, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param fields fields to retrieve and delete.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Executions<Long> hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields);
+
     /**
      * Set the value of a hash field, only if the field does not exist.
      *
diff --git a/src/main/java/io/lettuce/core/dynamic/SimpleBatcher.java b/src/main/java/io/lettuce/core/dynamic/SimpleBatcher.java
index a2ecf4883..53e23fad6 100644
--- a/src/main/java/io/lettuce/core/dynamic/SimpleBatcher.java
+++ b/src/main/java/io/lettuce/core/dynamic/SimpleBatcher.java
@@ -40,6 +40,7 @@ import io.lettuce.core.protocol.RedisCommand;
  *
  * @author Mark Paluch
  * @author Lucio Paiva
+ * @author Ivo Gaydajiev
  */
 class SimpleBatcher implements Batcher {
 
@@ -51,6 +52,11 @@ class SimpleBatcher implements Batcher {
 
     private final AtomicBoolean flushing = new AtomicBoolean();
 
+    // forceFlushRequested indicates that a flush was requested while there is already a flush in progress
+    // This flag is used to ensure we will flush again after the current flush is done
+    // to ensure that any commands added while dispatching the current flush are also dispatched
+    private final AtomicBoolean forceFlushRequested = new AtomicBoolean();
+
     public SimpleBatcher(StatefulConnection<Object, Object> connection, int batchSize) {
 
         LettuceAssert.isTrue(batchSize == -1 || batchSize > 1, "Batch size must be greater zero or -1");
@@ -95,37 +101,56 @@ class SimpleBatcher implements Batcher {
 
         List<RedisCommand<?, ?, ?>> commands = newDrainTarget();
 
-        while (flushing.compareAndSet(false, true)) {
+        while (true) {
+            if (flushing.compareAndSet(false, true)) {
+                try {
 
-            try {
+                    int consume = -1;
 
-                int consume = -1;
+                    if (!forcedFlush) {
+                        long queuedItems = queue.size();
+                        if (queuedItems >= batchSize) {
+                            consume = batchSize;
+                            defaultFlush = true;
+                        }
+                    }
 
-                if (!forcedFlush) {
-                    long queuedItems = queue.size();
-                    if (queuedItems >= batchSize) {
-                        consume = batchSize;
-                        defaultFlush = true;
+                    List<? extends RedisCommand<?, ?, ?>> batch = doFlush(forcedFlush, defaultFlush, consume);
+                    if (batch != null) {
+                        commands.addAll(batch);
                     }
-                }
 
-                List<? extends RedisCommand<?, ?, ?>> batch = doFlush(forcedFlush, defaultFlush, consume);
-                if (batch != null) {
-                    commands.addAll(batch);
-                }
+                    if (defaultFlush && !queue.isEmpty() && queue.size() > batchSize) {
+                        continue;
+                    }
+
+                    if (forceFlushRequested.compareAndSet(true, false)) {
+                        continue;
+                    }
 
-                if (defaultFlush && !queue.isEmpty() && queue.size() > batchSize) {
-                    continue;
+                    return new BatchTasks(commands);
+
+                } finally {
+                    flushing.set(false);
                 }
 
-                return new BatchTasks(commands);
+            } else {
+                // Another thread is already flushing
+                if (forcedFlush) {
+                    forceFlushRequested.set(true);
+                }
 
-            } finally {
-                flushing.set(false);
+                if (commands.isEmpty()) {
+                    return BatchTasks.EMPTY;
+                } else {
+                    // Scenario: A default flush is started in Thread T1.
+                    // If multiple default batches need processing, T1 will release `flushing` and try to reacquire it.
+                    // However, in the brief moment when T1 releases `flushing`, another thread (T2) might acquire it.
+                    // This lead to a state where T2 has taken over processing from T1 and T1 should return commands processed
+                    return new BatchTasks(commands);
+                }
             }
         }
-
-        return BatchTasks.EMPTY;
     }
 
     private List<? extends RedisCommand<?, ?, ?>> doFlush(boolean forcedFlush, boolean defaultFlush, int consume) {
diff --git a/src/main/java/io/lettuce/core/json/DefaultJsonParser.java b/src/main/java/io/lettuce/core/json/DefaultJsonParser.java
index afab7afa7..dd20fa41c 100644
--- a/src/main/java/io/lettuce/core/json/DefaultJsonParser.java
+++ b/src/main/java/io/lettuce/core/json/DefaultJsonParser.java
@@ -10,6 +10,7 @@ package io.lettuce.core.json;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.NullNode;
 
 import java.io.IOException;
 import java.nio.ByteBuffer;
@@ -61,6 +62,10 @@ public class DefaultJsonParser implements JsonParser {
     }
 
     private JsonValue parse(String value) {
+        if (value == null) {
+            return DelegateJsonValue.wrap(NullNode.getInstance());
+        }
+
         ObjectMapper mapper = new ObjectMapper();
         try {
             JsonNode root = mapper.readTree(value);
@@ -72,6 +77,10 @@ public class DefaultJsonParser implements JsonParser {
     }
 
     private JsonValue parse(ByteBuffer byteBuffer) {
+        if (byteBuffer == null) {
+            return DelegateJsonValue.wrap(NullNode.getInstance());
+        }
+
         ObjectMapper mapper = new ObjectMapper();
         try {
             byte[] bytes = new byte[byteBuffer.remaining()];
diff --git a/src/main/java/io/lettuce/core/json/UnproccessedJsonValue.java b/src/main/java/io/lettuce/core/json/UnproccessedJsonValue.java
index 0a0016750..92e98418a 100644
--- a/src/main/java/io/lettuce/core/json/UnproccessedJsonValue.java
+++ b/src/main/java/io/lettuce/core/json/UnproccessedJsonValue.java
@@ -45,6 +45,11 @@ class UnproccessedJsonValue implements JsonValue {
 
     @Override
     public String toString() {
+
+        if (unprocessedData == null) {
+            return null;
+        }
+
         if (isDeserialized()) {
             return jsonValue.toString();
         }
@@ -161,7 +166,9 @@ class UnproccessedJsonValue implements JsonValue {
             try {
                 if (!isDeserialized()) {
                     jsonValue = parser.createJsonValue(unprocessedData);
-                    unprocessedData.clear();
+                    if (unprocessedData != null) {
+                        unprocessedData.clear();
+                    }
                 }
             } finally {
                 lock.unlock();
diff --git a/src/main/java/io/lettuce/core/output/JsonValueListOutput.java b/src/main/java/io/lettuce/core/output/JsonValueListOutput.java
index 389b69662..e415f46a7 100644
--- a/src/main/java/io/lettuce/core/output/JsonValueListOutput.java
+++ b/src/main/java/io/lettuce/core/output/JsonValueListOutput.java
@@ -39,9 +39,13 @@ public class JsonValueListOutput<K, V> extends CommandOutput<K, V, List<JsonValu
             multi(1);
         }
 
-        ByteBuffer fetched = ByteBuffer.allocate(bytes.remaining());
-        fetched.put(bytes);
-        fetched.flip();
+        ByteBuffer fetched = null;
+        if (bytes != null) {
+            fetched = ByteBuffer.allocate(bytes.remaining());
+            fetched.put(bytes);
+            fetched.flip();
+        }
+
         output.add(parser.loadJsonValue(fetched));
     }
 
diff --git a/src/main/java/io/lettuce/core/output/ReplayOutput.java b/src/main/java/io/lettuce/core/output/ReplayOutput.java
index 4dafced0f..f8407723d 100644
--- a/src/main/java/io/lettuce/core/output/ReplayOutput.java
+++ b/src/main/java/io/lettuce/core/output/ReplayOutput.java
@@ -33,6 +33,11 @@ public class ReplayOutput<K, V> extends CommandOutput<K, V, List<ReplayOutput.Si
         output.add(new Integer(integer));
     }
 
+    @Override
+    public void set(double number) {
+        output.add(new Double(number));
+    }
+
     @Override
     public void setError(ByteBuffer error) {
         error.mark();
@@ -130,6 +135,21 @@ public class ReplayOutput<K, V> extends CommandOutput<K, V, List<ReplayOutput.Si
 
     }
 
+    static class Double extends Signal {
+
+        final double message;
+
+        Double(double message) {
+            this.message = message;
+        }
+
+        @Override
+        protected void replay(CommandOutput<?, ?, ?> target) {
+            target.set(message);
+        }
+
+    }
+
     public static class ErrorBytes extends BulkStringSupport {
 
         ErrorBytes(ByteBuffer message) {
diff --git a/src/main/java/io/lettuce/core/protocol/CommandExpiryWriter.java b/src/main/java/io/lettuce/core/protocol/CommandExpiryWriter.java
index dcd71e6d4..160deed18 100644
--- a/src/main/java/io/lettuce/core/protocol/CommandExpiryWriter.java
+++ b/src/main/java/io/lettuce/core/protocol/CommandExpiryWriter.java
@@ -179,8 +179,8 @@ public class CommandExpiryWriter implements RedisChannelWriter {
 
         Timeout commandTimeout = timer.newTimeout(t -> {
             if (!command.isDone()) {
-                executors.submit(() -> command.completeExceptionally(
-                        ExceptionFactory.createTimeoutException(Duration.ofNanos(timeUnit.toNanos(timeout)))));
+                executors.submit(() -> command.completeExceptionally(ExceptionFactory
+                        .createTimeoutException(command.getType().toString(), Duration.ofNanos(timeUnit.toNanos(timeout)))));
 
             }
         }, timeout, timeUnit);
diff --git a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java
index c9d782afd..2e2e5f457 100644
--- a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java
+++ b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java
@@ -41,7 +41,7 @@ public enum CommandKeyword implements ProtocolKeyword {
 
     IDLETIME, JUSTID, KILL, KEYSLOT, LEFT, LEN, LIMIT, LINKS, LIST, LOAD, LOG, MATCH,
 
-    MAX, MAXLEN, MEET, MIN, MINID, MINMATCHLEN, MOVED, NO, NOACK, NOCOMMANDS, NODE, NODES, NOMKSTREAM, NOPASS, NOSAVE, NOT, NOVALUES, NUMSUB, SHARDCHANNELS, SHARDNUMSUB, NUMPAT, NX, OFF, ON, ONE, OR, PAUSE, PREFIXES,
+    MAX, MAXLEN, MEET, MIN, MINID, MINMATCHLEN, MOVED, NO, NOACK, NOCOMMANDS, NODE, NODES, NOMKSTREAM, NOPASS, NOSAVE, NOT, NOVALUES, NUMSUB, SHARDCHANNELS, SHARDNUMSUB, NUMPAT, NX, FNX, OFF, ON, ONE, OR, PAUSE, PREFIXES,
 
     REFCOUNT, REMOVE, RELOAD, REPLACE, REDIRECT, REPLICATE, REPLICAS, REV, RESET, RESETCHANNELS, RESETKEYS, RESETPASS,
 
@@ -49,7 +49,7 @@ public enum CommandKeyword implements ProtocolKeyword {
 
     MIGRATING, IMPORTING, SAVE, SKIPME, SLAVES, STREAM, STORE, SUM, SEGFAULT, SETUSER, TAKEOVER, TRACKING, TRACKINGINFO, TYPE, UNBLOCK, USERS, USAGE, WEIGHTS, WHOAMI,
 
-    WITHMATCHLEN, WITHSCORE, WITHSCORES, WITHVALUES, XOR, XX, YES, INDENT, NEWLINE, SPACE, GT, LT;
+    WITHMATCHLEN, WITHSCORE, WITHSCORES, WITHVALUES, XOR, XX, FXX, YES, INDENT, NEWLINE, SPACE, GT, LT;
 
     public final byte[] bytes;
 
diff --git a/src/main/java/io/lettuce/core/protocol/CommandType.java b/src/main/java/io/lettuce/core/protocol/CommandType.java
index 9a2fcf83f..39d49ea5c 100644
--- a/src/main/java/io/lettuce/core/protocol/CommandType.java
+++ b/src/main/java/io/lettuce/core/protocol/CommandType.java
@@ -62,7 +62,7 @@ public enum CommandType implements ProtocolKeyword {
 
     // Hash
 
-    HDEL, HEXISTS, HGET, HGETALL, HINCRBY, HINCRBYFLOAT, HKEYS, HLEN, HSTRLEN, HMGET, HMSET, HRANDFIELD, HSET, HSETNX, HVALS, HSCAN,
+    HDEL, HEXISTS, HGET, HGETDEL, HGETALL, HINCRBY, HINCRBYFLOAT, HKEYS, HLEN, HSTRLEN, HMGET, HMSET, HRANDFIELD, HSET, HSETEX, HGETEX, HSETNX, HVALS, HSCAN,
 
     // Transaction
 
diff --git a/src/main/java/io/lettuce/core/protocol/DefaultEndpoint.java b/src/main/java/io/lettuce/core/protocol/DefaultEndpoint.java
index 502ce4a76..79f2f05f1 100644
--- a/src/main/java/io/lettuce/core/protocol/DefaultEndpoint.java
+++ b/src/main/java/io/lettuce/core/protocol/DefaultEndpoint.java
@@ -33,6 +33,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.Consumer;
+import java.util.function.Predicate;
 import java.util.function.Supplier;
 
 import io.lettuce.core.ClientOptions;
@@ -81,6 +82,8 @@ public class DefaultEndpoint implements RedisChannelWriter, Endpoint, PushHandle
 
     private final Reliability reliability;
 
+    private final Predicate<RedisCommand<?, ?, ?>> replayFilter;
+
     private final ClientOptions clientOptions;
 
     private final ClientResources clientResources;
@@ -139,6 +142,7 @@ public class DefaultEndpoint implements RedisChannelWriter, Endpoint, PushHandle
         this.clientOptions = clientOptions;
         this.clientResources = clientResources;
         this.reliability = clientOptions.isAutoReconnect() ? Reliability.AT_LEAST_ONCE : Reliability.AT_MOST_ONCE;
+        this.replayFilter = clientOptions.getReplayFilter();
         this.disconnectedBuffer = LettuceFactories.newConcurrentQueue(clientOptions.getRequestQueueSize());
         this.commandBuffer = LettuceFactories.newConcurrentQueue(clientOptions.getRequestQueueSize());
         this.boundedQueues = clientOptions.getRequestQueueSize() != Integer.MAX_VALUE;
@@ -343,6 +347,13 @@ public class DefaultEndpoint implements RedisChannelWriter, Endpoint, PushHandle
             return;
         }
 
+        if (replayFilter.test(command)) {
+            if (debugEnabled) {
+                logger.debug("{} writeToDisconnectedBuffer() Filtering out command {}", logPrefix(), command);
+            }
+            return;
+        }
+
         if (debugEnabled) {
             logger.debug("{} writeToDisconnectedBuffer() buffering (disconnected) command {}", logPrefix(), command);
         }
@@ -1033,10 +1044,16 @@ public class DefaultEndpoint implements RedisChannelWriter, Endpoint, PushHandle
         private void potentiallyRequeueCommands(Channel channel, RedisCommand<?, ?, ?> sentCommand,
                 Collection<? extends RedisCommand<?, ?, ?>> sentCommands) {
 
+            // do not requeue commands that are done
             if (sentCommand != null && sentCommand.isDone()) {
                 return;
             }
 
+            // do not requeue commands that are to be filtered out
+            if (this.endpoint.replayFilter.test(sentCommand)) {
+                return;
+            }
+
             if (sentCommands != null) {
 
                 boolean foundToSend = false;
diff --git a/src/main/java/io/lettuce/core/support/ConnectionPoolSupport.java b/src/main/java/io/lettuce/core/support/ConnectionPoolSupport.java
index f3ab8cb85..a048dfe27 100644
--- a/src/main/java/io/lettuce/core/support/ConnectionPoolSupport.java
+++ b/src/main/java/io/lettuce/core/support/ConnectionPoolSupport.java
@@ -6,6 +6,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Predicate;
 import java.util.function.Supplier;
 
 import org.apache.commons.pool2.BasePooledObjectFactory;
@@ -60,6 +61,7 @@ import io.lettuce.core.support.ConnectionWrapping.Origin;
  * </pre>
  *
  * @author Mark Paluch
+ * @author dae won
  * @since 4.3
  */
 public abstract class ConnectionPoolSupport {
@@ -69,7 +71,8 @@ public abstract class ConnectionPoolSupport {
 
     /**
      * Creates a new {@link GenericObjectPool} using the {@link Supplier}. Allocated instances are wrapped and must not be
-     * returned with {@link ObjectPool#returnObject(Object)}.
+     * returned with {@link ObjectPool#returnObject(Object)}. By default, connections are validated by checking their
+     * {@link StatefulConnection#isOpen()} method.
      *
      * @param connectionSupplier must not be {@code null}.
      * @param config must not be {@code null}.
@@ -78,16 +81,32 @@ public abstract class ConnectionPoolSupport {
      */
     public static <T extends StatefulConnection<?, ?>> GenericObjectPool<T> createGenericObjectPool(
             Supplier<T> connectionSupplier, GenericObjectPoolConfig<T> config) {
-        return createGenericObjectPool(connectionSupplier, config, true);
+        return createGenericObjectPool(connectionSupplier, config, true, (c) -> c.isOpen());
     }
 
     /**
-     * Creates a new {@link GenericObjectPool} using the {@link Supplier}.
+     * Creates a new {@link GenericObjectPool} using the {@link Supplier}. Allocated instances are wrapped and must not be
+     * returned with {@link ObjectPool#returnObject(Object)}.
+     *
+     * @param connectionSupplier must not be {@code null}.
+     * @param config must not be {@code null}.
+     * @param validationPredicate a {@link Predicate} to help validate connections
+     * @param <T> connection type.
+     * @return the connection pool.
+     */
+    public static <T extends StatefulConnection<?, ?>> GenericObjectPool<T> createGenericObjectPool(
+            Supplier<T> connectionSupplier, GenericObjectPoolConfig<T> config, Predicate<T> validationPredicate) {
+        return createGenericObjectPool(connectionSupplier, config, true, validationPredicate);
+    }
+
+    /**
+     * Creates a new {@link GenericObjectPool} using the {@link Supplier}. By default, connections are validated by checking
+     * their {@link StatefulConnection#isOpen()} method.
      *
      * @param connectionSupplier must not be {@code null}.
      * @param config must not be {@code null}.
      * @param wrapConnections {@code false} to return direct connections that need to be returned to the pool using
-     *        {@link ObjectPool#returnObject(Object)}. {@code true} to return wrapped connection that are returned to the pool
+     *        {@link ObjectPool#returnObject(Object)}. {@code true} to return wrapped connections that are returned to the pool
      *        when invoking {@link StatefulConnection#close()}.
      * @param <T> connection type.
      * @return the connection pool.
@@ -95,13 +114,34 @@ public abstract class ConnectionPoolSupport {
     @SuppressWarnings("unchecked")
     public static <T extends StatefulConnection<?, ?>> GenericObjectPool<T> createGenericObjectPool(
             Supplier<T> connectionSupplier, GenericObjectPoolConfig<T> config, boolean wrapConnections) {
+        return createGenericObjectPool(connectionSupplier, config, wrapConnections, (c) -> c.isOpen());
+    }
+
+    /**
+     * Creates a new {@link GenericObjectPool} using the {@link Supplier}.
+     *
+     * @param connectionSupplier must not be {@code null}.
+     * @param config must not be {@code null}.
+     * @param wrapConnections {@code false} to return direct connections that need to be returned to the pool using
+     *        {@link ObjectPool#returnObject(Object)}. {@code true} to return wrapped connections that are returned to the pool
+     *        when invoking {@link StatefulConnection#close()}.
+     * @param validationPredicate a {@link Predicate} to help validate connections
+     * @param <T> connection type.
+     * @return the connection pool.
+     */
+    @SuppressWarnings("unchecked")
+    public static <T extends StatefulConnection<?, ?>> GenericObjectPool<T> createGenericObjectPool(
+            Supplier<T> connectionSupplier, GenericObjectPoolConfig<T> config, boolean wrapConnections,
+            Predicate<T> validationPredicate) {
 
         LettuceAssert.notNull(connectionSupplier, "Connection supplier must not be null");
         LettuceAssert.notNull(config, "GenericObjectPoolConfig must not be null");
+        LettuceAssert.notNull(validationPredicate, "Connection validator must not be null");
 
         AtomicReference<Origin<T>> poolRef = new AtomicReference<>();
 
-        GenericObjectPool<T> pool = new GenericObjectPool<T>(new RedisPooledObjectFactory<T>(connectionSupplier), config) {
+        GenericObjectPool<T> pool = new GenericObjectPool<T>(
+                new RedisPooledObjectFactory<>(connectionSupplier, validationPredicate), config) {
 
             @Override
             public T borrowObject() throws Exception {
@@ -144,7 +184,7 @@ public abstract class ConnectionPoolSupport {
      *
      * @param connectionSupplier must not be {@code null}.
      * @param wrapConnections {@code false} to return direct connections that need to be returned to the pool using
-     *        {@link ObjectPool#returnObject(Object)}. {@code true} to return wrapped connection that are returned to the pool
+     *        {@link ObjectPool#returnObject(Object)}. {@code true} to return wrapped connections that are returned to the pool
      *        when invoking {@link StatefulConnection#close()}.
      * @param <T> connection type.
      * @return the connection pool.
@@ -152,12 +192,30 @@ public abstract class ConnectionPoolSupport {
     @SuppressWarnings("unchecked")
     public static <T extends StatefulConnection<?, ?>> SoftReferenceObjectPool<T> createSoftReferenceObjectPool(
             Supplier<T> connectionSupplier, boolean wrapConnections) {
+        return createSoftReferenceObjectPool(connectionSupplier, wrapConnections, (c) -> c.isOpen());
+    }
+
+    /**
+     * Creates a new {@link SoftReferenceObjectPool} using the {@link Supplier}.
+     *
+     * @param connectionSupplier must not be {@code null}.
+     * @param wrapConnections {@code false} to return direct connections that need to be returned to the pool using
+     *        {@link ObjectPool#returnObject(Object)}. {@code true} to return wrapped connections that are returned to the pool
+     *        when invoking {@link StatefulConnection#close()}.
+     * @param validationPredicate a {@link Predicate} to help validate connections
+     * @param <T> connection type.
+     * @return the connection pool.
+     */
+    @SuppressWarnings("unchecked")
+    public static <T extends StatefulConnection<?, ?>> SoftReferenceObjectPool<T> createSoftReferenceObjectPool(
+            Supplier<T> connectionSupplier, boolean wrapConnections, Predicate<T> validationPredicate) {
 
         LettuceAssert.notNull(connectionSupplier, "Connection supplier must not be null");
 
         AtomicReference<Origin<T>> poolRef = new AtomicReference<>();
 
-        SoftReferenceObjectPool<T> pool = new SoftReferenceObjectPool<T>(new RedisPooledObjectFactory<>(connectionSupplier)) {
+        SoftReferenceObjectPool<T> pool = new SoftReferenceObjectPool<T>(
+                new RedisPooledObjectFactory<>(connectionSupplier, validationPredicate)) {
 
             private final Lock lock = new ReentrantLock();
 
@@ -200,8 +258,11 @@ public abstract class ConnectionPoolSupport {
 
         private final Supplier<T> connectionSupplier;
 
-        RedisPooledObjectFactory(Supplier<T> connectionSupplier) {
+        private final Predicate<T> validationPredicate;
+
+        RedisPooledObjectFactory(Supplier<T> connectionSupplier, Predicate<T> validationPredicate) {
             this.connectionSupplier = connectionSupplier;
+            this.validationPredicate = validationPredicate;
         }
 
         @Override
@@ -221,7 +282,7 @@ public abstract class ConnectionPoolSupport {
 
         @Override
         public boolean validateObject(PooledObject<T> p) {
-            return p.getObject().isOpen();
+            return this.validationPredicate.test(p.getObject());
         }
 
     }
diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt
index 3bee6f59b..72aec71cb 100644
--- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt
+++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt
@@ -28,6 +28,8 @@ import io.lettuce.core.KeyValue
 import io.lettuce.core.MapScanCursor
 import io.lettuce.core.ScanArgs
 import io.lettuce.core.ScanCursor
+import io.lettuce.core.HGetExArgs
+import io.lettuce.core.HSetExArgs
 import java.time.Duration
 import java.time.Instant
 import java.util.*
@@ -276,6 +278,37 @@ interface RedisHashCoroutinesCommands<K : Any, V : Any> {
      */
     suspend fun hset(key: K, map: Map<K, V>): Long?
 
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hSetExArgs hsetex arguments.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    suspend fun hsetex(key: K, hSetExArgs: HSetExArgs, map: Map<K, V>): Long?
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return List<KeyValue<K, V>> array-reply list of fields and their values.
+     * @since 6.6
+     */
+    fun hgetex(key: K, hGetExArgs: HGetExArgs, vararg fields: K): Flow<KeyValue<K, V>>
+
+    /**
+     * Get and delete one or more hash fields.
+     *
+     * @param key the hash key.
+     * @param fields one or more fields whose values will be retrieved and deleted.
+     * @return List<KeyValue<K, V>> array-reply list of fields and their values.
+     */
+    fun hgetdel(key: K, vararg fields: K): Flow<KeyValue<K, V>>
+
     /**
      * Set the value of a hash field, only if the field does not exist.
      *
diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt
index 23231b5f5..cd945c3c0 100644
--- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt
+++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt
@@ -62,6 +62,13 @@ internal class RedisHashCoroutinesCommandsImpl<K : Any, V : Any>(internal val op
     override fun hmget(key: K, vararg fields: K): Flow<KeyValue<K, V>> =
         ops.hmget(key, *fields).asFlow()
 
+    override suspend fun hsetex(key: K, hSetExArgs: HSetExArgs, map: Map<K, V>): Long? =
+        ops.hsetex(key, hSetExArgs, map).awaitFirstOrNull()
+
+    override fun hgetex(key: K, hGetExArgs: HGetExArgs, vararg fields: K): Flow<KeyValue<K, V>> = ops.hgetex(key, hGetExArgs, *fields).asFlow()
+
+    override fun hgetdel(key: K, vararg fields: K): Flow<KeyValue<K, V>> = ops.hgetdel(key, *fields).asFlow()
+
     override suspend fun hrandfield(key: K): K? = ops.hrandfield(key).awaitFirstOrNull();
 
     override suspend fun hrandfield(key: K, count: Long): List<K> =
diff --git a/src/main/templates/io/lettuce/core/api/RedisHashCommands.java b/src/main/templates/io/lettuce/core/api/RedisHashCommands.java
index 461563ad6..e0f362492 100644
--- a/src/main/templates/io/lettuce/core/api/RedisHashCommands.java
+++ b/src/main/templates/io/lettuce/core/api/RedisHashCommands.java
@@ -31,6 +31,8 @@ import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
 import io.lettuce.core.ScanArgs;
 import io.lettuce.core.ScanCursor;
+import io.lettuce.core.HGetExArgs;
+import io.lettuce.core.HSetExArgs;
 import io.lettuce.core.StreamScanCursor;
 import io.lettuce.core.output.KeyStreamingChannel;
 import io.lettuce.core.output.KeyValueStreamingChannel;
@@ -392,6 +394,78 @@ public interface RedisHashCommands<K, V> {
      */
     Long hset(K key, Map<K, V> map);
 
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Long hsetex(K key, Map<K, V> map);
+
+    /**
+     * Set the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hSetExArgs hsetex arguments.
+     * @param map the field/value pairs to update.
+     * @return Long long-reply: 0 if no fields were set, 1 if all the fields were set
+     * @since 6.6
+     */
+    Long hsetex(K key, HSetExArgs hSetExArgs, Map<K, V> map);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    List<KeyValue<K, V>> hgetex(K key, K... fields);
+
+    /**
+     * Get the value of one or more fields of a given hash key, and optionally set their expiration
+     *
+     * @param key the key of the hash.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     * @since 6.6
+     */
+    List<KeyValue<K, V>> hgetex(K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param hGetExArgs hgetex arguments.
+     * @param fields fields to retrieve.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Long hgetex(KeyValueStreamingChannel<K, V> channel, K key, HGetExArgs hGetExArgs, K... fields);
+
+    /**
+     * Get and delete one or more hash fields.
+     *
+     * @param key the hash key.
+     * @param fields fields to retrieve and delete.
+     * @return List&lt;KeyValue&lt;K, V&gt;&gt; array-reply list of fields and their values.
+     */
+    List<KeyValue<K, V>> hgetdel(K key, K... fields);
+
+    /**
+     * Stream over the values of all the given hash fields.
+     *
+     * @param channel the channel.
+     * @param key the key.
+     * @param fields fields to retrieve and delete.
+     * @return Long the number of fields that were removed from the hash.
+     */
+    Long hgetdel(KeyValueStreamingChannel<K, V> channel, K key, K... fields);
+
     /**
      * Set the value of a hash field, only if the field does not exist.
      *


Test patch:
diff --git a/src/test/java/io/lettuce/authx/DefaultAzureCredentialsIntegrationTests.java b/src/test/java/io/lettuce/authx/DefaultAzureCredentialsIntegrationTests.java
new file mode 100644
index 000000000..fa913ebda
--- /dev/null
+++ b/src/test/java/io/lettuce/authx/DefaultAzureCredentialsIntegrationTests.java
@@ -0,0 +1,102 @@
+package io.lettuce.authx;
+
+import com.azure.identity.DefaultAzureCredential;
+import com.azure.identity.DefaultAzureCredentialBuilder;
+import io.lettuce.core.ClientOptions;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisCredentials;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.SocketOptions;
+import io.lettuce.core.TimeoutOptions;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.api.sync.RedisCommands;
+import io.lettuce.test.env.Endpoints;
+import io.lettuce.test.env.Endpoints.Endpoint;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import redis.clients.authentication.core.TokenAuthConfig;
+import redis.clients.authentication.entraid.AzureTokenAuthConfigBuilder;
+
+import java.time.Duration;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+
+import static io.lettuce.TestTags.ENTRA_ID;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+@Tag(ENTRA_ID)
+public class DefaultAzureCredentialsIntegrationTests {
+
+    private static final EntraIdTestContext testCtx = EntraIdTestContext.DEFAULT;
+
+    private RedisClient client;
+
+    private Endpoint standalone;
+
+    private ClientOptions clientOptions;
+
+    private TokenBasedRedisCredentialsProvider credentialsProvider;
+
+    @BeforeEach
+    public void setup() {
+        standalone = Endpoints.DEFAULT.getEndpoint("standalone-entraid-acl");
+        assumeTrue(standalone != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");
+        Assumptions.assumeTrue(testCtx.getClientId() != null && testCtx.getClientSecret() != null,
+                "Skipping EntraID tests. Azure AD credentials not provided!");
+
+        clientOptions = ClientOptions.builder()
+                .socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
+                .timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
+                .reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();
+
+        DefaultAzureCredential credential = new DefaultAzureCredentialBuilder().build();
+
+        TokenAuthConfig tokenAuthConfig = AzureTokenAuthConfigBuilder.builder().defaultAzureCredential(credential)
+                .tokenRequestExecTimeoutInMs(2000).build();
+
+        credentialsProvider = TokenBasedRedisCredentialsProvider.create(tokenAuthConfig);
+
+        client = createClient(credentialsProvider);
+    }
+
+    @AfterEach
+    public void cleanUp() {
+        if (credentialsProvider != null) {
+            credentialsProvider.close();
+        }
+        if (client != null) {
+            client.shutdown();
+        }
+    }
+
+    @Test
+    public void azureTokenAuthWithDefaultAzureCredentials() throws ExecutionException, InterruptedException {
+
+        RedisCredentials credentials = credentialsProvider.resolveCredentials().block(Duration.ofSeconds(5));
+        assertThat(credentials).isNotNull();
+
+        String key = UUID.randomUUID().toString();
+        try (StatefulRedisConnection<String, String> connection = client.connect()) {
+            RedisCommands<String, String> sync = connection.sync();
+            assertThat(sync.aclWhoami()).isEqualTo(credentials.getUsername());
+            sync.set(key, "value");
+            assertThat(sync.get(key)).isEqualTo("value");
+            assertThat(connection.async().get(key).get()).isEqualTo("value");
+            assertThat(connection.reactive().get(key).block()).isEqualTo("value");
+            sync.del(key);
+        }
+    }
+
+    private RedisClient createClient(TokenBasedRedisCredentialsProvider credentialsProvider) {
+        RedisURI uri = RedisURI.create((standalone.getEndpoints().get(0)));
+        uri.setCredentialsProvider(credentialsProvider);
+        RedisClient redis = RedisClient.create(uri);
+        redis.setOptions(clientOptions);
+        return redis;
+    }
+
+}
diff --git a/src/test/java/io/lettuce/authx/EntraIdIntegrationTests.java b/src/test/java/io/lettuce/authx/EntraIdIntegrationTests.java
index ba2f08d76..da482e882 100644
--- a/src/test/java/io/lettuce/authx/EntraIdIntegrationTests.java
+++ b/src/test/java/io/lettuce/authx/EntraIdIntegrationTests.java
@@ -1,26 +1,15 @@
 package io.lettuce.authx;
 
-import io.lettuce.core.ClientOptions;
-import io.lettuce.core.RedisClient;
-import io.lettuce.core.RedisFuture;
-import io.lettuce.core.RedisURI;
-import io.lettuce.core.SocketOptions;
-import io.lettuce.core.TimeoutOptions;
-import io.lettuce.core.TransactionResult;
+import io.lettuce.core.*;
 import io.lettuce.core.api.StatefulRedisConnection;
 import io.lettuce.core.api.async.RedisAsyncCommands;
 import io.lettuce.core.api.sync.RedisCommands;
-import io.lettuce.core.cluster.ClusterClientOptions;
 import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
 import io.lettuce.core.support.PubSubTestListener;
 import io.lettuce.test.Wait;
 import io.lettuce.test.env.Endpoints;
 import io.lettuce.test.env.Endpoints.Endpoint;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.Assumptions;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.*;
 import redis.clients.authentication.core.TokenAuthConfig;
 import redis.clients.authentication.entraid.EntraIDTokenAuthConfigBuilder;
 
@@ -41,52 +30,49 @@ public class EntraIdIntegrationTests {
 
     private static final EntraIdTestContext testCtx = EntraIdTestContext.DEFAULT;
 
-    private static TokenBasedRedisCredentialsProvider credentialsProvider;
+    private RedisClient client;
 
-    private static RedisClient client;
+    private Endpoint standalone;
 
-    private static Endpoint standalone;
+    private ClientOptions clientOptions;
 
-    @BeforeAll
-    public static void setup() {
+    private TokenBasedRedisCredentialsProvider credentialsProvider;
+
+    @BeforeEach
+    public void setup() {
         standalone = Endpoints.DEFAULT.getEndpoint("standalone-entraid-acl");
-        if (standalone != null) {
-            Assumptions.assumeTrue(testCtx.getClientId() != null && testCtx.getClientSecret() != null,
-                    "Skipping EntraID tests. Azure AD credentials not provided!");
-            // Configure timeout options to assure fast test failover
-            ClusterClientOptions clientOptions = ClusterClientOptions.builder()
-                    .socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
-                    .timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
-                    // enable re-authentication
-                    .reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();
-
-            TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
-                    .secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes())
-                    .expirationRefreshRatio(0.0000001F).build();
-
-            credentialsProvider = TokenBasedRedisCredentialsProvider.create(tokenAuthConfig);
-
-            RedisURI uri = RedisURI.create((standalone.getEndpoints().get(0)));
-            uri.setCredentialsProvider(credentialsProvider);
-            client = RedisClient.create(uri);
-            client.setOptions(clientOptions);
+        assumeTrue(standalone != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");
+        Assumptions.assumeTrue(testCtx.getClientId() != null && testCtx.getClientSecret() != null,
+                "Skipping EntraID tests. Azure AD credentials not provided!");
 
-        }
+        clientOptions = ClientOptions.builder()
+                .socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
+                .timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
+                .reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();
+
+        TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
+                .secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes())
+                .expirationRefreshRatio(0.0000001F).build();
+
+        credentialsProvider = TokenBasedRedisCredentialsProvider.create(tokenAuthConfig);
+
+        client = createClient(credentialsProvider);
     }
 
-    @AfterAll
-    public static void cleanup() {
+    @AfterEach
+    public void cleanUp() {
         if (credentialsProvider != null) {
             credentialsProvider.close();
         }
+        if (client != null) {
+            client.shutdown();
+        }
     }
 
     // T.1.1
     // Verify authentication using Azure AD with service principals using Redis Standalone client
     @Test
     public void standaloneWithSecret_azureServicePrincipalIntegrationTest() throws ExecutionException, InterruptedException {
-        assumeTrue(standalone != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");
-
         try (StatefulRedisConnection<String, String> connection = client.connect()) {
             RedisCommands<String, String> sync = connection.sync();
             String key = UUID.randomUUID().toString();
@@ -102,32 +88,23 @@ public class EntraIdIntegrationTests {
     // Test that the Redis client is not blocked/interrupted during token renewal.
     @Test
     public void renewalDuringOperationsTest() throws InterruptedException {
-        assumeTrue(standalone != null, "Skipping EntraID tests. Redis host with enabled EntraId not provided!");
-
-        // Counter to track the number of command cycles
         AtomicInteger commandCycleCount = new AtomicInteger(0);
 
-        // Start a thread to continuously send Redis commands
         Thread commandThread = new Thread(() -> {
             try (StatefulRedisConnection<String, String> connection = client.connect()) {
                 RedisAsyncCommands<String, String> async = connection.async();
                 for (int i = 1; i <= 10; i++) {
-                    // Start a transaction with SET and INCRBY commands
-                    RedisFuture<String> multi = async.multi();
-                    RedisFuture<String> set = async.set("key", "1");
-                    RedisFuture<Long> incrby = async.incrby("key", 1);
+                    async.multi();
+                    async.set("key", "1");
+                    async.incrby("key", 1);
                     RedisFuture<TransactionResult> exec = async.exec();
                     TransactionResult results = exec.get(1, TimeUnit.SECONDS);
 
-                    // Increment the command cycle count after each execution
                     commandCycleCount.incrementAndGet();
 
-                    // Verify the results from EXEC
-                    assertThat(results).hasSize(2); // We expect 2 responses: SET and INCRBY
-
-                    // Check the response from each command in the transaction
-                    assertThat((String) results.get(0)).isEqualTo("OK"); // SET "key" = "1"
-                    assertThat((Long) results.get(1)).isEqualTo(2L); // INCRBY "key" by 1, expected result is 2
+                    assertThat(results).hasSize(2);
+                    assertThat((String) results.get(0)).isEqualTo("OK");
+                    assertThat((Long) results.get(1)).isEqualTo(2L);
                 }
             } catch (Exception e) {
                 fail("Command execution failed during token refresh", e);
@@ -136,16 +113,12 @@ public class EntraIdIntegrationTests {
 
         commandThread.start();
 
-        CountDownLatch latch = new CountDownLatch(10); // Wait for at least 10 token renewals
-
-        credentialsProvider.credentials().subscribe(cred -> {
-            latch.countDown(); // Signal each renewal as it's received
-        });
+        CountDownLatch latch = new CountDownLatch(10); // Wait for at least 10 token renewalss
+        credentialsProvider.credentials().subscribe(cred -> latch.countDown());
 
-        assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue(); // Wait to reach 10 renewals
+        assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue(); // Wait to reach 10 renewals
         commandThread.join(); // Wait for the command thread to finish
 
-        // Verify that at least 10 command cycles were executed during the test
         assertThat(commandCycleCount.get()).isGreaterThanOrEqualTo(10);
     }
 
@@ -162,7 +135,6 @@ public class EntraIdIntegrationTests {
             connectionPubSub.addListener(listener);
             connectionPubSub.sync().subscribe("channel");
 
-            // Start a thread to continuously send Redis commands
             Thread pubsubThread = new Thread(() -> {
                 for (int i = 1; i <= 100; i++) {
                     connectionPubSub1.sync().publish("channel", "message");
@@ -172,17 +144,22 @@ public class EntraIdIntegrationTests {
             pubsubThread.start();
 
             CountDownLatch latch = new CountDownLatch(10);
-            credentialsProvider.credentials().subscribe(cred -> {
-                latch.countDown();
-            });
+            credentialsProvider.credentials().subscribe(cred -> latch.countDown());
 
             assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue(); // Wait for at least 10 token renewals
             pubsubThread.join(); // Wait for the pub/sub thread to finish
 
-            // Verify that all messages were received
             Wait.untilEquals(100, () -> listener.getMessages().size()).waitOrTimeout();
             assertThat(listener.getMessages()).allMatch(msg -> msg.equals("message"));
         }
     }
 
+    private RedisClient createClient(TokenBasedRedisCredentialsProvider credentialsProvider) {
+        RedisURI uri = RedisURI.create((standalone.getEndpoints().get(0)));
+        uri.setCredentialsProvider(credentialsProvider);
+        RedisClient redis = RedisClient.create(uri);
+        redis.setOptions(clientOptions);
+        return redis;
+    }
+
 }
diff --git a/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java b/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java
index 2d4f1cf40..a02ae2194 100644
--- a/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java
+++ b/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java
@@ -8,6 +8,8 @@ import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -162,6 +164,63 @@ class RedisCommandBuilderUnitTests {
                         + "3\r\n" + "$7\r\n" + "hField1\r\n" + "$7\r\n" + "hField2\r\n" + "$7\r\n" + "hField3\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructHgetdel() {
+
+        Command<String, String, ?> command = sut.hgetdel(MY_KEY, "one", "two");
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*6\r\n" + "$7\r\n" + "HGETDEL\r\n" + "$4\r\n" + "hKey\r\n"
+                + "$6\r\n" + "FIELDS\r\n" + "$1\r\n" + "2\r\n" + "$3\r\n" + "one\r\n" + "$3\r\n" + "two\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructHsetex() {
+        Command<String, String, ?> command = sut.hsetex(MY_KEY, Collections.singletonMap("one", "1"));
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*6\r\n" + "$6\r\n" + "HSETEX\r\n" + "$4\r\n" + "hKey\r\n"
+                + "$6\r\n" + "FIELDS\r\n" + "$1\r\n" + "1\r\n" + "$3\r\n" + "one\r\n" + "$1\r\n" + "1\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructHsetexWithArgs() {
+        Command<String, String, ?> command = sut.hsetex(MY_KEY, HSetExArgs.Builder.ex(Duration.ofSeconds(10)).fnx(),
+                Collections.singletonMap("one", "1"));
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        String expected = "*9\r\n" + "$6\r\n" + "HSETEX\r\n" + "$4\r\n" + "hKey\r\n" + "$2\r\n" + "EX\r\n" + "$2\r\n" + "10\r\n"
+                + "$3\r\n" + "FNX\r\n" + "$6\r\n" + "FIELDS\r\n" + "$1\r\n" + "1\r\n" + "$3\r\n" + "one\r\n" + "$1\r\n"
+                + "1\r\n";
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*9\r\n" + "$6\r\n" + "HSETEX\r\n" + "$4\r\n" + "hKey\r\n"
+                + "$2\r\n" + "EX\r\n" + "$2\r\n" + "10\r\n" + "$3\r\n" + "FNX\r\n" + "$6\r\n" + "FIELDS\r\n" + "$1\r\n"
+                + "1\r\n" + "$3\r\n" + "one\r\n" + "$1\r\n" + "1\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructHgetex() {
+        Command<String, String, ?> command = sut.hgetex(MY_KEY, "one");
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*5\r\n" + "$6\r\n" + "HGETEX\r\n" + "$4\r\n" + "hKey\r\n"
+                + "$6\r\n" + "FIELDS\r\n" + "$1\r\n" + "1\r\n" + "$3\r\n" + "one\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructHgetexWithArgs() {
+        Command<String, String, ?> command = sut.hgetex(MY_KEY, HGetExArgs.Builder.ex(Duration.ofSeconds(10)).persist(), "one");
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo(
+                "*8\r\n" + "$6\r\n" + "HGETEX\r\n" + "$4\r\n" + "hKey\r\n" + "$2\r\n" + "EX\r\n" + "$2\r\n" + "10\r\n"
+                        + "$7\r\n" + "PERSIST\r\n" + "$6\r\n" + "FIELDS\r\n" + "$1\r\n" + "1\r\n" + "$3\r\n" + "one\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructClientTrackinginfo() {
 
diff --git a/src/test/java/io/lettuce/core/RedisContainerIntegrationTests.java b/src/test/java/io/lettuce/core/RedisContainerIntegrationTests.java
deleted file mode 100644
index caa258f74..000000000
--- a/src/test/java/io/lettuce/core/RedisContainerIntegrationTests.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright 2024, Redis Ltd. and Contributors
- * All rights reserved.
- *
- * Licensed under the MIT License.
- */
-
-package io.lettuce.core;
-
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-import org.junit.jupiter.api.BeforeAll;
-import org.testcontainers.containers.ComposeContainer;
-import org.testcontainers.containers.output.OutputFrame;
-import org.testcontainers.containers.wait.strategy.Wait;
-import org.testcontainers.junit.jupiter.Testcontainers;
-
-import java.io.File;
-
-@Testcontainers
-public class RedisContainerIntegrationTests {
-
-    private static final Logger LOGGER = LogManager.getLogger(RedisContainerIntegrationTests.class);
-
-    private static final String REDIS_STACK_STANDALONE = "standalone-stack";
-
-    private static final String REDIS_STACK_CLUSTER = "clustered-stack";
-
-    private static final String REDIS_STACK_VERSION = System.getProperty("REDIS_STACK_VERSION");
-
-    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)
-                    .withExposedService(REDIS_STACK_STANDALONE, 6379).withPull(false).withLocalCompose(true);
-
-    // 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;
-
-        if (REDIS_STACK_VERSION != null && !REDIS_STACK_VERSION.isEmpty()) {
-            CLUSTERED_STACK.withEnv("REDIS_VERSION", REDIS_STACK_VERSION);
-        }
-        // 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("Cluster created with nodes:.*", 1));
-        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/RedisJsonCommandBuilderUnitTests.java b/src/test/java/io/lettuce/core/RedisJsonCommandBuilderUnitTests.java
index 22f3aed34..28df5f01a 100644
--- a/src/test/java/io/lettuce/core/RedisJsonCommandBuilderUnitTests.java
+++ b/src/test/java/io/lettuce/core/RedisJsonCommandBuilderUnitTests.java
@@ -57,8 +57,18 @@ class RedisJsonCommandBuilderUnitTests {
         ByteBuf buf = Unpooled.directBuffer();
         command.encode(buf);
 
-        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$14\r\n" + "JSON.ARRAPPEND\r\n" + "$15\r\n"
-                + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$14\r\nJSON.ARRAPPEND\r\n"
+                + "$15\r\nbikes:inventory\r\n" + "$17\r\n$..commuter_bikes\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructJsonArrappendRootPath() {
+        Command<String, String, List<Long>> command = builder.jsonArrappend(MY_KEY, JsonPath.ROOT_PATH, ELEMENT);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo(
+                "*3\r\n" + "$14\r\nJSON.ARRAPPEND\r\n" + "$15\r\nbikes:inventory\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
     }
 
     @Test
@@ -68,9 +78,21 @@ class RedisJsonCommandBuilderUnitTests {
         ByteBuf buf = Unpooled.directBuffer();
         command.encode(buf);
 
-        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*6\r\n" + "$13\r\n" + "JSON.ARRINDEX\r\n" + "$15\r\n"
-                + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n" + "$1" + "\r\n"
-                + "0" + "\r\n" + "$1" + "\r\n" + "1" + "\r\n");
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*6\r\n" + "$13\r\nJSON.ARRINDEX\r\n" + "$15\r\nbikes:inventory\r\n" + "$17\r\n$..commuter_bikes\r\n"
+                        + "$14\r\n" + ID_BIKE_6 + "\r\n" + "$1\r\n0\r\n" + "$1\r\n1\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructJsonArrindexRootPath() {
+        JsonRangeArgs range = JsonRangeArgs.Builder.start(0).stop(1);
+        Command<String, String, List<Long>> command = builder.jsonArrindex(MY_KEY, JsonPath.ROOT_PATH, ELEMENT, range);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*6\r\n" + "$13\r\nJSON.ARRINDEX\r\n" + "$15\r\nbikes:inventory\r\n" + "$1\r\n$\r\n" + "$14\r\n"
+                        + ID_BIKE_6 + "\r\n" + "$1\r\n0\r\n" + "$1\r\n1\r\n");
     }
 
     @Test
@@ -80,8 +102,18 @@ class RedisJsonCommandBuilderUnitTests {
         command.encode(buf);
 
         assertThat(buf.toString(StandardCharsets.UTF_8))
-                .isEqualTo("*5\r\n" + "$14\r\n" + "JSON.ARRINSERT\r\n" + "$15\r\n" + "bikes:inventory\r\n" + "$17\r\n"
-                        + "$..commuter_bikes\r\n" + "$1" + "\r\n" + "1" + "\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
+                .isEqualTo("*5\r\n" + "$14\r\nJSON.ARRINSERT\r\n" + "$15\r\nbikes:inventory\r\n"
+                        + "$17\r\n$..commuter_bikes\r\n" + "$1\r\n1\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructJsonArrinsertRootPath() {
+        Command<String, String, List<Long>> command = builder.jsonArrinsert(MY_KEY, JsonPath.ROOT_PATH, 1, ELEMENT);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*5\r\n" + "$14\r\n" + "JSON.ARRINSERT\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n" + "$1" + "\r\n" + "1" + "\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
     }
 
     @Test
@@ -94,6 +126,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonArrlenRootPath() {
+        Command<String, String, List<Long>> command = builder.jsonArrlen(MY_KEY, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*2\r\n" + "$11\r\nJSON.ARRLEN\r\n" + "$15\r\nbikes:inventory\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonArrpop() {
         Command<String, String, List<JsonValue>> command = builder.jsonArrpop(MY_KEY, MY_PATH, 3);
@@ -104,6 +146,36 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n" + "$1" + "\r\n" + "3" + "\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonArrpopNoIndex() {
+        Command<String, String, List<JsonValue>> command = builder.jsonArrpop(MY_KEY, MY_PATH, -1);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*3\r\n" + "$11\r\n" + "JSON.ARRPOP\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructJsonArrpopRootPathNoIndex() {
+        Command<String, String, List<JsonValue>> command = builder.jsonArrpop(MY_KEY, JsonPath.ROOT_PATH, -1);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*2\r\n" + "$11\r\n" + "JSON.ARRPOP\r\n" + "$15\r\n" + "bikes:inventory\r\n");
+    }
+
+    @Test
+    void shouldCorrectlyConstructJsonArrpopRootPath() {
+        Command<String, String, List<JsonValue>> command = builder.jsonArrpop(MY_KEY, JsonPath.ROOT_PATH, 1);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$11\r\n" + "JSON.ARRPOP\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n" + "$1\r\n" + "1\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonArrtrim() {
         JsonRangeArgs range = JsonRangeArgs.Builder.start(0).stop(1);
@@ -116,6 +188,17 @@ class RedisJsonCommandBuilderUnitTests {
                         + "$..commuter_bikes\r\n" + "$1" + "\r\n" + "0" + "\r\n" + "$1" + "\r\n" + "1" + "\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonArrtrimRootPath() {
+        JsonRangeArgs range = JsonRangeArgs.Builder.start(0).stop(1);
+        Command<String, String, List<Long>> command = builder.jsonArrtrim(MY_KEY, JsonPath.ROOT_PATH, range);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*5\r\n" + "$12\r\n" + "JSON.ARRTRIM\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n" + "$1" + "\r\n" + "0" + "\r\n" + "$1" + "\r\n" + "1" + "\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonClear() {
         Command<String, String, Long> command = builder.jsonClear(MY_KEY, MY_PATH);
@@ -126,6 +209,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonClearRootPath() {
+        Command<String, String, Long> command = builder.jsonClear(MY_KEY, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*2\r\n" + "$10\r\n" + "JSON.CLEAR\r\n" + "$15\r\n" + "bikes:inventory\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonGet() {
         JsonGetArgs args = JsonGetArgs.Builder.indent("   ").newline("\n").space("/");
@@ -138,6 +231,18 @@ class RedisJsonCommandBuilderUnitTests {
                 + "\n\r\n" + "$5\r\n" + "SPACE\r\n" + "$1\r\n" + "/\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonGetRootPath() {
+        JsonGetArgs args = JsonGetArgs.Builder.indent("   ").newline("\n").space("/");
+        Command<String, String, List<JsonValue>> command = builder.jsonGet(MY_KEY, args, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*9\r\n" + "$8\r\n" + "JSON.GET\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$6\r\n" + "INDENT\r\n" + "$3\r\n" + "   \r\n" + "$7\r\n" + "NEWLINE\r\n" + "$1\r\n"
+                + "\n\r\n" + "$5\r\n" + "SPACE\r\n" + "$1\r\n" + "/\r\n" + "$1\r\n" + "$\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonMerge() {
         Command<String, String, String> command = builder.jsonMerge(MY_KEY, MY_PATH, ELEMENT);
@@ -148,6 +253,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonMergeRootPath() {
+        Command<String, String, String> command = builder.jsonMerge(MY_KEY, JsonPath.ROOT_PATH, ELEMENT);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$10\r\n" + "JSON.MERGE\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonMget() {
         Command<String, String, List<JsonValue>> command = builder.jsonMGet(MY_PATH, MY_KEY, MY_KEY2);
@@ -158,6 +273,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$15\r\n" + "bikes:repairLog\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonMgetRootPath() {
+        Command<String, String, List<JsonValue>> command = builder.jsonMGet(JsonPath.ROOT_PATH, MY_KEY, MY_KEY2);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$9\r\n" + "JSON.MGET\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$15\r\n" + "bikes:repairLog\r\n" + "$1\r\n" + "$\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonMset() {
         JsonMsetArgs<String, String> args1 = new JsonMsetArgs<>(MY_KEY, MY_PATH, ELEMENT);
@@ -169,6 +294,17 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonMsetRootPath() {
+        JsonMsetArgs<String, String> args1 = new JsonMsetArgs<>(MY_KEY, JsonPath.ROOT_PATH, ELEMENT);
+        Command<String, String, String> command = builder.jsonMSet(Collections.singletonList(args1));
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$9\r\n" + "JSON.MSET\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonNumincrby() {
         Command<String, String, List<Number>> command = builder.jsonNumincrby(MY_KEY, MY_PATH, 3);
@@ -179,6 +315,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n" + "$1" + "\r\n" + "3" + "\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonNumincrbyRootPath() {
+        Command<String, String, List<Number>> command = builder.jsonNumincrby(MY_KEY, JsonPath.ROOT_PATH, 3);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$14\r\n" + "JSON.NUMINCRBY\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n" + "$1" + "\r\n" + "3" + "\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonObjkeys() {
         Command<String, String, List<String>> command = builder.jsonObjkeys(MY_KEY, MY_PATH);
@@ -189,6 +335,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonObjkeysRootPath() {
+        Command<String, String, List<String>> command = builder.jsonObjkeys(MY_KEY, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*2\r\n" + "$12\r\n" + "JSON.OBJKEYS\r\n" + "$15\r\n" + "bikes:inventory\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonObjlen() {
         Command<String, String, List<Long>> command = builder.jsonObjlen(MY_KEY, MY_PATH);
@@ -199,6 +355,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonObjlenRootPath() {
+        Command<String, String, List<Long>> command = builder.jsonObjlen(MY_KEY, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*2\r\n" + "$11\r\n" + "JSON.OBJLEN\r\n" + "$15\r\n" + "bikes:inventory\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonSet() {
         JsonSetArgs args = JsonSetArgs.Builder.nx();
@@ -211,6 +377,17 @@ class RedisJsonCommandBuilderUnitTests {
                         + "$..commuter_bikes\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n" + "$2\r\n" + "NX\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonSetRootPath() {
+        JsonSetArgs args = JsonSetArgs.Builder.nx();
+        Command<String, String, String> command = builder.jsonSet(MY_KEY, JsonPath.ROOT_PATH, ELEMENT, args);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*5\r\n" + "$8\r\n" + "JSON.SET\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n" + "$2\r\n" + "NX\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonStrappend() {
         Command<String, String, List<Long>> command = builder.jsonStrappend(MY_KEY, MY_PATH, ELEMENT);
@@ -221,6 +398,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonStrappendRootPath() {
+        Command<String, String, List<Long>> command = builder.jsonStrappend(MY_KEY, JsonPath.ROOT_PATH, ELEMENT);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo("*3\r\n" + "$14\r\n" + "JSON.STRAPPEND\r\n" + "$15\r\n"
+                + "bikes:inventory\r\n" + "$14\r\n" + ID_BIKE_6 + "\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonStrlen() {
         Command<String, String, List<Long>> command = builder.jsonStrlen(MY_KEY, MY_PATH);
@@ -231,6 +418,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonStrlenRootPath() {
+        Command<String, String, List<Long>> command = builder.jsonStrlen(MY_KEY, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*2\r\n" + "$11\r\n" + "JSON.STRLEN\r\n" + "$15\r\n" + "bikes:inventory\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonToggle() {
         Command<String, String, List<Long>> command = builder.jsonToggle(MY_KEY, MY_PATH);
@@ -241,6 +438,16 @@ class RedisJsonCommandBuilderUnitTests {
                 + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonToggleRootPath() {
+        Command<String, String, List<Long>> command = builder.jsonToggle(MY_KEY, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*3\r\n" + "$11\r\n" + "JSON.TOGGLE\r\n" + "$15\r\n" + "bikes:inventory\r\n" + "$1\r\n" + "$\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonDel() {
         Command<String, String, Long> command = builder.jsonDel(MY_KEY, MY_PATH);
@@ -251,6 +458,16 @@ class RedisJsonCommandBuilderUnitTests {
                 "*3\r\n" + "$8\r\n" + "JSON.DEL\r\n" + "$15\r\n" + "bikes:inventory\r\n" + "$17\r\n" + "$..commuter_bikes\r\n");
     }
 
+    @Test
+    void shouldCorrectlyConstructJsonDelRootPath() {
+        Command<String, String, Long> command = builder.jsonDel(MY_KEY, JsonPath.ROOT_PATH);
+        ByteBuf buf = Unpooled.directBuffer();
+        command.encode(buf);
+
+        assertThat(buf.toString(StandardCharsets.UTF_8))
+                .isEqualTo("*2\r\n" + "$8\r\n" + "JSON.DEL\r\n" + "$15\r\n" + "bikes:inventory\r\n");
+    }
+
     @Test
     void shouldCorrectlyConstructJsonType() {
         Command<String, String, ?> command = builder.jsonType(MY_KEY, MY_PATH);
diff --git a/src/test/java/io/lettuce/core/cluster/ClusterNodeEndpointUnitTests.java b/src/test/java/io/lettuce/core/cluster/ClusterNodeEndpointUnitTests.java
index 12b2cd66c..c11c28c30 100644
--- a/src/test/java/io/lettuce/core/cluster/ClusterNodeEndpointUnitTests.java
+++ b/src/test/java/io/lettuce/core/cluster/ClusterNodeEndpointUnitTests.java
@@ -56,6 +56,7 @@ class ClusterNodeEndpointUnitTests {
     @BeforeEach
     void before() {
 
+        when(clientOptions.getReplayFilter()).thenReturn((cmd) -> false);
         when(clientOptions.getRequestQueueSize()).thenReturn(1000);
         when(clientOptions.getDisconnectedBehavior()).thenReturn(ClientOptions.DisconnectedBehavior.DEFAULT);
 
diff --git a/src/test/java/io/lettuce/core/commands/CommandInterfacesIntegrationTests.java b/src/test/java/io/lettuce/core/commands/CommandInterfacesIntegrationTests.java
index 88325440a..87843459c 100644
--- a/src/test/java/io/lettuce/core/commands/CommandInterfacesIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/commands/CommandInterfacesIntegrationTests.java
@@ -20,9 +20,7 @@
 package io.lettuce.core.commands;
 
 import io.lettuce.core.RedisClient;
-import io.lettuce.core.RedisContainerIntegrationTests;
 import io.lettuce.core.RedisURI;
-import io.lettuce.core.TestSupport;
 import io.lettuce.core.api.StatefulConnection;
 import io.lettuce.core.api.StatefulRedisConnection;
 import io.lettuce.core.api.sync.RedisCommands;
@@ -30,28 +28,17 @@ import io.lettuce.core.dynamic.Commands;
 import io.lettuce.core.dynamic.RedisCommandFactory;
 import io.lettuce.core.dynamic.annotation.Command;
 import io.lettuce.core.dynamic.annotation.Param;
-import io.lettuce.test.LettuceExtension;
-import io.lettuce.test.condition.EnabledOnCommand;
 import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.TestInstance;
-import org.junit.jupiter.api.extension.ExtendWith;
 import reactor.core.publisher.Flux;
 
-import javax.inject.Inject;
 import java.lang.reflect.Proxy;
 import java.util.List;
 
 import static io.lettuce.TestTags.INTEGRATION_TEST;
-import static io.lettuce.core.SetArgs.Builder.ex;
-import static io.lettuce.core.SetArgs.Builder.exAt;
-import static io.lettuce.core.SetArgs.Builder.px;
-import static io.lettuce.core.SetArgs.Builder.pxAt;
-import static io.lettuce.core.StringMatchResult.Position;
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
  * Integration tests for {@link io.lettuce.core.dynamic.annotation.Command}.
@@ -59,7 +46,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
  * @author Tihomir Mateev
  */
 @Tag(INTEGRATION_TEST)
-public class CommandInterfacesIntegrationTests extends RedisContainerIntegrationTests {
+public class CommandInterfacesIntegrationTests {
 
     protected static RedisClient client;
 
diff --git a/src/test/java/io/lettuce/core/commands/ConsolidatedAclCommandIntegrationTests.java b/src/test/java/io/lettuce/core/commands/ConsolidatedAclCommandIntegrationTests.java
index f0519af54..08264b4b3 100644
--- a/src/test/java/io/lettuce/core/commands/ConsolidatedAclCommandIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/commands/ConsolidatedAclCommandIntegrationTests.java
@@ -37,7 +37,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
  * @author M Sazzadul Hoque
  */
 @Tag(INTEGRATION_TEST)
-public class ConsolidatedAclCommandIntegrationTests extends RedisContainerIntegrationTests {
+public class ConsolidatedAclCommandIntegrationTests {
 
     private static RedisClient client;
 
diff --git a/src/test/java/io/lettuce/core/commands/ConsolidatedConfigurationCommandIntegrationTests.java b/src/test/java/io/lettuce/core/commands/ConsolidatedConfigurationCommandIntegrationTests.java
index 3a009fc47..f25adff7a 100644
--- a/src/test/java/io/lettuce/core/commands/ConsolidatedConfigurationCommandIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/commands/ConsolidatedConfigurationCommandIntegrationTests.java
@@ -31,7 +31,6 @@ import io.lettuce.test.condition.RedisConditions;
 import org.junit.jupiter.api.*;
 
 import java.util.Collections;
-import java.util.Map;
 
 /**
  * Integration tests for {@link io.lettuce.core.api.sync.RedisServerCommands} with Redis modules since Redis 8.0.
@@ -39,7 +38,7 @@ import java.util.Map;
  * @author M Sazzadul Hoque
  */
 @Tag(INTEGRATION_TEST)
-public class ConsolidatedConfigurationCommandIntegrationTests extends RedisContainerIntegrationTests {
+public class ConsolidatedConfigurationCommandIntegrationTests {
 
     private static RedisClient client;
 
diff --git a/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java b/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java
index 149f61e6a..d3e2e6dba 100644
--- a/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java
@@ -20,6 +20,8 @@
 package io.lettuce.core.commands;
 
 import io.lettuce.core.ExpireArgs;
+import io.lettuce.core.HGetExArgs;
+import io.lettuce.core.HSetExArgs;
 import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
@@ -228,6 +230,96 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(values).containsEntry("one", "1").containsEntry("two", "2");
     }
 
+    @Test
+    @EnabledOnCommand("HGETDEL")
+    void hgetdel() {
+        setup();
+        List<KeyValue<String, String>> values = redis.hgetdel(key, "one", "two", "missing");
+        assertThat(values).hasSize(3);
+        assertThat(values.containsAll(list(kv("one", "1"), kv("two", "2"), kv("missing", null)))).isTrue();
+        assertThat(redis.hexists(key, "one")).isFalse();
+        assertThat(redis.hexists(key, "two")).isFalse();
+    }
+
+    @Test
+    @EnabledOnCommand("HGETDEL")
+    void hgetdelStreaming() {
+        setup();
+
+        KeyValueStreamingAdapter<String, String> streamingAdapter = new KeyValueStreamingAdapter<>();
+        Long count = redis.hgetdel(streamingAdapter, key, "one", "two", "missing");
+        Map<String, String> values = streamingAdapter.getMap();
+        assertThat(count).isEqualTo(3);
+        assertThat(values).hasSize(3);
+        assertThat(values).containsEntry("one", "1").containsEntry("two", "2").containsEntry("missing", null);
+        assertThat(redis.hexists(key, "one")).isFalse();
+        assertThat(redis.hexists(key, "two")).isFalse();
+    }
+
+    @Test
+    @EnabledOnCommand("HGETEX")
+    void hgetex() {
+        setup();
+        List<KeyValue<String, String>> values = redis.hgetex(key, "one", "two", "missing");
+        assertThat(values).hasSize(3);
+        assertThat(values.containsAll(list(kv("one", "1"), kv("two", "2"), kv("missing", null)))).isTrue();
+    }
+
+    @Test
+    @EnabledOnCommand("HGETEX")
+    void hgetexWithArgs() {
+        setup();
+        List<KeyValue<String, String>> values = redis.hgetex(key, HGetExArgs.Builder.ex(Duration.ofSeconds(100)), "one", "two",
+                "missing");
+        assertThat(values).hasSize(3);
+        assertThat(values.containsAll(list(kv("one", "1"), kv("two", "2"), kv("missing", null)))).isTrue();
+        assertThat(redis.httl(key, "one", "two")).allMatch(ttl -> ttl > 1);
+    }
+
+    @Test
+    @EnabledOnCommand("HGETEX")
+    void hgetexStreaming() {
+        setup();
+
+        KeyValueStreamingAdapter<String, String> streamingAdapter = new KeyValueStreamingAdapter<>();
+        Long count = redis.hgetex(streamingAdapter, key, HGetExArgs.Builder.ex(Duration.ofSeconds(100)), "one", "two",
+                "missing");
+        Map<String, String> values = streamingAdapter.getMap();
+        assertThat(count).isEqualTo(3);
+        assertThat(values).hasSize(3);
+        assertThat(values).containsEntry("one", "1").containsEntry("two", "2").containsEntry("missing", null);
+        assertThat(redis.httl(key, "one", "two")).allMatch(ttl -> ttl > 1);
+    }
+
+    @Test
+    @EnabledOnCommand("HSETEX")
+    void hsetex() {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("one", "1");
+        fields.put("two", "2");
+
+        Long result = redis.hsetex(key, fields);
+        assertThat(result).isEqualTo(1);
+
+        List<KeyValue<String, String>> values = redis.hmget(key, "one", "two", "missing");
+        assertThat(values.containsAll(list(kv("one", "1"), kv("two", "2")))).isTrue();
+    }
+
+    @Test
+    @EnabledOnCommand("HSETEX")
+    void hsetexWithArgs() {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("one", "1");
+        fields.put("two", "2");
+
+        Long result = redis.hsetex(key, HSetExArgs.Builder.ex(Duration.ofSeconds(100)), fields);
+        assertThat(result).isEqualTo(1);
+
+        List<KeyValue<String, String>> values = redis.hmget(key, "one", "two");
+        assertThat(values.containsAll(list(kv("one", "1"), kv("two", "2")))).isTrue();
+        assertThat(redis.httl(key, "one", "two")).allMatch(ttl -> ttl > 1);
+    }
+
     @Test
     void hmset() {
         Map<String, String> hash = new LinkedHashMap<>();
diff --git a/src/test/java/io/lettuce/core/dynamic/RedisCommandsIntegrationTests.java b/src/test/java/io/lettuce/core/dynamic/RedisCommandsIntegrationTests.java
index 21c2b1e98..aa0098796 100644
--- a/src/test/java/io/lettuce/core/dynamic/RedisCommandsIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/dynamic/RedisCommandsIntegrationTests.java
@@ -112,6 +112,28 @@ class RedisCommandsIntegrationTests extends TestSupport {
         pool.close();
     }
 
+    @Test
+    void shouldWorkWithPooledConnectionAndCustomValidation() throws Exception {
+
+        GenericObjectPool<StatefulRedisConnection<String, String>> pool = ConnectionPoolSupport
+                .createGenericObjectPool(client::connect, new GenericObjectPoolConfig<>(), connection -> {
+                    try {
+                        return "PONG".equals(connection.sync().ping());
+                    } catch (Exception e) {
+                        return false;
+                    }
+                });
+
+        try (StatefulRedisConnection<String, String> connection = pool.borrowObject()) {
+
+            RedisCommandFactory factory = new RedisCommandFactory(connection);
+            SimpleCommands commands = factory.getCommands(SimpleCommands.class);
+            commands.get("foo");
+        }
+
+        pool.close();
+    }
+
     @Test
     void shouldWorkWithAsyncPooledConnection() {
 
diff --git a/src/test/java/io/lettuce/core/dynamic/SimpleBatcherUnitTests.java b/src/test/java/io/lettuce/core/dynamic/SimpleBatcherUnitTests.java
index 592bfe29b..4b8861dfc 100644
--- a/src/test/java/io/lettuce/core/dynamic/SimpleBatcherUnitTests.java
+++ b/src/test/java/io/lettuce/core/dynamic/SimpleBatcherUnitTests.java
@@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
 import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
 
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
@@ -21,6 +22,7 @@ import io.lettuce.core.protocol.RedisCommand;
 
 /**
  * @author Mark Paluch
+ * @author Ivo Gaydajiev
  */
 @Tag(UNIT_TEST)
 @ExtendWith(MockitoExtension.class)
@@ -127,6 +129,44 @@ class SimpleBatcherUnitTests {
         verify(connection).dispatch(Arrays.asList(c1, c2));
     }
 
+    @Test
+    void shouldDispatchCommandsQueuedDuringOngoingFlush() throws InterruptedException {
+        RedisCommand<Object, Object, Object> c1 = createCommand();
+        RedisCommand<Object, Object, Object> c2 = createCommand();
+
+        CountDownLatch batchFlushLatch1 = new CountDownLatch(1);
+        CountDownLatch batchFlushLatch2 = new CountDownLatch(1);
+
+        when(connection.dispatch((RedisCommand<Object, Object, Object>) any())).thenAnswer(invocation -> {
+            batchFlushLatch1.countDown();
+            batchFlushLatch2.await();
+
+            return null;
+        });
+
+        SimpleBatcher batcher = new SimpleBatcher(connection, 4);
+
+        Thread batchThread1 = new Thread(() -> {
+            batcher.batch(c1, CommandBatching.flush());
+        });
+        batchThread1.start();
+
+        Thread batchThread2 = new Thread(() -> {
+            try {
+                batchFlushLatch1.await();
+            } catch (InterruptedException ignored) {
+            }
+            batcher.batch(c2, CommandBatching.flush());
+            batchFlushLatch2.countDown();
+        });
+        batchThread2.start();
+
+        batchThread1.join();
+        batchThread2.join();
+        verify(connection, times(1)).dispatch(c1);
+        verify(connection, times(1)).dispatch(c2);
+    }
+
     private static RedisCommand<Object, Object, Object> createCommand() {
         return new AsyncCommand<>(new Command<>(CommandType.COMMAND, null, null));
     }
diff --git a/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java b/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java
index 5a3eefeeb..ee8d7c40c 100644
--- a/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/json/RedisJsonClusterIntegrationTests.java
@@ -7,7 +7,6 @@
 
 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;
@@ -39,7 +38,7 @@ import static io.lettuce.TestTags.INTEGRATION_TEST;
 import static org.assertj.core.api.Assertions.assertThat;
 
 @Tag(INTEGRATION_TEST)
-public class RedisJsonClusterIntegrationTests extends RedisContainerIntegrationTests {
+public class RedisJsonClusterIntegrationTests {
 
     protected static RedisClusterClient client;
 
diff --git a/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java b/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java
index b8d4ac924..225dd749f 100644
--- a/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/json/RedisJsonIntegrationTests.java
@@ -9,7 +9,6 @@ package io.lettuce.core.json;
 
 import io.lettuce.core.ClientOptions;
 import io.lettuce.core.RedisClient;
-import io.lettuce.core.RedisContainerIntegrationTests;
 import io.lettuce.core.RedisFuture;
 import io.lettuce.core.RedisURI;
 import io.lettuce.core.api.StatefulRedisConnection;
@@ -26,7 +25,6 @@ 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.core.publisher.Mono;
 import reactor.test.StepVerifier;
 
 import java.io.IOException;
@@ -43,7 +41,7 @@ import static io.lettuce.TestTags.INTEGRATION_TEST;
 import static org.assertj.core.api.Assertions.assertThat;
 
 @Tag(INTEGRATION_TEST)
-public class RedisJsonIntegrationTests extends RedisContainerIntegrationTests {
+public class RedisJsonIntegrationTests {
 
     private static final String BIKES_INVENTORY = "bikes:inventory";
 
@@ -159,6 +157,24 @@ public class RedisJsonIntegrationTests extends RedisContainerIntegrationTests {
                 "{\"id\":\"bike:3\",\"model\":\"Weywot\",\"description\":\"This bike gives kids aged six years and old");
     }
 
+    @Test
+    public void jsonArrpopEmptyArray() {
+        JsonValue value = redis.getJsonParser().createJsonValue("[\"one\"]");
+        redis.jsonSet("myKey", JsonPath.ROOT_PATH, value);
+        List<JsonValue> result = redis.jsonArrpop("myKey");
+        assertThat(result.toString()).isEqualTo("[\"one\"]");
+        assertThat(redis.jsonGet("myKey").get(0).toString()).isEqualTo("[]");
+    }
+
+    @Test
+    public void jsonArrpopWithRootPathAndIndex() {
+        JsonValue value = redis.getJsonParser().createJsonValue("[\"one\",\"two\",\"three\"]");
+        redis.jsonSet("myKey", JsonPath.ROOT_PATH, value);
+        List<JsonValue> result = redis.jsonArrpop("myKey", JsonPath.ROOT_PATH, 1);
+        assertThat(result.toString()).isEqualTo("[\"two\"]");
+        assertThat(redis.jsonGet("myKey").get(0).toString()).isEqualTo("[\"one\",\"three\"]");
+    }
+
     @ParameterizedTest(name = "With {0} as path")
     @ValueSource(strings = { BIKE_COLORS_V1, BIKE_COLORS_V2 })
     void jsonArrtrim(String path) {
diff --git a/src/test/java/io/lettuce/core/pubsub/PubSubCommandIntegrationTests.java b/src/test/java/io/lettuce/core/pubsub/PubSubCommandIntegrationTests.java
index 9936ba428..347266028 100644
--- a/src/test/java/io/lettuce/core/pubsub/PubSubCommandIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/pubsub/PubSubCommandIntegrationTests.java
@@ -28,6 +28,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Matcher;
@@ -543,6 +544,15 @@ class PubSubCommandIntegrationTests extends AbstractRedisClientTest {
         assertThat(messages.take()).isEqualTo(shardMessage);
     }
 
+    @Test
+    void interleaveCommands() throws ExecutionException, InterruptedException {
+
+        // relay Double and Long
+        assertThat(pubsub.zadd("myzset", 1.0, "one").get()).isEqualTo(1L);
+        assertThat(pubsub.zadd("myzset", 2.0, "two").get()).isEqualTo(1L);
+        assertThat(pubsub.zpopmin("myzset", 1).get().get(0).getValue()).isEqualTo("one");
+    }
+
     @Test
     void adapter() throws Exception {
         final BlockingQueue<Long> localCounts = LettuceFactories.newBlockingQueue();
diff --git a/src/test/java/io/lettuce/core/reliability/AtLeastOnceIntegrationTests.java b/src/test/java/io/lettuce/core/reliability/AtLeastOnceIntegrationTests.java
index 7dbac3bf9..df420ce7d 100644
--- a/src/test/java/io/lettuce/core/reliability/AtLeastOnceIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/reliability/AtLeastOnceIntegrationTests.java
@@ -8,8 +8,10 @@ import java.time.Duration;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
 
 import io.lettuce.core.TimeoutOptions;
+import io.lettuce.core.protocol.RedisCommand;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
@@ -372,6 +374,54 @@ class AtLeastOnceIntegrationTests extends AbstractRedisClientTest {
         verificationConnection.getStatefulConnection().close();
     }
 
+    @Test
+    void retryAfterConnectionIsDisconnectedButFiltered() throws Exception {
+        // Do not replay DECR commands after reconnect for some reason
+        Predicate<RedisCommand<?, ?, ?>> filter = cmd -> cmd.getType().toString().equalsIgnoreCase("DECR");
+
+        client.setOptions(ClientOptions.builder().autoReconnect(true).replayFilter(filter)
+                .timeoutOptions(TimeoutOptions.builder().timeoutCommands(false).build()).build());
+
+        // needs to be increased on slow systems...perhaps...
+        client.setDefaultTimeout(3, TimeUnit.SECONDS);
+
+        StatefulRedisConnection<String, String> connection = client.connect();
+        RedisCommands<String, String> verificationConnection = client.connect().sync();
+
+        connection.sync().set(key, "1");
+
+        ConnectionWatchdog connectionWatchdog = ConnectionTestUtil.getConnectionWatchdog(connection);
+        connectionWatchdog.setListenOnChannelInactive(false);
+
+        connection.async().quit();
+        while (connection.isOpen()) {
+            Delay.delay(Duration.ofMillis(100));
+        }
+
+        assertThat(connection.async().incr(key).await(1, TimeUnit.SECONDS)).isFalse();
+        assertThat(connection.async().decr(key).await(1, TimeUnit.SECONDS)).isFalse();
+        assertThat(connection.async().decr(key).await(1, TimeUnit.SECONDS)).isFalse();
+
+        assertThat(verificationConnection.get("key")).isEqualTo("1");
+
+        assertThat(ConnectionTestUtil.getDisconnectedBuffer(connection).size()).isGreaterThan(0);
+        assertThat(ConnectionTestUtil.getCommandBuffer(connection)).isEmpty();
+
+        connectionWatchdog.setListenOnChannelInactive(true);
+        connectionWatchdog.scheduleReconnect();
+
+        while (!ConnectionTestUtil.getCommandBuffer(connection).isEmpty()
+                || !ConnectionTestUtil.getDisconnectedBuffer(connection).isEmpty()) {
+            Delay.delay(Duration.ofMillis(10));
+        }
+
+        assertThat(connection.sync().get(key)).isEqualTo("2");
+        assertThat(verificationConnection.get(key)).isEqualTo("2");
+
+        connection.close();
+        verificationConnection.getStatefulConnection().close();
+    }
+
     private Throwable getException(RedisFuture<?> command) {
         try {
             command.get();
diff --git a/src/test/java/io/lettuce/core/sentinel/SentinelAclIntegrationTests.java b/src/test/java/io/lettuce/core/sentinel/SentinelAclIntegrationTests.java
index 9c515d005..43a2cf250 100644
--- a/src/test/java/io/lettuce/core/sentinel/SentinelAclIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/sentinel/SentinelAclIntegrationTests.java
@@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.*;
 import javax.inject.Inject;
 
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -28,6 +29,7 @@ import io.lettuce.test.settings.TestSettings;
 @Tag(INTEGRATION_TEST)
 @ExtendWith(LettuceExtension.class)
 @EnabledOnCommand("ACL")
+@Disabled("Failing with latest versions of Linux")
 public class SentinelAclIntegrationTests extends TestSupport {
 
     private final RedisClient redisClient;
diff --git a/src/test/java/io/lettuce/core/support/ConnectionPoolSupportIntegrationTests.java b/src/test/java/io/lettuce/core/support/ConnectionPoolSupportIntegrationTests.java
index ed38435ed..db64d7c33 100644
--- a/src/test/java/io/lettuce/core/support/ConnectionPoolSupportIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/support/ConnectionPoolSupportIntegrationTests.java
@@ -131,6 +131,27 @@ class ConnectionPoolSupportIntegrationTests extends TestSupport {
         pool.close();
     }
 
+    @Test
+    void genericPoolShouldWorkWithValidationPredicate() throws Exception {
+
+        GenericObjectPool<StatefulRedisConnection<String, String>> pool = ConnectionPoolSupport
+                .createGenericObjectPool(() -> client.connect(), new GenericObjectPoolConfig<>(), false, connection -> {
+                    try {
+                        return "PONG".equals(connection.sync().ping());
+                    } catch (Exception e) {
+                        return false;
+                    }
+                });
+
+        borrowAndReturn(pool);
+
+        StatefulRedisConnection<String, String> connection = pool.borrowObject();
+        assertThat(Proxy.isProxyClass(connection.getClass())).isFalse();
+        pool.returnObject(connection);
+
+        pool.close();
+    }
+
     @Test
     void softReferencePoolShouldWorkWithPlainConnections() throws Exception {
 
@@ -147,6 +168,28 @@ class ConnectionPoolSupportIntegrationTests extends TestSupport {
         pool.close();
     }
 
+    @Test
+    void softReferencePoolShouldWorkWithValidationPredicate() throws Exception {
+
+        SoftReferenceObjectPool<StatefulRedisConnection<String, String>> pool = ConnectionPoolSupport
+                .createSoftReferenceObjectPool(() -> client.connect(), false, connection -> {
+                    try {
+                        return "PONG".equals(connection.sync().ping());
+                    } catch (Exception e) {
+                        return false;
+                    }
+                });
+
+        borrowAndReturn(pool);
+
+        StatefulRedisConnection<String, String> connection = pool.borrowObject();
+        assertThat(Proxy.isProxyClass(connection.getClass())).isFalse();
+        pool.returnObject(connection);
+
+        connection.close();
+        pool.close();
+    }
+
     @Test
     void genericPoolUsingWrappingShouldPropagateExceptionsCorrectly() throws Exception {
 
diff --git a/src/test/java/io/redis/examples/async/ListExample.java b/src/test/java/io/redis/examples/async/ListExample.java
new file mode 100644
index 000000000..396af59c7
--- /dev/null
+++ b/src/test/java/io/redis/examples/async/ListExample.java
@@ -0,0 +1,542 @@
+// EXAMPLE: list_tutorial
+package io.redis.examples.async;
+
+import io.lettuce.core.*;
+import io.lettuce.core.api.async.RedisAsyncCommands;
+import io.lettuce.core.api.StatefulRedisConnection;
+
+import java.util.concurrent.CompletableFuture;
+// REMOVE_START
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+// REMOVE_END
+
+public class ListExample {
+
+    // REMOVE_START
+    @Test
+    // REMOVE_END
+    public void run() {
+        RedisClient redisClient = RedisClient.create("redis://localhost:6379");
+
+        try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
+            RedisAsyncCommands<String, String> asyncCommands = connection.async();
+            // REMOVE_START
+            asyncCommands.del("bikes:repairs", "bikes:finished", "new_bikes", "new_bikes_string").toCompletableFuture().join();
+            // REMOVE_END
+
+            // STEP_START queue
+            CompletableFuture<Void> queue = asyncCommands.lpush("bikes:repairs", "bike:1").thenCompose(res1 -> {
+                System.out.println(res1); // >>> 1
+                // REMOVE_START
+                assertThat(res1).isEqualTo(1);
+                // REMOVE_END
+
+                return asyncCommands.lpush("bikes:repairs", "bike:2");
+            }).thenCompose(res2 -> {
+                System.out.println(res2); // >>> 2
+                // REMOVE_START
+                assertThat(res2).isEqualTo(2);
+                // REMOVE_END
+
+                return asyncCommands.rpop("bikes:repairs");
+            }).thenCompose(res3 -> {
+                System.out.println(res3); // >>> bike:1
+                // REMOVE_START
+                assertThat(res3).isEqualTo("bike:1");
+                // REMOVE_END
+
+                return asyncCommands.rpop("bikes:repairs");
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isEqualTo("bike:2");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> bike:2
+                    .toCompletableFuture();
+            // STEP_END
+            queue.join();
+
+            // STEP_START stack
+            CompletableFuture<Void> stack = asyncCommands.lpush("bikes:repairs", "bike:1").thenCompose(res4 -> {
+                System.out.println(res4); // >>> 1
+                // REMOVE_START
+                assertThat(res4).isEqualTo(1);
+                // REMOVE_END
+
+                return asyncCommands.lpush("bikes:repairs", "bike:2");
+            }).thenCompose(res5 -> {
+                System.out.println(res5); // >>> 2
+                // REMOVE_START
+                assertThat(res5).isEqualTo(2);
+                // REMOVE_END
+
+                return asyncCommands.lpop("bikes:repairs");
+            }).thenCompose(res6 -> {
+                System.out.println(res6); // >>> bike:2
+                // REMOVE_START
+                assertThat(res6).isEqualTo("bike:2");
+                // REMOVE_END
+
+                return asyncCommands.lpop("bikes:repairs");
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isEqualTo("bike:1");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> bike:1
+                    .toCompletableFuture();
+            // STEP_END
+            stack.join();
+
+            // STEP_START llen
+            CompletableFuture<Void> llen = asyncCommands.llen("bikes:repairs")
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isZero();
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> 0
+                    .toCompletableFuture();
+            // STEP_END
+            llen.join();
+
+            // STEP_START lmove_lrange
+            CompletableFuture<Void> lmovelrange = asyncCommands.lpush("bikes:repairs", "bike:1").thenCompose(res7 -> {
+                System.out.println(res7); // >>> 1
+                // REMOVE_START
+                assertThat(res7).isEqualTo(1);
+                // REMOVE_END
+
+                return asyncCommands.lpush("bikes:repairs", "bike:2");
+            }).thenCompose(res8 -> {
+                System.out.println(res8); // >>> 2
+                // REMOVE_START
+                assertThat(res8).isEqualTo(2);
+                // REMOVE_END
+
+                return asyncCommands.lmove("bikes:repairs", "bikes:finished", LMoveArgs.Builder.leftLeft());
+            }).thenCompose(res9 -> {
+                System.out.println(res9); // >>> bike:2
+                // REMOVE_START
+                assertThat(res9).isEqualTo("bike:2");
+                // REMOVE_END
+
+                return asyncCommands.lrange("bikes:repairs", 0, -1);
+            }).thenCompose(res10 -> {
+                System.out.println(res10); // >>> [bike:1]
+                // REMOVE_START
+                assertThat(res10.toString()).isEqualTo("[bike:1]");
+                // REMOVE_END
+
+                return asyncCommands.lrange("bikes:finished", 0, -1);
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[bike:2]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> [bike:2]
+                    .toCompletableFuture();
+            // STEP_END
+            lmovelrange.join();
+
+            // REMOVE_START
+            asyncCommands.del("bikes:repairs").toCompletableFuture().join();
+            // REMOVE_END
+
+            // STEP_START lpush_rpush
+            CompletableFuture<Void> lpushrpush = asyncCommands.rpush("bikes:repairs", "bike:1").thenCompose(res11 -> {
+                System.out.println(res11); // >>> 1
+                // REMOVE_START
+                assertThat(res11).isEqualTo(1);
+                // REMOVE_END
+
+                return asyncCommands.rpush("bikes:repairs", "bike:2");
+            }).thenCompose(res12 -> {
+                System.out.println(res12); // >>> 2
+                // REMOVE_START
+                assertThat(res12).isEqualTo(2);
+                // REMOVE_END
+
+                return asyncCommands.lpush("bikes:repairs", "bike:important_bike");
+            }).thenCompose(res13 -> {
+                System.out.println(res13); // >>> 3
+                // REMOVE_START
+                assertThat(res13).isEqualTo(3);
+                // REMOVE_END
+
+                return asyncCommands.lrange("bikes:repairs", 0, -1);
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[bike:important_bike, bike:1, bike:2]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [bike:important_bike, bike:1, bike:2]
+                    .toCompletableFuture();
+            // STEP_END
+            lpushrpush.join();
+
+            // REMOVE_START
+            asyncCommands.del("bikes:repairs").toCompletableFuture().join();
+            // REMOVE_END
+
+            // STEP_START variadic
+            CompletableFuture<Void> variadic = asyncCommands.rpush("bikes:repairs", "bike:1", "bike:2", "bike:3")
+                    .thenCompose(res14 -> {
+                        System.out.println(res14); // >>> 3
+                        // REMOVE_START
+                        assertThat(res14).isEqualTo(3);
+                        // REMOVE_END
+
+                        return asyncCommands.lpush("bikes:repairs", "bike:important_bike", "bike:very_important_bike");
+                    }).thenCompose(res15 -> {
+                        System.out.println(res15); // >>> 5
+                        // REMOVE_START
+                        assertThat(res15).isEqualTo(5);
+                        // REMOVE_END
+
+                        return asyncCommands.lrange("bikes:repairs", 0, -1);
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString())
+                                .isEqualTo("[bike:very_important_bike, bike:important_bike, bike:1, bike:2, bike:3]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [bike:very_important_bike, bike:important_bike, bike:1, bike:2, bike:3]
+                    .toCompletableFuture();
+            // STEP_END
+            variadic.join();
+
+            // REMOVE_START
+            asyncCommands.del("bikes:repairs").toCompletableFuture().join();
+            // REMOVE_END
+
+            // STEP_START lpop_rpop
+            CompletableFuture<Void> lpoprpop = asyncCommands.rpush("bikes:repairs", "bike:1", "bike:2", "bike:3")
+                    .thenCompose(res16 -> {
+                        System.out.println(res16); // >>> 3
+                        // REMOVE_START
+                        assertThat(res16).isEqualTo(3);
+                        // REMOVE_END
+
+                        return asyncCommands.rpop("bikes:repairs");
+                    }).thenCompose(res17 -> {
+                        System.out.println(res17); // >>> bike:3
+                        // REMOVE_START
+                        assertThat(res17).isEqualTo("bike:3");
+                        // REMOVE_END
+
+                        return asyncCommands.lpop("bikes:repairs");
+                    }).thenCompose(res18 -> {
+                        System.out.println(res18); // >>> bike:1
+                        // REMOVE_START
+                        assertThat(res18).isEqualTo("bike:1");
+                        // REMOVE_END
+
+                        return asyncCommands.rpop("bikes:repairs");
+                    }).thenCompose(res19 -> {
+                        System.out.println(res19); // >>> bike:2
+                        // REMOVE_START
+                        assertThat(res19).isEqualTo("bike:2");
+                        // REMOVE_END
+
+                        return asyncCommands.rpop("bikes:repairs");
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isNull();
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> null
+                    .toCompletableFuture();
+            // STEP_END
+            lpoprpop.join();
+
+            // STEP_START ltrim
+            CompletableFuture<Void> ltrim = asyncCommands
+                    .lpush("bikes:repairs", "bike:1", "bike:2", "bike:3", "bike:4", "bike:5").thenCompose(res20 -> {
+                        System.out.println(res20); // >>> 5
+                        // REMOVE_START
+                        assertThat(res20).isEqualTo(5);
+                        // REMOVE_END
+
+                        return asyncCommands.ltrim("bikes:repairs", 0, 2);
+                    }).thenCompose(res21 -> {
+                        System.out.println(res21); // >>> OK
+                        // REMOVE_START
+                        assertThat(res21).isEqualTo("OK");
+                        // REMOVE_END
+
+                        return asyncCommands.lrange("bikes:repairs", 0, -1);
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[bike:5, bike:4, bike:3]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [bike:5, bike:4, bike:3]
+                    .toCompletableFuture();
+            // STEP_END
+            ltrim.join();
+
+            // REMOVE_START
+            asyncCommands.del("bikes:repairs");
+            // REMOVE_END
+
+            // STEP_START ltrim_end_of_list
+            CompletableFuture<Void> ltrimendoflist = asyncCommands
+                    .rpush("bikes:repairs", "bike:1", "bike:2", "bike:3", "bike:4", "bike:5").thenCompose(res22 -> {
+                        System.out.println(res22); // >>> 5
+                        // REMOVE_START
+                        assertThat(res22).isEqualTo(5);
+                        // REMOVE_END
+
+                        return asyncCommands.ltrim("bikes:repairs", -3, -1);
+                    }).thenCompose(res23 -> {
+                        System.out.println(res23); // >>> OK
+                        // REMOVE_START
+                        assertThat(res23).isEqualTo("OK");
+                        // REMOVE_END
+
+                        return asyncCommands.lrange("bikes:repairs", 0, -1);
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[bike:3, bike:4, bike:5]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [bike:3, bike:4, bike:5]
+                    .toCompletableFuture();
+            // STEP_END
+            ltrimendoflist.join();
+
+            // REMOVE_START
+            asyncCommands.del("bikes:repairs");
+            // REMOVE_END
+
+            // STEP_START brpop
+            CompletableFuture<Void> brpop = asyncCommands.rpush("bikes:repairs", "bike:1", "bike:2").thenCompose(res24 -> {
+                System.out.println(res24); // >>> 2
+                // REMOVE_START
+                assertThat(res24).isEqualTo(2);
+                // REMOVE_END
+
+                return asyncCommands.brpop(1, "bikes:repairs");
+            }).thenCompose(res25 -> {
+                System.out.println(res25);
+                // >>> KeyValue[bikes:repairs, bike:2]
+                // REMOVE_START
+                assertThat(res25.toString()).isEqualTo("KeyValue[bikes:repairs, bike:2]");
+                // REMOVE_END
+
+                return asyncCommands.brpop(1, "bikes:repairs");
+            }).thenCompose(res26 -> {
+                System.out.println(res26);
+                // >>> KeyValue[bikes:repairs, bike:1]
+                // REMOVE_START
+                assertThat(res26.toString()).isEqualTo("KeyValue[bikes:repairs, bike:1]");
+                // REMOVE_END
+
+                return asyncCommands.brpop(1, "bikes:repairs");
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isNull();
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> null
+                    .toCompletableFuture();
+            // STEP_END
+            brpop.join();
+
+            // STEP_START rule_1
+            CompletableFuture<Void> rule1 = asyncCommands.del("new_bikes").thenCompose(res27 -> {
+                System.out.println(res27); // >>> 0
+
+                return asyncCommands.lpush("new_bikes", "bike:1", "bike:2", "bike:3");
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isEqualTo(3);
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> 3
+                    .toCompletableFuture();
+            // STEP_END
+            rule1.join();
+
+            // STEP_START rule_1.1
+            CompletableFuture<Void> rule11 = asyncCommands.set("new_bikes_string", "bike:1").thenCompose(res28 -> {
+                System.out.println(res28); // >>> OK
+                // REMOVE_START
+                assertThat(res28).isEqualTo("OK");
+                // REMOVE_END
+
+                return asyncCommands.type("new_bikes_string");
+            }).thenCompose(res29 -> {
+                System.out.println(res29); // >>> string
+                // REMOVE_START
+                assertThat(res29).isEqualTo("string");
+                // REMOVE_END
+
+                return asyncCommands.lpush("new_bikes_string", "bike:2", "bike:3");
+            }).handle((res, ex) -> {
+                if (ex == null) {
+                    return res;
+                } else {
+                    System.out.println(ex);
+                    // >>> java.util.concurrent.CompletionException:
+                    // >>> io.lettuce.core.RedisCommandExecutionException:
+                    // >>> WRONGTYPE Operation against a key holding the wrong
+                    // >>> kind of value
+                    // REMOVE_START
+                    assertThat(ex.toString()).isEqualTo(
+                            "java.util.concurrent.CompletionException: io.lettuce.core.RedisCommandExecutionException: WRONGTYPE Operation against a key holding the wrong kind of value");
+                    // REMOVE_END
+
+                    return -1L;
+                }
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isEqualTo(-1L);
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> -1
+                    .toCompletableFuture();
+            // STEP_END
+            rule11.join();
+
+            // REMOVE_START
+            asyncCommands.del("bikes:repairs");
+            // REMOVE_END
+
+            // STEP_START rule_2
+            CompletableFuture<Void> rule2 = asyncCommands.lpush("bikes:repairs", "bike:1", "bike:2", "bike:3")
+                    .thenCompose(res30 -> {
+                        System.out.println(res30); // >>> 3
+                        // REMOVE_START
+                        assertThat(res30).isEqualTo(3);
+                        // REMOVE_END
+
+                        return asyncCommands.exists("bikes:repairs");
+                    }).thenCompose(res31 -> {
+                        System.out.println(res31); // >>> 1
+                        // REMOVE_START
+                        assertThat(res31).isEqualTo(1);
+                        // REMOVE_END
+
+                        return asyncCommands.lpop("bikes:repairs");
+                    }).thenCompose(res32 -> {
+                        System.out.println(res32); // >>> bike:3
+                        // REMOVE_START
+                        assertThat(res32).isEqualTo("bike:3");
+                        // REMOVE_END
+
+                        return asyncCommands.lpop("bikes:repairs");
+                    }).thenCompose(res33 -> {
+                        System.out.println(res33); // >>> bike:2
+                        // REMOVE_START
+                        assertThat(res33).isEqualTo("bike:2");
+                        // REMOVE_END
+
+                        return asyncCommands.lpop("bikes:repairs");
+                    }).thenCompose(res34 -> {
+                        System.out.println(res34); // >>> bike:1
+                        // REMOVE_START
+                        assertThat(res34).isEqualTo("bike:1");
+                        // REMOVE_END
+
+                        return asyncCommands.exists("bikes:repairs");
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isZero();
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> 0
+                    .toCompletableFuture();
+            // STEP_END
+            rule2.join();
+
+            // STEP_START rule_3
+            CompletableFuture<Void> rule3 = asyncCommands.del("bikes:repairs").thenCompose(res35 -> {
+                System.out.println(res35); // >>> 0
+
+                return asyncCommands.llen("bikes:repairs");
+            }).thenCompose(res36 -> {
+                System.out.println(res36); // >>> 0
+                // REMOVE_START
+                assertThat(res36).isZero();
+                // REMOVE_END
+
+                return asyncCommands.lpop("bikes:repairs");
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isNull();
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> null
+                    .toCompletableFuture();
+            // STEP_END
+            rule3.join();
+
+            // STEP_START ltrim.1
+            CompletableFuture<Void> ltrim1 = asyncCommands
+                    .lpush("bikes:repairs", "bike:1", "bike:2", "bike:3", "bike:4", "bike:5").thenCompose(res37 -> {
+                        System.out.println(res37); // >>> 5
+                        // REMOVE_START
+                        assertThat(res37).isEqualTo(5);
+                        // REMOVE_END
+
+                        return asyncCommands.ltrim("bikes:repairs", 0, 2);
+                    }).thenCompose(res38 -> {
+                        System.out.println(res38); // >>> OK
+                        // REMOVE_START
+                        assertThat(res38).isEqualTo("OK");
+                        // REMOVE_END
+
+                        return asyncCommands.lrange("bikes:repairs", 0, -1);
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[bike:5, bike:4, bike:3]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> [bike:5, bike:4, bike:3]
+                    .toCompletableFuture();
+            // STEP_END
+            ltrim1.join();
+            // HIDE_START
+        } finally {
+            redisClient.shutdown();
+        }
+    }
+
+}
+// HIDE_END
diff --git a/src/test/java/io/redis/examples/async/SortedSetExample.java b/src/test/java/io/redis/examples/async/SortedSetExample.java
new file mode 100644
index 000000000..88183a5ea
--- /dev/null
+++ b/src/test/java/io/redis/examples/async/SortedSetExample.java
@@ -0,0 +1,239 @@
+// EXAMPLE: ss_tutorial
+package io.redis.examples.async;
+
+import io.lettuce.core.*;
+import io.lettuce.core.api.async.RedisAsyncCommands;
+import io.lettuce.core.api.StatefulRedisConnection;
+
+// REMOVE_START
+import org.junit.jupiter.api.Test;
+// REMOVE_END
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+// REMOVE_START
+import static org.assertj.core.api.Assertions.assertThat;
+// REMOVE_END
+
+public class SortedSetExample {
+
+    @Test
+    public void run() {
+        RedisClient redisClient = RedisClient.create("redis://localhost:6379");
+
+        try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
+            RedisAsyncCommands<String, String> asyncCommands = connection.async();
+            // REMOVE_START
+            asyncCommands.del("racer_scores").toCompletableFuture().join();
+            // REMOVE_END
+
+            // STEP_START zadd
+            CompletableFuture<Void> zadd = asyncCommands.zadd("racer_scores", ScoredValue.just(10d, "Norem"))
+                    .thenCompose(res1 -> {
+                        System.out.println(res1); // >>> 1
+                        // REMOVE_START
+                        assertThat(res1).isEqualTo(1);
+                        // REMOVE_END
+
+                        return asyncCommands.zadd("racer_scores", ScoredValue.just(12d, "Castilla"));
+                    }).thenCompose(res2 -> {
+                        System.out.println(res2); // >>> 1
+                        // REMOVE_START
+                        assertThat(res2).isEqualTo(1);
+                        // REMOVE_END
+
+                        return asyncCommands.zadd("racer_scores", ScoredValue.just(8d, "Sam-Bodden"),
+                                ScoredValue.just(10d, "Royce"), ScoredValue.just(6d, "Ford"),
+                                ScoredValue.just(14d, "Prickett"));
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isEqualTo(4);
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> 4
+                    .toCompletableFuture();
+            // STEP_END
+            zadd.join();
+
+            // STEP_START zrange
+            CompletableFuture<Void> zrange = asyncCommands.zrange("racer_scores", 0, -1).thenCompose(res3 -> {
+                System.out.println(res3);
+                // >>> [Ford, Sam-Bodden, Norem, Royce, Castilla, Prickett]
+                // REMOVE_START
+                assertThat(res3.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce, Castilla, Prickett]");
+                // REMOVE_END
+
+                return asyncCommands.zrevrange("racer_scores", 0, -1);
+            })
+                    // REMOVE_START
+                    .thenApply((List<String> res) -> {
+                        assertThat(res.toString()).isEqualTo("[Prickett, Castilla, Royce, Norem, Sam-Bodden, Ford]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [Prickett, Castilla, Royce, Norem, Sam-Bodden, Ford]
+                    .toCompletableFuture();
+            // STEP_END
+            zrange.join();
+
+            // STEP_START zrange_withscores
+            CompletableFuture<Void> zrangeWithScores = asyncCommands.zrangeWithScores("racer_scores", 0, -1)
+                    // REMOVE_START
+                    .thenApply((List<ScoredValue<String>> res) -> {
+                        assertThat(res.toString()).isEqualTo("[ScoredValue[6.000000, Ford], ScoredValue[8.000000, Sam-Bodden],"
+                                + " ScoredValue[10.000000, Norem], ScoredValue[10.000000, Royce],"
+                                + " ScoredValue[12.000000, Castilla], ScoredValue[14.000000, Prickett]]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [ScoredValue[6.000000, Ford], ScoredValue[8.000000, Sam-Bodden]...
+                    .toCompletableFuture();
+            // STEP_END
+            zrangeWithScores.join();
+
+            // STEP_START zrangebyscore
+            CompletableFuture<Void> zrangebyscore = asyncCommands
+                    .zrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 10))
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[Ford, Sam-Bodden, Norem, Royce]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [Ford, Sam-Bodden, Norem, Royce]
+                    .toCompletableFuture();
+            // STEP_END
+            zrangebyscore.join();
+
+            // STEP_START zremrangebyscore
+            CompletableFuture<Void> zremrangebyscore = asyncCommands.zrem("racer_scores", "Castilla").thenCompose(res4 -> {
+                System.out.println(res4); // >>> 1
+                // REMOVE_START
+                assertThat(res4).isEqualTo(1);
+                // REMOVE_END
+
+                return asyncCommands.zremrangebyscore("racer_scores", Range.create(Double.MIN_VALUE, 9));
+            }).thenCompose(res5 -> {
+                System.out.println(res5); // >>> 2
+                // REMOVE_START
+                assertThat(res5).isEqualTo(2);
+                // REMOVE_END
+
+                return asyncCommands.zrange("racer_scores", 0, -1);
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[Norem, Royce, Prickett]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [Norem, Royce, Prickett]
+                    .toCompletableFuture();
+            // STEP_END
+            zremrangebyscore.join();
+
+            // STEP_START zrank
+            CompletableFuture<Void> zrank = asyncCommands.zrank("racer_scores", "Norem").thenCompose(res6 -> {
+                System.out.println(res6); // >>> 0
+                // REMOVE_START
+                assertThat(res6).isZero();
+                // REMOVE_END
+
+                return asyncCommands.zrevrank("racer_scores", "Norem");
+            })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isEqualTo(2);
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> 2
+                    .toCompletableFuture();
+            // STEP_END
+            zrank.join();
+
+            // STEP_START zadd_lex
+            CompletableFuture<Void> zaddLex = asyncCommands.zadd("racer_scores", ScoredValue.just(0d, "Norem"),
+                    ScoredValue.just(0d, "Sam-Bodden"), ScoredValue.just(0d, "Royce"), ScoredValue.just(0d, "Castilla"),
+                    ScoredValue.just(0d, "Prickett"), ScoredValue.just(0d, "Ford")).thenCompose(res7 -> {
+                        System.out.println(res7); // >>> 3
+                        // REMOVE_START
+                        assertThat(res7).isEqualTo(3);
+                        // REMOVE_END
+
+                        return asyncCommands.zrange("racer_scores", 0, -1);
+                    }).thenCompose(res8 -> {
+                        System.out.println(res8);
+                        // >>> [Castilla, Ford, Norem, Prickett, Royce, Sam-Bodden]
+                        // REMOVE_START
+                        assertThat(res8.toString()).isEqualTo("[Castilla, Ford, Norem, Prickett, Royce, Sam-Bodden]");
+                        // REMOVE_END
+
+                        return asyncCommands.zrangebylex("racer_scores", Range.create("A", "L"));
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res.toString()).isEqualTo("[Castilla, Ford]");
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println)
+                    // >>> [Castilla, Ford]
+                    .toCompletableFuture();
+            // STEP_END
+            zaddLex.join();
+
+            // STEP_START leaderboard
+            CompletableFuture<Void> leaderboard = asyncCommands.zadd("racer_scores", ScoredValue.just(100, "Wood"))
+                    .thenCompose(res9 -> {
+                        System.out.println(res9); // >>> 1
+                        // REMOVE_START
+                        assertThat(res9).isEqualTo(1);
+                        // REMOVE_END
+
+                        return asyncCommands.zadd("racer_scores", ScoredValue.just(100, "Henshaw"));
+                    }).thenCompose(res10 -> {
+                        System.out.println(res10); // >>> 1
+                        // REMOVE_START
+                        assertThat(res10).isEqualTo(1);
+                        // REMOVE_END
+
+                        return asyncCommands.zadd("racer_scores", ScoredValue.just(150, "Henshaw"));
+                    }).thenCompose(res11 -> {
+                        System.out.println(res11); // >>> 0
+                        // REMOVE_START
+                        assertThat(res11).isZero();
+                        // REMOVE_END
+
+                        return asyncCommands.zincrby("racer_scores", 50, "Wood");
+                    }).thenCompose(res12 -> {
+                        System.out.println(res12); // >>> 150
+                        // REMOVE_START
+                        assertThat(res12).isEqualTo(150);
+                        // REMOVE_END
+
+                        return asyncCommands.zincrby("racer_scores", 50, "Henshaw");
+                    })
+                    // REMOVE_START
+                    .thenApply(res -> {
+                        assertThat(res).isEqualTo(200);
+                        return res;
+                    })
+                    // REMOVE_END
+                    .thenAccept(System.out::println) // >>> 200
+                    .toCompletableFuture();
+            // STEP_END
+            leaderboard.join();
+            // HIDE_START
+        } finally {
+            redisClient.shutdown();
+        }
+    }
+
+}
+// HIDE_END
diff --git a/src/test/resources/docker-env/.env b/src/test/resources/docker-env/.env
index b294422b6..1bacc7438 100644
--- a/src/test/resources/docker-env/.env
+++ b/src/test/resources/docker-env/.env
@@ -1,2 +1,3 @@
 REDIS_ENV_WORK_DIR=../../../../work/docker
-REDIS_VERSION=8.0-M04-pre
+REDIS_VERSION=8.0.1-pre
+REDIS_STACK_VERSION=8.0.1-pre
\ No newline at end of file
diff --git a/src/test/resources/docker-env/.env.v7.2 b/src/test/resources/docker-env/.env.v7.2
new file mode 100644
index 000000000..35a627f2d
--- /dev/null
+++ b/src/test/resources/docker-env/.env.v7.2
@@ -0,0 +1,3 @@
+REDIS_ENV_WORK_DIR=../../../../work/docker
+REDIS_VERSION=7.2.7
+REDIS_STACK_VERSION=rs-7.2.0-v14
\ No newline at end of file
diff --git a/src/test/resources/docker-env/.env.v7.4 b/src/test/resources/docker-env/.env.v7.4
new file mode 100644
index 000000000..68aba20a3
--- /dev/null
+++ b/src/test/resources/docker-env/.env.v7.4
@@ -0,0 +1,3 @@
+REDIS_ENV_WORK_DIR=../../../../work/docker
+REDIS_VERSION=7.4.2
+REDIS_STACK_VERSION=rs-7.4.0-v2
\ No newline at end of file
diff --git a/src/test/resources/docker-env/docker-compose.yml b/src/test/resources/docker-env/docker-compose.yml
index 130fb1f71..193ff3b9d 100644
--- a/src/test/resources/docker-env/docker-compose.yml
+++ b/src/test/resources/docker-env/docker-compose.yml
@@ -1,5 +1,7 @@
 x-client-libs-image: &client-libs-image
-  image: "redislabs/client-libs-test:${REDIS_VERSION:-8.0-M04-pre}"
+  image: "redislabs/client-libs-test:${REDIS_VERSION:-8.0.1-pre}"
+x-client-libs-stack-image: &client-libs-stack-image
+  image: "redislabs/client-libs-test:${REDIS_STACK_VERSION:-8.0.1-pre}"
 
 services:
   # Standalone Redis Servers
@@ -101,6 +103,21 @@ services:
     networks:
       - redis-network
 
+  standalone-stack:
+    <<: *client-libs-stack-image
+    environment:
+      - REDIS_CLUSTER=no
+      - PORT=6379
+    ports:
+      - "16379:6379"
+
+  clustered-stack:
+    <<: *client-libs-stack-image
+    environment:
+      - REDIS_CLUSTER=yes
+      - PORT=36379
+    ports:
+      - "36379-36381:36379-36381"
 
   ssl-test-cluster:
     <<: *client-libs-image
diff --git a/src/test/resources/docker/docker-compose.yml b/src/test/resources/docker/docker-compose.yml
deleted file mode 100644
index 58a83da33..000000000
--- a/src/test/resources/docker/docker-compose.yml
+++ /dev/null
@@ -1,21 +0,0 @@
----
-x-client-libs-stack-image: &client-libs-stack-image
-    image: "redislabs/client-libs-test:${REDIS_STACK_VERSION:-8.0-M04-pre}"
-
-services:
-
-  standalone-stack:
-    <<: *client-libs-stack-image
-    environment:
-        - REDIS_CLUSTER=no
-        - PORT=6379
-    ports:
-      - "16379:6379"
-
-  clustered-stack:
-    <<: *client-libs-stack-image
-    environment:
-      - REDIS_CLUSTER=yes
-      - PORT=36379
-    ports:
-      - "36379-36381:36379-36381"
\ No newline at end of file