From 548c72045db465e90cf926c0336f64c81115026b Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Fri, 28 Aug 2026 14:21:50 +0100 Subject: [PATCH] DOC-7017 Assert cumulative SCAN totals instead of pinning per-call counts in cmds_generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan2's five SCAN calls no longer assert that the last call alone returns 18 matches; each client now sums matches across all five calls and asserts the cumulative total (19), which is what SCAN actually guarantees. Applied to redis-py, node-redis, ioredis, predis, jedis, lettuce-async, lettuce-reactive, and go-redis (rust already did this; NRedisStack's scan2 step is an empty stub, untouched). Verifying that fix surfaced a second copy of the same bug one step later: scan3's `SCAN ... TYPE zset` also assumed a single default-COUNT call would find both matches. It was invisible before because scan2 always crashed first — the crash was hiding a second, independent defect. Reproduced the scan3 failure 100% of the time on a fresh, empty Redis instance (not just leftover state from repeated local runs), so it's a real defect and not environmental noise. Fixed the same way: loop until the cursor is exhausted, accumulate, assert on the accumulated set. The lettuce-async/lettuce-reactive fix needed a second pass. The first attempt nested the accumulate-until-done loop's blocking `.join()`/`.block()` calls inside the existing `.thenCompose()`/`.flatMap()` async callback chain those files use for every other step. That deadlocks: the blocking call sits on the same Netty event-loop thread that needs to run to deliver the SCAN response, so the command hangs until Lettuce's own 1-minute command timeout kills it — passed locally as a hang, not a failure, so it needed a timing read to catch. Rewrote scan3 to drop out of the async chain entirely and use plain sequential blocking calls, matching the pattern scan2 already established in these same two files. Learned: a step masked by an earlier crash can hide its own independent bug — fixing the crash can unmask a different defect downstream, not just prove the fix works. Constraint: in lettuce-async/lettuce-reactive cmds_generic, never nest a blocking .join()/.block() call inside a thenCompose/thenAccept/flatMap/doOnNext callback — it deadlocks the event-loop thread that would deliver the response. Rejected: keeping scan3's async-chained thenCompose/thenAccept style and looping inside it | deadlocks Lettuce's event loop; use plain sequential blocking calls instead, as scan1/scan2 already do in both files. Gaps: only verified against local Redis 7.2.7, 3x per client; Redis 8.8 was unavailable. scan4 remains unverified end-to-end — every client that reaches it fails on HSCAN NOVALUES, a separate pre-existing version-gated (Redis 7.4+) issue this branch doesn't touch. Ticket: DOC-7017 --- .../go-redis/cmds_generic_test.go | 31 +++++++-- .../cmds_generic/ioredis/cmds-generic.js | 20 +++++- .../jedis/CmdsGenericExample.java | 23 +++++-- .../lettuce-async/CmdsGenericExample.java | 67 ++++++++++--------- .../lettuce-reactive/CmdsGenericExample.java | 64 +++++++++--------- .../cmds_generic/node-redis/cmds-generic.js | 26 +++++-- .../cmds_generic/predis/CmdsGenericTest.php | 10 ++- .../cmds_generic/redis-py/cmds_generic.py | 45 +++++++++---- 8 files changed, 191 insertions(+), 95 deletions(-) diff --git a/local_examples/cmds_generic/go-redis/cmds_generic_test.go b/local_examples/cmds_generic/go-redis/cmds_generic_test.go index 7e3c0ab6cb..e9023e235a 100644 --- a/local_examples/cmds_generic/go-redis/cmds_generic_test.go +++ b/local_examples/cmds_generic/go-redis/cmds_generic_test.go @@ -381,6 +381,7 @@ func ExampleClient_scan2_cmd() { var scan2Cursor uint64 var scan2Keys []string var err error + scan2Total := 0 for i := 0; i < 4; i++ { scan2Keys, scan2Cursor, err = rdb.Scan(ctx, scan2Cursor, "*11*", 0).Result() @@ -388,6 +389,7 @@ func ExampleClient_scan2_cmd() { if err != nil { panic(err) } + scan2Total += len(scan2Keys) } // A larger COUNT forces more scanning in a single iteration, so the remaining @@ -397,8 +399,10 @@ func ExampleClient_scan2_cmd() { if err != nil { panic(err) } + scan2Total += len(scan2Keys) - fmt.Println(len(scan2Keys)) // >>> 18 + // The per-call split isn't guaranteed, but the cumulative total is. + fmt.Println(scan2Total) // >>> 19 // STEP_END // REMOVE_START @@ -406,7 +410,7 @@ func ExampleClient_scan2_cmd() { // REMOVE_END // Output: - // 18 + // 19 } func ExampleClient_scan3_cmd() { @@ -457,14 +461,27 @@ func ExampleClient_scan3_cmd() { fmt.Println(scan3Result4) // >>> zset - scan3Result5, _, err := rdb.ScanType(ctx, 0, "", 0, "zset").Result() + // A single call isn't guaranteed to find every match, so loop until the cursor + // returns to 0, accumulating matches from every call. + var scan3Cursor uint64 + var scan3Keys []string + var scan3Batch []string - if err != nil { - panic(err) + for { + scan3Batch, scan3Cursor, err = rdb.ScanType(ctx, scan3Cursor, "", 0, "zset").Result() + + if err != nil { + panic(err) + } + scan3Keys = append(scan3Keys, scan3Batch...) + + if scan3Cursor == 0 { + break + } } - sort.Strings(scan3Result5) - fmt.Println(scan3Result5) // >>> [geokey zkey] + sort.Strings(scan3Keys) + fmt.Println(scan3Keys) // >>> [geokey zkey] // STEP_END // Output: diff --git a/local_examples/cmds_generic/ioredis/cmds-generic.js b/local_examples/cmds_generic/ioredis/cmds-generic.js index ca4325fa83..a8ff7717f7 100644 --- a/local_examples/cmds_generic/ioredis/cmds-generic.js +++ b/local_examples/cmds_generic/ioredis/cmds-generic.js @@ -58,21 +58,27 @@ await scan2Pipeline.exec(); // MATCH filters after the elements are fetched, so most iterations return nothing. let [scan2Cursor, scan2Keys] = await redis.scan(0, 'MATCH', '*11*'); +let scan2Total = scan2Keys.length; console.log(scan2Keys.length); for (let i = 0; i < 3; i++) { [scan2Cursor, scan2Keys] = await redis.scan(scan2Cursor, 'MATCH', '*11*'); + scan2Total += scan2Keys.length; console.log(scan2Keys.length); } // A larger COUNT forces more scanning in a single iteration, so the rest of the // matches arrive together. The scan continues from the cursor reached above. [scan2Cursor, scan2Keys] = await redis.scan(scan2Cursor, 'MATCH', '*11*', 'COUNT', 1000); -console.log(scan2Keys.length); // >>> 18 +scan2Total += scan2Keys.length; +console.log(scan2Keys.length); + +// The per-call split isn't guaranteed, but the cumulative total is. +console.log(scan2Total); // >>> 19 // STEP_END // REMOVE_START -assert.equal(scan2Keys.length, 18); +assert.equal(scan2Total, 19); await redis.flushdb(); // REMOVE_END @@ -86,7 +92,15 @@ console.log(scan3Res2); // >>> 1 console.log(await redis.type('geokey')); // >>> zset console.log(await redis.type('zkey')); // >>> zset -const [, scan3Keys] = await redis.scan(0, 'TYPE', 'zset'); +// A single call isn't guaranteed to find every match, so loop until the cursor +// returns to 0, accumulating matches from every call. +let scan3Cursor = '0'; +let scan3Keys = []; +do { + let scan3Batch; + [scan3Cursor, scan3Batch] = await redis.scan(scan3Cursor, 'TYPE', 'zset'); + scan3Keys = scan3Keys.concat(scan3Batch); +} while (scan3Cursor !== '0'); console.log(scan3Keys.sort()); // >>> ['geokey', 'zkey'] // STEP_END diff --git a/local_examples/cmds_generic/jedis/CmdsGenericExample.java b/local_examples/cmds_generic/jedis/CmdsGenericExample.java index 673b231a52..649d21f9e3 100644 --- a/local_examples/cmds_generic/jedis/CmdsGenericExample.java +++ b/local_examples/cmds_generic/jedis/CmdsGenericExample.java @@ -199,21 +199,27 @@ public void run() { // iterations return few keys or none at all. String scan2Cursor = "0"; ScanResult scan2Result; + int scan2Total = 0; for (int i = 0; i < 4; i++) { scan2Result = jedis.scan(scan2Cursor, new ScanParams().match("*11*")); scan2Cursor = scan2Result.getCursor(); + scan2Total += scan2Result.getResult().size(); System.out.println(scan2Result.getResult().size()); } // A larger COUNT forces more scanning in a single iteration, so the remaining // matches arrive together. This continues from the cursor reached above. scan2Result = jedis.scan(scan2Cursor, new ScanParams().match("*11*").count(1000)); - System.out.println(scan2Result.getResult().size()); // >>> 18 + scan2Total += scan2Result.getResult().size(); + System.out.println(scan2Result.getResult().size()); + + // The per-call split isn't guaranteed, but the cumulative total is. + System.out.println(scan2Total); // >>> 19 // STEP_END // REMOVE_START - assertEquals(18, scan2Result.getResult().size()); + assertEquals(19, scan2Total); jedis.flushDB(); // REMOVE_END @@ -227,10 +233,15 @@ public void run() { System.out.println(jedis.type("geokey")); // >>> zset System.out.println(jedis.type("zkey")); // >>> zset - ScanResult scan3Result3 = jedis.scan( - "0", new ScanParams(), "zset" - ); - ArrayList scan3Keys = new ArrayList<>(scan3Result3.getResult()); + // A single call isn't guaranteed to find every match, so loop until the cursor + // returns to "0", accumulating matches from every call. + String scan3Cursor = "0"; + ArrayList scan3Keys = new ArrayList<>(); + do { + ScanResult scan3Result3 = jedis.scan(scan3Cursor, new ScanParams(), "zset"); + scan3Cursor = scan3Result3.getCursor(); + scan3Keys.addAll(scan3Result3.getResult()); + } while (!scan3Cursor.equals("0")); Collections.sort(scan3Keys); System.out.println(scan3Keys); // >>> [geokey, zkey] // STEP_END diff --git a/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java b/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java index dc407f23a3..1514e68290 100644 --- a/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java +++ b/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java @@ -151,12 +151,14 @@ public void run() { // the next one needs the cursor this one returns. KeyScanCursor scan2Cursor = asyncCommands .scan(ScanArgs.Builder.matches("*11*")).toCompletableFuture().join(); + int scan2Total = scan2Cursor.getKeys().size(); System.out.println(scan2Cursor.getKeys().size()); for (int i = 0; i < 3; i++) { scan2Cursor = asyncCommands .scan(scan2Cursor, ScanArgs.Builder.matches("*11*")) .toCompletableFuture().join(); + scan2Total += scan2Cursor.getKeys().size(); System.out.println(scan2Cursor.getKeys().size()); } @@ -165,46 +167,51 @@ public void run() { scan2Cursor = asyncCommands .scan(scan2Cursor, ScanArgs.Builder.matches("*11*").limit(1000)) .toCompletableFuture().join(); - System.out.println(scan2Cursor.getKeys().size()); // >>> 18 + scan2Total += scan2Cursor.getKeys().size(); + System.out.println(scan2Cursor.getKeys().size()); + + // The per-call split isn't guaranteed, but the cumulative total is. + System.out.println(scan2Total); // >>> 19 // STEP_END // REMOVE_START - assertThat(scan2Cursor.getKeys()).hasSize(18); + assertThat(scan2Total).isEqualTo(19); asyncCommands.flushdb().toCompletableFuture().join(); // REMOVE_END // STEP_START scan3 - CompletableFuture scan3Example = asyncCommands - .geoadd("geokey", 0, 0, "value") - .thenCompose(scan3Res1 -> { - System.out.println(scan3Res1); // >>> 1 - return asyncCommands.zadd("zkey", 1000, "value"); - }) - .thenCompose(scan3Res2 -> { - System.out.println(scan3Res2); // >>> 1 - return asyncCommands.type("geokey"); - }) - .thenCompose(scan3Res3 -> { - System.out.println(scan3Res3); // >>> zset - return asyncCommands.type("zkey"); - }) - .thenCompose(scan3Res4 -> { - System.out.println(scan3Res4); // >>> zset - return asyncCommands.scan(KeyScanArgs.Builder.type("zset")); - }) - .thenAccept(scan3Res5 -> { - List keys = new java.util.ArrayList<>(scan3Res5.getKeys()); - Collections.sort(keys); - System.out.println(keys); // >>> [geokey, zkey] - // REMOVE_START - assertThat(keys).hasSize(2); - // REMOVE_END - }) - .toCompletableFuture(); + long scan3Result1 = asyncCommands.geoadd("geokey", 0, 0, "value") + .toCompletableFuture().join(); + System.out.println(scan3Result1); // >>> 1 + + long scan3Result2 = asyncCommands.zadd("zkey", 1000, "value") + .toCompletableFuture().join(); + System.out.println(scan3Result2); // >>> 1 + + String scan3Result3 = asyncCommands.type("geokey").toCompletableFuture().join(); + System.out.println(scan3Result3); // >>> zset + + String scan3Result4 = asyncCommands.type("zkey").toCompletableFuture().join(); + System.out.println(scan3Result4); // >>> zset + + // A single call isn't guaranteed to find every match, so loop until + // the cursor is finished, accumulating matches from every call. + List scan3Keys = new java.util.ArrayList<>(); + KeyScanCursor scan3Cursor = asyncCommands + .scan(KeyScanArgs.Builder.type("zset")).toCompletableFuture().join(); + scan3Keys.addAll(scan3Cursor.getKeys()); + while (!scan3Cursor.isFinished()) { + scan3Cursor = asyncCommands + .scan(scan3Cursor, KeyScanArgs.Builder.type("zset")) + .toCompletableFuture().join(); + scan3Keys.addAll(scan3Cursor.getKeys()); + } + Collections.sort(scan3Keys); + System.out.println(scan3Keys); // >>> [geokey, zkey] // STEP_END - scan3Example.join(); // REMOVE_START + assertThat(scan3Keys).hasSize(2); asyncCommands.del("geokey", "zkey").toCompletableFuture().join(); // REMOVE_END diff --git a/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java b/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java index 207dc1ae3a..a3490a6ca7 100644 --- a/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java +++ b/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java @@ -139,11 +139,13 @@ public void run() { // turn because the next one needs the cursor this one returns. KeyScanCursor scan2Cursor = reactiveCommands .scan(ScanArgs.Builder.matches("*11*")).block(); + int scan2Total = scan2Cursor.getKeys().size(); System.out.println(scan2Cursor.getKeys().size()); for (int i = 0; i < 3; i++) { scan2Cursor = reactiveCommands .scan(scan2Cursor, ScanArgs.Builder.matches("*11*")).block(); + scan2Total += scan2Cursor.getKeys().size(); System.out.println(scan2Cursor.getKeys().size()); } @@ -151,46 +153,48 @@ public void run() { // matches arrive together. This continues from the cursor reached above. scan2Cursor = reactiveCommands .scan(scan2Cursor, ScanArgs.Builder.matches("*11*").limit(1000)).block(); - System.out.println(scan2Cursor.getKeys().size()); // >>> 18 + scan2Total += scan2Cursor.getKeys().size(); + System.out.println(scan2Cursor.getKeys().size()); + + // The per-call split isn't guaranteed, but the cumulative total is. + System.out.println(scan2Total); // >>> 19 // STEP_END // REMOVE_START - assertThat(scan2Cursor.getKeys()).hasSize(18); + assertThat(scan2Total).isEqualTo(19); reactiveCommands.flushdb().block(); // REMOVE_END // STEP_START scan3 - Mono scan3Example = reactiveCommands - .geoadd("geokey", 0, 0, "value") - .flatMap(scan3Res1 -> { - System.out.println(scan3Res1); // >>> 1 - return reactiveCommands.zadd("zkey", 1000, "value"); - }) - .flatMap(scan3Res2 -> { - System.out.println(scan3Res2); // >>> 1 - return reactiveCommands.type("geokey"); - }) - .flatMap(scan3Res3 -> { - System.out.println(scan3Res3); // >>> zset - return reactiveCommands.type("zkey"); - }) - .flatMap(scan3Res4 -> { - System.out.println(scan3Res4); // >>> zset - return reactiveCommands.scan(KeyScanArgs.Builder.type("zset")); - }) - .doOnNext(scan3Res5 -> { - List keys = new java.util.ArrayList<>(scan3Res5.getKeys()); - Collections.sort(keys); - System.out.println(keys); // >>> [geokey, zkey] - // REMOVE_START - assertThat(keys).hasSize(2); - // REMOVE_END - }) - .then(); + long scan3Result1 = reactiveCommands.geoadd("geokey", 0, 0, "value").block(); + System.out.println(scan3Result1); // >>> 1 + + long scan3Result2 = reactiveCommands.zadd("zkey", 1000, "value").block(); + System.out.println(scan3Result2); // >>> 1 + + String scan3Result3 = reactiveCommands.type("geokey").block(); + System.out.println(scan3Result3); // >>> zset + + String scan3Result4 = reactiveCommands.type("zkey").block(); + System.out.println(scan3Result4); // >>> zset + + // A single call isn't guaranteed to find every match, so loop until + // the cursor is finished, accumulating matches from every call. + List scan3Keys = new java.util.ArrayList<>(); + KeyScanCursor scan3Cursor = reactiveCommands + .scan(KeyScanArgs.Builder.type("zset")).block(); + scan3Keys.addAll(scan3Cursor.getKeys()); + while (!scan3Cursor.isFinished()) { + scan3Cursor = reactiveCommands + .scan(scan3Cursor, KeyScanArgs.Builder.type("zset")).block(); + scan3Keys.addAll(scan3Cursor.getKeys()); + } + Collections.sort(scan3Keys); + System.out.println(scan3Keys); // >>> [geokey, zkey] // STEP_END - Mono.when(scan3Example).block(); // REMOVE_START + assertThat(scan3Keys).hasSize(2); reactiveCommands.del("geokey", "zkey").block(); // REMOVE_END diff --git a/local_examples/cmds_generic/node-redis/cmds-generic.js b/local_examples/cmds_generic/node-redis/cmds-generic.js index aada717c5d..a3ce115f6e 100644 --- a/local_examples/cmds_generic/node-redis/cmds-generic.js +++ b/local_examples/cmds_generic/node-redis/cmds-generic.js @@ -156,23 +156,33 @@ for (let i = 1; i <= 1000; i++) { let cursor = '0'; let scanResult; +let total = 0; scanResult = await client.scan(cursor, { MATCH: '*11*' }); +total += scanResult.keys.length; console.log(scanResult.cursor, scanResult.keys); scanResult = await client.scan(scanResult.cursor, { MATCH: '*11*' }); +total += scanResult.keys.length; console.log(scanResult.cursor, scanResult.keys); scanResult = await client.scan(scanResult.cursor, { MATCH: '*11*' }); +total += scanResult.keys.length; console.log(scanResult.cursor, scanResult.keys); scanResult = await client.scan(scanResult.cursor, { MATCH: '*11*' }); +total += scanResult.keys.length; console.log(scanResult.cursor, scanResult.keys); scanResult = await client.scan(scanResult.cursor, { MATCH: '*11*', COUNT: 1000 }); +total += scanResult.keys.length; console.log(scanResult.cursor, scanResult.keys); + +// The per-call split isn't guaranteed, but the cumulative total is. +console.log(total); +// >>> 19 // REMOVE_START -console.assert(scanResult.keys.length === 18); +assert.strictEqual(total, 19); cursor = '0'; const prefix = 'key:*'; do { @@ -206,10 +216,18 @@ console.log(scan3Res4); // zset console.assert(scan3Res4 === 'zset'); // REMOVE_END -const scan3Res5 = await client.scan('0', { TYPE: 'zset' }); -console.log(scan3Res5.keys); // ['zkey', 'geokey'] +// A single call isn't guaranteed to find every match, so loop until the cursor +// returns to 0, accumulating matches from every call. +let scan3Cursor = '0'; +let scan3Keys = []; +do { + const scan3Res5 = await client.scan(scan3Cursor, { TYPE: 'zset' }); + scan3Cursor = scan3Res5.cursor; + scan3Keys = scan3Keys.concat(scan3Res5.keys); +} while (scan3Cursor !== '0'); +console.log(scan3Keys.sort()); // ['geokey', 'zkey'] // REMOVE_START -console.assert(scan3Res5.keys.sort().toString() === ['zkey', 'geokey'].sort().toString()); +assert.deepStrictEqual(scan3Keys.sort(), ['geokey', 'zkey']); await client.del(['geokey', 'zkey']); // REMOVE_END // STEP_END diff --git a/local_examples/cmds_generic/predis/CmdsGenericTest.php b/local_examples/cmds_generic/predis/CmdsGenericTest.php index 2bb4f65459..6efc22e72e 100644 --- a/local_examples/cmds_generic/predis/CmdsGenericTest.php +++ b/local_examples/cmds_generic/predis/CmdsGenericTest.php @@ -93,20 +93,26 @@ public function testCmdsGeneric() { // MATCH is applied after elements are fetched, so with the default COUNT most // iterations return few keys or none at all. $scan2Cursor = 0; + $scan2Total = 0; for ($i = 0; $i < 4; $i++) { [$scan2Cursor, $scan2Keys] = $r->scan($scan2Cursor, ['MATCH' => '*11*']); + $scan2Total += count($scan2Keys); echo count($scan2Keys) . PHP_EOL; } // A larger COUNT forces more scanning in a single iteration, so the remaining // matches arrive together. This continues from the cursor reached above. [$scan2Cursor, $scan2Keys] = $r->scan($scan2Cursor, ['MATCH' => '*11*', 'COUNT' => 1000]); - echo count($scan2Keys) . PHP_EOL; // >>> 18 + $scan2Total += count($scan2Keys); + echo count($scan2Keys) . PHP_EOL; + + // The per-call split isn't guaranteed, but the cumulative total is. + echo $scan2Total . PHP_EOL; // >>> 19 // STEP_END // REMOVE_START - $this->assertEquals(18, count($scan2Keys)); + $this->assertEquals(19, $scan2Total); $r->flushdb(); // REMOVE_END diff --git a/local_examples/cmds_generic/redis-py/cmds_generic.py b/local_examples/cmds_generic/redis-py/cmds_generic.py index 4d4493f737..2e48e62366 100644 --- a/local_examples/cmds_generic/redis-py/cmds_generic.py +++ b/local_examples/cmds_generic/redis-py/cmds_generic.py @@ -171,23 +171,34 @@ r.set(f"key:{i}", i) # REMOVE_END -cursor, key = r.scan(cursor=0, match='*11*') -print(cursor, key) +total = 0 -cursor, key = r.scan(cursor, match='*11*') -print(cursor, key) +cursor, keys = r.scan(cursor=0, match='*11*') +total += len(keys) +print(cursor, keys) -cursor, key = r.scan(cursor, match='*11*') -print(cursor, key) +cursor, keys = r.scan(cursor, match='*11*') +total += len(keys) +print(cursor, keys) -cursor, key = r.scan(cursor, match='*11*') -print(cursor, key) +cursor, keys = r.scan(cursor, match='*11*') +total += len(keys) +print(cursor, keys) + +cursor, keys = r.scan(cursor, match='*11*') +total += len(keys) +print(cursor, keys) cursor, keys = r.scan(cursor, match='*11*', count=1000) +total += len(keys) print(cursor, keys) +# The per-call split isn't guaranteed, but the cumulative total is. +print(total) +# >>> 19 + # REMOVE_START -assert len(keys) == 18 +assert total == 19 cursor = '0' prefix = "key:*" while cursor != 0: @@ -220,11 +231,19 @@ assert res == "zset" # REMOVE_END -cursor, keys = r.scan(cursor=0, _type="zset") -print(keys) -# >>> ['zkey', 'geokey'] +# A single call isn't guaranteed to find every match, so loop until the cursor +# returns to 0, accumulating matches from every call. +cursor = 0 +scan3_keys = [] +while True: + cursor, keys = r.scan(cursor=cursor, _type="zset") + scan3_keys.extend(keys) + if cursor == 0: + break +print(sorted(scan3_keys)) +# >>> ['geokey', 'zkey'] # REMOVE_START -assert sorted(keys) == sorted(["zkey", "geokey"]) +assert sorted(scan3_keys) == sorted(["zkey", "geokey"]) r.delete("geokey", "zkey") # REMOVE_END # STEP_END