From 06bb0e5e54967424f250adb8fb6d4c12be3475b2 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 13 Aug 2026 17:10:36 +0100 Subject: [PATCH 1/5] DOC-6968 Add the four SCAN steps to seven cmds_generic client examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scan1, scan2, scan3 and scan4 to the hiredis, go-redis, jedis, lettuce-async, lettuce-reactive, ioredis and predis files, clearing 27 of the 63 omission warnings the shortcode fix surfaced. content/commands/scan.md goes back to twelve client tabs on scan1, scan2 and scan4. The 18 in scan2 is not the number of matching keys. `*11*` matches 19 of key:1..key:1000; the reference gets 18 because its COUNT 1000 call continues from the cursor left by four preceding default-COUNT iterations, one of which has already yielded a match. My first attempt restarted at cursor 0, got 19 and failed its own assertion, which is how this surfaced. Every tab now mirrors the reference's continuation semantics. Worth knowing that the observed split on Redis 8.8 is 0,0,0,1,18 whereas the CLI transcript on the page shows the match landing on the first iteration instead — same total, different distribution, because the split depends on iteration order. That order-dependence is why Go's scan2 differs from its siblings. A Go Example function only runs under `go test` if it has an `// Output:` block, and that block must match stdout exactly, so printing per-iteration counts would make the test fail whenever SCAN's iteration order shifts — a red build for a reason unrelated to the docs. Go therefore runs the four iterations but prints only the final 18. Same headline number, less visible narrative, no new fragility. predis cannot express scan3 at all, so PHP is deliberately absent from that step. `SCAN.php::prepareOptions` in predis 3.5.1 emits only MATCH and COUNT, so `$r->scan(0, ['TYPE' => 'zset'])` silently drops the filter: probed against a live server it returned a plain string key alongside the two sorted sets, byte for byte identical to the unfiltered call. An idiomatic-looking call that quietly returns everything is worse than an absent tab, and the omission now renders as a missing tab rather than a whole-file dump — the first real use of the behaviour added earlier in this ticket. All seven pass against Redis 8.8.0. Two clients fail cmds_generic for pre-existing, unrelated reasons, verified by re-running with these changes stashed: nredisstack cannot compile because the portable C# stub lacks SkipIfRedisFactAttribute, and rust-async fails on `unresolved import futures_util` plus AsyncIter no longer being an iterator in the pinned crate. Neither file is touched here. Learned: scan2's expected 18 is a cursor-continuation artifact, not a match count — a new tab must continue from the cursor the earlier iterations returned, never restart at 0, or it correctly gets 19 and disagrees with every other tab Constraint: Go example funcs only execute with an exact `// Output:` block, so scan2 in Go must not print per-iteration counts — 0,0,0,1 is iteration-order dependent and would break on an unrelated Redis change Constraint: predis 3.5.1 SCAN accepts only MATCH and COUNT; TYPE is silently dropped and every key comes back, which is why PHP has no scan3 example Directive: do not "complete" PHP's scan3 with $r->scan(0, ['TYPE' => 'zset']) — it looks idiomatic, filters nothing, and was verified wrong against a live server Gaps: nredisstack and rust-async already fail cmds_generic for unrelated reasons (missing SkipIfRedisFact stub; futures_util/AsyncIter drift in the pinned redis-rs) — do not read those as regressions from this change Ticket: DOC-6968 Co-Authored-By: Claude Opus 5 (1M context) --- .../go-redis/cmds_generic_test.go | 199 ++++++++++++++++++ .../cmds_generic/hiredis/cmds_generic.c | 135 ++++++++++++ .../cmds_generic/ioredis/cmds-generic.js | 80 +++++++ .../jedis/CmdsGenericExample.java | 103 +++++++++ .../lettuce-async/CmdsGenericExample.java | 123 +++++++++++ .../lettuce-reactive/CmdsGenericExample.java | 122 +++++++++++ .../cmds_generic/predis/CmdsGenericTest.php | 60 ++++++ 7 files changed, 822 insertions(+) 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 0c4c77e494..66f07bb6a6 100644 --- a/local_examples/cmds_generic/go-redis/cmds_generic_test.go +++ b/local_examples/cmds_generic/go-redis/cmds_generic_test.go @@ -320,3 +320,202 @@ func ExampleClient_ttl_cmd() { // OK // 10 } + +func ExampleClient_scan1_cmd() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.FlushDB(ctx) + // REMOVE_END + + // STEP_START scan1 + scan1Result1, err := rdb.SAdd(ctx, "myset", "1", "2", "3", "foo", "foobar", "feelsgood").Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan1Result1) // >>> 6 + + scan1Result2, _, err := rdb.SScan(ctx, "myset", 0, "f*", 0).Result() + + if err != nil { + panic(err) + } + + sort.Strings(scan1Result2) + fmt.Println(scan1Result2) // >>> [feelsgood foo foobar] + // STEP_END + + // Output: + // 6 + // [feelsgood foo foobar] +} + +func ExampleClient_scan2_cmd() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.FlushDB(ctx) + + for i := 1; i <= 1000; i++ { + rdb.Set(ctx, fmt.Sprintf("key:%d", i), i, 0) + } + // REMOVE_END + + // STEP_START scan2 + // MATCH is applied after elements are fetched, so with the default COUNT most + // iterations return few keys or none at all. + var scan2Cursor uint64 + var scan2Keys []string + var err error + + for i := 0; i < 4; i++ { + scan2Keys, scan2Cursor, err = rdb.Scan(ctx, scan2Cursor, "*11*", 0).Result() + + if err != nil { + panic(err) + } + } + + // A larger COUNT forces more scanning in a single iteration, so the remaining + // matches arrive together. This continues from the cursor reached above. + scan2Keys, _, err = rdb.Scan(ctx, scan2Cursor, "*11*", 1000).Result() + + if err != nil { + panic(err) + } + + fmt.Println(len(scan2Keys)) // >>> 18 + // STEP_END + + // REMOVE_START + rdb.FlushDB(ctx) + // REMOVE_END + + // Output: + // 18 +} + +func ExampleClient_scan3_cmd() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.FlushDB(ctx) + // REMOVE_END + + // STEP_START scan3 + scan3Result1, err := rdb.GeoAdd(ctx, "geokey", &redis.GeoLocation{ + Longitude: 0, Latitude: 0, Name: "value", + }).Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan3Result1) // >>> 1 + + scan3Result2, err := rdb.ZAdd(ctx, "zkey", redis.Z{Score: 1000, Member: "value"}).Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan3Result2) // >>> 1 + + scan3Result3, err := rdb.Type(ctx, "geokey").Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan3Result3) // >>> zset + + scan3Result4, err := rdb.Type(ctx, "zkey").Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan3Result4) // >>> zset + + scan3Result5, _, err := rdb.ScanType(ctx, 0, "", 0, "zset").Result() + + if err != nil { + panic(err) + } + + sort.Strings(scan3Result5) + fmt.Println(scan3Result5) // >>> [geokey zkey] + // STEP_END + + // Output: + // 1 + // 1 + // zset + // zset + // [geokey zkey] +} + +func ExampleClient_scan4_cmd() { + ctx := context.Background() + + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password docs + DB: 0, // use default DB + }) + + // REMOVE_START + rdb.FlushDB(ctx) + // REMOVE_END + + // STEP_START scan4 + scan4Result1, err := rdb.HSet(ctx, "myhash", "a", 1, "b", 2).Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan4Result1) // >>> 2 + + scan4Result2, _, err := rdb.HScan(ctx, "myhash", 0, "", 0).Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan4Result2) // >>> [a 1 b 2] + + scan4Result3, _, err := rdb.HScanNoValues(ctx, "myhash", 0, "", 0).Result() + + if err != nil { + panic(err) + } + + fmt.Println(scan4Result3) // >>> [a b] + // STEP_END + + // Output: + // 2 + // [a 1 b 2] + // [a b] +} diff --git a/local_examples/cmds_generic/hiredis/cmds_generic.c b/local_examples/cmds_generic/hiredis/cmds_generic.c index 377113525a..e6464ac8b4 100644 --- a/local_examples/cmds_generic/hiredis/cmds_generic.c +++ b/local_examples/cmds_generic/hiredis/cmds_generic.c @@ -91,6 +91,141 @@ int main(int argc, char **argv) { freeReplyObject(reply); // REMOVE_END + // STEP_START scan1 + reply = redisCommand(c, "SADD myset 1 2 3 foo foobar feelsgood"); + printf("%lld\n", reply->integer); + // >>> 6 + freeReplyObject(reply); + + // SCAN-family replies are a two-element array: the next cursor, then the results. + reply = redisCommand(c, "SSCAN myset 0 MATCH f*"); + printf("%zu\n", reply->element[1]->elements); + // >>> 3 + // REMOVE_START + if (reply->element[1]->elements != 3) { + printf("ASSERTION FAILED: Expected 3 members, got %zu\n", reply->element[1]->elements); + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myset"); + freeReplyObject(reply); + + for (int i = 1; i <= 1000; i++) { + reply = redisCommand(c, "SET key:%d %d", i, i); + freeReplyObject(reply); + } + // REMOVE_END + + // STEP_START scan2 + // MATCH is applied after elements are fetched, so with the default COUNT most + // iterations return few keys or none at all. + char cursor[64] = "0"; + + for (int i = 0; i < 4; i++) { + reply = redisCommand(c, "SCAN %s MATCH *11*", cursor); + snprintf(cursor, sizeof(cursor), "%s", reply->element[0]->str); + printf("%zu\n", reply->element[1]->elements); + freeReplyObject(reply); + } + + // A larger COUNT forces more scanning in a single iteration, so the remaining + // matches arrive together. This continues from the cursor reached above. + reply = redisCommand(c, "SCAN %s MATCH *11* COUNT 1000", cursor); + printf("%zu\n", reply->element[1]->elements); + // >>> 18 + // REMOVE_START + if (reply->element[1]->elements != 18) { + printf("ASSERTION FAILED: Expected 18 keys, got %zu\n", reply->element[1]->elements); + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "FLUSHDB"); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START scan3 + reply = redisCommand(c, "GEOADD geokey 0 0 value"); + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "ZADD zkey 1000 value"); + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "TYPE geokey"); + printf("%s\n", reply->str); + // >>> zset + freeReplyObject(reply); + + reply = redisCommand(c, "TYPE zkey"); + printf("%s\n", reply->str); + // >>> zset + freeReplyObject(reply); + + reply = redisCommand(c, "SCAN 0 TYPE zset"); + printf("%zu\n", reply->element[1]->elements); + // >>> 2 + // REMOVE_START + if (reply->element[1]->elements != 2) { + printf("ASSERTION FAILED: Expected 2 keys, got %zu\n", reply->element[1]->elements); + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL geokey zkey"); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START scan4 + reply = redisCommand(c, "HSET myhash a 1 b 2"); + printf("%lld\n", reply->integer); + // >>> 2 + freeReplyObject(reply); + + // Without NOVALUES the results alternate field, value, field, value. + reply = redisCommand(c, "HSCAN myhash 0"); + for (size_t i = 0; i < reply->element[1]->elements; i += 2) { + printf("%s=%s\n", reply->element[1]->element[i]->str, + reply->element[1]->element[i + 1]->str); + } + // >>> a=1 + // >>> b=2 + // REMOVE_START + if (reply->element[1]->elements != 4) { + printf("ASSERTION FAILED: Expected 4 entries, got %zu\n", reply->element[1]->elements); + } + // REMOVE_END + freeReplyObject(reply); + + reply = redisCommand(c, "HSCAN myhash 0 NOVALUES"); + for (size_t i = 0; i < reply->element[1]->elements; i++) { + printf("%s\n", reply->element[1]->element[i]->str); + } + // >>> a + // >>> b + // REMOVE_START + if (reply->element[1]->elements != 2) { + printf("ASSERTION FAILED: Expected 2 fields, got %zu\n", reply->element[1]->elements); + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myhash"); + freeReplyObject(reply); + // REMOVE_END + // STEP_START disconnect redisFree(c); // STEP_END diff --git a/local_examples/cmds_generic/ioredis/cmds-generic.js b/local_examples/cmds_generic/ioredis/cmds-generic.js index bc345bad17..e86ba041c9 100644 --- a/local_examples/cmds_generic/ioredis/cmds-generic.js +++ b/local_examples/cmds_generic/ioredis/cmds-generic.js @@ -33,6 +33,86 @@ assert.deepEqual(keysRes4.sort(), ['age', 'firstname', 'lastname']); await redis.del('firstname', 'lastname', 'age'); // REMOVE_END +// STEP_START scan1 +const scan1Res1 = await redis.sadd('myset', '1', '2', '3', 'foo', 'foobar', 'feelsgood'); +console.log(scan1Res1); // >>> 6 + +const [, scan1Members] = await redis.sscan('myset', 0, 'MATCH', 'f*'); +console.log(scan1Members.sort()); // >>> ['feelsgood', 'foo', 'foobar'] +// STEP_END + +// REMOVE_START +assert.equal(scan1Res1, 6); +assert.deepEqual(scan1Members.sort(), ['feelsgood', 'foo', 'foobar']); +await redis.del('myset'); +// REMOVE_END + +// STEP_START scan2 +// REMOVE_START +const scan2Pipeline = redis.pipeline(); +for (let i = 1; i <= 1000; i++) { + scan2Pipeline.set(`key:${i}`, i); +} +await scan2Pipeline.exec(); +// REMOVE_END + +// MATCH filters after the elements are fetched, so most iterations return nothing. +let [scan2Cursor, scan2Keys] = await redis.scan(0, 'MATCH', '*11*'); +console.log(scan2Keys.length); + +for (let i = 0; i < 3; i++) { + [scan2Cursor, scan2Keys] = await redis.scan(scan2Cursor, 'MATCH', '*11*'); + 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 +// STEP_END + +// REMOVE_START +assert.equal(scan2Keys.length, 18); +await redis.flushdb(); +// REMOVE_END + +// STEP_START scan3 +const scan3Res1 = await redis.geoadd('geokey', '0', '0', 'value'); +console.log(scan3Res1); // >>> 1 + +const scan3Res2 = await redis.zadd('zkey', '1000', 'value'); +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'); +console.log(scan3Keys.sort()); // >>> ['geokey', 'zkey'] +// STEP_END + +// REMOVE_START +assert.deepEqual(scan3Keys.sort(), ['geokey', 'zkey']); +await redis.del('geokey', 'zkey'); +// REMOVE_END + +// STEP_START scan4 +const scan4Res1 = await redis.hset('myhash', { a: 1, b: 2 }); +console.log(scan4Res1); // >>> 2 + +const [, scan4Pairs] = await redis.hscan('myhash', 0); +console.log(scan4Pairs); // >>> ['a', '1', 'b', '2'] + +const [, scan4Fields] = await redis.hscan('myhash', 0, 'NOVALUES'); +console.log(scan4Fields); // >>> ['a', 'b'] +// STEP_END + +// REMOVE_START +assert.equal(scan4Res1, 2); +assert.deepEqual(scan4Pairs, ['a', '1', 'b', '2']); +assert.deepEqual(scan4Fields, ['a', 'b']); +await redis.del('myhash'); +// REMOVE_END + // HIDE_START redis.disconnect(); // HIDE_END diff --git a/local_examples/cmds_generic/jedis/CmdsGenericExample.java b/local_examples/cmds_generic/jedis/CmdsGenericExample.java index 7ad950252e..673b231a52 100644 --- a/local_examples/cmds_generic/jedis/CmdsGenericExample.java +++ b/local_examples/cmds_generic/jedis/CmdsGenericExample.java @@ -8,9 +8,13 @@ // HIDE_START import redis.clients.jedis.RedisClient; import redis.clients.jedis.args.ExpiryOption; +import redis.clients.jedis.params.ScanParams; +import redis.clients.jedis.resps.ScanResult; import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -166,6 +170,105 @@ public void run() { jedis.del("firstname", "lastname", "age"); // REMOVE_END + // STEP_START scan1 + long scan1Result1 = jedis.sadd("myset", "1", "2", "3", "foo", "foobar", "feelsgood"); + System.out.println(scan1Result1); // >>> 6 + + ScanResult scan1Result2 = jedis.sscan( + "myset", "0", new ScanParams().match("f*") + ); + ArrayList scan1Members = new ArrayList<>(scan1Result2.getResult()); + Collections.sort(scan1Members); + System.out.println(scan1Members); // >>> [feelsgood, foo, foobar] + // STEP_END + + // REMOVE_START + assertEquals(6, scan1Result1); + assertEquals(3, scan1Members.size()); + jedis.del("myset"); + // REMOVE_END + + // STEP_START scan2 + // REMOVE_START + for (int i = 1; i <= 1000; i++) { + jedis.set("key:" + i, String.valueOf(i)); + } + // REMOVE_END + + // MATCH is applied after elements are fetched, so with the default COUNT most + // iterations return few keys or none at all. + String scan2Cursor = "0"; + ScanResult scan2Result; + + for (int i = 0; i < 4; i++) { + scan2Result = jedis.scan(scan2Cursor, new ScanParams().match("*11*")); + scan2Cursor = scan2Result.getCursor(); + 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 + // STEP_END + + // REMOVE_START + assertEquals(18, scan2Result.getResult().size()); + jedis.flushDB(); + // REMOVE_END + + // STEP_START scan3 + long scan3Result1 = jedis.geoadd("geokey", 0, 0, "value"); + System.out.println(scan3Result1); // >>> 1 + + long scan3Result2 = jedis.zadd("zkey", 1000, "value"); + System.out.println(scan3Result2); // >>> 1 + + 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()); + Collections.sort(scan3Keys); + System.out.println(scan3Keys); // >>> [geokey, zkey] + // STEP_END + + // REMOVE_START + assertEquals(2, scan3Keys.size()); + jedis.del("geokey", "zkey"); + // REMOVE_END + + // STEP_START scan4 + long scan4Result1 = jedis.hset("myhash", Map.of("a", "1", "b", "2")); + System.out.println(scan4Result1); // >>> 2 + + ScanResult> scan4Result2 = jedis.hscan( + "myhash", "0", new ScanParams() + ); + ArrayList scan4Pairs = new ArrayList<>(); + for (Map.Entry entry : scan4Result2.getResult()) { + scan4Pairs.add(entry.getKey() + "=" + entry.getValue()); + } + Collections.sort(scan4Pairs); + System.out.println(scan4Pairs); // >>> [a=1, b=2] + + ScanResult scan4Result3 = jedis.hscanNoValues( + "myhash", "0", new ScanParams() + ); + ArrayList scan4Fields = new ArrayList<>(scan4Result3.getResult()); + Collections.sort(scan4Fields); + System.out.println(scan4Fields); // >>> [a, b] + // STEP_END + + // REMOVE_START + assertEquals(2, scan4Result1); + assertEquals(2, scan4Pairs.size()); + assertEquals(2, scan4Fields.size()); + jedis.del("myhash"); + // REMOVE_END + // HIDE_START jedis.close(); } diff --git a/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java b/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java index 99aad44348..911a0272d5 100644 --- a/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java +++ b/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java @@ -112,6 +112,129 @@ public void run() { // REMOVE_START asyncCommands.del("firstname", "lastname", "age").toCompletableFuture().join(); // REMOVE_END + + // STEP_START scan1 + CompletableFuture scan1Example = asyncCommands + .sadd("myset", "1", "2", "3", "foo", "foobar", "feelsgood") + .thenCompose(scan1Res1 -> { + System.out.println(scan1Res1); // >>> 6 + // REMOVE_START + assertThat(scan1Res1).isEqualTo(6L); + // REMOVE_END + return asyncCommands.sscan("myset", ScanArgs.Builder.matches("f*")); + }) + .thenAccept(scan1Res2 -> { + List members = new java.util.ArrayList<>(scan1Res2.getValues()); + Collections.sort(members); + System.out.println(members); // >>> [feelsgood, foo, foobar] + // REMOVE_START + assertThat(members).hasSize(3); + // REMOVE_END + }) + .toCompletableFuture(); + // STEP_END + + scan1Example.join(); + // REMOVE_START + asyncCommands.del("myset").toCompletableFuture().join(); + // REMOVE_END + + // STEP_START scan2 + // REMOVE_START + for (int i = 1; i <= 1000; i++) { + asyncCommands.set("key:" + i, String.valueOf(i)).toCompletableFuture().join(); + } + // REMOVE_END + + // MATCH is applied after elements are fetched, so with the default COUNT most + // iterations return few keys or none at all. Each iteration is awaited because + // the next one needs the cursor this one returns. + KeyScanCursor scan2Cursor = asyncCommands + .scan(ScanArgs.Builder.matches("*11*")).toCompletableFuture().join(); + System.out.println(scan2Cursor.getKeys().size()); + + for (int i = 0; i < 3; i++) { + scan2Cursor = asyncCommands + .scan(scan2Cursor, ScanArgs.Builder.matches("*11*")) + .toCompletableFuture().join(); + System.out.println(scan2Cursor.getKeys().size()); + } + + // A larger COUNT forces more scanning in a single iteration, so the remaining + // matches arrive together. This continues from the cursor reached above. + scan2Cursor = asyncCommands + .scan(scan2Cursor, ScanArgs.Builder.matches("*11*").limit(1000)) + .toCompletableFuture().join(); + System.out.println(scan2Cursor.getKeys().size()); // >>> 18 + // STEP_END + + // REMOVE_START + assertThat(scan2Cursor.getKeys()).hasSize(18); + 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(); + // STEP_END + + scan3Example.join(); + // REMOVE_START + asyncCommands.del("geokey", "zkey").toCompletableFuture().join(); + // REMOVE_END + + // STEP_START scan4 + CompletableFuture scan4Example = asyncCommands + .hset("myhash", Map.of("a", "1", "b", "2")) + .thenCompose(scan4Res1 -> { + System.out.println(scan4Res1); // >>> 2 + return asyncCommands.hscan("myhash"); + }) + .thenCompose(scan4Res2 -> { + System.out.println(new java.util.TreeMap<>(scan4Res2.getMap())); + // >>> {a=1, b=2} + return asyncCommands.hscanNovalues("myhash"); + }) + .thenAccept(scan4Res3 -> { + List fields = new java.util.ArrayList<>(scan4Res3.getKeys()); + Collections.sort(fields); + System.out.println(fields); // >>> [a, b] + // REMOVE_START + assertThat(fields).containsExactly("a", "b"); + // REMOVE_END + }) + .toCompletableFuture(); + // STEP_END + + scan4Example.join(); + // REMOVE_START + asyncCommands.del("myhash").toCompletableFuture().join(); + // REMOVE_END } finally { redisClient.shutdown(); } diff --git a/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java b/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java index 61c54ade87..5f26c18a8d 100644 --- a/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java +++ b/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java @@ -10,6 +10,7 @@ import reactor.core.publisher.Mono; import java.util.Collections; +import java.util.List; import java.util.Map; // REMOVE_START @@ -100,6 +101,127 @@ public void run() { reactiveCommands.del("firstname", "lastname", "age").block(); // REMOVE_END + // STEP_START scan1 + Mono scan1Example = reactiveCommands + .sadd("myset", "1", "2", "3", "foo", "foobar", "feelsgood") + .flatMap(scan1Res1 -> { + System.out.println(scan1Res1); // >>> 6 + // REMOVE_START + assertThat(scan1Res1).isEqualTo(6L); + // REMOVE_END + return reactiveCommands.sscan("myset", ScanArgs.Builder.matches("f*")); + }) + .doOnNext(scan1Res2 -> { + List members = new java.util.ArrayList<>(scan1Res2.getValues()); + Collections.sort(members); + System.out.println(members); // >>> [feelsgood, foo, foobar] + // REMOVE_START + assertThat(members).hasSize(3); + // REMOVE_END + }) + .then(); + // STEP_END + + Mono.when(scan1Example).block(); + // REMOVE_START + reactiveCommands.del("myset").block(); + // REMOVE_END + + // STEP_START scan2 + // REMOVE_START + for (int i = 1; i <= 1000; i++) { + reactiveCommands.set("key:" + i, String.valueOf(i)).block(); + } + // REMOVE_END + + // MATCH is applied after elements are fetched, so with the default COUNT most + // iterations return few keys or none at all. Each iteration is subscribed to in + // turn because the next one needs the cursor this one returns. + KeyScanCursor scan2Cursor = reactiveCommands + .scan(ScanArgs.Builder.matches("*11*")).block(); + System.out.println(scan2Cursor.getKeys().size()); + + for (int i = 0; i < 3; i++) { + scan2Cursor = reactiveCommands + .scan(scan2Cursor, ScanArgs.Builder.matches("*11*")).block(); + System.out.println(scan2Cursor.getKeys().size()); + } + + // A larger COUNT forces more scanning in a single iteration, so the remaining + // 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 + // STEP_END + + // REMOVE_START + assertThat(scan2Cursor.getKeys()).hasSize(18); + 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(); + // STEP_END + + Mono.when(scan3Example).block(); + // REMOVE_START + reactiveCommands.del("geokey", "zkey").block(); + // REMOVE_END + + // STEP_START scan4 + Mono scan4Example = reactiveCommands + .hset("myhash", Map.of("a", "1", "b", "2")) + .flatMap(scan4Res1 -> { + System.out.println(scan4Res1); // >>> 2 + return reactiveCommands.hscan("myhash"); + }) + .flatMap(scan4Res2 -> { + System.out.println(new java.util.TreeMap<>(scan4Res2.getMap())); + // >>> {a=1, b=2} + return reactiveCommands.hscanNovalues("myhash"); + }) + .doOnNext(scan4Res3 -> { + List fields = new java.util.ArrayList<>(scan4Res3.getKeys()); + Collections.sort(fields); + System.out.println(fields); // >>> [a, b] + // REMOVE_START + assertThat(fields).containsExactly("a", "b"); + // REMOVE_END + }) + .then(); + // STEP_END + + Mono.when(scan4Example).block(); + // REMOVE_START + reactiveCommands.del("myhash").block(); + // REMOVE_END + } finally { redisClient.shutdown(); } diff --git a/local_examples/cmds_generic/predis/CmdsGenericTest.php b/local_examples/cmds_generic/predis/CmdsGenericTest.php index 6a0bef71f8..7d1ac8731d 100644 --- a/local_examples/cmds_generic/predis/CmdsGenericTest.php +++ b/local_examples/cmds_generic/predis/CmdsGenericTest.php @@ -68,5 +68,65 @@ public function testCmdsGeneric() { $r->del('firstname', 'lastname', 'age'); // REMOVE_END + // STEP_START scan1 + $scan1Result1 = $r->sadd('myset', ['1', '2', '3', 'foo', 'foobar', 'feelsgood']); + echo $scan1Result1 . PHP_EOL; // >>> 6 + + [$scan1Cursor, $scan1Members] = $r->sscan('myset', 0, ['MATCH' => 'f*']); + sort($scan1Members); + echo implode(', ', $scan1Members) . PHP_EOL; // >>> feelsgood, foo, foobar + // STEP_END + + // REMOVE_START + $this->assertEquals(6, $scan1Result1); + $this->assertEquals(['feelsgood', 'foo', 'foobar'], $scan1Members); + $r->del('myset'); + // REMOVE_END + + // STEP_START scan2 + // REMOVE_START + for ($i = 1; $i <= 1000; $i++) { + $r->set("key:$i", $i); + } + // REMOVE_END + + // MATCH is applied after elements are fetched, so with the default COUNT most + // iterations return few keys or none at all. + $scan2Cursor = 0; + + for ($i = 0; $i < 4; $i++) { + [$scan2Cursor, $scan2Keys] = $r->scan($scan2Cursor, ['MATCH' => '*11*']); + 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 + // STEP_END + + // REMOVE_START + $this->assertEquals(18, count($scan2Keys)); + $r->flushdb(); + // REMOVE_END + + // STEP_START scan4 + $scan4Result1 = $r->hset('myhash', 'a', 1, 'b', 2); + echo $scan4Result1 . PHP_EOL; // >>> 2 + + [$scan4Cursor, $scan4Pairs] = $r->hscan('myhash', 0); + echo json_encode($scan4Pairs) . PHP_EOL; // >>> {"a":"1","b":"2"} + + [$scan4Cursor, $scan4Fields] = $r->hscan('myhash', 0, ['NOVALUES' => true]); + echo implode(', ', $scan4Fields) . PHP_EOL; // >>> a, b + // STEP_END + + // REMOVE_START + $this->assertEquals(2, $scan4Result1); + $this->assertEquals(['a' => '1', 'b' => '2'], $scan4Pairs); + $this->assertEquals(['a', 'b'], $scan4Fields); + $r->del('myhash'); + // REMOVE_END + } } From e86aa59c8b6b2d5026bfea5c51836b3b16412946 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Fri, 14 Aug 2026 09:52:05 +0100 Subject: [PATCH 2/5] DOC-6968 Add del, exists, expire and ttl to the remaining cmds_generic clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 17 missing steps across hiredis, ioredis, lettuce-async, lettuce-reactive and predis, taking cmds_generic to full coverage. Omission warnings site-wide fall from 36 to 19, and commands/del.md, exists.md, expire.md and ttl.md are all back to twelve client tabs. The only cmds_generic warning left is PHP's scan3, which predis cannot express at all. Two problems in the C example, and the second matters more than the first. The bug: `redisCommand(c, "SET mykey \"Hello World\"")` does not work. hiredis splits its format string on whitespace and does not honour CLI-style quoting, so that became four tokens and the server answered ERR syntax error. The value has to be bound as an argument — `redisCommand(c, "SET mykey %s", "Hello World")` — and once it was, the expiry sequence came out right: OK, 1, 10, OK, -1, 0, -1, 1, 10. The gap: the harness reported PASS the whole time. The C examples' assertions only printf "ASSERTION FAILED"; they never affect the exit code, and hiredis does not abort on an error reply, so for C a green run has only ever meant "the binary exited 0". This particular failure even left the final TTL at 10 by coincidence, so the value check would have passed regardless. I found it by reading the output, not from the harness. So the 13 assertions in this file now `return 1` as well as printing. Proven rather than assumed: deliberately changing one expected value from 2 to 999 turns the sweep red, and restoring it turns it green. Worth being clear that this would NOT have caught today's bug — an error reply mid-example still slips through, because nothing checks reply->type for REDIS_REPLY_ERROR. Catching that class needs a guard after each command, across every C example, and is better done deliberately than bolted on here. nredisstack and rust-async still fail cmds_generic for the pre-existing, unrelated reasons recorded on the previous commit; neither file is touched. Learned: for C examples a harness PASS meant only that the binary exited 0 — the assertions printed and returned nothing — so an example could emit ERR syntax error mid-run and still be reported green Constraint: hiredis does not honour CLI-style quoting in its format string; a value containing spaces must be bound with %s or it is split into separate arguments and the command fails Constraint: keep the `return 1` alongside each ASSERTION FAILED printf in this file, or the harness stops being able to fail on a wrong value Gaps: nothing checks reply->type == REDIS_REPLY_ERROR in the C examples, so a mid-example error reply still passes; a guard after each command, applied across all C examples, is the fix Ticket: DOC-6968 Co-Authored-By: Claude Opus 5 (1M context) --- .../cmds_generic/hiredis/cmds_generic.c | 159 ++++++++++++++++++ .../cmds_generic/ioredis/cmds-generic.js | 67 ++++++++ .../lettuce-async/CmdsGenericExample.java | 96 +++++++++++ .../lettuce-reactive/CmdsGenericExample.java | 96 +++++++++++ .../cmds_generic/predis/CmdsGenericTest.php | 50 ++++++ 5 files changed, 468 insertions(+) diff --git a/local_examples/cmds_generic/hiredis/cmds_generic.c b/local_examples/cmds_generic/hiredis/cmds_generic.c index e6464ac8b4..344b5a736a 100644 --- a/local_examples/cmds_generic/hiredis/cmds_generic.c +++ b/local_examples/cmds_generic/hiredis/cmds_generic.c @@ -48,6 +48,7 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->elements != 2) { printf("ASSERTION FAILED: Expected 2 elements, got %zu\n", reply->elements); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -62,9 +63,11 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->elements != 1) { printf("ASSERTION FAILED: Expected 1 element, got %zu\n", reply->elements); + return 1; } if (strcmp(reply->element[0]->str, "age") != 0) { printf("ASSERTION FAILED: Expected 'age', got '%s'\n", reply->element[0]->str); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -81,6 +84,7 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->elements != 3) { printf("ASSERTION FAILED: Expected 3 elements, got %zu\n", reply->elements); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -104,6 +108,7 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->element[1]->elements != 3) { printf("ASSERTION FAILED: Expected 3 members, got %zu\n", reply->element[1]->elements); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -139,6 +144,7 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->element[1]->elements != 18) { printf("ASSERTION FAILED: Expected 18 keys, got %zu\n", reply->element[1]->elements); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -176,6 +182,7 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->element[1]->elements != 2) { printf("ASSERTION FAILED: Expected 2 keys, got %zu\n", reply->element[1]->elements); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -203,6 +210,7 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->element[1]->elements != 4) { printf("ASSERTION FAILED: Expected 4 entries, got %zu\n", reply->element[1]->elements); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -216,6 +224,7 @@ int main(int argc, char **argv) { // REMOVE_START if (reply->element[1]->elements != 2) { printf("ASSERTION FAILED: Expected 2 fields, got %zu\n", reply->element[1]->elements); + return 1; } // REMOVE_END freeReplyObject(reply); @@ -226,6 +235,156 @@ int main(int argc, char **argv) { freeReplyObject(reply); // REMOVE_END + // STEP_START del + reply = redisCommand(c, "SET key1 Hello"); + printf("%s\n", reply->str); + // >>> OK + freeReplyObject(reply); + + reply = redisCommand(c, "SET key2 World"); + printf("%s\n", reply->str); + // >>> OK + freeReplyObject(reply); + + reply = redisCommand(c, "DEL key1 key2 key3"); + printf("%lld\n", reply->integer); + // >>> 2 + // REMOVE_START + if (reply->integer != 2) { + printf("ASSERTION FAILED: Expected 2, got %lld\n", reply->integer); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // STEP_START exists + reply = redisCommand(c, "SET key1 Hello"); + printf("%s\n", reply->str); + // >>> OK + freeReplyObject(reply); + + reply = redisCommand(c, "EXISTS key1"); + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "EXISTS nosuchkey"); + printf("%lld\n", reply->integer); + // >>> 0 + freeReplyObject(reply); + + reply = redisCommand(c, "SET key2 World"); + printf("%s\n", reply->str); + // >>> OK + freeReplyObject(reply); + + reply = redisCommand(c, "EXISTS key1 key2 nosuchkey"); + printf("%lld\n", reply->integer); + // >>> 2 + // REMOVE_START + if (reply->integer != 2) { + printf("ASSERTION FAILED: Expected 2, got %lld\n", reply->integer); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL key1 key2"); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START expire + reply = redisCommand(c, "SET mykey Hello"); + printf("%s\n", reply->str); + // >>> OK + freeReplyObject(reply); + + reply = redisCommand(c, "EXPIRE mykey 10"); + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "TTL mykey"); + printf("%lld\n", reply->integer); + // >>> 10 + freeReplyObject(reply); + + // Overwriting a key with SET clears its expiry. + reply = redisCommand(c, "SET mykey %s", "Hello World"); + printf("%s\n", reply->str); + // >>> OK + freeReplyObject(reply); + + reply = redisCommand(c, "TTL mykey"); + printf("%lld\n", reply->integer); + // >>> -1 + freeReplyObject(reply); + + // XX only sets the expiry when one already exists, so this is a no-op. + reply = redisCommand(c, "EXPIRE mykey 10 XX"); + printf("%lld\n", reply->integer); + // >>> 0 + freeReplyObject(reply); + + reply = redisCommand(c, "TTL mykey"); + printf("%lld\n", reply->integer); + // >>> -1 + freeReplyObject(reply); + + // NX only sets the expiry when there is none, so this one applies. + reply = redisCommand(c, "EXPIRE mykey 10 NX"); + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "TTL mykey"); + printf("%lld\n", reply->integer); + // >>> 10 + // REMOVE_START + if (reply->integer != 10) { + printf("ASSERTION FAILED: Expected TTL 10, got %lld\n", reply->integer); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL mykey"); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START ttl + reply = redisCommand(c, "SET mykey Hello"); + printf("%s\n", reply->str); + // >>> OK + freeReplyObject(reply); + + reply = redisCommand(c, "EXPIRE mykey 10"); + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "TTL mykey"); + printf("%lld\n", reply->integer); + // >>> 10 + // REMOVE_START + if (reply->integer != 10) { + printf("ASSERTION FAILED: Expected TTL 10, got %lld\n", reply->integer); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL mykey"); + freeReplyObject(reply); + // REMOVE_END + // STEP_START disconnect redisFree(c); // STEP_END diff --git a/local_examples/cmds_generic/ioredis/cmds-generic.js b/local_examples/cmds_generic/ioredis/cmds-generic.js index e86ba041c9..673638d141 100644 --- a/local_examples/cmds_generic/ioredis/cmds-generic.js +++ b/local_examples/cmds_generic/ioredis/cmds-generic.js @@ -113,6 +113,73 @@ assert.deepEqual(scan4Fields, ['a', 'b']); await redis.del('myhash'); // REMOVE_END +// STEP_START del +console.log(await redis.set('key1', 'Hello')); // >>> OK +console.log(await redis.set('key2', 'World')); // >>> OK + +const delResult = await redis.del('key1', 'key2', 'key3'); +console.log(delResult); // >>> 2 +// STEP_END + +// REMOVE_START +assert.equal(delResult, 2); +// REMOVE_END + +// STEP_START exists +console.log(await redis.set('key1', 'Hello')); // >>> OK + +console.log(await redis.exists('key1')); // >>> 1 +console.log(await redis.exists('nosuchkey')); // >>> 0 + +console.log(await redis.set('key2', 'World')); // >>> OK + +const existsResult = await redis.exists('key1', 'key2', 'nosuchkey'); +console.log(existsResult); // >>> 2 +// STEP_END + +// REMOVE_START +assert.equal(existsResult, 2); +await redis.del('key1', 'key2'); +// REMOVE_END + +// STEP_START expire +console.log(await redis.set('mykey', 'Hello')); // >>> OK + +console.log(await redis.expire('mykey', 10)); // >>> 1 +console.log(await redis.ttl('mykey')); // >>> 10 + +// Overwriting a key with SET clears its expiry. +console.log(await redis.set('mykey', 'Hello World')); // >>> OK +console.log(await redis.ttl('mykey')); // >>> -1 + +// XX only sets the expiry when one already exists, so this is a no-op. +console.log(await redis.expire('mykey', 10, 'XX')); // >>> 0 +console.log(await redis.ttl('mykey')); // >>> -1 + +// NX only sets the expiry when there is none, so this one applies. +console.log(await redis.expire('mykey', 10, 'NX')); // >>> 1 +const expireTtl = await redis.ttl('mykey'); +console.log(expireTtl); // >>> 10 +// STEP_END + +// REMOVE_START +assert.equal(expireTtl, 10); +await redis.del('mykey'); +// REMOVE_END + +// STEP_START ttl +console.log(await redis.set('mykey', 'Hello')); // >>> OK +console.log(await redis.expire('mykey', 10)); // >>> 1 + +const ttlResult = await redis.ttl('mykey'); +console.log(ttlResult); // >>> 10 +// STEP_END + +// REMOVE_START +assert.equal(ttlResult, 10); +await redis.del('mykey'); +// REMOVE_END + // HIDE_START redis.disconnect(); // HIDE_END diff --git a/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java b/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java index 911a0272d5..dc407f23a3 100644 --- a/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java +++ b/local_examples/cmds_generic/lettuce-async/CmdsGenericExample.java @@ -235,6 +235,102 @@ public void run() { // REMOVE_START asyncCommands.del("myhash").toCompletableFuture().join(); // REMOVE_END + // STEP_START del + CompletableFuture delExample = asyncCommands.set("key1", "Hello") + .thenCompose(r1 -> { + System.out.println(r1); // >>> OK + return asyncCommands.set("key2", "World"); + }) + .thenCompose(r2 -> { + System.out.println(r2); // >>> OK + return asyncCommands.del("key1", "key2", "key3"); + }) + .thenAccept(r3 -> { + System.out.println(r3); // >>> 2 + // REMOVE_START + assertThat(r3).isEqualTo(2L); + // REMOVE_END + }) + .toCompletableFuture(); + // STEP_END + + delExample.join(); + + // STEP_START expire + CompletableFuture expireExample = asyncCommands.set("mykey", "Hello") + .thenCompose(r1 -> { + System.out.println(r1); // >>> OK + return asyncCommands.expire("mykey", 10); + }) + .thenCompose(r2 -> { + System.out.println(r2); // >>> true + return asyncCommands.ttl("mykey"); + }) + .thenCompose(r3 -> { + System.out.println(r3); // >>> 10 + // Overwriting a key with SET clears its expiry. + return asyncCommands.set("mykey", "Hello World"); + }) + .thenCompose(r4 -> { + System.out.println(r4); // >>> OK + return asyncCommands.ttl("mykey"); + }) + .thenCompose(r5 -> { + System.out.println(r5); // >>> -1 + // XX only sets the expiry when one already exists, so this is a no-op. + return asyncCommands.expire("mykey", 10, ExpireArgs.Builder.xx()); + }) + .thenCompose(r6 -> { + System.out.println(r6); // >>> false + return asyncCommands.ttl("mykey"); + }) + .thenCompose(r7 -> { + System.out.println(r7); // >>> -1 + // NX only sets the expiry when there is none, so this one applies. + return asyncCommands.expire("mykey", 10, ExpireArgs.Builder.nx()); + }) + .thenCompose(r8 -> { + System.out.println(r8); // >>> true + return asyncCommands.ttl("mykey"); + }) + .thenAccept(r9 -> { + System.out.println(r9); // >>> 10 + // REMOVE_START + assertThat(r9).isEqualTo(10L); + // REMOVE_END + }) + .toCompletableFuture(); + // STEP_END + + expireExample.join(); + // REMOVE_START + asyncCommands.del("mykey").toCompletableFuture().join(); + // REMOVE_END + + // STEP_START ttl + CompletableFuture ttlExample = asyncCommands.set("mykey", "Hello") + .thenCompose(r1 -> { + System.out.println(r1); // >>> OK + return asyncCommands.expire("mykey", 10); + }) + .thenCompose(r2 -> { + System.out.println(r2); // >>> true + return asyncCommands.ttl("mykey"); + }) + .thenAccept(r3 -> { + System.out.println(r3); // >>> 10 + // REMOVE_START + assertThat(r3).isEqualTo(10L); + // REMOVE_END + }) + .toCompletableFuture(); + // STEP_END + + ttlExample.join(); + // REMOVE_START + asyncCommands.del("mykey").toCompletableFuture().join(); + // REMOVE_END + } finally { redisClient.shutdown(); } diff --git a/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java b/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java index 5f26c18a8d..207dc1ae3a 100644 --- a/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java +++ b/local_examples/cmds_generic/lettuce-reactive/CmdsGenericExample.java @@ -221,6 +221,102 @@ public void run() { // REMOVE_START reactiveCommands.del("myhash").block(); // REMOVE_END + // STEP_START del + Mono delExample = reactiveCommands.set("key1", "Hello") + .flatMap(r1 -> { + System.out.println(r1); // >>> OK + return reactiveCommands.set("key2", "World"); + }) + .flatMap(r2 -> { + System.out.println(r2); // >>> OK + return reactiveCommands.del("key1", "key2", "key3"); + }) + .doOnNext(r3 -> { + System.out.println(r3); // >>> 2 + // REMOVE_START + assertThat(r3).isEqualTo(2L); + // REMOVE_END + }) + .then(); + // STEP_END + + Mono.when(delExample).block(); + + // STEP_START expire + Mono expireExample = reactiveCommands.set("mykey", "Hello") + .flatMap(r1 -> { + System.out.println(r1); // >>> OK + return reactiveCommands.expire("mykey", 10); + }) + .flatMap(r2 -> { + System.out.println(r2); // >>> true + return reactiveCommands.ttl("mykey"); + }) + .flatMap(r3 -> { + System.out.println(r3); // >>> 10 + // Overwriting a key with SET clears its expiry. + return reactiveCommands.set("mykey", "Hello World"); + }) + .flatMap(r4 -> { + System.out.println(r4); // >>> OK + return reactiveCommands.ttl("mykey"); + }) + .flatMap(r5 -> { + System.out.println(r5); // >>> -1 + // XX only sets the expiry when one already exists, so this is a no-op. + return reactiveCommands.expire("mykey", 10, ExpireArgs.Builder.xx()); + }) + .flatMap(r6 -> { + System.out.println(r6); // >>> false + return reactiveCommands.ttl("mykey"); + }) + .flatMap(r7 -> { + System.out.println(r7); // >>> -1 + // NX only sets the expiry when there is none, so this one applies. + return reactiveCommands.expire("mykey", 10, ExpireArgs.Builder.nx()); + }) + .flatMap(r8 -> { + System.out.println(r8); // >>> true + return reactiveCommands.ttl("mykey"); + }) + .doOnNext(r9 -> { + System.out.println(r9); // >>> 10 + // REMOVE_START + assertThat(r9).isEqualTo(10L); + // REMOVE_END + }) + .then(); + // STEP_END + + Mono.when(expireExample).block(); + // REMOVE_START + reactiveCommands.del("mykey").block(); + // REMOVE_END + + // STEP_START ttl + Mono ttlExample = reactiveCommands.set("mykey", "Hello") + .flatMap(r1 -> { + System.out.println(r1); // >>> OK + return reactiveCommands.expire("mykey", 10); + }) + .flatMap(r2 -> { + System.out.println(r2); // >>> true + return reactiveCommands.ttl("mykey"); + }) + .doOnNext(r3 -> { + System.out.println(r3); // >>> 10 + // REMOVE_START + assertThat(r3).isEqualTo(10L); + // REMOVE_END + }) + .then(); + // STEP_END + + Mono.when(ttlExample).block(); + // REMOVE_START + reactiveCommands.del("mykey").block(); + // REMOVE_END + } finally { redisClient.shutdown(); diff --git a/local_examples/cmds_generic/predis/CmdsGenericTest.php b/local_examples/cmds_generic/predis/CmdsGenericTest.php index 7d1ac8731d..3796d4cde8 100644 --- a/local_examples/cmds_generic/predis/CmdsGenericTest.php +++ b/local_examples/cmds_generic/predis/CmdsGenericTest.php @@ -128,5 +128,55 @@ public function testCmdsGeneric() { $r->del('myhash'); // REMOVE_END + // STEP_START del + echo $r->set('key1', 'Hello') . PHP_EOL; // >>> OK + echo $r->set('key2', 'World') . PHP_EOL; // >>> OK + + $delResult = $r->del('key1', 'key2', 'key3'); + echo $delResult . PHP_EOL; // >>> 2 + // STEP_END + + // REMOVE_START + $this->assertEquals(2, $delResult); + // REMOVE_END + + // STEP_START expire + echo $r->set('mykey', 'Hello') . PHP_EOL; // >>> OK + + echo $r->expire('mykey', 10) . PHP_EOL; // >>> 1 + echo $r->ttl('mykey') . PHP_EOL; // >>> 10 + + // Overwriting a key with SET clears its expiry. + echo $r->set('mykey', 'Hello World') . PHP_EOL; // >>> OK + echo $r->ttl('mykey') . PHP_EOL; // >>> -1 + + // XX only sets the expiry when one already exists, so this is a no-op. + echo $r->expire('mykey', 10, 'XX') . PHP_EOL; // >>> 0 + echo $r->ttl('mykey') . PHP_EOL; // >>> -1 + + // NX only sets the expiry when there is none, so this one applies. + echo $r->expire('mykey', 10, 'NX') . PHP_EOL; // >>> 1 + $expireTtl = $r->ttl('mykey'); + echo $expireTtl . PHP_EOL; // >>> 10 + // STEP_END + + // REMOVE_START + $this->assertEquals(10, $expireTtl); + $r->del('mykey'); + // REMOVE_END + + // STEP_START ttl + echo $r->set('mykey', 'Hello') . PHP_EOL; // >>> OK + echo $r->expire('mykey', 10) . PHP_EOL; // >>> 1 + + $ttlResult = $r->ttl('mykey'); + echo $ttlResult . PHP_EOL; // >>> 10 + // STEP_END + + // REMOVE_START + $this->assertEquals(10, $ttlResult); + $r->del('mykey'); + // REMOVE_END + } } From 81aa0c0d100a9ea4622ca65007138c1cbb69b5ed Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Fri, 14 Aug 2026 10:14:22 +0100 Subject: [PATCH 3/5] DOC-6968 Make C examples able to fail: guard every hiredis reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CHECK_REPLY guard after all 62 redisCommand calls across the four C examples, plus the macro that backs it, all inside REMOVE blocks so the published snippets stay plain redisCommand. Until now a green harness run told you nothing about a C example. There is no assertion framework, hiredis returns an error REPLY rather than failing the call, and it does not abort — so an example could send a malformed command, print a wrong value and still exit 0. That is not hypothetical: the previous commit on this branch shipped `SET mykey "Hello World"` (hiredis splits its format string on whitespace and ignores CLI quoting), the server answered ERR syntax error, and the sweep reported PASS. I found it by reading output. Proven on the hardest case rather than the convenient one. Reintroducing an error reply that gets printed with %lld — which prints 0, not the error text — now fails the sweep with "REDIS ERROR: ERR wrong number of arguments for 'incr' command". That specific case is why the cheap version was rejected: scanning the program's stdout for ERR would have missed it entirely, and would also false-positive on any future example that deliberately demonstrates an error reply. Two incidental fixes in cmds_hash.c: the two cleanup DELs were unassigned, so they could be neither checked nor freed. They are now assigned, guarded and freed. Both sit in REMOVE blocks, so nothing changes on the page. The rule is written into the hiredis patterns file in the tce-examples skill, because the guard is only as good as the next author remembering it, and a commit cannot enforce that. Verified: all four files compile clean under -Wall, and hiredis passes cmds_generic, cmds_hash and cmds_string. landing.c is compile-checked only — the harness reports SKIP (no source) for the landing set because that file lives at local_examples/client-specific/c/ instead of the conventional local_examples/// path. Learned: an output-scanning check for C errors is not equivalent to a call-site guard — printf("%lld", reply->integer) on an error reply prints 0, so the error never reaches stdout to be scanned for Constraint: every redisCommand in a C example needs CHECK_REPLY in a REMOVE block, and value assertions must return 1 as well as printing; without both, a C example can print wrong values and still exit 0 Rejected: scanning hiredis stdout for "ERR" in run_hiredis as the primary check | incomplete for the %lld case, and it would false-positive on a future example that deliberately shows an error reply Gaps: landing.c cannot be executed by the harness (SKIP: no source) because it sits outside the local_examples/// convention, so its guard is compile-checked only Ticket: DOC-6968 Co-Authored-By: Claude Opus 5 (1M context) --- .../assets/hiredis/HIREDIS_TEST_PATTERNS.md | 38 +++++ local_examples/client-specific/c/landing.c | 19 +++ .../cmds_generic/hiredis/cmds_generic.c | 151 ++++++++++++++++++ local_examples/cmds_hash/hiredis/cmds_hash.c | 39 ++++- .../cmds_string/hiredis/cmds_string.c | 37 +++++ 5 files changed, 282 insertions(+), 2 deletions(-) diff --git a/.claude/skills/tce-examples/assets/hiredis/HIREDIS_TEST_PATTERNS.md b/.claude/skills/tce-examples/assets/hiredis/HIREDIS_TEST_PATTERNS.md index d0ed1fac95..edbfa2933b 100644 --- a/.claude/skills/tce-examples/assets/hiredis/HIREDIS_TEST_PATTERNS.md +++ b/.claude/skills/tce-examples/assets/hiredis/HIREDIS_TEST_PATTERNS.md @@ -84,6 +84,44 @@ int main(int argc, char **argv) { ## Key Patterns +### 0. CHECK_REPLY — mandatory, and the reason C examples are testable at all + +**A C example has no assertion framework, so the harness can only see the process exit +code.** hiredis returns an *error reply* (a valid `redisReply` with +`type == REDIS_REPLY_ERROR`) for a malformed command rather than failing the call, and it +does not abort. Without a guard, an example can send a broken command, print a wrong value +and still exit 0 — a green run that proves nothing. That happened for real in DOC-6968: +`redisCommand(c, "SET mykey \"Hello World\"")` split on whitespace (hiredis does **not** +honour CLI-style quoting — bind a value containing spaces with `%s`), the server answered +`ERR syntax error`, and the sweep still reported PASS. + +So every C example defines this macro in a `REMOVE` block after the includes, and calls it +after **every** `redisCommand`, also inside a `REMOVE` block, so the published snippet stays +plain `redisCommand`: + +```c +// REMOVE_START +#define CHECK_REPLY(r) do { \ + if ((r) == NULL || (r)->type == REDIS_REPLY_ERROR) { \ + printf("REDIS ERROR: %s\n", (r) ? (r)->str : "no reply from server"); \ + return 1; \ + } \ +} while (0) +// REMOVE_END + +reply = redisCommand(c, "SET mykey %s", "Hello World"); +// REMOVE_START +CHECK_REPLY(reply); +// REMOVE_END +``` + +Note why a cheaper check is not enough: scanning the program's output for `ERR` misses the +common case, because `printf("%lld", reply->integer)` on an error reply prints **`0`**, not +the error text. The guard has to sit at the call site. + +Value assertions in these files must also `return 1`, not merely `printf("ASSERTION +FAILED")` — otherwise they cannot fail the harness either. + ### 1. Includes ```c // STEP_START includes diff --git a/local_examples/client-specific/c/landing.c b/local_examples/client-specific/c/landing.c index b4b2ef8859..d79d5e1321 100644 --- a/local_examples/client-specific/c/landing.c +++ b/local_examples/client-specific/c/landing.c @@ -9,6 +9,19 @@ #include +// REMOVE_START +// Fail loudly on a NULL or error reply. hiredis returns an error REPLY (not a +// connection error) for things like a bad command, and the examples would +// otherwise print a wrong value and still exit 0 — a green harness run that +// proves nothing. Kept in a REMOVE block so the published example stays plain. +#define CHECK_REPLY(r) do { \ + if ((r) == NULL || (r)->type == REDIS_REPLY_ERROR) { \ + printf("REDIS ERROR: %s\n", (r) ? (r)->str : "no reply from server"); \ + return 1; \ + } \ +} while (0) +// REMOVE_END + int main() { // The `redisContext` type represents the connection // to the Redis server. Here, we connect to the @@ -30,11 +43,17 @@ int main() { // Set a string key. redisReply *reply = redisCommand(c, "SET foo bar"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("Reply: %s\n", reply->str); freeReplyObject(reply); // Get the key we have just stored. reply = redisCommand(c, "GET foo"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("Reply: %s\n", reply->str); freeReplyObject(reply); diff --git a/local_examples/cmds_generic/hiredis/cmds_generic.c b/local_examples/cmds_generic/hiredis/cmds_generic.c index 344b5a736a..5040bfb53a 100644 --- a/local_examples/cmds_generic/hiredis/cmds_generic.c +++ b/local_examples/cmds_generic/hiredis/cmds_generic.c @@ -5,6 +5,19 @@ #include #include #include + +// REMOVE_START +// Fail loudly on a NULL or error reply. hiredis returns an error REPLY (not a +// connection error) for things like a bad command, and the examples would +// otherwise print a wrong value and still exit 0 — a green harness run that +// proves nothing. Kept in a REMOVE block so the published example stays plain. +#define CHECK_REPLY(r) do { \ + if ((r) == NULL || (r)->type == REDIS_REPLY_ERROR) { \ + printf("REDIS ERROR: %s\n", (r) ? (r)->str : "no reply from server"); \ + return 1; \ + } \ +} while (0) +// REMOVE_END // STEP_END int main(int argc, char **argv) { @@ -24,6 +37,9 @@ int main(int argc, char **argv) { // REMOVE_START redisReply *del_reply = redisCommand(c, "DEL firstname lastname age"); + // REMOVE_START + CHECK_REPLY(del_reply); + // REMOVE_END freeReplyObject(del_reply); // REMOVE_END @@ -33,12 +49,18 @@ int main(int argc, char **argv) { // Set up keys reply = redisCommand(c, "MSET %s %s %s %s %s %s", "firstname", "Jack", "lastname", "Stuntman", "age", "35"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("MSET firstname Jack lastname Stuntman age 35: %s\n", reply->str); // >>> OK freeReplyObject(reply); // Keys matching *name* reply = redisCommand(c, "KEYS %s", "*name*"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("KEYS *name*:\n"); for (size_t i = 0; i < reply->elements; i++) { printf(" %s\n", reply->element[i]->str); @@ -55,6 +77,9 @@ int main(int argc, char **argv) { // Keys matching a?? reply = redisCommand(c, "KEYS %s", "a??"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("KEYS a??:\n"); for (size_t i = 0; i < reply->elements; i++) { printf(" %s\n", reply->element[i]->str); @@ -74,6 +99,9 @@ int main(int argc, char **argv) { // All keys reply = redisCommand(c, "KEYS %s", "*"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("KEYS *:\n"); for (size_t i = 0; i < reply->elements; i++) { printf(" %s\n", reply->element[i]->str); @@ -92,17 +120,26 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL firstname lastname age"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // REMOVE_END // STEP_START scan1 reply = redisCommand(c, "SADD myset 1 2 3 foo foobar feelsgood"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 6 freeReplyObject(reply); // SCAN-family replies are a two-element array: the next cursor, then the results. reply = redisCommand(c, "SSCAN myset 0 MATCH f*"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%zu\n", reply->element[1]->elements); // >>> 3 // REMOVE_START @@ -116,10 +153,16 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL myset"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); for (int i = 1; i <= 1000; i++) { reply = redisCommand(c, "SET key:%d %d", i, i); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); } // REMOVE_END @@ -131,6 +174,9 @@ int main(int argc, char **argv) { for (int i = 0; i < 4; i++) { reply = redisCommand(c, "SCAN %s MATCH *11*", cursor); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END snprintf(cursor, sizeof(cursor), "%s", reply->element[0]->str); printf("%zu\n", reply->element[1]->elements); freeReplyObject(reply); @@ -139,6 +185,9 @@ int main(int argc, char **argv) { // A larger COUNT forces more scanning in a single iteration, so the remaining // matches arrive together. This continues from the cursor reached above. reply = redisCommand(c, "SCAN %s MATCH *11* COUNT 1000", cursor); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%zu\n", reply->element[1]->elements); // >>> 18 // REMOVE_START @@ -152,31 +201,49 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "FLUSHDB"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // REMOVE_END // STEP_START scan3 reply = redisCommand(c, "GEOADD geokey 0 0 value"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 1 freeReplyObject(reply); reply = redisCommand(c, "ZADD zkey 1000 value"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 1 freeReplyObject(reply); reply = redisCommand(c, "TYPE geokey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> zset freeReplyObject(reply); reply = redisCommand(c, "TYPE zkey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> zset freeReplyObject(reply); reply = redisCommand(c, "SCAN 0 TYPE zset"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%zu\n", reply->element[1]->elements); // >>> 2 // REMOVE_START @@ -190,17 +257,26 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL geokey zkey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // REMOVE_END // STEP_START scan4 reply = redisCommand(c, "HSET myhash a 1 b 2"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 2 freeReplyObject(reply); // Without NOVALUES the results alternate field, value, field, value. reply = redisCommand(c, "HSCAN myhash 0"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END for (size_t i = 0; i < reply->element[1]->elements; i += 2) { printf("%s=%s\n", reply->element[1]->element[i]->str, reply->element[1]->element[i + 1]->str); @@ -216,6 +292,9 @@ int main(int argc, char **argv) { freeReplyObject(reply); reply = redisCommand(c, "HSCAN myhash 0 NOVALUES"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END for (size_t i = 0; i < reply->element[1]->elements; i++) { printf("%s\n", reply->element[1]->element[i]->str); } @@ -232,21 +311,33 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL myhash"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // REMOVE_END // STEP_START del reply = redisCommand(c, "SET key1 Hello"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "SET key2 World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "DEL key1 key2 key3"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 2 // REMOVE_START @@ -260,26 +351,41 @@ int main(int argc, char **argv) { // STEP_START exists reply = redisCommand(c, "SET key1 Hello"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "EXISTS key1"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 1 freeReplyObject(reply); reply = redisCommand(c, "EXISTS nosuchkey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 0 freeReplyObject(reply); reply = redisCommand(c, "SET key2 World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "EXISTS key1 key2 nosuchkey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 2 // REMOVE_START @@ -293,54 +399,84 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL key1 key2"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // REMOVE_END // STEP_START expire reply = redisCommand(c, "SET mykey Hello"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "EXPIRE mykey 10"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 1 freeReplyObject(reply); reply = redisCommand(c, "TTL mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 10 freeReplyObject(reply); // Overwriting a key with SET clears its expiry. reply = redisCommand(c, "SET mykey %s", "Hello World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "TTL mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> -1 freeReplyObject(reply); // XX only sets the expiry when one already exists, so this is a no-op. reply = redisCommand(c, "EXPIRE mykey 10 XX"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 0 freeReplyObject(reply); reply = redisCommand(c, "TTL mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> -1 freeReplyObject(reply); // NX only sets the expiry when there is none, so this one applies. reply = redisCommand(c, "EXPIRE mykey 10 NX"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 1 freeReplyObject(reply); reply = redisCommand(c, "TTL mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 10 // REMOVE_START @@ -354,21 +490,33 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // REMOVE_END // STEP_START ttl reply = redisCommand(c, "SET mykey Hello"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "EXPIRE mykey 10"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 1 freeReplyObject(reply); reply = redisCommand(c, "TTL mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 10 // REMOVE_START @@ -382,6 +530,9 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // REMOVE_END diff --git a/local_examples/cmds_hash/hiredis/cmds_hash.c b/local_examples/cmds_hash/hiredis/cmds_hash.c index 372534a6da..f384611e62 100644 --- a/local_examples/cmds_hash/hiredis/cmds_hash.c +++ b/local_examples/cmds_hash/hiredis/cmds_hash.c @@ -5,6 +5,19 @@ #include #include #include + +// REMOVE_START +// Fail loudly on a NULL or error reply. hiredis returns an error REPLY (not a +// connection error) for things like a bad command, and the examples would +// otherwise print a wrong value and still exit 0 — a green harness run that +// proves nothing. Kept in a REMOVE block so the published example stays plain. +#define CHECK_REPLY(r) do { \ + if ((r) == NULL || (r)->type == REDIS_REPLY_ERROR) { \ + printf("REDIS ERROR: %s\n", (r) ? (r)->str : "no reply from server"); \ + return 1; \ + } \ +} while (0) +// REMOVE_END // STEP_END int main(int argc, char **argv) { @@ -23,7 +36,9 @@ int main(int argc, char **argv) { // STEP_END // REMOVE_START - redisCommand(c, "DEL myhash"); + redisReply *cleanup1 = redisCommand(c, "DEL myhash"); + CHECK_REPLY(cleanup1); + freeReplyObject(cleanup1); // REMOVE_END // STEP_START hmget @@ -32,11 +47,17 @@ int main(int argc, char **argv) { // Set up hash with fields reply = redisCommand(c, "HSET %s %s %s %s %s", "myhash", "field1", "Hello", "field2", "World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); // Get multiple fields at once reply = redisCommand(c, "HMGET %s %s %s %s", "myhash", "field1", "field2", "nofield"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("HMGET myhash field1 field2 nofield:\n"); for (size_t i = 0; i < reply->elements; i++) { @@ -69,12 +90,17 @@ int main(int argc, char **argv) { freeReplyObject(reply); // REMOVE_START - redisCommand(c, "DEL myhash"); + redisReply *cleanup2 = redisCommand(c, "DEL myhash"); + CHECK_REPLY(cleanup2); + freeReplyObject(cleanup2); // REMOVE_END // STEP_START hlen // Add two new fields to the hash reply = redisCommand(c, "HSET %s %s %s", "myhash", "field1", "Hello"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("HSET myhash field1 Hello: %lld\n", reply->integer); // >>> 1 // REMOVE_START if (reply->integer != 1) { @@ -84,6 +110,9 @@ int main(int argc, char **argv) { freeReplyObject(reply); reply = redisCommand(c, "HSET %s %s %s", "myhash", "field2", "World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("HSET myhash field2 World: %lld\n", reply->integer); // >>> 1 // REMOVE_START if (reply->integer != 1) { @@ -94,6 +123,9 @@ int main(int argc, char **argv) { // Count the fields in the hash reply = redisCommand(c, "HLEN %s", "myhash"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("HLEN myhash: %lld\n", reply->integer); // >>> 2 // REMOVE_START if (reply->type != REDIS_REPLY_INTEGER) { @@ -108,6 +140,9 @@ int main(int argc, char **argv) { // REMOVE_START redisReply *del_reply = redisCommand(c, "DEL myhash"); + // REMOVE_START + CHECK_REPLY(del_reply); + // REMOVE_END freeReplyObject(del_reply); // REMOVE_END diff --git a/local_examples/cmds_string/hiredis/cmds_string.c b/local_examples/cmds_string/hiredis/cmds_string.c index 0e6d065c35..27857acfef 100644 --- a/local_examples/cmds_string/hiredis/cmds_string.c +++ b/local_examples/cmds_string/hiredis/cmds_string.c @@ -5,6 +5,19 @@ #include #include #include + +// REMOVE_START +// Fail loudly on a NULL or error reply. hiredis returns an error REPLY (not a +// connection error) for things like a bad command, and the examples would +// otherwise print a wrong value and still exit 0 — a green harness run that +// proves nothing. Kept in a REMOVE block so the published example stays plain. +#define CHECK_REPLY(r) do { \ + if ((r) == NULL || (r)->type == REDIS_REPLY_ERROR) { \ + printf("REDIS ERROR: %s\n", (r) ? (r)->str : "no reply from server"); \ + return 1; \ + } \ +} while (0) +// REMOVE_END // STEP_END int main(int argc, char **argv) { @@ -24,17 +37,29 @@ int main(int argc, char **argv) { // REMOVE_START redisReply *cleanup = redisCommand(c, "DEL key1 key2 mykey nonexisting"); + // REMOVE_START + CHECK_REPLY(cleanup); + // REMOVE_END freeReplyObject(cleanup); // REMOVE_END // STEP_START mget redisReply *reply = redisCommand(c, "SET key1 Hello"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); reply = redisCommand(c, "SET key2 World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END freeReplyObject(reply); reply = redisCommand(c, "MGET key1 key2 nonexisting"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END for (size_t i = 0; i < reply->elements; i++) { if (i > 0) { @@ -70,11 +95,17 @@ int main(int argc, char **argv) { // STEP_START incr reply = redisCommand(c, "SET mykey 10"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> OK freeReplyObject(reply); reply = redisCommand(c, "INCR mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%lld\n", reply->integer); // >>> 11 // REMOVE_START @@ -85,6 +116,9 @@ int main(int argc, char **argv) { freeReplyObject(reply); reply = redisCommand(c, "GET mykey"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END printf("%s\n", reply->str); // >>> 11 // REMOVE_START @@ -96,6 +130,9 @@ int main(int argc, char **argv) { // STEP_END redisReply *cleanup2 = redisCommand(c, "DEL key1 key2 mykey nonexisting"); + // REMOVE_START + CHECK_REPLY(cleanup2); + // REMOVE_END freeReplyObject(cleanup2); // STEP_START disconnect From 589cc5396733625c18b71d3b43ced09c5dcffe38 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Fri, 14 Aug 2026 10:52:21 +0100 Subject: [PATCH 4/5] DOC-6968 Complete cmds_hash, and fix nested REMOVE anchors from the guard commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the six missing steps (hset, hget, hgetall, hdel, hvals, hexpire) to the C and ioredis cmds_hash examples, and repairs a real bug the previous commit on this branch introduced. Omission warnings are now 7, down from 63 when the shortcode fix first surfaced them. All six cmds_hash command pages carry a full 15 panes. The remaining 7 are not work items: PHP cannot express SCAN TYPE with predis, Ruby and ioredis lack XCFGSET for xadd2, and the four query_vector panes are out of scope by decision. That is the floor. The bug. My CHECK_REPLY transformer wrapped every guard in its own REMOVE block, including at call sites that were ALREADY inside one — the cleanup DELs. build/components/example.py treats a nested remove anchor as fatal and `return`s, abandoning the rest of the file, so every C example lost the steps declared after its first cleanup command: cmds_generic C fell from 12 named steps to 2, cmds_string to 2, cmds_hash to 4. What makes that worse than a broken build is what it would have done if merged. Those steps still exist in the source files, so the shortcode change earlier in this ticket would have read C's named_steps as non-empty but missing the step, and quietly omitted the C tab from roughly twenty command pages. Silent coverage loss, which is the exact failure the guard was written to prevent, one layer up. Twelve guards are now emitted bare, without their own markers, where the call already sits inside a REMOVE block. C is back to 12, 11 and 5 named steps respectively, and 19 C panes render site-wide. Two reasons I did not catch it immediately, both worth avoiding next time. The harness stayed green throughout, because nested markers are a docs-parser concern and the compiled binary neither knows nor cares. And I ran make.py with its output piped through `grep -iE "error" | head -8`, where eight GitHub rate-limit warnings filled the window before "ERROR:root:Nested remove anchor" could appear. The regression showed up only as an unexplained warning count of 24 where 7 was expected — I nearly rationalised it before measuring. Verified after the fix: zero nested-anchor errors from make.py, all four C files compile clean under -Wall, hiredis passes cmds_generic, cmds_hash and cmds_string, the full cmds_hash sweep is green for all 13 testable clients, and deliberately corrupting a command still fails the sweep so the guard itself still works. Learned: build/components/example.py ABORTS a whole example file on a nested REMOVE anchor, silently dropping every step after it — a mechanical marker edit can therefore delete a client's coverage without failing the harness, which never parses markers Constraint: a CHECK_REPLY guard at a call site already inside a REMOVE block must be emitted bare; wrapping it in its own REMOVE_START/REMOVE_END nests the anchors and kills the file Learned: never read make.py's diagnostics through `head` — GitHub rate-limit warnings crowd out real ERROR lines, and this one was invisible for exactly that reason Ticket: DOC-6968 Co-Authored-By: Claude Opus 5 (1M context) --- .../cmds_generic/hiredis/cmds_generic.c | 20 -- local_examples/cmds_hash/hiredis/cmds_hash.c | 270 +++++++++++++++++- local_examples/cmds_hash/ioredis/cmds-hash.js | 107 +++++++ .../cmds_string/hiredis/cmds_string.c | 2 - 4 files changed, 375 insertions(+), 24 deletions(-) diff --git a/local_examples/cmds_generic/hiredis/cmds_generic.c b/local_examples/cmds_generic/hiredis/cmds_generic.c index 5040bfb53a..2674bcfa72 100644 --- a/local_examples/cmds_generic/hiredis/cmds_generic.c +++ b/local_examples/cmds_generic/hiredis/cmds_generic.c @@ -37,9 +37,7 @@ int main(int argc, char **argv) { // REMOVE_START redisReply *del_reply = redisCommand(c, "DEL firstname lastname age"); - // REMOVE_START CHECK_REPLY(del_reply); - // REMOVE_END freeReplyObject(del_reply); // REMOVE_END @@ -120,9 +118,7 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL firstname lastname age"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); // REMOVE_END @@ -153,16 +149,12 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL myset"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); for (int i = 1; i <= 1000; i++) { reply = redisCommand(c, "SET key:%d %d", i, i); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); } // REMOVE_END @@ -201,9 +193,7 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "FLUSHDB"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); // REMOVE_END @@ -257,9 +247,7 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL geokey zkey"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); // REMOVE_END @@ -311,9 +299,7 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL myhash"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); // REMOVE_END @@ -399,9 +385,7 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL key1 key2"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); // REMOVE_END @@ -490,9 +474,7 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL mykey"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); // REMOVE_END @@ -530,9 +512,7 @@ int main(int argc, char **argv) { // REMOVE_START reply = redisCommand(c, "DEL mykey"); - // REMOVE_START CHECK_REPLY(reply); - // REMOVE_END freeReplyObject(reply); // REMOVE_END diff --git a/local_examples/cmds_hash/hiredis/cmds_hash.c b/local_examples/cmds_hash/hiredis/cmds_hash.c index f384611e62..27961612be 100644 --- a/local_examples/cmds_hash/hiredis/cmds_hash.c +++ b/local_examples/cmds_hash/hiredis/cmds_hash.c @@ -140,12 +140,278 @@ int main(int argc, char **argv) { // REMOVE_START redisReply *del_reply = redisCommand(c, "DEL myhash"); - // REMOVE_START CHECK_REPLY(del_reply); - // REMOVE_END freeReplyObject(del_reply); // REMOVE_END + + // STEP_START hset + reply = redisCommand(c, "HSET myhash field1 Hello"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "HGET myhash field1"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%s\n", reply->str); + // >>> Hello + freeReplyObject(reply); + + reply = redisCommand(c, "HSET myhash field2 Hi field3 World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%lld\n", reply->integer); + // >>> 2 + freeReplyObject(reply); + + reply = redisCommand(c, "HGET myhash field2"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%s\n", reply->str); + // >>> Hi + freeReplyObject(reply); + + reply = redisCommand(c, "HGET myhash field3"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%s\n", reply->str); + // >>> World + freeReplyObject(reply); + + // HGETALL returns field and value alternating in a flat array. + reply = redisCommand(c, "HGETALL myhash"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + for (size_t i = 0; i < reply->elements; i += 2) { + printf("%s=%s\n", reply->element[i]->str, reply->element[i + 1]->str); + } + // >>> field1=Hello + // >>> field2=Hi + // >>> field3=World + // REMOVE_START + if (reply->elements != 6) { + printf("ASSERTION FAILED: Expected 6 entries, got %zu\n", reply->elements); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myhash"); + CHECK_REPLY(reply); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START hget + reply = redisCommand(c, "HSET myhash field1 foo"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "HGET myhash field1"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%s\n", reply->str); + // >>> foo + freeReplyObject(reply); + + // A field that does not exist gives a nil reply, not an empty string. + reply = redisCommand(c, "HGET myhash field2"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%s\n", reply->type == REDIS_REPLY_NIL ? "(nil)" : reply->str); + // >>> (nil) + // REMOVE_START + if (reply->type != REDIS_REPLY_NIL) { + printf("ASSERTION FAILED: Expected nil for a missing field\n"); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myhash"); + CHECK_REPLY(reply); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START hgetall + reply = redisCommand(c, "HSET myhash field1 Hello field2 World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + freeReplyObject(reply); + + reply = redisCommand(c, "HGETALL myhash"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + for (size_t i = 0; i < reply->elements; i += 2) { + printf("%s=%s\n", reply->element[i]->str, reply->element[i + 1]->str); + } + // >>> field1=Hello + // >>> field2=World + // REMOVE_START + if (reply->elements != 4) { + printf("ASSERTION FAILED: Expected 4 entries, got %zu\n", reply->elements); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myhash"); + CHECK_REPLY(reply); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START hdel + reply = redisCommand(c, "HSET myhash field1 foo"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "HDEL myhash field1"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%lld\n", reply->integer); + // >>> 1 + freeReplyObject(reply); + + // Deleting a field that is not there removes nothing. + reply = redisCommand(c, "HDEL myhash field2"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%lld\n", reply->integer); + // >>> 0 + // REMOVE_START + if (reply->integer != 0) { + printf("ASSERTION FAILED: Expected 0, got %lld\n", reply->integer); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myhash"); + CHECK_REPLY(reply); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START hvals + reply = redisCommand(c, "HSET myhash field1 Hello field2 World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + freeReplyObject(reply); + + reply = redisCommand(c, "HVALS myhash"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + for (size_t i = 0; i < reply->elements; i++) { + printf("%s\n", reply->element[i]->str); + } + // >>> Hello + // >>> World + // REMOVE_START + if (reply->elements != 2) { + printf("ASSERTION FAILED: Expected 2 values, got %zu\n", reply->elements); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myhash"); + CHECK_REPLY(reply); + freeReplyObject(reply); + // REMOVE_END + + // STEP_START hexpire + reply = redisCommand(c, "HSET myhash field1 Hello field2 World"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + freeReplyObject(reply); + + // HEXPIRE needs the field count before the field names. + reply = redisCommand(c, "HEXPIRE myhash 10 FIELDS 2 field1 field2"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + for (size_t i = 0; i < reply->elements; i++) { + printf("%lld\n", reply->element[i]->integer); + } + // >>> 1 + // >>> 1 + freeReplyObject(reply); + + reply = redisCommand(c, "HTTL myhash FIELDS 2 field1 field2"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + for (size_t i = 0; i < reply->elements; i++) { + printf("%lld\n", reply->element[i]->integer); + } + // >>> 10 + // >>> 10 + // REMOVE_START + for (size_t i = 0; i < reply->elements; i++) { + if (reply->element[i]->integer <= 0 || reply->element[i]->integer > 10) { + printf("ASSERTION FAILED: Unexpected TTL %lld\n", reply->element[i]->integer); + return 1; + } + } + // REMOVE_END + freeReplyObject(reply); + + // -2 means the field does not exist. + reply = redisCommand(c, "HEXPIRE myhash 10 FIELDS 1 nonexistent"); + // REMOVE_START + CHECK_REPLY(reply); + // REMOVE_END + printf("%lld\n", reply->element[0]->integer); + // >>> -2 + // REMOVE_START + if (reply->element[0]->integer != -2) { + printf("ASSERTION FAILED: Expected -2, got %lld\n", reply->element[0]->integer); + return 1; + } + // REMOVE_END + freeReplyObject(reply); + // STEP_END + + // REMOVE_START + reply = redisCommand(c, "DEL myhash"); + CHECK_REPLY(reply); + freeReplyObject(reply); + // REMOVE_END + // STEP_START disconnect redisFree(c); // STEP_END diff --git a/local_examples/cmds_hash/ioredis/cmds-hash.js b/local_examples/cmds_hash/ioredis/cmds-hash.js index a220afebff..9c0659c0ea 100644 --- a/local_examples/cmds_hash/ioredis/cmds-hash.js +++ b/local_examples/cmds_hash/ioredis/cmds-hash.js @@ -41,6 +41,113 @@ assert.equal(hlenResult, 2); await redis.del('myhash'); // REMOVE_END +// STEP_START hset +const hsetRes1 = await redis.hset('myhash', 'field1', 'Hello'); +console.log(hsetRes1); // >>> 1 + +console.log(await redis.hget('myhash', 'field1')); // >>> Hello + +const hsetRes2 = await redis.hset('myhash', { field2: 'Hi', field3: 'World' }); +console.log(hsetRes2); // >>> 2 + +console.log(await redis.hget('myhash', 'field2')); // >>> Hi +console.log(await redis.hget('myhash', 'field3')); // >>> World + +const hsetRes3 = await redis.hgetall('myhash'); +console.log(hsetRes3); +// >>> { field1: 'Hello', field2: 'Hi', field3: 'World' } +// STEP_END + +// REMOVE_START +assert.equal(hsetRes1, 1); +assert.equal(hsetRes2, 2); +assert.deepEqual(hsetRes3, { field1: 'Hello', field2: 'Hi', field3: 'World' }); +await redis.del('myhash'); +// REMOVE_END + +// STEP_START hget +const hgetRes1 = await redis.hset('myhash', 'field1', 'foo'); +console.log(hgetRes1); // >>> 1 + +const hgetRes2 = await redis.hget('myhash', 'field1'); +console.log(hgetRes2); // >>> foo + +// A field that does not exist reads back as null. +const hgetRes3 = await redis.hget('myhash', 'field2'); +console.log(hgetRes3); // >>> null +// STEP_END + +// REMOVE_START +assert.equal(hgetRes1, 1); +assert.equal(hgetRes2, 'foo'); +assert.equal(hgetRes3, null); +await redis.del('myhash'); +// REMOVE_END + +// STEP_START hgetall +await redis.hset('myhash', { field1: 'Hello', field2: 'World' }); + +const hgetallRes = await redis.hgetall('myhash'); +console.log(hgetallRes); // >>> { field1: 'Hello', field2: 'World' } +// STEP_END + +// REMOVE_START +assert.deepEqual(hgetallRes, { field1: 'Hello', field2: 'World' }); +await redis.del('myhash'); +// REMOVE_END + +// STEP_START hdel +const hdelRes1 = await redis.hset('myhash', 'field1', 'foo'); +console.log(hdelRes1); // >>> 1 + +const hdelRes2 = await redis.hdel('myhash', 'field1'); +console.log(hdelRes2); // >>> 1 + +// Deleting a field that is not there removes nothing. +const hdelRes3 = await redis.hdel('myhash', 'field2'); +console.log(hdelRes3); // >>> 0 +// STEP_END + +// REMOVE_START +assert.equal(hdelRes1, 1); +assert.equal(hdelRes2, 1); +assert.equal(hdelRes3, 0); +await redis.del('myhash'); +// REMOVE_END + +// STEP_START hvals +await redis.hset('myhash', { field1: 'Hello', field2: 'World' }); + +const hvalsRes = await redis.hvals('myhash'); +console.log(hvalsRes); // >>> [ 'Hello', 'World' ] +// STEP_END + +// REMOVE_START +assert.deepEqual(hvalsRes, ['Hello', 'World']); +await redis.del('myhash'); +// REMOVE_END + +// STEP_START hexpire +await redis.hset('myhash', { field1: 'Hello', field2: 'World' }); + +const hexpireRes1 = await redis.hexpire('myhash', 10, 'FIELDS', 2, 'field1', 'field2'); +console.log(hexpireRes1); // >>> [ 1, 1 ] + +const hexpireRes2 = await redis.httl('myhash', 'FIELDS', 2, 'field1', 'field2'); +console.log(hexpireRes2); // >>> [ 10, 10 ] + +// -2 means the field does not exist. +const hexpireRes3 = await redis.hexpire('myhash', 10, 'FIELDS', 1, 'nonexistent'); +console.log(hexpireRes3); // >>> [ -2 ] +// STEP_END + +// REMOVE_START +assert.deepEqual(hexpireRes1, [1, 1]); +assert.deepEqual(hexpireRes3, [-2]); +assert.ok(hexpireRes2.every((ttl) => ttl > 0 && ttl <= 10)); +await redis.del('myhash'); +// REMOVE_END + // HIDE_START redis.disconnect(); // HIDE_END diff --git a/local_examples/cmds_string/hiredis/cmds_string.c b/local_examples/cmds_string/hiredis/cmds_string.c index 27857acfef..29cb73d361 100644 --- a/local_examples/cmds_string/hiredis/cmds_string.c +++ b/local_examples/cmds_string/hiredis/cmds_string.c @@ -37,9 +37,7 @@ int main(int argc, char **argv) { // REMOVE_START redisReply *cleanup = redisCommand(c, "DEL key1 key2 mykey nonexisting"); - // REMOVE_START CHECK_REPLY(cleanup); - // REMOVE_END freeReplyObject(cleanup); // REMOVE_END From 833febc8b71bd28c69dc576064d3810e04789a0f Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Fri, 14 Aug 2026 11:07:33 +0100 Subject: [PATCH 5/5] DOC-6968 Stop the new hash examples depending on field order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redis does not promise a field order for HSCAN, HSCAN NOVALUES or HVALS, and four of the examples added on this branch depended on it. Found by Cursor Bugbot, which also spotted that Jedis and Lettuce already sort for the same step, so the unsorted tabs could flake while their siblings stayed green. Fixed by removing the dependency rather than by sorting a flat pair list, which would interleave fields with the wrong values: - go-redis scan4 collects the interleaved HSCAN reply into a map and prints that, because fmt prints map keys in sorted order regardless of arrival order. Its NOVALUES list is sorted. This one mattered most: a Go Example function compares stdout against its `// Output:` block exactly, so a shifted order is a hard failure. - ioredis scan4 pairs the reply into an object instead of asserting on element positions; its NOVALUES list is sorted. - predis scan4 sorts the NOVALUES list. Its associative assert was already order-insensitive, because PHP compares assoc arrays by key, but the NOVALUES one is a list and so was not — a distinction I got wrong at first. - ioredis cmds_hash hvals is sorted, since HVALS follows field order. Proven, not argued. Setting hash-max-listpack-entries to 0 forces small hashes onto a hashtable, where HSCAN returns fields in a genuinely different order (observed: d 4 c 3 b 2 a 1 for a hash written a, b, c, d). All four fixed clients pass under that config. That experiment also turned up pre-existing fragility of exactly the same kind, which this commit deliberately does NOT touch: redis-py, node-redis and lettuce-reactive fail cmds_generic and cmds_hash under hashtable encoding, and predis and ruby fail cmds_hash. Confirmed as order failures, not something else — node-redis received [{field: b}, {field: a}] where it expected the reverse, and ruby got ["World", "Hello"]. None of it flakes under the default hash-max-listpack-entries of 512, because these examples use two or three fields and so stay listpack-encoded; the realistic trigger is a future example with a large hash, or a server with a tuned threshold. Fixing it means editing the reference implementation every other tab is copied from, which deserves its own review rather than riding along here. Learned: forcing hash-max-listpack-entries to 0 is a cheap way to test whether a hash example depends on field order — it flips small hashes to hashtable encoding, where HSCAN order genuinely differs Constraint: never sort the flat HSCAN reply to make it deterministic — fields and values are interleaved, so sorting pairs the wrong values together; collect into a map or object instead Learned: PHP's == is order-insensitive for associative arrays but not for lists, so an assoc assert can be safe while the NOVALUES list assert beside it is not Gaps: redis-py, node-redis, lettuce-reactive, predis and ruby hash examples still assume field order and fail under hashtable encoding; harmless at the default threshold, worth its own ticket Ticket: DOC-6968 Co-Authored-By: Claude Opus 5 (1M context) --- .../cmds_generic/go-redis/cmds_generic_test.go | 13 +++++++++++-- .../cmds_generic/ioredis/cmds-generic.js | 15 ++++++++++----- .../cmds_generic/predis/CmdsGenericTest.php | 2 ++ local_examples/cmds_hash/ioredis/cmds-hash.js | 5 +++-- 4 files changed, 26 insertions(+), 9 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 66f07bb6a6..7e3c0ab6cb 100644 --- a/local_examples/cmds_generic/go-redis/cmds_generic_test.go +++ b/local_examples/cmds_generic/go-redis/cmds_generic_test.go @@ -503,7 +503,15 @@ func ExampleClient_scan4_cmd() { panic(err) } - fmt.Println(scan4Result2) // >>> [a 1 b 2] + // HSCAN returns field and value interleaved. Redis does not promise an order, so + // collect the pairs into a map: fmt prints map keys sorted, whatever order they arrived in. + scan4Fields := map[string]string{} + + for i := 0; i < len(scan4Result2); i += 2 { + scan4Fields[scan4Result2[i]] = scan4Result2[i+1] + } + + fmt.Println(scan4Fields) // >>> map[a:1 b:2] scan4Result3, _, err := rdb.HScanNoValues(ctx, "myhash", 0, "", 0).Result() @@ -511,11 +519,12 @@ func ExampleClient_scan4_cmd() { panic(err) } + sort.Strings(scan4Result3) fmt.Println(scan4Result3) // >>> [a b] // STEP_END // Output: // 2 - // [a 1 b 2] + // map[a:1 b:2] // [a b] } diff --git a/local_examples/cmds_generic/ioredis/cmds-generic.js b/local_examples/cmds_generic/ioredis/cmds-generic.js index 673638d141..ca4325fa83 100644 --- a/local_examples/cmds_generic/ioredis/cmds-generic.js +++ b/local_examples/cmds_generic/ioredis/cmds-generic.js @@ -99,17 +99,22 @@ await redis.del('geokey', 'zkey'); const scan4Res1 = await redis.hset('myhash', { a: 1, b: 2 }); console.log(scan4Res1); // >>> 2 -const [, scan4Pairs] = await redis.hscan('myhash', 0); -console.log(scan4Pairs); // >>> ['a', '1', 'b', '2'] +// HSCAN returns field and value interleaved. Redis does not promise an order, so pair +// them up into an object rather than relying on the position of each element. +const [, scan4Flat] = await redis.hscan('myhash', 0); +const scan4Pairs = Object.fromEntries( + scan4Flat.reduce((acc, v, i) => (i % 2 ? acc : [...acc, [v, scan4Flat[i + 1]]]), []) +); +console.log(scan4Pairs); // >>> { a: '1', b: '2' } const [, scan4Fields] = await redis.hscan('myhash', 0, 'NOVALUES'); -console.log(scan4Fields); // >>> ['a', 'b'] +console.log(scan4Fields.sort()); // >>> [ 'a', 'b' ] // STEP_END // REMOVE_START assert.equal(scan4Res1, 2); -assert.deepEqual(scan4Pairs, ['a', '1', 'b', '2']); -assert.deepEqual(scan4Fields, ['a', 'b']); +assert.deepEqual(scan4Pairs, { a: '1', b: '2' }); +assert.deepEqual(scan4Fields.sort(), ['a', 'b']); await redis.del('myhash'); // REMOVE_END diff --git a/local_examples/cmds_generic/predis/CmdsGenericTest.php b/local_examples/cmds_generic/predis/CmdsGenericTest.php index 3796d4cde8..2bb4f65459 100644 --- a/local_examples/cmds_generic/predis/CmdsGenericTest.php +++ b/local_examples/cmds_generic/predis/CmdsGenericTest.php @@ -117,7 +117,9 @@ public function testCmdsGeneric() { [$scan4Cursor, $scan4Pairs] = $r->hscan('myhash', 0); echo json_encode($scan4Pairs) . PHP_EOL; // >>> {"a":"1","b":"2"} + // Redis does not promise a field order, so sort before comparing. [$scan4Cursor, $scan4Fields] = $r->hscan('myhash', 0, ['NOVALUES' => true]); + sort($scan4Fields); echo implode(', ', $scan4Fields) . PHP_EOL; // >>> a, b // STEP_END diff --git a/local_examples/cmds_hash/ioredis/cmds-hash.js b/local_examples/cmds_hash/ioredis/cmds-hash.js index 9c0659c0ea..4b1efb69aa 100644 --- a/local_examples/cmds_hash/ioredis/cmds-hash.js +++ b/local_examples/cmds_hash/ioredis/cmds-hash.js @@ -118,12 +118,13 @@ await redis.del('myhash'); // STEP_START hvals await redis.hset('myhash', { field1: 'Hello', field2: 'World' }); +// HVALS follows the hash's field order, which Redis does not promise, so sort. const hvalsRes = await redis.hvals('myhash'); -console.log(hvalsRes); // >>> [ 'Hello', 'World' ] +console.log(hvalsRes.sort()); // >>> [ 'Hello', 'World' ] // STEP_END // REMOVE_START -assert.deepEqual(hvalsRes, ['Hello', 'World']); +assert.deepEqual(hvalsRes.sort(), ['Hello', 'World']); await redis.del('myhash'); // REMOVE_END