Repository: redis/rueidis

Issue:
Add new `XDELEX` commands to `commands.json`
We need to update `commands.json` and run [go generate](https://github.com/redis/rueidis?tab=readme-ov-file#generate-command-builders) to update the command builder for the new XDELEX command.

```
XDELEX key [KEEPREF | DELREF | ACKED] IDS numids id [id ...]
```

See https://github.com/redis/redis/pull/14130.

Hints from issue comments:
@mingdaoy: I'd like to work on this. Thanks

Solution patch:
diff --git a/client.go b/client.go
index bce8f73..ed907e8 100644
--- a/client.go
+++ b/client.go
@@ -30,10 +30,14 @@ func newSingleClient(opt *ClientOption, prev conn, connFn connFn, retryer retryH
 
 	conn := connFn(opt.InitAddress[0], opt)
 	conn.Override(prev)
-	if err := conn.Dial(); err != nil {
-		return nil, err
+	err := conn.Dial()
+	if err != nil {
+		if !opt.ForceSingleClient {
+			return nil, err
+		}
 	}
-	return newSingleClientWithConn(conn, cmds.NewBuilder(cmds.NoSlot), !opt.DisableRetry, opt.DisableCache, retryer, opt.ConnLifetime > 0), nil
+	// return a singleClient instance even when Dial fails if ForceSingleClient is true.
+	return newSingleClientWithConn(conn, cmds.NewBuilder(cmds.NoSlot), !opt.DisableRetry, opt.DisableCache, retryer, opt.ConnLifetime > 0), err
 }
 
 func newSingleClientWithConn(conn conn, builder Builder, retry, disableCache bool, retryer retryHandler, hasLftm bool) *singleClient {
diff --git a/hack/cmds/commands.json b/hack/cmds/commands.json
index 97541d0..8ed4306 100644
--- a/hack/cmds/commands.json
+++ b/hack/cmds/commands.json
@@ -6027,6 +6027,48 @@
     "since": "5.0.0",
     "group": "stream"
   },
+  "XACKDEL": {
+    "summary": "Acknowledges specified message IDs in a consumer group and attempts to delete corresponding stream entries.",
+    "complexity": "O(1) for each message ID processed.",
+    "arguments": [
+      {
+        "name": "key",
+        "type": "key"
+      },
+      {
+        "name": "group",
+        "type": "string"
+      },
+      {
+        "name": "action",
+        "type": "enum",
+        "enum": ["KEEPREF", "DELREF", "ACKED"],
+        "optional": true
+      },
+      {
+        "name": "ids-block",
+        "type": "block",
+        "arguments": [
+          {
+            "name": "ids",
+            "type": "pure-token",
+            "token": "IDS"
+          },
+          {
+            "name": "numids",
+            "type": "integer"
+          },
+          {
+            "name": "id",
+            "type": "string",
+            "multiple": true
+          }
+        ]
+      }
+    ],
+    "since": "8.2.0",
+    "group": "stream"
+  },
   "XADD": {
     "summary": "Appends a new entry to a stream",
     "complexity": "O(1) when adding a new entry, O(N) when trimming where N being the number of entries evicted.",
@@ -6236,6 +6278,54 @@
     "since": "5.0.0",
     "group": "stream"
   },
+  "XDELEX": {
+    "summary": "Deletes one or multiple entries from the stream.",
+    "complexity": "O(1) for each single item to delete in the stream, regardless of the stream size",
+    "arguments": [
+      {
+        "name": "key",
+        "type": "key"
+      },
+      {
+        "name": "condition",
+        "type": "oneof",
+        "optional": true,
+        "arguments": [
+          {
+            "name": "keepref",
+            "type": "pure-token",
+            "token": "KEEPREF"
+          },
+          {
+            "name": "delref",
+            "type": "pure-token",
+            "token": "DELREF"
+          },
+          {
+            "name": "acked",
+            "type": "pure-token",
+            "token": "ACKED"
+          }
+        ]
+      },
+      {
+        "name": "ids",
+        "type": "pure-token",
+        "token": "IDS"
+      },
+      {
+        "name": "numids",
+        "type": "integer"
+      },
+      {
+        "name": "id",
+        "type": "string",
+        "multiple": true
+      }
+    ],
+    "since": "8.2.0",
+    "group": "stream"
+  },
   "XGROUP CREATE": {
     "summary": "Create a consumer group.",
     "complexity": "O(1)",
diff --git a/internal/cmds/gen_stream.go b/internal/cmds/gen_stream.go
index a2e828b..94de218 100644
--- a/internal/cmds/gen_stream.go
+++ b/internal/cmds/gen_stream.go
@@ -48,6 +48,100 @@ func (c XackKey) Group(group string) XackGroup {
 	return (XackGroup)(c)
 }
 
+type Xackdel Incomplete
+
+func (b Builder) Xackdel() (c Xackdel) {
+	c = Xackdel{cs: get(), ks: b.ks}
+	c.cs.s = append(c.cs.s, "XACKDEL")
+	return c
+}
+
+func (c Xackdel) Key(key string) XackdelKey {
+	if c.ks&NoSlot == NoSlot {
+		c.ks = NoSlot | slot(key)
+	} else {
+		c.ks = check(c.ks, slot(key))
+	}
+	c.cs.s = append(c.cs.s, key)
+	return (XackdelKey)(c)
+}
+
+type XackdelActionAcked Incomplete
+
+func (c XackdelActionAcked) Ids() XackdelIdsBlockIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XackdelIdsBlockIds)(c)
+}
+
+type XackdelActionDelref Incomplete
+
+func (c XackdelActionDelref) Ids() XackdelIdsBlockIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XackdelIdsBlockIds)(c)
+}
+
+type XackdelActionKeepref Incomplete
+
+func (c XackdelActionKeepref) Ids() XackdelIdsBlockIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XackdelIdsBlockIds)(c)
+}
+
+type XackdelGroup Incomplete
+
+func (c XackdelGroup) Keepref() XackdelActionKeepref {
+	c.cs.s = append(c.cs.s, "KEEPREF")
+	return (XackdelActionKeepref)(c)
+}
+
+func (c XackdelGroup) Delref() XackdelActionDelref {
+	c.cs.s = append(c.cs.s, "DELREF")
+	return (XackdelActionDelref)(c)
+}
+
+func (c XackdelGroup) Acked() XackdelActionAcked {
+	c.cs.s = append(c.cs.s, "ACKED")
+	return (XackdelActionAcked)(c)
+}
+
+func (c XackdelGroup) Ids() XackdelIdsBlockIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XackdelIdsBlockIds)(c)
+}
+
+type XackdelIdsBlockId Incomplete
+
+func (c XackdelIdsBlockId) Id(id ...string) XackdelIdsBlockId {
+	c.cs.s = append(c.cs.s, id...)
+	return c
+}
+
+func (c XackdelIdsBlockId) Build() Completed {
+	c.cs.Build()
+	return Completed{cs: c.cs, cf: uint16(c.cf), ks: c.ks}
+}
+
+type XackdelIdsBlockIds Incomplete
+
+func (c XackdelIdsBlockIds) Numids(numids int64) XackdelIdsBlockNumids {
+	c.cs.s = append(c.cs.s, strconv.FormatInt(numids, 10))
+	return (XackdelIdsBlockNumids)(c)
+}
+
+type XackdelIdsBlockNumids Incomplete
+
+func (c XackdelIdsBlockNumids) Id(id ...string) XackdelIdsBlockId {
+	c.cs.s = append(c.cs.s, id...)
+	return (XackdelIdsBlockId)(c)
+}
+
+type XackdelKey Incomplete
+
+func (c XackdelKey) Group(group string) XackdelGroup {
+	c.cs.s = append(c.cs.s, group)
+	return (XackdelGroup)(c)
+}
+
 type Xadd Incomplete
 
 func (b Builder) Xadd() (c Xadd) {
@@ -565,6 +659,93 @@ func (c XdelKey) Id(id ...string) XdelId {
 	return (XdelId)(c)
 }
 
+type Xdelex Incomplete
+
+func (b Builder) Xdelex() (c Xdelex) {
+	c = Xdelex{cs: get(), ks: b.ks}
+	c.cs.s = append(c.cs.s, "XDELEX")
+	return c
+}
+
+func (c Xdelex) Key(key string) XdelexKey {
+	if c.ks&NoSlot == NoSlot {
+		c.ks = NoSlot | slot(key)
+	} else {
+		c.ks = check(c.ks, slot(key))
+	}
+	c.cs.s = append(c.cs.s, key)
+	return (XdelexKey)(c)
+}
+
+type XdelexConditionAcked Incomplete
+
+func (c XdelexConditionAcked) Ids() XdelexIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XdelexIds)(c)
+}
+
+type XdelexConditionDelref Incomplete
+
+func (c XdelexConditionDelref) Ids() XdelexIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XdelexIds)(c)
+}
+
+type XdelexConditionKeepref Incomplete
+
+func (c XdelexConditionKeepref) Ids() XdelexIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XdelexIds)(c)
+}
+
+type XdelexId Incomplete
+
+func (c XdelexId) Id(id ...string) XdelexId {
+	c.cs.s = append(c.cs.s, id...)
+	return c
+}
+
+func (c XdelexId) Build() Completed {
+	c.cs.Build()
+	return Completed{cs: c.cs, cf: uint16(c.cf), ks: c.ks}
+}
+
+type XdelexIds Incomplete
+
+func (c XdelexIds) Numids(numids int64) XdelexNumids {
+	c.cs.s = append(c.cs.s, strconv.FormatInt(numids, 10))
+	return (XdelexNumids)(c)
+}
+
+type XdelexKey Incomplete
+
+func (c XdelexKey) Keepref() XdelexConditionKeepref {
+	c.cs.s = append(c.cs.s, "KEEPREF")
+	return (XdelexConditionKeepref)(c)
+}
+
+func (c XdelexKey) Delref() XdelexConditionDelref {
+	c.cs.s = append(c.cs.s, "DELREF")
+	return (XdelexConditionDelref)(c)
+}
+
+func (c XdelexKey) Acked() XdelexConditionAcked {
+	c.cs.s = append(c.cs.s, "ACKED")
+	return (XdelexConditionAcked)(c)
+}
+
+func (c XdelexKey) Ids() XdelexIds {
+	c.cs.s = append(c.cs.s, "IDS")
+	return (XdelexIds)(c)
+}
+
+type XdelexNumids Incomplete
+
+func (c XdelexNumids) Id(id ...string) XdelexId {
+	c.cs.s = append(c.cs.s, id...)
+	return (XdelexId)(c)
+}
+
 type XgroupCreate Incomplete
 
 func (b Builder) XgroupCreate() (c XgroupCreate) {
diff --git a/rueidiscompat/adapter.go b/rueidiscompat/adapter.go
index b44a153..80e776d 100644
--- a/rueidiscompat/adapter.go
+++ b/rueidiscompat/adapter.go
@@ -444,7 +444,7 @@ type SearchCmdable interface {
 	FTAliasAdd(ctx context.Context, index string, alias string) *StatusCmd
 	FTAliasDel(ctx context.Context, alias string) *StatusCmd
 	FTAliasUpdate(ctx context.Context, index string, alias string) *StatusCmd
-	FTAlter(ctx context.Context, index string, skipInitalScan bool, definition []interface{}) *StatusCmd
+	FTAlter(ctx context.Context, index string, skipInitialScan bool, definition []interface{}) *StatusCmd
 	FTConfigGet(ctx context.Context, option string) *MapMapStringInterfaceCmd
 	FTConfigSet(ctx context.Context, option string, value interface{}) *StatusCmd
 	FTCreate(ctx context.Context, index string, options *FTCreateOptions, schema ...*FieldSchema) *StatusCmd
@@ -5256,9 +5256,9 @@ func (c *Compat) FTAliasUpdate(ctx context.Context, index string, alias string)
 // For more information, please refer to the Redis documentation:
 // [FT.ALTER]: (https://redis.io/commands/ft.alter/)
 // see go-redis v9.7.0 https://github.com/redis/go-redis/blob/v9.7.0/search_commands.go#L815
-func (c *Compat) FTAlter(ctx context.Context, index string, skipInitalScan bool, definition []interface{}) *StatusCmd {
+func (c *Compat) FTAlter(ctx context.Context, index string, skipInitialScan bool, definition []interface{}) *StatusCmd {
 	_cmd := cmds.Incomplete(c.client.B().FtAlter().Index(index))
-	if skipInitalScan {
+	if skipInitialScan {
 		_cmd = cmds.Incomplete(cmds.FtAlterIndex(_cmd).Skipinitialscan())
 	}
 	if len(definition) != 2 {
diff --git a/rueidiscompat/pipeline.go b/rueidiscompat/pipeline.go
index 37c800a..25e2799 100644
--- a/rueidiscompat/pipeline.go
+++ b/rueidiscompat/pipeline.go
@@ -3012,8 +3012,8 @@ func (c *Pipeline) FTAliasUpdate(ctx context.Context, index string, alias string
 	return ret
 }
 
-func (c *Pipeline) FTAlter(ctx context.Context, index string, skipInitalScan bool, definition []interface{}) *StatusCmd {
-	ret := c.comp.FTAlter(ctx, index, skipInitalScan, definition)
+func (c *Pipeline) FTAlter(ctx context.Context, index string, skipInitialScan bool, definition []interface{}) *StatusCmd {
+	ret := c.comp.FTAlter(ctx, index, skipInitialScan, definition)
 	c.rets = append(c.rets, ret)
 	return ret
 }



Test patch:
diff --git a/internal/cmds/gen_stream_test.go b/internal/cmds/gen_stream_test.go
index e551ff3..b8f2994 100644
--- a/internal/cmds/gen_stream_test.go
+++ b/internal/cmds/gen_stream_test.go
@@ -6,6 +6,10 @@ import "testing"
 
 func stream0(s Builder) {
 	s.Xack().Key("1").Group("1").Id("1").Id("1").Build()
+	s.Xackdel().Key("1").Group("1").Keepref().Ids().Numids(1).Id("1").Id("1").Build()
+	s.Xackdel().Key("1").Group("1").Delref().Ids().Numids(1).Id("1").Id("1").Build()
+	s.Xackdel().Key("1").Group("1").Acked().Ids().Numids(1).Id("1").Id("1").Build()
+	s.Xackdel().Key("1").Group("1").Ids().Numids(1).Id("1").Id("1").Build()
 	s.Xadd().Key("1").Nomkstream().Maxlen().Exact().Threshold("1").Limit(1).Keepref().Id("1").FieldValue().FieldValue("1", "1").FieldValue("1", "1").Build()
 	s.Xadd().Key("1").Nomkstream().Maxlen().Exact().Threshold("1").Limit(1).Delref().Id("1").FieldValue().FieldValue("1", "1").FieldValue("1", "1").Build()
 	s.Xadd().Key("1").Nomkstream().Maxlen().Exact().Threshold("1").Limit(1).Acked().Id("1").FieldValue().FieldValue("1", "1").FieldValue("1", "1").Build()
@@ -50,6 +54,10 @@ func stream0(s Builder) {
 	s.Xclaim().Key("1").Group("1").Consumer("1").MinIdleTime("1").Id("1").Id("1").Lastid().Build()
 	s.Xclaim().Key("1").Group("1").Consumer("1").MinIdleTime("1").Id("1").Id("1").Build()
 	s.Xdel().Key("1").Id("1").Id("1").Build()
+	s.Xdelex().Key("1").Keepref().Ids().Numids(1).Id("1").Id("1").Build()
+	s.Xdelex().Key("1").Delref().Ids().Numids(1).Id("1").Id("1").Build()
+	s.Xdelex().Key("1").Acked().Ids().Numids(1).Id("1").Id("1").Build()
+	s.Xdelex().Key("1").Ids().Numids(1).Id("1").Id("1").Build()
 	s.XgroupCreate().Key("1").Group("1").Id("1").Mkstream().Entriesread(1).Build()
 	s.XgroupCreate().Key("1").Group("1").Id("1").Mkstream().Build()
 	s.XgroupCreate().Key("1").Group("1").Id("1").Entriesread(1).Build()
diff --git a/rueidis_test.go b/rueidis_test.go
index 2b7ca06..bcf7cb1 100644
--- a/rueidis_test.go
+++ b/rueidis_test.go
@@ -242,6 +242,54 @@ func TestFallBackSingleClient(t *testing.T) {
 	<-done
 }
 
+func TestForceSingleClientInitialDialError(t *testing.T) {
+	defer ShouldNotLeak(SetupLeakDetection())
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer ln.Close()
+
+	_, port, _ := net.SplitHostPort(ln.Addr().String())
+	client, err := NewClient(ClientOption{
+		InitAddress:       []string{"127.0.0.1:" + port},
+		ForceSingleClient: true,
+		Dialer:            net.Dialer{Timeout: time.Second / 10},
+	})
+	if client == nil || err == nil {
+		t.Fatal(err)
+	}
+	// discard NewClient dial attempt
+	if _, err := ln.Accept(); err != nil {
+		t.Errorf("unexpected error result: %v", err)
+	}
+
+	done := make(chan struct{})
+	go func() {
+		mock, err := accept(t, ln)
+		if err != nil {
+			return
+		}
+		mock.Expect("CLIENT", "SETINFO", "LIB-NAME", LibName).
+			ReplyError("UNKNOWN COMMAND")
+		mock.Expect("CLIENT", "SETINFO", "LIB-VER", LibVer).
+			ReplyError("UNKNOWN COMMAND")
+		mock.Expect("PING").ReplyString("OK")
+		mock.Close()
+		close(done)
+	}()
+
+	val, err := client.Do(context.Background(), client.B().Ping().Build()).ToMessage()
+	if err != nil {
+		t.Errorf("unexpected error result: %v", err)
+	}
+	if str, _ := val.ToString(); str != "OK" {
+		t.Errorf("unexpected result: %v", str)
+	}
+	client.Close()
+	<-done
+}
+
 func TestForceSingleClient(t *testing.T) {
 	defer ShouldNotLeak(SetupLeakDetection())
 	ln, err := net.Listen("tcp", "127.0.0.1:0")