Repository: redis/lettuce
Pull request #2816 (2024-04-04 12:39:38)

Support HSCAN with NOVALUES argument
Issue #2763

HSCAN has a new argument called NOVALUES. The effect is that only the keys in the hash are returned, without associated values.

<!--
Thank you for proposing a pull request. This template will guide you through the essential steps necessary for a pull request.
-->
Make sure that:

- [ ] You have read the [contribution guidelines](https://github.com/lettuce-io/lettuce-core/blob/main/.github/CONTRIBUTING.md).
- [ ] You have created a feature request first to discuss your contribution intent. Please reference the feature request ticket number in the pull request.
- [ ] You use the code formatters provided [here](https://github.com/lettuce-io/lettuce-core/blob/main/formatting.xml) and have them applied to your changes. Don’t submit any formatting related changes.
- [ ] You submit test cases (unit or integration tests) that back your changes.

 <!--
Great! Live long and prosper.
-->


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/redis/lettuce/pull/2816?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 77.71%. Comparing base [(`43843bf`)](https://app.codecov.io/gh/redis/lettuce/commit/43843bf4bdfb09c492efbcf14667da4293452479?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) to head [(`464eeac`)](https://app.codecov.io/gh/redis/lettuce/pull/2816?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).
> Report is 246 commits behind head on main.


<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##               main    #2816      +/-   ##
============================================
- Coverage     78.71%   77.71%   -1.01%     
- Complexity     6786     7210     +424     
============================================
  Files           508      537      +29     
  Lines         22765    24422    +1657     
  Branches       2446     2603     +157     
============================================
+ Hits          17919    18979    +1060     
- Misses         3717     4239     +522     
- Partials       1129     1204      +75     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/redis/lettuce/pull/2816?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).

gerzse: > Generally, the functionality is fine. There are some aspects that require further refinement:
> 
> * Our lettercasing uses camel-base based on word boundaries. Since `novalues` is a word on its own, method names should be `hscanNovalues` instead of `hscanNoValues`. We already have some mixture in letter casing. To align with how it should be, please use a lowercase `v` as in `Novalues`.
> * Missing `@since` tags. The next release aims towards 7.0, so please add `@since 7.0` to all newly introduced `public` methods. Adding the tag into the template is typically fine.
> * There are several unrelated changes such as changes to `RedisServerAsyncCommands` that result from committing all generated interfaces. Please remove these changes from the PR to have a focused pull request.

Thanks for the review!

* I did notice in the codebase the drift in the naming rules. I will switch to the one that you suggested. Also I'll add tags.
* About the unrelated changes, you're right, they don't belong here. What happened is that I ran the code generator and got many differences, so I though doing something about that. I will open a separate PR only about aligning the templates with the current state of the code, if it's OK.
* Besides this, @atakavci pointed out that I missed some interfaces that have the `hscan` method, so I'll try to cover those too, since `hscanNovalues` is a general alternative to `hscan`.
gerzse: > > Generally, the functionality is fine. There are some aspects that require further refinement:
> > 
> > * Our lettercasing uses camel-base based on word boundaries. Since `novalues` is a word on its own, method names should be `hscanNovalues` instead of `hscanNoValues`. We already have some mixture in letter casing. To align with how it should be, please use a lowercase `v` as in `Novalues`.
> > * Missing `@since` tags. The next release aims towards 7.0, so please add `@since 7.0` to all newly introduced `public` methods. Adding the tag into the template is typically fine.
> > * There are several unrelated changes such as changes to `RedisServerAsyncCommands` that result from committing all generated interfaces. Please remove these changes from the PR to have a focused pull request.
> 
> Thanks for the review!
> 
> * I did notice in the codebase the drift in the naming rules. I will switch to the one that you suggested. Also I'll add tags.
> * About the unrelated changes, you're right, they don't belong here. What happened is that I ran the code generator and got many differences, so I though doing something about that. I will open a separate PR only about aligning the templates with the current state of the code, if it's OK.
> * Besides this, @atakavci pointed out that I missed some interfaces that have the `hscan` method, so I'll try to cover those too, since `hscanNovalues` is a general alternative to `hscan`.

The code generator is a bit of Pandora's box, which I don't want to open. I ran the generator, reverted all the (quite many) unrelated changes, and kept strictly the changes related to HSCAN NOVALUES.

Patch:
diff --git a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java
index f8af981b5..f2c6c625c 100644
--- a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java
@@ -1169,42 +1169,83 @@ public abstract class AbstractRedisAsyncCommands<K, V> implements RedisAclAsyncC
         return dispatch(commandBuilder.hscan(key));
     }
 
+    @Override
+    public RedisFuture<KeyScanCursor<K>> hscanNovalues(K key) {
+        return dispatch(commandBuilder.hscanNovalues(key));
+    }
+
     @Override
     public RedisFuture<MapScanCursor<K, V>> hscan(K key, ScanArgs scanArgs) {
         return dispatch(commandBuilder.hscan(key, scanArgs));
     }
 
+    @Override
+    public RedisFuture<KeyScanCursor<K>> hscanNovalues(K key, ScanArgs scanArgs) {
+        return dispatch(commandBuilder.hscanNovalues(key, scanArgs));
+    }
+
     @Override
     public RedisFuture<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs) {
         return dispatch(commandBuilder.hscan(key, scanCursor, scanArgs));
     }
 
+    @Override
+    public RedisFuture<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs) {
+        return dispatch(commandBuilder.hscanNovalues(key, scanCursor, scanArgs));
+    }
+
     @Override
     public RedisFuture<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor) {
         return dispatch(commandBuilder.hscan(key, scanCursor));
     }
 
+    @Override
+    public RedisFuture<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor) {
+        return dispatch(commandBuilder.hscanNovalues(key, scanCursor));
+    }
+
     @Override
     public RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key) {
         return dispatch(commandBuilder.hscanStreaming(channel, key));
     }
 
+    @Override
+    public RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key) {
+        return dispatch(commandBuilder.hscanNoValuesStreaming(channel, key));
+    }
+
     @Override
     public RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs) {
         return dispatch(commandBuilder.hscanStreaming(channel, key, scanArgs));
     }
 
+    @Override
+    public RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs) {
+        return dispatch(commandBuilder.hscanNoValuesStreaming(channel, key, scanArgs));
+    }
+
     @Override
     public RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor,
             ScanArgs scanArgs) {
         return dispatch(commandBuilder.hscanStreaming(channel, key, scanCursor, scanArgs));
     }
 
+    @Override
+    public RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor,
+                                                       ScanArgs scanArgs) {
+        return dispatch(commandBuilder.hscanNoValuesStreaming(channel, key, scanCursor, scanArgs));
+    }
+
     @Override
     public RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor) {
         return dispatch(commandBuilder.hscanStreaming(channel, key, scanCursor));
     }
 
+    @Override
+    public RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor) {
+        return dispatch(commandBuilder.hscanNoValuesStreaming(channel, key, scanCursor));
+    }
+
     @Override
     public RedisFuture<Boolean> hset(K key, K field, V value) {
         return dispatch(commandBuilder.hset(key, field, value));
diff --git a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java
index 41d072906..47b473495 100644
--- a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java
+++ b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java
@@ -1232,42 +1232,83 @@ public abstract class AbstractRedisReactiveCommands<K, V>
         return createMono(() -> commandBuilder.hscan(key));
     }
 
+    @Override
+    public Mono<KeyScanCursor<K>> hscanNovalues(K key) {
+        return createMono(() -> commandBuilder.hscanNovalues(key));
+    }
+
     @Override
     public Mono<MapScanCursor<K, V>> hscan(K key, ScanArgs scanArgs) {
         return createMono(() -> commandBuilder.hscan(key, scanArgs));
     }
 
+    @Override
+    public Mono<KeyScanCursor<K>> hscanNovalues(K key, ScanArgs scanArgs) {
+        return createMono(() -> commandBuilder.hscanNovalues(key, scanArgs));
+    }
+
     @Override
     public Mono<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs) {
         return createMono(() -> commandBuilder.hscan(key, scanCursor, scanArgs));
     }
 
+    @Override
+    public Mono<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs) {
+        return createMono(() -> commandBuilder.hscanNovalues(key, scanCursor, scanArgs));
+    }
+
     @Override
     public Mono<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor) {
         return createMono(() -> commandBuilder.hscan(key, scanCursor));
     }
 
+    @Override
+    public Mono<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor) {
+        return createMono(() -> commandBuilder.hscanNovalues(key, scanCursor));
+    }
+
     @Override
     public Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key) {
         return createMono(() -> commandBuilder.hscanStreaming(channel, key));
     }
 
+    @Override
+    public Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key) {
+        return createMono(() -> commandBuilder.hscanNoValuesStreaming(channel, key));
+    }
+
     @Override
     public Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs) {
         return createMono(() -> commandBuilder.hscanStreaming(channel, key, scanArgs));
     }
 
+    @Override
+    public Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs) {
+        return createMono(() -> commandBuilder.hscanNoValuesStreaming(channel, key, scanArgs));
+    }
+
     @Override
     public Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor,
             ScanArgs scanArgs) {
         return createMono(() -> commandBuilder.hscanStreaming(channel, key, scanCursor, scanArgs));
     }
 
+    @Override
+    public Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor,
+                                                ScanArgs scanArgs) {
+        return createMono(() -> commandBuilder.hscanNoValuesStreaming(channel, key, scanCursor, scanArgs));
+    }
+
     @Override
     public Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor) {
         return createMono(() -> commandBuilder.hscanStreaming(channel, key, scanCursor));
     }
 
+    @Override
+    public Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor) {
+        return createMono(() -> commandBuilder.hscanNoValuesStreaming(channel, key, scanCursor));
+    }
+
     @Override
     public Mono<Boolean> hset(K key, K field, V value) {
         return createMono(() -> commandBuilder.hset(key, field, value));
diff --git a/src/main/java/io/lettuce/core/RedisCommandBuilder.java b/src/main/java/io/lettuce/core/RedisCommandBuilder.java
index 67c5839be..3d3d2be0b 100644
--- a/src/main/java/io/lettuce/core/RedisCommandBuilder.java
+++ b/src/main/java/io/lettuce/core/RedisCommandBuilder.java
@@ -1535,18 +1535,36 @@ class RedisCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return hscan(key, ScanCursor.INITIAL, null);
     }
 
+    Command<K, V, KeyScanCursor<K>> hscanNovalues(K key) {
+        notNullKey(key);
+
+        return hscanNovalues(key, ScanCursor.INITIAL, null);
+    }
+
     Command<K, V, MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor) {
         notNullKey(key);
 
         return hscan(key, scanCursor, null);
     }
 
+    Command<K, V, KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor) {
+        notNullKey(key);
+
+        return hscanNovalues(key, scanCursor, null);
+    }
+
     Command<K, V, MapScanCursor<K, V>> hscan(K key, ScanArgs scanArgs) {
         notNullKey(key);
 
         return hscan(key, ScanCursor.INITIAL, scanArgs);
     }
 
+    Command<K, V, KeyScanCursor<K>> hscanNovalues(K key, ScanArgs scanArgs) {
+        notNullKey(key);
+
+        return hscanNovalues(key, ScanCursor.INITIAL, scanArgs);
+    }
+
     Command<K, V, MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs) {
         notNullKey(key);
 
@@ -1559,6 +1577,20 @@ class RedisCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return createCommand(HSCAN, output, args);
     }
 
+    Command<K, V, KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs) {
+        notNullKey(key);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec);
+        args.addKey(key);
+
+        scanArgs(scanCursor, scanArgs, args);
+
+        args.add(NOVALUES);
+
+        KeyScanOutput<K, V> output = new KeyScanOutput<>(codec);
+        return createCommand(HSCAN, output, args);
+    }
+
     Command<K, V, StreamScanCursor> hscanStreaming(KeyValueStreamingChannel<K, V> channel, K key) {
         notNullKey(key);
         notNull(channel);
@@ -1566,6 +1598,13 @@ class RedisCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return hscanStreaming(channel, key, ScanCursor.INITIAL, null);
     }
 
+    Command<K, V, StreamScanCursor> hscanNoValuesStreaming(KeyStreamingChannel<K> channel, K key) {
+        notNullKey(key);
+        notNull(channel);
+
+        return hscanNoValuesStreaming(channel, key, ScanCursor.INITIAL, null);
+    }
+
     Command<K, V, StreamScanCursor> hscanStreaming(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor) {
         notNullKey(key);
         notNull(channel);
@@ -1573,6 +1612,13 @@ class RedisCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return hscanStreaming(channel, key, scanCursor, null);
     }
 
+    Command<K, V, StreamScanCursor> hscanNoValuesStreaming(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor) {
+        notNullKey(key);
+        notNull(channel);
+
+        return hscanNoValuesStreaming(channel, key, scanCursor, null);
+    }
+
     Command<K, V, StreamScanCursor> hscanStreaming(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs) {
         notNullKey(key);
         notNull(channel);
@@ -1580,6 +1626,13 @@ class RedisCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return hscanStreaming(channel, key, ScanCursor.INITIAL, scanArgs);
     }
 
+    Command<K, V, StreamScanCursor> hscanNoValuesStreaming(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs) {
+        notNullKey(key);
+        notNull(channel);
+
+        return hscanNoValuesStreaming(channel, key, ScanCursor.INITIAL, scanArgs);
+    }
+
     Command<K, V, StreamScanCursor> hscanStreaming(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor,
             ScanArgs scanArgs) {
         notNullKey(key);
@@ -1594,6 +1647,22 @@ class RedisCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return createCommand(HSCAN, output, args);
     }
 
+    Command<K, V, StreamScanCursor> hscanNoValuesStreaming(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor,
+                                                           ScanArgs scanArgs) {
+        notNullKey(key);
+        notNull(channel);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec);
+
+        args.addKey(key);
+        scanArgs(scanCursor, scanArgs, args);
+
+        args.add(NOVALUES);
+
+        KeyScanStreamingOutput<K, V> output = new KeyScanStreamingOutput<>(codec, channel);
+        return createCommand(HSCAN, output, args);
+    }
+
     Command<K, V, Boolean> hset(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/ScanIterator.java b/src/main/java/io/lettuce/core/ScanIterator.java
index f5af7ff7b..11cec3513 100644
--- a/src/main/java/io/lettuce/core/ScanIterator.java
+++ b/src/main/java/io/lettuce/core/ScanIterator.java
@@ -104,6 +104,21 @@ public abstract class ScanIterator<T> implements Iterator<T> {
         return hscan(commands, key, Optional.empty());
     }
 
+    /**
+     * Sequentially iterate over keys in a hash identified by {@code key}. This method uses {@code HSCAN NOVALUES} to perform an
+     * iterative scan.
+     *
+     * @param commands the commands interface, must not be {@code null}.
+     * @param key the hash to scan.
+     * @param <K> Key type.
+     * @param <V> Value type.
+     * @return a new {@link ScanIterator}.
+     * @since 7.0
+     */
+    public static <K, V> ScanIterator<K> hscanNovalues(RedisHashCommands<K, V> commands, K key) {
+        return hscanNovalues(commands, key, Optional.empty());
+    }
+
     /**
      * Sequentially iterate over entries in a hash identified by {@code key}. This method uses {@code HSCAN} to perform an
      * iterative scan.
@@ -122,6 +137,25 @@ public abstract class ScanIterator<T> implements Iterator<T> {
         return hscan(commands, key, Optional.of(scanArgs));
     }
 
+    /**
+     * Sequentially iterate over keys in a hash identified by {@code key}. This method uses {@code HSCAN NOVALUES} to perform an
+     * iterative scan.
+     *
+     * @param commands the commands interface, must not be {@code null}.
+     * @param key the hash to scan.
+     * @param scanArgs the scan arguments, must not be {@code null}.
+     * @param <K> Key type.
+     * @param <V> Value type.
+     * @return a new {@link ScanIterator}.
+     * @since 7.0
+     */
+    public static <K, V> ScanIterator<K> hscanNovalues(RedisHashCommands<K, V> commands, K key, ScanArgs scanArgs) {
+
+        LettuceAssert.notNull(scanArgs, "ScanArgs must not be null");
+
+        return hscanNovalues(commands, key, Optional.of(scanArgs));
+    }
+
     private static <K, V> ScanIterator<KeyValue<K, V>> hscan(RedisHashCommands<K, V> commands, K key,
             Optional<ScanArgs> scanArgs) {
 
@@ -151,6 +185,35 @@ public abstract class ScanIterator<T> implements Iterator<T> {
         };
     }
 
+    private static <K, V> ScanIterator<K> hscanNovalues(RedisHashCommands<K, V> commands, K key,
+            Optional<ScanArgs> scanArgs) {
+
+        LettuceAssert.notNull(commands, "RedisKeyCommands must not be null");
+        LettuceAssert.notNull(key, "Key must not be null");
+
+        return new SyncScanIterator<K>() {
+
+            @Override
+            protected ScanCursor nextScanCursor(ScanCursor scanCursor) {
+
+                KeyScanCursor<K> cursor = getNextScanCursor(scanCursor);
+                chunk = cursor.getKeys().iterator();
+                return cursor;
+            }
+
+            private KeyScanCursor<K> getNextScanCursor(ScanCursor scanCursor) {
+
+                if (scanCursor == null) {
+                    return scanArgs.map(scanArgs -> commands.hscanNovalues(key, scanArgs)).orElseGet(() -> commands.hscanNovalues(key));
+                }
+
+                return scanArgs.map((scanArgs) -> commands.hscanNovalues(key, scanCursor, scanArgs))
+                        .orElseGet(() -> commands.hscanNovalues(key, scanCursor));
+            }
+
+        };
+    }
+
     /**
      * Sequentially iterate over elements in a set identified by {@code key}. This method uses {@code SSCAN} to perform an
      * iterative scan.
diff --git a/src/main/java/io/lettuce/core/ScanStream.java b/src/main/java/io/lettuce/core/ScanStream.java
index 3d25986fa..f9fe147ab 100644
--- a/src/main/java/io/lettuce/core/ScanStream.java
+++ b/src/main/java/io/lettuce/core/ScanStream.java
@@ -105,6 +105,21 @@ public abstract class ScanStream {
         return hscan(commands, key, Optional.empty());
     }
 
+    /**
+     * Sequentially iterate over keys in a hash identified by {@code key}. This method uses {@code HSCAN NOVALUES} to perform an
+     * iterative scan.
+     *
+     * @param commands the commands interface, must not be {@code null}.
+     * @param key the hash to scan.
+     * @param <K> Key type.
+     * @param <V> Value type.
+     * @return a new {@link Flux}.
+     * @since 7.0
+     */
+    public static <K, V> Flux<K> hscanNovalues(RedisHashReactiveCommands<K, V> commands, K key) {
+        return hscanNovalues(commands, key, Optional.empty());
+    }
+
     /**
      * Sequentially iterate over entries in a hash identified by {@code key}. This method uses {@code HSCAN} to perform an
      * iterative scan.
@@ -123,6 +138,25 @@ public abstract class ScanStream {
         return hscan(commands, key, Optional.of(scanArgs));
     }
 
+    /**
+     * Sequentially iterate over keys in a hash identified by {@code key}. This method uses {@code HSCAN NOVALUES} to perform an
+     * iterative scan.
+     *
+     * @param commands the commands interface, must not be {@code null}.
+     * @param key the hash to scan.
+     * @param scanArgs the scan arguments, must not be {@code null}.
+     * @param <K> Key type.
+     * @param <V> Value type.
+     * @return a new {@link Flux}.
+     * @since 7.0
+     */
+    public static <K, V> Flux<K> hscanNovalues(RedisHashReactiveCommands<K, V> commands, K key, ScanArgs scanArgs) {
+
+        LettuceAssert.notNull(scanArgs, "ScanArgs must not be null");
+
+        return hscanNovalues(commands, key, Optional.of(scanArgs));
+    }
+
     private static <K, V> Flux<KeyValue<K, V>> hscan(RedisHashReactiveCommands<K, V> commands, K key,
             Optional<ScanArgs> scanArgs) {
 
@@ -143,6 +177,23 @@ public abstract class ScanStream {
                 });
     }
 
+    private static <K, V> Flux<K> hscanNovalues(RedisHashReactiveCommands<K, V> commands, K key,
+            Optional<ScanArgs> scanArgs) {
+
+        LettuceAssert.notNull(commands, "RedisHashReactiveCommands must not be null");
+        LettuceAssert.notNull(key, "Key must not be null");
+
+        return scanArgs.map(it -> commands.hscanNovalues(key, it)).orElseGet(() -> commands.hscanNovalues(key))
+                .expand(c -> !c.isFinished()
+                        ? scanArgs.map(it -> commands.hscanNovalues(key, c, it)).orElseGet(() -> commands.hscanNovalues(key, c))
+                        : Mono.empty())
+                .flatMapIterable(c -> {
+                    List<K> list = new ArrayList<>(c.getKeys().size());
+                    list.addAll(c.getKeys());
+                    return list;
+                });
+    }
+
     /**
      * Sequentially iterate over elements in a set identified by {@code key}. This method uses {@code SSCAN} to perform an
      * iterative scan.
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 b57b20895..cc9b8b148 100644
--- a/src/main/java/io/lettuce/core/api/async/RedisHashAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/api/async/RedisHashAsyncCommands.java
@@ -22,6 +22,7 @@ package io.lettuce.core.api.async;
 import java.util.List;
 import java.util.Map;
 
+import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
 import io.lettuce.core.RedisFuture;
@@ -214,6 +215,15 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<MapScanCursor<K, V>> hscan(K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<KeyScanCursor<K>> hscanNovalues(K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -223,6 +233,16 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<MapScanCursor<K, V>> hscan(K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<KeyScanCursor<K>> hscanNovalues(K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -233,6 +253,17 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -242,6 +273,16 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -251,6 +292,16 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -261,6 +312,17 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -272,6 +334,18 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -282,6 +356,17 @@ public interface RedisHashAsyncCommands<K, V> {
      */
     RedisFuture<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    RedisFuture<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor);
+
     /**
      * Set the string value of a hash field.
      *
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 e27714bee..080087510 100644
--- a/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java
+++ b/src/main/java/io/lettuce/core/api/reactive/RedisHashReactiveCommands.java
@@ -23,6 +23,7 @@ import java.util.Map;
 
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
 import io.lettuce.core.ScanArgs;
@@ -222,6 +223,15 @@ public interface RedisHashReactiveCommands<K, V> {
      */
     Mono<MapScanCursor<K, V>> hscan(K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Mono<KeyScanCursor<K>> hscanNovalues(K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -231,6 +241,16 @@ public interface RedisHashReactiveCommands<K, V> {
      */
     Mono<MapScanCursor<K, V>> hscan(K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Mono<KeyScanCursor<K>> hscanNovalues(K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -241,6 +261,17 @@ public interface RedisHashReactiveCommands<K, V> {
      */
     Mono<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Mono<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -250,6 +281,16 @@ public interface RedisHashReactiveCommands<K, V> {
      */
     Mono<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Mono<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -261,6 +302,17 @@ public interface RedisHashReactiveCommands<K, V> {
     @Deprecated
     Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @return StreamScanCursor scan cursor.
+     * @deprecated since 7.0 in favor of consuming large results through the {@link org.reactivestreams.Publisher} returned by {@link #hscanNovalues}.
+     */
+    @Deprecated
+    Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -273,6 +325,18 @@ public interface RedisHashReactiveCommands<K, V> {
     @Deprecated
     Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @deprecated since 7.0 in favor of consuming large results through the {@link org.reactivestreams.Publisher} returned by {@link #hscanNovalues}.
+     */
+    @Deprecated
+    Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -286,6 +350,19 @@ public interface RedisHashReactiveCommands<K, V> {
     @Deprecated
     Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @deprecated since 7.0 in favor of consuming large results through the {@link org.reactivestreams.Publisher} returned by {@link #hscanNovalues}.
+     */
+    @Deprecated
+    Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -298,6 +375,18 @@ public interface RedisHashReactiveCommands<K, V> {
     @Deprecated
     Mono<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return StreamScanCursor scan cursor.
+     * @deprecated since 7.0 in favor of consuming large results through the {@link org.reactivestreams.Publisher} returned by {@link #hscanNovalues}.
+     */
+    @Deprecated
+    Mono<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor);
+
     /**
      * Set the string value of a hash field.
      *
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 b4cdad955..816ec0822 100644
--- a/src/main/java/io/lettuce/core/api/sync/RedisHashCommands.java
+++ b/src/main/java/io/lettuce/core/api/sync/RedisHashCommands.java
@@ -22,6 +22,7 @@ package io.lettuce.core.api.sync;
 import java.util.List;
 import java.util.Map;
 
+import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
 import io.lettuce.core.ScanArgs;
@@ -215,6 +216,15 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -224,6 +234,16 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -234,6 +254,17 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -243,6 +274,16 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key, ScanCursor scanCursor);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -252,6 +293,16 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -262,6 +313,17 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -273,6 +335,18 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -283,6 +357,17 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor);
+
     /**
      * Set the string value of a hash field.
      *
diff --git a/src/main/java/io/lettuce/core/cluster/ClusterPubSubConnectionProvider.java b/src/main/java/io/lettuce/core/cluster/ClusterPubSubConnectionProvider.java
index 9d79ae0cb..ce5abb67e 100644
--- a/src/main/java/io/lettuce/core/cluster/ClusterPubSubConnectionProvider.java
+++ b/src/main/java/io/lettuce/core/cluster/ClusterPubSubConnectionProvider.java
@@ -167,6 +167,11 @@ class ClusterPubSubConnectionProvider<K, V> extends PooledClusterConnectionProvi
             notifications.punsubscribed(getNode(), pattern, count);
         }
 
+        @Override
+        public void ssubscribed(K channel, long count) {
+            notifications.ssubscribed(getNode(), channel, count);
+        }
+
         private RedisClusterNode getNode() {
             return nodeId != null ? getPartitions().getPartitionByNodeId(nodeId) : getPartitions().getPartition(host, port);
         }
diff --git a/src/main/java/io/lettuce/core/cluster/ClusterReadOnlyCommands.java b/src/main/java/io/lettuce/core/cluster/ClusterReadOnlyCommands.java
index 5a9e7d17b..77bcecade 100644
--- a/src/main/java/io/lettuce/core/cluster/ClusterReadOnlyCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/ClusterReadOnlyCommands.java
@@ -58,7 +58,7 @@ public class ClusterReadOnlyCommands {
     enum CommandName {
 
         // Pub/Sub commands are no key-space commands so they are safe to execute on replica nodes
-        PUBLISH, PUBSUB, PSUBSCRIBE, PUNSUBSCRIBE, SUBSCRIBE, UNSUBSCRIBE
+        PUBLISH, PUBSUB, PSUBSCRIBE, PUNSUBSCRIBE, SUBSCRIBE, UNSUBSCRIBE, SSUBSCRIBE
     }
 
 }
diff --git a/src/main/java/io/lettuce/core/cluster/PubSubClusterEndpoint.java b/src/main/java/io/lettuce/core/cluster/PubSubClusterEndpoint.java
index 2074867f6..5679e04c8 100644
--- a/src/main/java/io/lettuce/core/cluster/PubSubClusterEndpoint.java
+++ b/src/main/java/io/lettuce/core/cluster/PubSubClusterEndpoint.java
@@ -88,6 +88,9 @@ public class PubSubClusterEndpoint<K, V> extends PubSubEndpoint<K, V> {
             case unsubscribe:
                 multicast.unsubscribed(clusterNode, output.channel(), output.count());
                 break;
+            case ssubscribe:
+                multicast.ssubscribed(clusterNode, output.channel(), output.count());
+                break;
             default:
                 throw new UnsupportedOperationException("Operation " + output.type() + " not supported");
         }
@@ -189,6 +192,12 @@ public class PubSubClusterEndpoint<K, V> extends PubSubEndpoint<K, V> {
             clusterListeners.forEach(listener -> listener.punsubscribed(node, pattern, count));
         }
 
+        @Override
+        public void ssubscribed(RedisClusterNode node, K channel, long count) {
+            getListeners().forEach(listener -> listener.ssubscribed(channel, count));
+            clusterListeners.forEach(listener -> listener.ssubscribed(node, channel, count));
+        }
+
     }
 
 }
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 e6635535e..dd99fd23b 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
@@ -22,6 +22,7 @@ package io.lettuce.core.cluster.api.async;
 import java.util.List;
 import java.util.Map;
 
+import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
 import io.lettuce.core.ScanArgs;
@@ -213,6 +214,15 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<MapScanCursor<K, V>> hscan(K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<KeyScanCursor<K>> hscanNovalues(K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -222,6 +232,16 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<MapScanCursor<K, V>> hscan(K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<KeyScanCursor<K>> hscanNovalues(K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -232,6 +252,17 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -241,6 +272,16 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -250,6 +291,16 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -260,6 +311,17 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -271,6 +333,18 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -281,6 +355,17 @@ public interface NodeSelectionHashAsyncCommands<K, V> {
      */
     AsyncExecutions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    AsyncExecutions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor);
+
     /**
      * Set the string value of a hash field.
      *
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 716976016..4d9a3c694 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
@@ -22,6 +22,7 @@ package io.lettuce.core.cluster.api.sync;
 import java.util.List;
 import java.util.Map;
 
+import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.KeyValue;
 import io.lettuce.core.MapScanCursor;
 import io.lettuce.core.ScanArgs;
@@ -213,6 +214,15 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<MapScanCursor<K, V>> hscan(K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Executions<KeyScanCursor<K>> hscanNovalues(K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -222,6 +232,16 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<MapScanCursor<K, V>> hscan(K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Executions<KeyScanCursor<K>> hscanNovalues(K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -232,6 +252,17 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Executions<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -241,6 +272,16 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<MapScanCursor<K, V>> hscan(K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    Executions<KeyScanCursor<K>> hscanNovalues(K key, ScanCursor scanCursor);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -250,6 +291,16 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    Executions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -260,6 +311,17 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    Executions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -271,6 +333,18 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    Executions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -281,6 +355,17 @@ public interface NodeSelectionHashCommands<K, V> {
      */
     Executions<StreamScanCursor> hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    Executions<StreamScanCursor> hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor);
+
     /**
      * Set the string value of a hash field.
      *
diff --git a/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubAdapter.java b/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubAdapter.java
index fc57e5cc2..c38e672ee 100644
--- a/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubAdapter.java
+++ b/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubAdapter.java
@@ -42,4 +42,9 @@ public class RedisClusterPubSubAdapter<K, V> implements RedisClusterPubSubListen
         // empty adapter method
     }
 
+    @Override
+    public void ssubscribed(RedisClusterNode node, K channel, long count) {
+        // empty adapter method
+    }
+
 }
diff --git a/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubListener.java b/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubListener.java
index be12794b1..650dc233c 100644
--- a/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubListener.java
+++ b/src/main/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubListener.java
@@ -67,4 +67,16 @@ public interface RedisClusterPubSubListener<K, V> {
      */
     void punsubscribed(RedisClusterNode node, K pattern, long count);
 
+    /**
+     * Subscribed to a shard channel.
+     *
+     * @param node the {@link RedisClusterNode} from which the {@code message} originates.
+     * @param shardChannel Shard channel
+     * @param count Subscription count.
+     * @since 7.0
+     */
+    default void ssubscribed(RedisClusterNode node, K shardChannel, long count) {
+        subscribed(node, shardChannel, count);
+    }
+
 }
diff --git a/src/main/java/io/lettuce/core/cluster/pubsub/api/async/NodeSelectionPubSubAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/pubsub/api/async/NodeSelectionPubSubAsyncCommands.java
index 68e1c4b9a..ee8630378 100644
--- a/src/main/java/io/lettuce/core/cluster/pubsub/api/async/NodeSelectionPubSubAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/pubsub/api/async/NodeSelectionPubSubAsyncCommands.java
@@ -42,4 +42,12 @@ public interface NodeSelectionPubSubAsyncCommands<K, V> {
      */
     AsyncExecutions<Void> unsubscribe(K... channels);
 
+    /**
+     * Listen for messages published to the given shard channels.
+     *
+     * @param shardChannels the channels
+     * @return RedisFuture&lt;Void&gt; Future to synchronize {@code subscribe} completion
+     * @since 7.0
+     */
+    AsyncExecutions<Void> ssubscribe(K... shardChannels);
 }
diff --git a/src/main/java/io/lettuce/core/cluster/pubsub/api/reactive/NodeSelectionPubSubReactiveCommands.java b/src/main/java/io/lettuce/core/cluster/pubsub/api/reactive/NodeSelectionPubSubReactiveCommands.java
index fa53bbfe2..b61395cf2 100644
--- a/src/main/java/io/lettuce/core/cluster/pubsub/api/reactive/NodeSelectionPubSubReactiveCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/pubsub/api/reactive/NodeSelectionPubSubReactiveCommands.java
@@ -42,4 +42,13 @@ public interface NodeSelectionPubSubReactiveCommands<K, V> {
      */
     ReactiveExecutions<Void> unsubscribe(K... channels);
 
+    /**
+     * Listen for messages published to the given shard channels.
+     *
+     * @param shardCchannels the channels
+     * @return RedisFuture&lt;Void&gt; Future to synchronize {@code subscribe} completion
+     * @since 7.0
+     */
+    ReactiveExecutions<Void> ssubscribe(K... shardCchannels);
+
 }
diff --git a/src/main/java/io/lettuce/core/cluster/pubsub/api/sync/NodeSelectionPubSubCommands.java b/src/main/java/io/lettuce/core/cluster/pubsub/api/sync/NodeSelectionPubSubCommands.java
index 1f1fd1b59..ba7fe76af 100644
--- a/src/main/java/io/lettuce/core/cluster/pubsub/api/sync/NodeSelectionPubSubCommands.java
+++ b/src/main/java/io/lettuce/core/cluster/pubsub/api/sync/NodeSelectionPubSubCommands.java
@@ -42,4 +42,13 @@ public interface NodeSelectionPubSubCommands<K, V> {
      */
     Executions<Void> unsubscribe(K... channels);
 
+    /**
+     * Listen for messages published to the given shard channels.
+     *
+     * @param shardChannels the channels
+     * @return Executions Future to synchronize {@code subscribe} completion
+     * @since 7.0
+     */
+    Executions<Void> ssubscribe(K... shardChannels);
+
 }
diff --git a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java
index d8c6c4829..1d8f7266a 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, LIST, LOAD, LOG, MATCH,
 
-    MAX, MAXLEN, MEET, MIN, MINID, MOVED, NO, NOACK, NOCOMMANDS, NODE, NODES, NOMKSTREAM, NOPASS, NOSAVE, NOT, NUMSUB, SHARDCHANNELS, SHARDNUMSUB, NUMPAT, NX, OFF, ON, ONE, OR, PAUSE,
+    MAX, MAXLEN, MEET, MIN, MINID, MOVED, NO, NOACK, NOCOMMANDS, NODE, NODES, NOMKSTREAM, NOPASS, NOSAVE, NOT, NOVALUES, NUMSUB, SHARDCHANNELS, SHARDNUMSUB, NUMPAT, NX, OFF, ON, ONE, OR, PAUSE,
 
     REFCOUNT, REMOVE, RELOAD, REPLACE, REPLICATE, REPLICAS, REV, RESET, RESETCHANNELS, RESETKEYS, RESETPASS,
 
diff --git a/src/main/java/io/lettuce/core/protocol/CommandType.java b/src/main/java/io/lettuce/core/protocol/CommandType.java
index 446007b17..7a9d87524 100644
--- a/src/main/java/io/lettuce/core/protocol/CommandType.java
+++ b/src/main/java/io/lettuce/core/protocol/CommandType.java
@@ -74,7 +74,7 @@ public enum CommandType implements ProtocolKeyword {
 
     // Pub/Sub
 
-    PSUBSCRIBE, PUBLISH, PUNSUBSCRIBE, SUBSCRIBE, UNSUBSCRIBE, PUBSUB,
+    PSUBSCRIBE, PUBLISH, PUNSUBSCRIBE, SUBSCRIBE, UNSUBSCRIBE, PUBSUB, SSUBSCRIBE,
 
     // Sets
 
diff --git a/src/main/java/io/lettuce/core/pubsub/PubSubCommandBuilder.java b/src/main/java/io/lettuce/core/pubsub/PubSubCommandBuilder.java
index 9c1e74870..c733435cb 100644
--- a/src/main/java/io/lettuce/core/pubsub/PubSubCommandBuilder.java
+++ b/src/main/java/io/lettuce/core/pubsub/PubSubCommandBuilder.java
@@ -110,6 +110,14 @@ class PubSubCommandBuilder<K, V> extends BaseRedisCommandBuilder<K, V> {
         return pubSubCommand(UNSUBSCRIBE, new PubSubOutput<>(codec), channels);
     }
 
+    @SafeVarargs
+    final Command<K, V, V> ssubscribe(K... shardChannels) {
+        LettuceAssert.notEmpty(shardChannels, "Shard channels " + MUST_NOT_BE_EMPTY);
+
+        CommandArgs<K, V> args = new CommandArgs<>(codec).addKeys(shardChannels);
+        return createCommand(SSUBSCRIBE, new PubSubOutput<>(codec), args);
+    }
+
     @SafeVarargs
     final <T> Command<K, V, T> pubSubCommand(CommandType type, CommandOutput<K, V, T> output, K... keys) {
         return new Command<>(type, output, new PubSubCommandArgs<>(codec).addKeys(keys));
diff --git a/src/main/java/io/lettuce/core/pubsub/PubSubCommandHandler.java b/src/main/java/io/lettuce/core/pubsub/PubSubCommandHandler.java
index 8b43d602e..0cb9bd1a5 100644
--- a/src/main/java/io/lettuce/core/pubsub/PubSubCommandHandler.java
+++ b/src/main/java/io/lettuce/core/pubsub/PubSubCommandHandler.java
@@ -218,6 +218,9 @@ public class PubSubCommandHandler<K, V> extends CommandHandler {
             case subscribe:
                 return commandType.equalsIgnoreCase("SUBSCRIBE");
 
+            case ssubscribe:
+                return commandType.equalsIgnoreCase("SSUBSCRIBE");
+
             case psubscribe:
                 return commandType.equalsIgnoreCase("PSUBSCRIBE");
 
diff --git a/src/main/java/io/lettuce/core/pubsub/PubSubEndpoint.java b/src/main/java/io/lettuce/core/pubsub/PubSubEndpoint.java
index bad26297d..febb31f2c 100644
--- a/src/main/java/io/lettuce/core/pubsub/PubSubEndpoint.java
+++ b/src/main/java/io/lettuce/core/pubsub/PubSubEndpoint.java
@@ -27,6 +27,7 @@ import java.util.List;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
 
 import io.lettuce.core.ClientOptions;
 import io.lettuce.core.ConnectionState;
@@ -56,6 +57,8 @@ public class PubSubEndpoint<K, V> extends DefaultEndpoint {
 
     private final Set<Wrapper<K>> channels;
 
+    private final Set<Wrapper<K>> shardChannels;
+
     private final Set<Wrapper<K>> patterns;
 
     private volatile boolean subscribeWritten = false;
@@ -70,6 +73,7 @@ public class PubSubEndpoint<K, V> extends DefaultEndpoint {
         ALLOWED_COMMANDS_SUBSCRIBED.add(CommandType.PSUBSCRIBE.name());
         ALLOWED_COMMANDS_SUBSCRIBED.add(CommandType.UNSUBSCRIBE.name());
         ALLOWED_COMMANDS_SUBSCRIBED.add(CommandType.PUNSUBSCRIBE.name());
+        ALLOWED_COMMANDS_SUBSCRIBED.add(CommandType.SSUBSCRIBE.name());
         ALLOWED_COMMANDS_SUBSCRIBED.add(CommandType.QUIT.name());
         ALLOWED_COMMANDS_SUBSCRIBED.add(CommandType.PING.name());
 
@@ -77,6 +81,7 @@ public class PubSubEndpoint<K, V> extends DefaultEndpoint {
 
         SUBSCRIBE_COMMANDS.add(CommandType.SUBSCRIBE.name());
         SUBSCRIBE_COMMANDS.add(CommandType.PSUBSCRIBE.name());
+        SUBSCRIBE_COMMANDS.add(CommandType.SSUBSCRIBE.name());
     }
 
     /**
@@ -91,6 +96,7 @@ public class PubSubEndpoint<K, V> extends DefaultEndpoint {
 
         this.channels = ConcurrentHashMap.newKeySet();
         this.patterns = ConcurrentHashMap.newKeySet();
+        this.shardChannels = ConcurrentHashMap.newKeySet();
     }
 
     /**
@@ -257,6 +263,9 @@ public class PubSubEndpoint<K, V> extends DefaultEndpoint {
                 case unsubscribe:
                     listener.unsubscribed(message.channel(), message.count());
                     break;
+                case ssubscribe:
+                    listener.ssubscribed(message.channel(), message.count());
+                    break;
                 default:
                     throw new UnsupportedOperationException("Operation " + message.type() + " not supported");
             }
@@ -278,6 +287,9 @@ public class PubSubEndpoint<K, V> extends DefaultEndpoint {
             case unsubscribe:
                 channels.remove(new Wrapper<>(message.channel()));
                 break;
+            case ssubscribe:
+                shardChannels.add(new Wrapper<>(message.channel()));
+                break;
             default:
                 break;
         }
diff --git a/src/main/java/io/lettuce/core/pubsub/PubSubOutput.java b/src/main/java/io/lettuce/core/pubsub/PubSubOutput.java
index 16309ca69..1bcb61b7a 100644
--- a/src/main/java/io/lettuce/core/pubsub/PubSubOutput.java
+++ b/src/main/java/io/lettuce/core/pubsub/PubSubOutput.java
@@ -38,7 +38,7 @@ public class PubSubOutput<K, V> extends CommandOutput<K, V, V> implements PubSub
 
     public enum Type {
 
-        message, pmessage, psubscribe, punsubscribe, subscribe, unsubscribe;
+        message, pmessage, psubscribe, punsubscribe, subscribe, unsubscribe, ssubscribe;
 
         private final static Set<String> names = new HashSet<>();
 
@@ -122,6 +122,7 @@ public class PubSubOutput<K, V> extends CommandOutput<K, V, V> implements PubSub
                 break;
             case subscribe:
             case unsubscribe:
+            case ssubscribe:
                 channel = codec.decodeKey(bytes);
                 break;
             default:
diff --git a/src/main/java/io/lettuce/core/pubsub/RedisPubSubAdapter.java b/src/main/java/io/lettuce/core/pubsub/RedisPubSubAdapter.java
index e63dc2879..ef16fed6f 100644
--- a/src/main/java/io/lettuce/core/pubsub/RedisPubSubAdapter.java
+++ b/src/main/java/io/lettuce/core/pubsub/RedisPubSubAdapter.java
@@ -59,4 +59,9 @@ public class RedisPubSubAdapter<K, V> implements RedisPubSubListener<K, V> {
         // empty adapter method
     }
 
+    @Override
+    public void ssubscribed(K shardChannel, long count) {
+        // empty adapter method
+    }
+
 }
diff --git a/src/main/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImpl.java b/src/main/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImpl.java
index 9e6752816..f69f24fbb 100644
--- a/src/main/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImpl.java
+++ b/src/main/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImpl.java
@@ -100,6 +100,12 @@ public class RedisPubSubAsyncCommandsImpl<K, V> extends RedisAsyncCommandsImpl<K
         return dispatch(commandBuilder.pubsubShardNumsub(shardChannels));
     }
 
+    @Override
+    @SuppressWarnings("unchecked")
+    public RedisFuture<Void> ssubscribe(K... channels) {
+        return (RedisFuture<Void>) dispatch(commandBuilder.ssubscribe(channels));
+    }
+
     @Override
     @SuppressWarnings("unchecked")
     public StatefulRedisPubSubConnection<K, V> getStatefulConnection() {
diff --git a/src/main/java/io/lettuce/core/pubsub/RedisPubSubListener.java b/src/main/java/io/lettuce/core/pubsub/RedisPubSubListener.java
index 2099197bb..71b5a6ad1 100644
--- a/src/main/java/io/lettuce/core/pubsub/RedisPubSubListener.java
+++ b/src/main/java/io/lettuce/core/pubsub/RedisPubSubListener.java
@@ -77,4 +77,15 @@ public interface RedisPubSubListener<K, V> {
      */
     void punsubscribed(K pattern, long count);
 
+    /**
+     * Subscribed to a Shard channel.
+     *
+     * @param shardChannel Shard channel
+     * @param count Subscription count.
+     * @since 7.0
+     */
+    default void ssubscribed(K shardChannel, long count) {
+        subscribed(shardChannel, count);
+    }
+
 }
diff --git a/src/main/java/io/lettuce/core/pubsub/RedisPubSubReactiveCommandsImpl.java b/src/main/java/io/lettuce/core/pubsub/RedisPubSubReactiveCommandsImpl.java
index 6120f1665..218fbe9c7 100644
--- a/src/main/java/io/lettuce/core/pubsub/RedisPubSubReactiveCommandsImpl.java
+++ b/src/main/java/io/lettuce/core/pubsub/RedisPubSubReactiveCommandsImpl.java
@@ -26,6 +26,7 @@ import reactor.core.publisher.FluxSink;
 import reactor.core.publisher.Mono;
 import io.lettuce.core.RedisReactiveCommandsImpl;
 import io.lettuce.core.codec.RedisCodec;
+import io.lettuce.core.protocol.Command;
 import io.lettuce.core.pubsub.api.reactive.ChannelMessage;
 import io.lettuce.core.pubsub.api.reactive.PatternMessage;
 import io.lettuce.core.pubsub.api.reactive.RedisPubSubReactiveCommands;
@@ -158,6 +159,11 @@ public class RedisPubSubReactiveCommandsImpl<K, V> extends RedisReactiveCommands
         return createMono(() -> commandBuilder.pubsubShardNumsub(shardChannels));
     }
 
+    @Override
+    public Mono<Void> ssubscribe(K... shardChannels) {
+        return createFlux(() -> commandBuilder.ssubscribe(shardChannels)).then();
+    }
+
     @Override
     @SuppressWarnings("unchecked")
     public StatefulRedisPubSubConnection<K, V> getStatefulConnection() {
diff --git a/src/main/java/io/lettuce/core/pubsub/api/async/RedisPubSubAsyncCommands.java b/src/main/java/io/lettuce/core/pubsub/api/async/RedisPubSubAsyncCommands.java
index 1689639a6..90c301a58 100644
--- a/src/main/java/io/lettuce/core/pubsub/api/async/RedisPubSubAsyncCommands.java
+++ b/src/main/java/io/lettuce/core/pubsub/api/async/RedisPubSubAsyncCommands.java
@@ -51,4 +51,13 @@ public interface RedisPubSubAsyncCommands<K, V> extends RedisAsyncCommands<K, V>
      */
     StatefulRedisPubSubConnection<K, V> getStatefulConnection();
 
+    /**
+     * Listen for messages published to the given shard channels.
+     *
+     * @param shardChannels the shard channels
+     * @return RedisFuture&lt;Void&gt; Future to synchronize {@code subscribe} completion
+     * @since 7.0
+     */
+    RedisFuture<Void> ssubscribe(K... shardChannels);
+
 }
diff --git a/src/main/java/io/lettuce/core/pubsub/api/reactive/RedisPubSubReactiveCommands.java b/src/main/java/io/lettuce/core/pubsub/api/reactive/RedisPubSubReactiveCommands.java
index ae5bd54a0..d6f0476d6 100644
--- a/src/main/java/io/lettuce/core/pubsub/api/reactive/RedisPubSubReactiveCommands.java
+++ b/src/main/java/io/lettuce/core/pubsub/api/reactive/RedisPubSubReactiveCommands.java
@@ -97,6 +97,16 @@ public interface RedisPubSubReactiveCommands<K, V> extends RedisReactiveCommands
      */
     Mono<Void> unsubscribe(K... channels);
 
+    /**
+     * Listen for messages published to the given shard channels. The {@link Mono} completes without a result as soon as the *
+     * subscription is registered.
+     *
+     * @param shardChannels the channels.
+     * @return Mono&lt;Void&gt; Mono for {@code subscribe} command.
+     * @since 7.0
+     */
+    Mono<Void> ssubscribe(K... shardChannels);
+
     /**
      * @return the underlying connection.
      * @since 6.2, will be removed with Lettuce 7 to avoid exposing the underlying connection.
diff --git a/src/main/java/io/lettuce/core/pubsub/api/sync/RedisPubSubCommands.java b/src/main/java/io/lettuce/core/pubsub/api/sync/RedisPubSubCommands.java
index 93d44b35c..44a59b6c6 100644
--- a/src/main/java/io/lettuce/core/pubsub/api/sync/RedisPubSubCommands.java
+++ b/src/main/java/io/lettuce/core/pubsub/api/sync/RedisPubSubCommands.java
@@ -41,6 +41,14 @@ public interface RedisPubSubCommands<K, V> extends RedisCommands<K, V> {
      */
     void unsubscribe(K... channels);
 
+    /**
+     * Listen for messages published to the given shard channels.
+     *
+     * @param shardChannels the channels
+     * @since 7.0
+     */
+    void ssubscribe(K... shardChannels);
+
     /**
      * @return the underlying connection.
      */
diff --git a/src/main/kotlin/io/lettuce/core/ScanFlow.kt b/src/main/kotlin/io/lettuce/core/ScanFlow.kt
index 9fc7216e9..7cc560fb6 100644
--- a/src/main/kotlin/io/lettuce/core/ScanFlow.kt
+++ b/src/main/kotlin/io/lettuce/core/ScanFlow.kt
@@ -75,6 +75,28 @@ object ScanFlow {
         }.asFlow()
     }
 
+    /**
+     * Sequentially iterate hash key, without associated values.
+     *
+     * @param commands coroutines commands
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return `Flow<KeyValue<K, V>>` flow of key-values.
+     * @since 7.0
+     */
+    fun <K : Any, V : Any> hscanNovalues(commands: RedisHashCoroutinesCommands<K, V>, key: K, scanArgs: ScanArgs? = null): Flow<K> {
+        val ops = when (commands) {
+            is RedisCoroutinesCommandsImpl -> commands.ops
+            is RedisClusterCoroutinesCommandsImpl -> commands.ops
+            is RedisHashCoroutinesCommandsImpl -> commands.ops
+            else -> throw IllegalArgumentException("Cannot access underlying reactive API")
+        }
+        return when (scanArgs) {
+            null -> ScanStream.hscanNovalues(ops, key)
+            else -> ScanStream.hscanNovalues(ops, key, scanArgs)
+        }.asFlow()
+    }
+
     /**
      * Sequentially iterate Set elements.
      *
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 f6ea8f53f..6ab774660 100644
--- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt
+++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommands.kt
@@ -176,6 +176,15 @@ interface RedisHashCoroutinesCommands<K : Any, V : Any> {
      */
     suspend fun hscan(key: K): MapScanCursor<K, V>?
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @return KeyScanCursor<K> key scan cursor.
+     * @since 7.0
+     */
+    suspend fun hscanNovalues(key: K): KeyScanCursor<K>?
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -185,6 +194,16 @@ interface RedisHashCoroutinesCommands<K : Any, V : Any> {
      */
     suspend fun hscan(key: K, scanArgs: ScanArgs): MapScanCursor<K, V>?
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor<K> key scan cursor.
+     * @since 7.0
+     */
+    suspend fun hscanNovalues(key: K, scanArgs: ScanArgs): KeyScanCursor<K>?
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -195,6 +214,17 @@ interface RedisHashCoroutinesCommands<K : Any, V : Any> {
      */
     suspend fun hscan(key: K, scanCursor: ScanCursor, scanArgs: ScanArgs): MapScanCursor<K, V>?
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be `null`.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor<K> key scan cursor.
+     * @since 7.0
+     */
+    suspend fun hscanNovalues(key: K, scanCursor: ScanCursor, scanArgs: ScanArgs): KeyScanCursor<K>?
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -204,6 +234,16 @@ interface RedisHashCoroutinesCommands<K : Any, V : Any> {
      */
     suspend fun hscan(key: K, scanCursor: ScanCursor): MapScanCursor<K, V>?
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be `null`.
+     * @return KeyScanCursor<K> key scan cursor.
+     * @since 7.0
+     */
+    suspend fun hscanNovalues(key: K, scanCursor: ScanCursor): KeyScanCursor<K>?
+
     /**
      * Set the string value of a hash field.
      *
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 dc7412b79..ec1a48873 100644
--- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt
+++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisHashCoroutinesCommandsImpl.kt
@@ -76,18 +76,33 @@ internal class RedisHashCoroutinesCommandsImpl<K : Any, V : Any>(internal val op
     override suspend fun hscan(key: K): MapScanCursor<K, V>? =
         ops.hscan(key).awaitFirstOrNull()
 
+    override suspend fun hscanNovalues(key: K): KeyScanCursor<K>? =
+        ops.hscanNovalues(key).awaitFirstOrNull()
+
     override suspend fun hscan(key: K, scanArgs: ScanArgs): MapScanCursor<K, V>? =
         ops.hscan(key, scanArgs).awaitFirstOrNull()
 
+    override suspend fun hscanNovalues(key: K, scanArgs: ScanArgs): KeyScanCursor<K>? =
+        ops.hscanNovalues(key, scanArgs).awaitFirstOrNull()
+
     override suspend fun hscan(
         key: K,
         scanCursor: ScanCursor,
         scanArgs: ScanArgs
     ): MapScanCursor<K, V>? = ops.hscan(key, scanCursor, scanArgs).awaitFirstOrNull()
 
+    override suspend fun hscanNovalues(
+        key: K,
+        scanCursor: ScanCursor,
+        scanArgs: ScanArgs
+    ): KeyScanCursor<K>? = ops.hscanNovalues(key, scanCursor, scanArgs).awaitFirstOrNull()
+
     override suspend fun hscan(key: K, scanCursor: ScanCursor): MapScanCursor<K, V>? =
         ops.hscan(key, scanCursor).awaitFirstOrNull()
 
+    override suspend fun hscanNovalues(key: K, scanCursor: ScanCursor): KeyScanCursor<K>? =
+        ops.hscanNovalues(key, scanCursor).awaitFirstOrNull()
+
     override suspend fun hset(key: K, field: K, value: V): Boolean? = ops.hset(key, field, value).awaitFirstOrNull()
 
     override suspend fun hset(key: K, map: Map<K, V>): Long? = ops.hset(key, map).awaitFirstOrNull()
diff --git a/src/main/templates/io/lettuce/core/api/RedisHashCommands.java b/src/main/templates/io/lettuce/core/api/RedisHashCommands.java
index 03435441d..ddef1ce24 100644
--- a/src/main/templates/io/lettuce/core/api/RedisHashCommands.java
+++ b/src/main/templates/io/lettuce/core/api/RedisHashCommands.java
@@ -22,6 +22,12 @@ package io.lettuce.core.api;
 import java.util.List;
 import java.util.Map;
 
+import io.lettuce.core.KeyScanCursor;
+import io.lettuce.core.KeyValue;
+import io.lettuce.core.MapScanCursor;
+import io.lettuce.core.ScanArgs;
+import io.lettuce.core.ScanCursor;
+import io.lettuce.core.StreamScanCursor;
 import io.lettuce.core.output.KeyStreamingChannel;
 import io.lettuce.core.output.KeyValueStreamingChannel;
 import io.lettuce.core.output.ValueStreamingChannel;
@@ -207,6 +213,15 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -216,6 +231,16 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -226,6 +251,17 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -235,6 +271,16 @@ public interface RedisHashCommands<K, V> {
      */
     MapScanCursor<K, V> hscan(K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return KeyScanCursor&lt;K&gt; key scan cursor.
+     * @since 7.0
+     */
+    KeyScanCursor<K> hscanNovalues(K key, ScanCursor scanCursor);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -244,6 +290,16 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -254,6 +310,17 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -265,6 +332,18 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @param scanArgs scan arguments.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor, ScanArgs scanArgs);
+
     /**
      * Incrementally iterate hash fields and associated values.
      *
@@ -275,6 +354,17 @@ public interface RedisHashCommands<K, V> {
      */
     StreamScanCursor hscan(KeyValueStreamingChannel<K, V> channel, K key, ScanCursor scanCursor);
 
+    /**
+     * Incrementally iterate hash fields, without associated values.
+     *
+     * @param channel streaming channel that receives a call for every key.
+     * @param key the key.
+     * @param scanCursor cursor to resume from a previous scan, must not be {@code null}.
+     * @return StreamScanCursor scan cursor.
+     * @since 7.0
+     */
+    StreamScanCursor hscanNovalues(KeyStreamingChannel<K> channel, K key, ScanCursor scanCursor);
+
     /**
      * Set the string value of a hash field.
      *


Test patch:
diff --git a/src/test/java/io/lettuce/core/ScanIteratorIntegrationTests.java b/src/test/java/io/lettuce/core/ScanIteratorIntegrationTests.java
index a8bafea0a..31cabb187 100644
--- a/src/test/java/io/lettuce/core/ScanIteratorIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/ScanIteratorIntegrationTests.java
@@ -121,6 +121,23 @@ class ScanIteratorIntegrationTests extends TestSupport {
         }
     }
 
+    @Test
+    void hscanNovaluesShouldThrowNoSuchElementExceptionOnEmpty() {
+
+        redis.mset(KeysAndValues.MAP);
+
+        ScanIterator<String> scan = ScanIterator.hscanNovalues(redis, "none",
+                ScanArgs.Builder.limit(50).match("key-foo"));
+
+        assertThat(scan.hasNext()).isFalse();
+        try {
+            scan.next();
+            fail("Missing NoSuchElementException");
+        } catch (NoSuchElementException e) {
+            assertThat(e).isInstanceOf(NoSuchElementException.class);
+        }
+    }
+
     @Test
     void hashSinglePass() {
 
@@ -140,6 +157,27 @@ class ScanIteratorIntegrationTests extends TestSupport {
         assertThat(scan.hasNext()).isFalse();
     }
 
+    @Test
+    void hashNoValuesSinglePass() {
+
+        redis.hmset(key, KeysAndValues.MAP);
+
+        ScanIterator<String> scan = ScanIterator.hscanNovalues(redis, key,
+                ScanArgs.Builder.limit(50).match("key-11*"));
+
+        assertThat(scan.hasNext()).isTrue();
+        assertThat(scan.hasNext()).isTrue();
+
+        for (int i = 0; i < 11; i++) {
+            assertThat(scan.hasNext()).isTrue();
+            String next = scan.next();
+            assertThat(next).isNotNull();
+            assertThat(next).startsWith("key-11");
+        }
+
+        assertThat(scan.hasNext()).isFalse();
+    }
+
     @Test
     void hashMultiPass() {
 
@@ -153,6 +191,18 @@ class ScanIteratorIntegrationTests extends TestSupport {
                 KeysAndValues.KEYS.stream().map(s -> KeyValue.fromNullable(s, KeysAndValues.MAP.get(s))).collect(Collectors.toList()));
     }
 
+    @Test
+    void hashNoValuesMultiPass() {
+
+        redis.hmset(key, KeysAndValues.MAP);
+
+        ScanIterator<String> scan = ScanIterator.hscanNovalues(redis, key);
+
+        List<String> keys = scan.stream().collect(Collectors.toList());
+
+        assertThat(keys).containsAll(KeysAndValues.KEYS);
+    }
+
     @Test
     void sscanShouldThrowNoSuchElementExceptionOnEmpty() {
 
diff --git a/src/test/java/io/lettuce/core/ScanStreamIntegrationTests.java b/src/test/java/io/lettuce/core/ScanStreamIntegrationTests.java
index c8e6419c0..1dc7a2262 100644
--- a/src/test/java/io/lettuce/core/ScanStreamIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/ScanStreamIntegrationTests.java
@@ -89,6 +89,20 @@ class ScanStreamIntegrationTests extends TestSupport {
         StepVerifier.create(ScanStream.hscan(reactive, key)).expectNextCount(1000).verifyComplete();
     }
 
+    @Test
+    void shouldHscanNovaluesIteratively() {
+
+        for (int i = 0; i < 1000; i++) {
+            redis.hset(key, "field-" + i, "value-" + i);
+        }
+
+        RedisReactiveCommands<String, String> reactive = redis.getStatefulConnection().reactive();
+
+        StepVerifier.create(ScanStream.hscanNovalues(reactive, key, ScanArgs.Builder.limit(200)).take(250)).expectNextCount(250)
+                .verifyComplete();
+        StepVerifier.create(ScanStream.hscanNovalues(reactive, key)).expectNextCount(1000).verifyComplete();
+    }
+
     @Test
     void shouldSscanIteratively() {
 
@@ -139,4 +153,27 @@ class ScanStreamIntegrationTests extends TestSupport {
 
         assertThat(redis.scard(targetKey)).isEqualTo(5_000);
     }
+
+    @Test
+    void shouldCorrectlyEmitKeysWithConcurrentPoll() {
+
+        RedisReactiveCommands<String, String> commands = connection.reactive();
+
+        String sourceKey = "source";
+        String targetKey = "target";
+
+        IntStream.range(0, 10_000).forEach(num -> connection.async().hset(sourceKey, String.valueOf(num), String.valueOf(num)));
+
+        redis.del(targetKey);
+
+        ScanStream.hscanNovalues(commands, sourceKey) //
+                .map(Integer::parseInt) //
+                .filter(num -> num % 2 == 0) //
+                .concatMap(item -> commands.sadd(targetKey, String.valueOf(item))) //
+                .as(StepVerifier::create) //
+                .expectNextCount(5000) //
+                .verifyComplete();
+
+        assertThat(redis.scard(targetKey)).isEqualTo(5_000);
+    }
 }
diff --git a/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java b/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java
index 52da2acbd..b1f6cad9f 100644
--- a/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java
+++ b/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java
@@ -16,7 +16,7 @@ class ClusterReadOnlyCommandsUnitTests {
 
     @Test
     void testCount() {
-        assertThat(ClusterReadOnlyCommands.getReadOnlyCommands()).hasSize(85);
+        assertThat(ClusterReadOnlyCommands.getReadOnlyCommands()).hasSize(86);
     }
 
     @Test
@@ -26,4 +26,5 @@ class ClusterReadOnlyCommandsUnitTests {
             assertThat(readOnlyCommand.name()).isEqualTo(CommandType.valueOf(readOnlyCommand.name()).name());
         }
     }
+
 }
diff --git a/src/test/java/io/lettuce/core/cluster/ScanIteratorIntegrationTests.java b/src/test/java/io/lettuce/core/cluster/ScanIteratorIntegrationTests.java
index 2ae850d69..966878a7b 100644
--- a/src/test/java/io/lettuce/core/cluster/ScanIteratorIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/cluster/ScanIteratorIntegrationTests.java
@@ -144,6 +144,23 @@ class ScanIteratorIntegrationTests extends TestSupport {
         }
     }
 
+    @Test
+    void hscanNovaluesShouldThrowNoSuchElementExceptionOnEmpty() {
+
+        redis.mset(KeysAndValues.MAP);
+
+        ScanIterator<String> scan = ScanIterator.hscanNovalues(redis, "none",
+                ScanArgs.Builder.limit(50).match("key-foo"));
+
+        assertThat(scan.hasNext()).isFalse();
+        try {
+            scan.next();
+            fail("Missing NoSuchElementException");
+        } catch (NoSuchElementException e) {
+            assertThat(e).isInstanceOf(NoSuchElementException.class);
+        }
+    }
+
     @Test
     void hashSinglePass() {
 
@@ -163,6 +180,24 @@ class ScanIteratorIntegrationTests extends TestSupport {
         assertThat(scan.hasNext()).isFalse();
     }
 
+    @Test
+    void hashNovaluesSinglePass() {
+
+        redis.hmset(key, KeysAndValues.MAP);
+
+        ScanIterator<String> scan = ScanIterator.hscanNovalues(redis, key,
+                ScanArgs.Builder.limit(50).match("key-11*"));
+
+        for (int i = 0; i < 11; i++) {
+            assertThat(scan.hasNext()).isTrue();
+            String next = scan.next();
+            assertThat(next).isNotNull();
+            assertThat(next).startsWith("key-11");
+        }
+
+        assertThat(scan.hasNext()).isFalse();
+    }
+
     @Test
     void hashMultiPass() {
 
@@ -176,6 +211,18 @@ class ScanIteratorIntegrationTests extends TestSupport {
                 .collect(Collectors.toList()));
     }
 
+    @Test
+    void hashNovaluesMultiPass() {
+
+        redis.hmset(key, KeysAndValues.MAP);
+
+        ScanIterator<String> scan = ScanIterator.hscanNovalues(redis, key);
+
+        List<String> keys = scan.stream().collect(Collectors.toList());
+
+        assertThat(keys).containsAll(KeysAndValues.KEYS);
+    }
+
     @Test
     void sscanShouldThrowNoSuchElementExceptionOnEmpty() {
 
diff --git a/src/test/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubConnectionIntegrationTests.java b/src/test/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubConnectionIntegrationTests.java
index 4dee286e7..1e8503839 100644
--- a/src/test/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubConnectionIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/cluster/pubsub/RedisClusterPubSubConnectionIntegrationTests.java
@@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.*;
 import java.util.List;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
 
 import javax.inject.Inject;
 
@@ -21,19 +22,25 @@ import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
 import io.lettuce.core.cluster.models.partitions.RedisClusterNode;
 import io.lettuce.core.cluster.pubsub.api.async.NodeSelectionPubSubAsyncCommands;
 import io.lettuce.core.cluster.pubsub.api.async.PubSubAsyncNodeSelection;
+import io.lettuce.core.cluster.pubsub.api.async.RedisClusterPubSubAsyncCommands;
 import io.lettuce.core.cluster.pubsub.api.reactive.NodeSelectionPubSubReactiveCommands;
 import io.lettuce.core.cluster.pubsub.api.reactive.PubSubReactiveNodeSelection;
 import io.lettuce.core.cluster.pubsub.api.sync.NodeSelectionPubSubCommands;
 import io.lettuce.core.cluster.pubsub.api.sync.PubSubNodeSelection;
 import io.lettuce.core.event.command.CommandFailedEvent;
 import io.lettuce.core.event.command.CommandListener;
+import io.lettuce.core.internal.LettuceFactories;
 import io.lettuce.core.pubsub.StatefulRedisPubSubConnection;
+import io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands;
 import io.lettuce.core.support.PubSubTestListener;
 import io.lettuce.test.LettuceExtension;
 import io.lettuce.test.TestFutures;
 import io.lettuce.test.Wait;
+import io.lettuce.test.condition.EnabledOnCommand;
 
 /**
+ * Integration tests for Cluster Pub/Sub.
+ *
  * @author Mark Paluch
  */
 @ExtendWith(LettuceExtension.class)
@@ -51,9 +58,17 @@ class RedisClusterPubSubConnectionIntegrationTests extends TestSupport {
 
     private StatefulRedisClusterPubSubConnection<String, String> pubSubConnection2;
 
+    BlockingQueue<String> shardChannels;
+
+    String shardChannel = "shard-channel";
+
+    String shardTestChannel = "shard-test-channel";
+
     @Inject
     RedisClusterPubSubConnectionIntegrationTests(RedisClusterClient clusterClient) {
         this.clusterClient = clusterClient;
+        shardChannels = LettuceFactories.newBlockingQueue();
+
     }
 
     @BeforeEach
@@ -85,7 +100,6 @@ class RedisClusterPubSubConnectionIntegrationTests extends TestSupport {
         assertThat(channelsOnOtherNode).isEmpty();
     }
 
-
     @Test
     void testRegularClientPubSubShardChannels() {
 
@@ -101,6 +115,26 @@ class RedisClusterPubSubConnectionIntegrationTests extends TestSupport {
         assertThat(channelsOnOtherNode).isEmpty();
     }
 
+    @Test
+    @EnabledOnCommand("SSUBSCRIBE")
+    void subscribeToShardChannel() throws Exception {
+        pubSubConnection.addListener(connectionListener);
+        pubSubConnection.async().ssubscribe(shardChannel);
+        assertThat(connectionListener.getChannels().poll(3, TimeUnit.SECONDS)).isEqualTo(shardChannel);
+    }
+
+    @Test
+    @EnabledOnCommand("SSUBSCRIBE")
+    void subscribeToShardChannelViaOtherEndpoint() throws Exception {
+        pubSubConnection.addListener(connectionListener);
+        RedisClusterPubSubAsyncCommands<String, String> pubSub = pubSubConnection.async();
+        String nodeId = pubSub.clusterMyId().get();
+        RedisPubSubAsyncCommands<String, String> other = pubSub
+                .nodes(node -> node.getRole().isUpstream() && !node.getNodeId().equals(nodeId)).commands(0);
+        other.ssubscribe(shardChannel);
+        assertThat(connectionListener.getChannels().poll(3, TimeUnit.SECONDS)).isEqualTo(shardChannel);
+    }
+
     @Test
     void myIdWorksAfterDisconnect() throws InterruptedException {
 
diff --git a/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java b/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java
index 3891f9c3e..e346b2912 100644
--- a/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java
+++ b/src/test/java/io/lettuce/core/commands/HashCommandIntegrationTests.java
@@ -22,9 +22,11 @@ package io.lettuce.core.commands;
 import static org.assertj.core.api.Assertions.*;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import javax.inject.Inject;
 
@@ -34,6 +36,7 @@ import org.junit.jupiter.api.TestInstance;
 import org.junit.jupiter.api.extension.ExtendWith;
 
 import io.lettuce.core.KeyValue;
+import io.lettuce.core.KeyScanCursor;
 import io.lettuce.core.MapScanCursor;
 import io.lettuce.core.ScanArgs;
 import io.lettuce.core.ScanCursor;
@@ -306,6 +309,16 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(cursor.getMap()).isEqualTo(Collections.singletonMap(key, value));
     }
 
+    @Test
+    void hscanNovalues() {
+        redis.hset(key, key, value);
+        KeyScanCursor<String> cursor = redis.hscanNovalues(key);
+
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+        assertThat(cursor.getKeys()).containsExactly(key);
+    }
+
     @Test
     void hscanWithCursor() {
         redis.hset(key, key, value);
@@ -317,6 +330,17 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(cursor.getMap()).isEqualTo(Collections.singletonMap(key, value));
     }
 
+    @Test
+    void hscanNoValuesWithCursor() {
+        redis.hset(key, key, value);
+
+        KeyScanCursor<String> cursor = redis.hscanNovalues(key, ScanCursor.INITIAL);
+
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+        assertThat(cursor.getKeys()).containsExactly(key);
+    }
+
     @Test
     void hscanWithCursorAndArgs() {
         redis.hset(key, key, value);
@@ -328,6 +352,17 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(cursor.getMap()).isEqualTo(Collections.singletonMap(key, value));
     }
 
+    @Test
+    void hscanNoValuesWithCursorAndArgs() {
+        redis.hset(key, key, value);
+
+        KeyScanCursor<String> cursor = redis.hscanNovalues(key, ScanCursor.INITIAL, ScanArgs.Builder.limit(2));
+
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+        assertThat(cursor.getKeys()).containsExactly(key);
+    }
+
     @Test
     void hscanStreaming() {
         redis.hset(key, key, value);
@@ -341,6 +376,19 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(adapter.getMap()).isEqualTo(Collections.singletonMap(key, value));
     }
 
+    @Test
+    void hscanNoValuesStreaming() {
+        redis.hset(key, key, value);
+        ListStreamingAdapter<String> adapter = new ListStreamingAdapter<>();
+
+        StreamScanCursor cursor = redis.hscanNovalues(adapter, key, ScanArgs.Builder.limit(100).match("*"));
+
+        assertThat(cursor.getCount()).isEqualTo(1);
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+        assertThat(adapter.getList()).containsExactly(key);
+    }
+
     @Test
     void hscanStreamingWithCursor() {
         redis.hset(key, key, value);
@@ -353,6 +401,19 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(cursor.isFinished()).isTrue();
     }
 
+    @Test
+    void hscanNoValuesStreamingWithCursor() {
+        redis.hset(key, key, value);
+        ListStreamingAdapter<String> adapter = new ListStreamingAdapter<>();
+
+        StreamScanCursor cursor = redis.hscanNovalues(adapter, key, ScanCursor.INITIAL);
+
+        assertThat(cursor.getCount()).isEqualTo(1);
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+        assertThat(adapter.getList()).containsExactly(key);
+    }
+
     @Test
     void hscanStreamingWithCursorAndArgs() {
         redis.hset(key, key, value);
@@ -365,6 +426,19 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(cursor3.isFinished()).isTrue();
     }
 
+    @Test
+    void hscanNoValuesStreamingWithCursorAndArgs() {
+        redis.hset(key, key, value);
+        ListStreamingAdapter<String> adapter = new ListStreamingAdapter<>();
+
+        StreamScanCursor cursor = redis.hscanNovalues(adapter, key, ScanCursor.INITIAL, ScanArgs.Builder.limit(100).match("*"));
+
+        assertThat(cursor.getCount()).isEqualTo(1);
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+        assertThat(adapter.getList()).containsExactly(key);
+    }
+
     @Test
     void hscanStreamingWithArgs() {
         redis.hset(key, key, value);
@@ -377,6 +451,19 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(cursor.isFinished()).isTrue();
     }
 
+    @Test
+    void hscanNoValuesStreamingWithArgs() {
+        redis.hset(key, key, value);
+        ListStreamingAdapter<String> adapter = new ListStreamingAdapter<>();
+
+        StreamScanCursor cursor = redis.hscanNovalues(adapter, key);
+
+        assertThat(cursor.getCount()).isEqualTo(1);
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+        assertThat(adapter.getList()).containsExactly(key);
+    }
+
     @Test
     void hscanMultiple() {
 
@@ -402,6 +489,30 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(check).isEqualTo(expect);
     }
 
+    @Test
+    void hscanNoValuesMultiple() {
+
+        Map<String, String> expect = new LinkedHashMap<>();
+        setup100KeyValues(expect);
+
+        KeyScanCursor<String> cursor = redis.hscanNovalues(key, ScanArgs.Builder.limit(5));
+
+        assertThat(cursor.getCursor()).isNotNull();
+        assertThat(cursor.getKeys()).hasSize(100);
+
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+
+        Set<String> check = new HashSet<>(cursor.getKeys());
+
+        while (!cursor.isFinished()) {
+            cursor = redis.hscanNovalues(key, cursor);
+            check.addAll(cursor.getKeys());
+        }
+
+        assertThat(check).isEqualTo(expect.keySet());
+    }
+
     @Test
     void hscanMatch() {
 
@@ -416,6 +527,20 @@ public class HashCommandIntegrationTests extends TestSupport {
         assertThat(cursor.getMap()).hasSize(11);
     }
 
+    @Test
+    void hscanNoValuesMatch() {
+
+        Map<String, String> expect = new LinkedHashMap<>();
+        setup100KeyValues(expect);
+
+        KeyScanCursor<String> cursor = redis.hscanNovalues(key, ScanArgs.Builder.limit(100).match("key1*"));
+
+        assertThat(cursor.getCursor()).isEqualTo("0");
+        assertThat(cursor.isFinished()).isTrue();
+
+        assertThat(cursor.getKeys()).hasSize(11);
+    }
+
     void setup100KeyValues(Map<String, String> expect) {
         for (int i = 0; i < 100; i++) {
             expect.put(key + i, value + 1);
diff --git a/src/test/java/io/lettuce/core/pubsub/PubSubCommandBuilderUnitTests.java b/src/test/java/io/lettuce/core/pubsub/PubSubCommandBuilderUnitTests.java
index 59ec37bf4..4d4e79818 100644
--- a/src/test/java/io/lettuce/core/pubsub/PubSubCommandBuilderUnitTests.java
+++ b/src/test/java/io/lettuce/core/pubsub/PubSubCommandBuilderUnitTests.java
@@ -6,6 +6,8 @@ import io.lettuce.core.output.IntegerOutput;
 import io.lettuce.core.output.KeyListOutput;
 import io.lettuce.core.output.MapOutput;
 import io.lettuce.core.protocol.Command;
+import io.lettuce.core.protocol.CommandArgs;
+
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -13,11 +15,12 @@ import java.util.List;
 import java.util.Map;
 
 import static io.lettuce.core.protocol.CommandType.*;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.assertj.core.api.Assertions.assertThat;
 
 class PubSubCommandBuilderUnitTests {
 
     private PubSubCommandBuilder<String, String> commandBuilder;
+
     private final RedisCodec<String, String> codec = StringCodec.UTF8;
 
     @BeforeEach
@@ -31,10 +34,10 @@ class PubSubCommandBuilderUnitTests {
         String message = "message payload";
         Command<String, String, Long> command = this.commandBuilder.publish(channel, message);
 
-        assertEquals( PUBLISH, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "key<channel> value<message payload>",  command.getArgs().toCommandString());
-        assertInstanceOf(IntegerOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(PUBLISH);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("key<channel> value<message payload>");
+        assertThat(command.getOutput()).isInstanceOf(IntegerOutput.class);
     }
 
     @Test
@@ -42,10 +45,10 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, List<String>> command = this.commandBuilder.pubsubChannels(pattern);
 
-        assertEquals( PUBSUB, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "CHANNELS key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(KeyListOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(PUBSUB);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("CHANNELS key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(KeyListOutput.class);
     }
 
     @Test
@@ -53,10 +56,10 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, Map<String, Long>> command = this.commandBuilder.pubsubNumsub(pattern);
 
-        assertEquals( PUBSUB, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "NUMSUB key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(MapOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(PUBSUB);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("NUMSUB key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(MapOutput.class);
     }
 
     @Test
@@ -64,10 +67,10 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, List<String>> command = this.commandBuilder.pubsubShardChannels(pattern);
 
-        assertEquals( PUBSUB, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "SHARDCHANNELS key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(KeyListOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(PUBSUB);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("SHARDCHANNELS key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(KeyListOutput.class);
     }
 
     @Test
@@ -75,10 +78,10 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, Map<String, Long>> command = this.commandBuilder.pubsubShardNumsub(pattern);
 
-        assertEquals( PUBSUB, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "SHARDNUMSUB key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(MapOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(PUBSUB);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("SHARDNUMSUB key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(MapOutput.class);
     }
 
     @Test
@@ -86,10 +89,10 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, String> command = this.commandBuilder.psubscribe(pattern);
 
-        assertEquals( PSUBSCRIBE, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(PubSubOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(PSUBSCRIBE);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(PubSubOutput.class);
     }
 
     @Test
@@ -97,10 +100,10 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, String> command = this.commandBuilder.punsubscribe(pattern);
 
-        assertEquals( PUNSUBSCRIBE, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(PubSubOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(PUNSUBSCRIBE);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(PubSubOutput.class);
     }
 
     @Test
@@ -108,10 +111,10 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, String> command = this.commandBuilder.subscribe(pattern);
 
-        assertEquals( SUBSCRIBE, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(PubSubOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(SUBSCRIBE);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(PubSubOutput.class);
     }
 
     @Test
@@ -119,9 +122,21 @@ class PubSubCommandBuilderUnitTests {
         String pattern = "channelPattern";
         Command<String, String, String> command = this.commandBuilder.unsubscribe(pattern);
 
-        assertEquals( UNSUBSCRIBE, command.getType());
-        assertInstanceOf(PubSubCommandArgs.class, command.getArgs());
-        assertEquals( "key<channelPattern>",  command.getArgs().toCommandString());
-        assertInstanceOf(PubSubOutput.class, command.getOutput());
+        assertThat(command.getType()).isEqualTo(UNSUBSCRIBE);
+        assertThat(command.getArgs()).isInstanceOf(PubSubCommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(PubSubOutput.class);
+    }
+
+    @Test
+    void ssubscribe() {
+        String channel = "channelPattern";
+        Command<String, String, String> command = this.commandBuilder.ssubscribe(channel);
+
+        assertThat(command.getType()).isEqualTo(SSUBSCRIBE);
+        assertThat(command.getArgs()).isInstanceOf(CommandArgs.class);
+        assertThat(command.getArgs().toCommandString()).isEqualTo("key<channelPattern>");
+        assertThat(command.getOutput()).isInstanceOf(PubSubOutput.class);
     }
+
 }
diff --git a/src/test/java/io/lettuce/core/pubsub/PubSubCommandTest.java b/src/test/java/io/lettuce/core/pubsub/PubSubCommandTest.java
index 8c934d1c3..5fe358def 100644
--- a/src/test/java/io/lettuce/core/pubsub/PubSubCommandTest.java
+++ b/src/test/java/io/lettuce/core/pubsub/PubSubCommandTest.java
@@ -36,12 +36,18 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-import io.lettuce.core.*;
+import io.lettuce.core.AbstractRedisClientTest;
+import io.lettuce.core.ClientOptions;
+import io.lettuce.core.KillArgs;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisFuture;
+import io.lettuce.core.RedisURI;
 import io.lettuce.core.api.async.RedisAsyncCommands;
 import io.lettuce.core.api.push.PushMessage;
 import io.lettuce.core.internal.LettuceFactories;
 import io.lettuce.core.protocol.ProtocolVersion;
 import io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands;
+import io.lettuce.core.support.PubSubTestListener;
 import io.lettuce.test.Delay;
 import io.lettuce.test.TestFutures;
 import io.lettuce.test.Wait;
@@ -60,21 +66,24 @@ import io.lettuce.test.resource.TestClientResources;
  * @author Tihomir Mateev
  * @author Ali Takavci
  */
-class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubListener<String, String> {
+class PubSubCommandTest extends AbstractRedisClientTest {
 
     RedisPubSubAsyncCommands<String, String> pubsub;
 
-    BlockingQueue<String> channels;
+    PubSubTestListener listener = new PubSubTestListener();
 
-    BlockingQueue<String> patterns;
+    BlockingQueue<String> channels = listener.getChannels();
 
-    BlockingQueue<String> messages;
+    BlockingQueue<String> patterns = listener.getPatterns();
 
-    BlockingQueue<Long> counts;
+    BlockingQueue<String> messages = listener.getMessages();
+
+    BlockingQueue<Long> counts = listener.getCounts();
 
     String channel = "channel0";
 
     String shardChannel = "shard-channel";
+
     private String pattern = "channel*";
 
     String message = "msg!";
@@ -85,12 +94,9 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
 
             client.setOptions(getOptions());
             pubsub = client.connectPubSub().async();
-            pubsub.getStatefulConnection().addListener(this);
+            pubsub.getStatefulConnection().addListener(listener);
         } finally {
-            channels = LettuceFactories.newBlockingQueue();
-            patterns = LettuceFactories.newBlockingQueue();
-            messages = LettuceFactories.newBlockingQueue();
-            counts = LettuceFactories.newBlockingQueue();
+            listener.clear();
         }
     }
 
@@ -112,7 +118,7 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
             client.setOptions(
                     ClientOptions.builder().protocolVersion(ProtocolVersion.RESP2).pingBeforeActivateConnection(false).build());
             RedisPubSubAsyncCommands<String, String> connection = client.connectPubSub().async();
-            connection.getStatefulConnection().addListener(PubSubCommandTest.this);
+            connection.getStatefulConnection().addListener(listener);
             connection.auth(passwd);
 
             connection.subscribe(channel);
@@ -128,7 +134,7 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
             client.setOptions(
                     ClientOptions.builder().protocolVersion(ProtocolVersion.RESP2).pingBeforeActivateConnection(false).build());
             RedisPubSubAsyncCommands<String, String> connection = client.connectPubSub().async();
-            connection.getStatefulConnection().addListener(PubSubCommandTest.this);
+            connection.getStatefulConnection().addListener(listener);
             connection.auth(username, passwd);
 
             connection.subscribe(channel);
@@ -145,7 +151,7 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
                     ClientOptions.builder().protocolVersion(ProtocolVersion.RESP2).pingBeforeActivateConnection(false).build());
 
             RedisPubSubAsyncCommands<String, String> connection = client.connectPubSub().async();
-            connection.getStatefulConnection().addListener(PubSubCommandTest.this);
+            connection.getStatefulConnection().addListener(listener);
             connection.auth(passwd);
 
             connection.clientSetname("authWithReconnect");
@@ -174,7 +180,7 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
                     ClientOptions.builder().protocolVersion(ProtocolVersion.RESP2).pingBeforeActivateConnection(false).build());
 
             RedisPubSubAsyncCommands<String, String> connection = client.connectPubSub().async();
-            connection.getStatefulConnection().addListener(PubSubCommandTest.this);
+            connection.getStatefulConnection().addListener(listener);
             connection.auth(username, passwd);
             connection.clientSetname("authWithReconnect");
             connection.subscribe(channel).get();
@@ -374,7 +380,7 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
 
     @Test
     @EnabledOnCommand("SPUBLISH")
-    void pubsubShardChannelsWithArg() {        
+    void pubsubShardChannelsWithArg() {
         /// TODO : uncomment after SSUBSCRIBE is implemented
         // TestFutures.awaitOrTimeout(pubsub.ssubscribe(channel));
         List<String> result = redis.pubsubShardChannels(pattern);
@@ -533,7 +539,7 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
         assertThat(channels.take()).isEqualTo(channel);
         assertThat(messages.take()).isEqualTo(message);
 
-        pubsub.getStatefulConnection().removeListener(this);
+        pubsub.getStatefulConnection().removeListener(listener);
 
         redis.publish(channel, message);
         assertThat(channels.poll(10, TimeUnit.MILLISECONDS)).isNull();
@@ -549,43 +555,4 @@ class PubSubCommandTest extends AbstractRedisClientTest implements RedisPubSubLi
         pubsub.unsubscribe(channel);
     }
 
-    // RedisPubSubListener implementation
-
-    @Override
-    public void message(String channel, String message) {
-        channels.add(channel);
-        messages.add(message);
-    }
-
-    @Override
-    public void message(String pattern, String channel, String message) {
-        patterns.add(pattern);
-        channels.add(channel);
-        messages.add(message);
-    }
-
-    @Override
-    public void subscribed(String channel, long count) {
-        channels.add(channel);
-        counts.add(count);
-    }
-
-    @Override
-    public void psubscribed(String pattern, long count) {
-        patterns.add(pattern);
-        counts.add(count);
-    }
-
-    @Override
-    public void unsubscribed(String channel, long count) {
-        channels.add(channel);
-        counts.add(count);
-    }
-
-    @Override
-    public void punsubscribed(String pattern, long count) {
-        patterns.add(pattern);
-        counts.add(count);
-    }
-
 }
diff --git a/src/test/java/io/lettuce/core/pubsub/PubSubReactiveTest.java b/src/test/java/io/lettuce/core/pubsub/PubSubReactiveTest.java
index 0e47031e5..86a96a256 100644
--- a/src/test/java/io/lettuce/core/pubsub/PubSubReactiveTest.java
+++ b/src/test/java/io/lettuce/core/pubsub/PubSubReactiveTest.java
@@ -59,6 +59,7 @@ class PubSubReactiveTest extends AbstractRedisClientTest implements RedisPubSubL
     private RedisPubSubReactiveCommands<String, String> pubsub2;
 
     private BlockingQueue<String> channels;
+    private BlockingQueue<String> shardChannels;
     private BlockingQueue<String> patterns;
     private BlockingQueue<String> messages;
     private BlockingQueue<Long> counts;
@@ -75,6 +76,7 @@ class PubSubReactiveTest extends AbstractRedisClientTest implements RedisPubSubL
         pubsub2 = client.connectPubSub().reactive();
         pubsub.getStatefulConnection().addListener(this);
         channels = LettuceFactories.newBlockingQueue();
+        shardChannels = LettuceFactories.newBlockingQueue();
         patterns = LettuceFactories.newBlockingQueue();
         messages = LettuceFactories.newBlockingQueue();
         counts = LettuceFactories.newBlockingQueue();
@@ -343,6 +345,13 @@ class PubSubReactiveTest extends AbstractRedisClientTest implements RedisPubSubL
 
     }
 
+    @Test
+    void ssubscribe() throws Exception {
+        StepVerifier.create(pubsub.ssubscribe(channel)).verifyComplete();
+        assertThat(shardChannels.take()).isEqualTo(channel);
+        assertThat((long) counts.take()).isGreaterThan(0);
+    }
+
     @Test
     void pubsubCloseOnClientShutdown() {
 
@@ -493,6 +502,12 @@ class PubSubReactiveTest extends AbstractRedisClientTest implements RedisPubSubL
         counts.add(count);
     }
 
+    @Override
+    public void ssubscribed(String shardChannel, long count) {
+        shardChannels.add(shardChannel);
+        counts.add(count);
+    }
+
     <T> T block(Mono<T> mono) {
         return mono.block();
     }
diff --git a/src/test/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImplUnitTests.java b/src/test/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImplUnitTests.java
index a95a73fc4..8eebf685f 100644
--- a/src/test/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImplUnitTests.java
+++ b/src/test/java/io/lettuce/core/pubsub/RedisPubSubAsyncCommandsImplUnitTests.java
@@ -7,7 +7,6 @@ import io.lettuce.core.output.KeyListOutput;
 import io.lettuce.core.output.MapOutput;
 import io.lettuce.core.protocol.AsyncCommand;
 import io.lettuce.core.protocol.RedisCommand;
-import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
@@ -15,8 +14,10 @@ import org.mockito.ArgumentCaptor;
 import java.util.concurrent.ExecutionException;
 
 import static io.lettuce.core.protocol.CommandType.*;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.Assert.*;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
 class RedisPubSubAsyncCommandsImplUnitTests {
@@ -42,9 +43,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( PSUBSCRIBE, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(PSUBSCRIBE);
         assertInstanceOf(PubSubOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -60,9 +61,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( PUNSUBSCRIBE, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(PUNSUBSCRIBE);
         assertInstanceOf(PubSubOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -78,9 +79,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( SUBSCRIBE, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(SUBSCRIBE);
         assertInstanceOf(PubSubOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -96,9 +97,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( UNSUBSCRIBE, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(UNSUBSCRIBE);
         assertInstanceOf(PubSubOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -116,9 +117,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( PUBLISH, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(PUBLISH);
         assertInstanceOf(IntegerOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "key<acmeChannel> value<acmeMessage>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("key<acmeChannel> value<acmeMessage>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -135,9 +136,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( PUBSUB, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(PUBSUB);
         assertInstanceOf(KeyListOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "CHANNELS key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("CHANNELS key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -154,9 +155,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( PUBSUB, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(PUBSUB);
         assertInstanceOf(MapOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "NUMSUB key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("NUMSUB key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -173,9 +174,9 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( PUBSUB, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(PUBSUB);
         assertInstanceOf(KeyListOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "SHARDCHANNELS key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("SHARDCHANNELS key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
@@ -192,9 +193,28 @@ class RedisPubSubAsyncCommandsImplUnitTests {
         ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);;
         verify(mockedConnection).dispatch(capturedCommand.capture());
 
-        Assertions.assertEquals( PUBSUB, capturedCommand.getValue().getType());
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(PUBSUB);
         assertInstanceOf(MapOutput.class, capturedCommand.getValue().getOutput());
-        Assertions.assertEquals( "SHARDNUMSUB key<channelPattern>",  capturedCommand.getValue().getArgs().toCommandString());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("SHARDNUMSUB key<channelPattern>");
+
+        assertNotEquals(capturedCommand.getValue(), dispachedMock);
+    }
+
+    @Test
+    void ssubscribe() throws ExecutionException, InterruptedException {
+        String pattern = "channelPattern";
+        AsyncCommand dispachedMock = mock(AsyncCommand.class);
+        when(mockedConnection.dispatch((RedisCommand<String, String, Object>) any())).thenReturn(dispachedMock);
+
+        commands.ssubscribe(pattern).get();
+
+        ArgumentCaptor<AsyncCommand> capturedCommand = ArgumentCaptor.forClass(AsyncCommand.class);
+        
+        verify(mockedConnection).dispatch(capturedCommand.capture());
+
+        assertThat(capturedCommand.getValue().getType()).isEqualTo(SSUBSCRIBE);
+        assertInstanceOf(PubSubOutput.class, capturedCommand.getValue().getOutput());
+        assertThat(capturedCommand.getValue().getArgs().toCommandString()).isEqualTo("key<channelPattern>");
 
         assertNotEquals(capturedCommand.getValue(), dispachedMock);
     }
diff --git a/src/test/java/io/lettuce/core/support/PubSubTestListener.java b/src/test/java/io/lettuce/core/support/PubSubTestListener.java
index bc429051b..3503c4c0c 100644
--- a/src/test/java/io/lettuce/core/support/PubSubTestListener.java
+++ b/src/test/java/io/lettuce/core/support/PubSubTestListener.java
@@ -1,3 +1,22 @@
+/*
+ * 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.support;
 
 import java.util.concurrent.BlockingQueue;
@@ -69,4 +88,14 @@ public class PubSubTestListener implements RedisPubSubListener<String, String> {
     public BlockingQueue<Long> getCounts() {
         return counts;
     }
+
+    /**
+     * Clear listener state (i.e. channels, patterns, messages, counts).
+     */
+    public void clear() {
+        channels.clear();
+        patterns.clear();
+        messages.clear();
+        counts.clear();
+    }
 }
diff --git a/src/test/kotlin/io/lettuce/core/ScanFlowIntegrationTests.kt b/src/test/kotlin/io/lettuce/core/ScanFlowIntegrationTests.kt
index 1bdc675c5..58f23c549 100644
--- a/src/test/kotlin/io/lettuce/core/ScanFlowIntegrationTests.kt
+++ b/src/test/kotlin/io/lettuce/core/ScanFlowIntegrationTests.kt
@@ -73,6 +73,18 @@ internal class ScanFlowIntegrationTests @Inject constructor(private val connecti
         }
     }
 
+    @Test
+    fun `should hscanNovalues iteratively`() = runBlocking<Unit> {
+        with(connection.coroutines()) {
+            repeat(iterations) {
+                hset(key, "field-$it", "value-$it")
+            }
+
+            assertThat(ScanFlow.hscanNovalues(this, key, ScanArgs.Builder.limit(200)).take(250).toList()).hasSize(250)
+            assertThat(ScanFlow.hscanNovalues(this, key).count()).isEqualTo(iterations)
+        }
+    }
+
     @Test
     fun shouldSscanIteratively() = runBlocking<Unit> {
         with(connection.coroutines()) {