Repository: redis/rueidis

Issue:
Feature Request: LuaScript and rueidislock support Retryable
Related to #919 

- Some lua script (not readonly) may also retryable ( for example the whole script represent a idempotent operation.
- The lua scripts in rueidislock mod can also considered idempotent operations.

For `Lua` add `LuaOption` for setting this lua script retryable.  
For all lua scripts in rueidislock mod, set retryable by default.



Discussion:
@rueian: Thanks @wyxloading!

Solution patch:
diff --git a/README.md b/README.md
index 6cfa69c..3eab4f5 100644
--- a/README.md
+++ b/README.md
@@ -66,13 +66,13 @@ Once a command is built, use either `client.Do()` or `client.DoMulti()` to send
 
 To reuse a command, use `Pin()` after `Build()` and it will prevent the command from being recycled.
 
-## [Pipelining](https://redis.io/docs/manual/pipelining/)
+## [Pipelining](https://redis.io/docs/latest/develop/using-commands/pipelining/)
 
 ### Auto Pipelining
 
 All concurrent non-blocking redis commands (such as `GET`, `SET`) are automatically pipelined by default,
 which reduces the overall round trips and system calls and gets higher throughput. You can easily get the benefit
-of [pipelining technique](https://redis.io/docs/manual/pipelining/) by just calling `client.Do()` from multiple goroutines concurrently.
+of [pipelining technique](https://redis.io/docs/latest/develop/using-commands/pipelining/) by just calling `client.Do()` from multiple goroutines concurrently.
 For example:
 
 ```go
diff --git a/lua.go b/lua.go
index da7265a..b925243 100644
--- a/lua.go
+++ b/lua.go
@@ -27,35 +27,49 @@ func WithLoadSHA1(enabled bool) LuaOption {
 // NewLuaScript creates a Lua instance whose Lua.Exec uses EVALSHA and EVAL.
 // By default, SHA-1 is calculated client-side. Use WithLoadSHA1(true) option to load SHA-1 from Redis instead.
 func NewLuaScript(script string, opts ...LuaOption) *Lua {
-	return newLuaScript(script, false, false, opts...)
+	return newLuaScript(script, false, false, false, opts...)
 }
 
 // NewLuaScriptReadOnly creates a Lua instance whose Lua.Exec uses EVALSHA_RO and EVAL_RO.
 // By default, SHA-1 is calculated client-side. Use WithLoadSHA1(true) option to load SHA-1 from Redis instead.
 func NewLuaScriptReadOnly(script string, opts ...LuaOption) *Lua {
-	return newLuaScript(script, true, false, opts...)
+	return newLuaScript(script, true, false, false, opts...)
 }
 
 // NewLuaScriptNoSha creates a Lua instance whose Lua.Exec uses EVAL only (never EVALSHA).
 // No SHA-1 is calculated or loaded. The script is sent to the server every time. Use this when you want
 // to avoid SHA-1 entirely (e.g., to fully avoid hash collision concerns).
 func NewLuaScriptNoSha(script string) *Lua {
-	return newLuaScript(script, false, true)
+	return newLuaScript(script, false, true, false)
 }
 
 // NewLuaScriptReadOnlyNoSha creates a Lua instance whose Lua.Exec uses EVAL_RO only (never EVALSHA_RO).
 // No SHA-1 is calculated or loaded. The script is sent to the server every time. Use this when you want
 // to avoid SHA-1 entirely (e.g., to fully avoid hash collision concerns).
 func NewLuaScriptReadOnlyNoSha(script string) *Lua {
-	return newLuaScript(script, true, true)
+	return newLuaScript(script, true, true, false)
 }
 
-func newLuaScript(script string, readonly bool, noSha1 bool, opts ...LuaOption) *Lua {
+// NewLuaScriptRetryable creates a retryable Lua instance whose Lua.Exec uses EVALSHA and EVAL.
+// By default, SHA-1 is calculated client-side. Use WithLoadSHA1(true) option to load SHA-1 from Redis instead.
+func NewLuaScriptRetryable(script string, opts ...LuaOption) *Lua {
+	return newLuaScript(script, false, false, true, opts...)
+}
+
+// NewLuaScriptNoShaRetryable creates a retryable Lua instance whose Lua.Exec uses EVAL only (never EVALSHA).
+// No SHA-1 is calculated or loaded. The script is sent to the server every time. Use this when you want
+// to avoid SHA-1 entirely (e.g., to fully avoid hash collision concerns).
+func NewLuaScriptNoShaRetryable(script string) *Lua {
+	return newLuaScript(script, false, true, true)
+}
+
+func newLuaScript(script string, readonly bool, noSha1, retryable bool, opts ...LuaOption) *Lua {
 	l := &Lua{
-		script:   script,
-		maxp:     runtime.GOMAXPROCS(0),
-		readonly: readonly,
-		noSha1:   noSha1,
+		script:    script,
+		maxp:      runtime.GOMAXPROCS(0),
+		readonly:  readonly,
+		noSha1:    noSha1,
+		retryable: retryable,
 	}
 	for _, opt := range opts {
 		opt(l)
@@ -70,14 +84,15 @@ func newLuaScript(script string, readonly bool, noSha1 bool, opts ...LuaOption)
 
 // Lua represents a redis lua script. It should be created from the NewLuaScript() or NewLuaScriptReadOnly().
 type Lua struct {
-	script   string
-	sha1     string
-	sha1Call call
-	maxp     int
-	sha1Mu   sync.RWMutex
-	readonly bool
-	noSha1   bool
-	loadSha1 bool
+	script    string
+	sha1      string
+	sha1Call  call
+	maxp      int
+	sha1Mu    sync.RWMutex
+	readonly  bool
+	noSha1    bool
+	loadSha1  bool
+	retryable bool
 }
 
 // Exec the script to the given Client.
@@ -99,7 +114,7 @@ func (s *Lua) Exec(ctx context.Context, c Client, keys, args []string) (resp Red
 		// If not loaded yet, use singleflight to load it.
 		if scriptSha1 == "" {
 			err := s.sha1Call.Do(ctx, func() error {
-				result := c.Do(ctx, c.B().ScriptLoad().Script(s.script).Build())
+				result := c.Do(ctx, c.B().ScriptLoad().Script(s.script).Build().ToRetryable())
 				if shaStr, err := result.ToString(); err == nil {
 					s.sha1Mu.Lock()
 					s.sha1 = shaStr
@@ -126,7 +141,7 @@ func (s *Lua) Exec(ctx context.Context, c Client, keys, args []string) (resp Red
 		if s.readonly {
 			resp = c.Do(ctx, c.B().EvalshaRo().Sha1(scriptSha1).Numkeys(int64(len(keys))).Key(keys...).Arg(args...).Build())
 		} else {
-			resp = c.Do(ctx, c.B().Evalsha().Sha1(scriptSha1).Numkeys(int64(len(keys))).Key(keys...).Arg(args...).Build())
+			resp = c.Do(ctx, s.mayRetryable(c.B().Evalsha().Sha1(scriptSha1).Numkeys(int64(len(keys))).Key(keys...).Arg(args...).Build()))
 		}
 		err, isErr := IsRedisErr(resp.Error())
 		isNoScript = isErr && err.IsNoScript()
@@ -135,7 +150,7 @@ func (s *Lua) Exec(ctx context.Context, c Client, keys, args []string) (resp Red
 		if s.readonly {
 			resp = c.Do(ctx, c.B().EvalRo().Script(s.script).Numkeys(int64(len(keys))).Key(keys...).Arg(args...).Build())
 		} else {
-			resp = c.Do(ctx, c.B().Eval().Script(s.script).Numkeys(int64(len(keys))).Key(keys...).Arg(args...).Build())
+			resp = c.Do(ctx, s.mayRetryable(c.B().Eval().Script(s.script).Numkeys(int64(len(keys))).Key(keys...).Arg(args...).Build()))
 		}
 	}
 	return resp
@@ -159,7 +174,7 @@ func (s *Lua) ExecMulti(ctx context.Context, c Client, multi ...LuaExec) (resp [
 		var e atomic.Value
 		var sha1Result atomic.Value
 		util.ParallelVals(s.maxp, c.Nodes(), func(n Client) {
-			result := n.Do(ctx, n.B().ScriptLoad().Script(s.script).Build())
+			result := n.Do(ctx, n.B().ScriptLoad().Script(s.script).Build().ToRetryable())
 			if err := result.Error(); err != nil {
 				e.CompareAndSwap(nil, &errs{error: err})
 			} else if s.loadSha1 {
@@ -200,15 +215,22 @@ func (s *Lua) ExecMulti(ctx context.Context, c Client, multi ...LuaExec) (resp [
 			if s.readonly {
 				cmds = append(cmds, c.B().EvalshaRo().Sha1(scriptSha1).Numkeys(int64(len(m.Keys))).Key(m.Keys...).Arg(m.Args...).Build())
 			} else {
-				cmds = append(cmds, c.B().Evalsha().Sha1(scriptSha1).Numkeys(int64(len(m.Keys))).Key(m.Keys...).Arg(m.Args...).Build())
+				cmds = append(cmds, s.mayRetryable(c.B().Evalsha().Sha1(scriptSha1).Numkeys(int64(len(m.Keys))).Key(m.Keys...).Arg(m.Args...).Build()))
 			}
 		} else {
 			if s.readonly {
 				cmds = append(cmds, c.B().EvalRo().Script(s.script).Numkeys(int64(len(m.Keys))).Key(m.Keys...).Arg(m.Args...).Build())
 			} else {
-				cmds = append(cmds, c.B().Eval().Script(s.script).Numkeys(int64(len(m.Keys))).Key(m.Keys...).Arg(m.Args...).Build())
+				cmds = append(cmds, s.mayRetryable(c.B().Eval().Script(s.script).Numkeys(int64(len(m.Keys))).Key(m.Keys...).Arg(m.Args...).Build()))
 			}
 		}
 	}
 	return c.DoMulti(ctx, cmds...)
 }
+
+func (s *Lua) mayRetryable(cmd Completed) Completed {
+	if s.retryable {
+		cmd = cmd.ToRetryable()
+	}
+	return cmd
+}
diff --git a/rueidisaside/README.md b/rueidisaside/README.md
index 0d346e2..db44bfe 100644
--- a/rueidisaside/README.md
+++ b/rueidisaside/README.md
@@ -1,6 +1,6 @@
 # rueidisaside
 
-A Cache-Aside pattern implementation enhanced by [Client Side Caching](https://redis.io/docs/manual/client-side-caching/).
+A Cache-Aside pattern implementation enhanced by [Client Side Caching](https://redis.io/docs/latest/develop/reference/client-side-caching/).
 
 ## Features backed by the Redis Client Side Caching
 
diff --git a/rueidislock/README.md b/rueidislock/README.md
index b389866..56c8197 100644
--- a/rueidislock/README.md
+++ b/rueidislock/README.md
@@ -1,6 +1,6 @@
 # rueidislock
 
-A [Redis Distributed Lock Pattern](https://redis.io/docs/latest/develop/use/patterns/distributed-locks/) enhanced by [Client Side Caching](https://redis.io/docs/manual/client-side-caching/).
+A [Redis Distributed Lock Pattern](https://redis.io/docs/latest/develop/use/patterns/distributed-locks/) enhanced by [Client Side Caching](https://redis.io/docs/latest/develop/reference/client-side-caching/).
 
 ```go
 package main
diff --git a/rueidislock/lock.go b/rueidislock/lock.go
index 49a02e5..c1b86ae 100644
--- a/rueidislock/lock.go
+++ b/rueidislock/lock.go
@@ -425,12 +425,12 @@ func (m *locker) Close() {
 }
 
 var (
-	delkey = rueidis.NewLuaScript(`if redis.call("GET",KEYS[1]) == ARGV[1] then return redis.call("DEL",KEYS[1]) end;return 0`)
-	extend = rueidis.NewLuaScript(`if redis.call("GET",KEYS[1]) == ARGV[1] then local r = redis.call("PEXPIREAT",KEYS[1],ARGV[2]);redis.call("GET",KEYS[1]);return r end;return 0`)
-	acqms  = rueidis.NewLuaScript(`local r = redis.call("SET",KEYS[1],ARGV[1],"NX","PX",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
-	acqat  = rueidis.NewLuaScript(`local r = redis.call("SET",KEYS[1],ARGV[1],"NX","PXAT",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
-	fcqms  = rueidis.NewLuaScript(`local r = redis.call("SET",KEYS[1],ARGV[1],"PX",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
-	fcqat  = rueidis.NewLuaScript(`local r = redis.call("SET",KEYS[1],ARGV[1],"PXAT",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
+	delkey = rueidis.NewLuaScriptRetryable(`if redis.call("GET",KEYS[1]) == ARGV[1] then return redis.call("DEL",KEYS[1]) end;return 0`)
+	extend = rueidis.NewLuaScriptRetryable(`if redis.call("GET",KEYS[1]) == ARGV[1] then local r = redis.call("PEXPIREAT",KEYS[1],ARGV[2]);redis.call("GET",KEYS[1]);return r end;return 0`)
+	acqms  = rueidis.NewLuaScriptRetryable(`local r = redis.call("SET",KEYS[1],ARGV[1],"NX","PX",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
+	acqat  = rueidis.NewLuaScriptRetryable(`local r = redis.call("SET",KEYS[1],ARGV[1],"NX","PXAT",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
+	fcqms  = rueidis.NewLuaScriptRetryable(`local r = redis.call("SET",KEYS[1],ARGV[1],"PX",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
+	fcqat  = rueidis.NewLuaScriptRetryable(`local r = redis.call("SET",KEYS[1],ARGV[1],"PXAT",ARGV[2]);redis.call("GET",KEYS[1]);return r`)
 )
 
 // ErrNotLocked is returned from the Locker.TryWithContext when it fails



Test patch:
diff --git a/lua_test.go b/lua_test.go
index 43af738..cda6eeb 100644
--- a/lua_test.go
+++ b/lua_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"crypto/sha1"
 	"encoding/hex"
+	"fmt"
 	"math/rand"
 	"reflect"
 	"strconv"
@@ -172,6 +173,80 @@ func TestNewLuaScriptReadOnlyNoSha(t *testing.T) {
 	}
 }
 
+func TestNewLuaScriptRetryable(t *testing.T) {
+	defer ShouldNotLeak(SetupLeakDetection())
+	body := strconv.Itoa(rand.Int())
+	sum := sha1.Sum([]byte(body))
+	sha := hex.EncodeToString(sum[:])
+
+	k := []string{"1", "2"}
+	a := []string{"3", "4"}
+
+	eval := false
+
+	c := &client{
+		BFn: func() Builder {
+			return cmds.NewBuilder(cmds.NoSlot)
+		},
+		DoFn: func(ctx context.Context, cmd Completed) (resp RedisResult) {
+			if reflect.DeepEqual(cmd.Commands(), []string{"EVALSHA", sha, "2", "1", "2", "3", "4"}) {
+				eval = true
+				if !cmd.IsRetryable() {
+					return newResult(strmsg('-', "cmd retryable unexpected"), nil)
+				}
+				return newResult(strmsg('-', "NOSCRIPT"), nil)
+			}
+			if eval && reflect.DeepEqual(cmd.Commands(), []string{"EVAL", body, "2", "1", "2", "3", "4"}) {
+				if !cmd.IsRetryable() {
+					return newResult(strmsg('-', "cmd retryable unexpected"), nil)
+				}
+				return newResult(RedisMessage{typ: '_'}, nil)
+			}
+			return newResult(strmsg('+', "unexpected"), nil)
+		},
+	}
+
+	script := NewLuaScriptRetryable(body)
+
+	if err, ok := IsRedisErr(script.Exec(context.Background(), c, k, a).Error()); ok && !err.IsNil() {
+		t.Fatalf("ret mismatch")
+	}
+}
+
+func TestNewLuaScriptNoShaRetryable(t *testing.T) {
+	defer ShouldNotLeak(SetupLeakDetection())
+	body := strconv.Itoa(rand.Int())
+	sum := sha1.Sum([]byte(body))
+	sha := hex.EncodeToString(sum[:])
+
+	k := []string{"1", "2"}
+	a := []string{"3", "4"}
+
+	c := &client{
+		BFn: func() Builder {
+			return cmds.NewBuilder(cmds.NoSlot)
+		},
+		DoFn: func(ctx context.Context, cmd Completed) (resp RedisResult) {
+			if reflect.DeepEqual(cmd.Commands(), []string{"EVALSHA", sha, "2", "1", "2", "3", "4"}) {
+				t.Fatal("EVALSHA must not be called")
+			}
+			if reflect.DeepEqual(cmd.Commands(), []string{"EVAL", body, "2", "1", "2", "3", "4"}) {
+				if !cmd.IsRetryable() {
+					return newResult(strmsg('-', "cmd retryable unexpected"), nil)
+				}
+				return newResult(RedisMessage{typ: '_'}, nil)
+			}
+			return newResult(strmsg('+', "unexpected"), nil)
+		},
+	}
+
+	script := NewLuaScriptNoShaRetryable(body)
+
+	if err, ok := IsRedisErr(script.Exec(context.Background(), c, k, a).Error()); ok && !err.IsNil() {
+		t.Fatalf("ret mismatch")
+	}
+}
+
 func TestNewLuaScriptExecMultiError(t *testing.T) {
 	defer ShouldNotLeak(SetupLeakDetection())
 	body := strconv.Itoa(rand.Int())
@@ -452,6 +527,59 @@ func TestNewLuaScriptWithLoadSha1(t *testing.T) {
 	}
 }
 
+func TestNewLuaScriptRetryableWithLoadSha1(t *testing.T) {
+	defer ShouldNotLeak(SetupLeakDetection())
+	body := strconv.Itoa(rand.Int())
+	sum := sha1.Sum([]byte(body))
+	sha := hex.EncodeToString(sum[:])
+
+	k := []string{"1", "2"}
+	a := []string{"3", "4"}
+
+	scriptLoadCalled := false
+	evalshaInvoked := false
+
+	c := &client{
+		BFn: func() Builder {
+			return cmds.NewBuilder(cmds.NoSlot)
+		},
+		DoFn: func(ctx context.Context, cmd Completed) (resp RedisResult) {
+			commands := cmd.Commands()
+			if reflect.DeepEqual(commands, []string{"SCRIPT", "LOAD", body}) {
+				scriptLoadCalled = true
+				if !cmd.IsRetryable() {
+					return newResult(strmsg('-', "cmd retryable unexpected"), nil)
+				}
+				return newResult(strmsg('+', sha), nil)
+			}
+			if reflect.DeepEqual(commands, []string{"EVALSHA", sha, "2", "1", "2", "3", "4"}) {
+				evalshaInvoked = true
+				if !cmd.IsRetryable() {
+					return newResult(strmsg('-', "cmd retryable unexpected"), nil)
+				}
+				return newResult(strmsg('+', "OK"), nil)
+			}
+			if reflect.DeepEqual(commands, []string{"EVAL", body, "2", "1", "2", "3", "4"}) {
+				t.Fatal("EVAL must not be called when load succeeds")
+			}
+			return newResult(strmsg('+', "unexpected"), nil)
+		},
+	}
+
+	script := NewLuaScriptRetryable(body, WithLoadSHA1(true))
+
+	if v, err := script.Exec(context.Background(), c, k, a).ToString(); err != nil || v != "OK" {
+		t.Fatalf("ret mismatch")
+	}
+
+	if !scriptLoadCalled {
+		t.Fatal("SCRIPT LOAD should have been called")
+	}
+	if !evalshaInvoked {
+		t.Fatal("EVALSHA should have been called")
+	}
+}
+
 func TestNewLuaScriptReadOnlyWithLoadSha1(t *testing.T) {
 	defer ShouldNotLeak(SetupLeakDetection())
 	body := strconv.Itoa(rand.Int())
@@ -593,6 +721,176 @@ func TestNewLuaScriptWithLoadSha1ExecMulti(t *testing.T) {
 	}
 }
 
+func TestNewLuaScript_MayRetryable(t *testing.T) {
+	defer ShouldNotLeak(SetupLeakDetection())
+	body := strconv.Itoa(rand.Int())
+	sum := sha1.Sum([]byte(body))
+	sha := hex.EncodeToString(sum[:])
+
+	k := []string{"1", "2"}
+	a := []string{"3", "4"}
+
+	type cmdAttr struct {
+		ReadOnly  bool
+		Retryable bool
+	}
+	type expect struct {
+		commands []string
+		reply    RedisResult
+		attr     cmdAttr
+	}
+	var expects []expect
+	should := func(e ...expect) { expects = e }
+	scriptLoad := func(_, _ bool) expect {
+		return expect{
+			commands: []string{"SCRIPT", "LOAD", body},
+			reply:    newResult(strmsg('+', sha), nil),
+			attr:     cmdAttr{ReadOnly: false, Retryable: true},
+		}
+	}
+	evalSha := func(readOnly, retryable bool) expect {
+		cmd := "EVALSHA"
+		if readOnly {
+			cmd = "EVALSHA_RO"
+		}
+		return expect{
+			commands: []string{cmd, sha, "2", "1", "2", "3", "4"},
+			reply:    newResult(strmsg('+', "OK"), nil),
+			attr:     cmdAttr{ReadOnly: readOnly, Retryable: readOnly || retryable},
+		}
+	}
+	eval := func(readOnly, retryable bool) expect {
+		cmd := "EVAL"
+		if readOnly {
+			cmd = "EVAL_RO"
+		}
+		return expect{
+			commands: []string{cmd, body, "2", "1", "2", "3", "4"},
+			reply:    newResult(strmsg('+', "OK"), nil),
+			attr:     cmdAttr{ReadOnly: readOnly, Retryable: readOnly || retryable},
+		}
+	}
+	genExpects := func(fn ...func(readOnly, retryable bool) expect) []func(readOnly, retryable bool) expect { return fn }
+
+	c := &client{
+		BFn: func() Builder {
+			return cmds.NewBuilder(cmds.NoSlot)
+		},
+		DoFn: func(ctx context.Context, cmd Completed) (resp RedisResult) {
+			commands := cmd.Commands()
+			for _, e := range expects {
+				if reflect.DeepEqual(commands, e.commands) {
+					if cmd.IsReadOnly() != e.attr.ReadOnly || cmd.IsRetryable() != e.attr.Retryable {
+						return newErrResult(fmt.Errorf("cmd attr not match, %v %v %v %v", cmd.IsReadOnly(), e.attr.ReadOnly, cmd.IsRetryable(), e.attr.Retryable))
+					}
+					return e.reply
+				}
+			}
+			return newResult(strmsg('-', "not expected"), nil)
+		},
+		DoMultiFn: func(ctx context.Context, multi ...Completed) (resp []RedisResult) {
+			for _, cmd := range multi {
+				commands := cmd.Commands()
+				found := false
+				for _, e := range expects {
+					if reflect.DeepEqual(commands, e.commands) {
+						if cmd.IsReadOnly() != e.attr.ReadOnly || cmd.IsRetryable() != e.attr.Retryable {
+							resp = append(resp, newErrResult(fmt.Errorf("cmd attr not match, %v %v %v %v", cmd.IsReadOnly(), e.attr.ReadOnly, cmd.IsRetryable(), e.attr.Retryable)))
+						} else {
+							resp = append(resp, e.reply)
+						}
+						found = true
+						break
+					}
+				}
+				if !found {
+					resp = append(resp, newResult(strmsg('-', "not expected"), nil))
+				}
+			}
+			return resp
+		},
+	}
+
+	testScript := func(t *testing.T, fn func() *Lua, readOnly, retryable bool,
+		execShould, execMultiShould []func(readOnly, retryable bool) expect) {
+		do := func(t *testing.T, opts ...LuaOption) {
+			var ecs []expect
+			for _, e := range execShould {
+				ecs = append(ecs, e(readOnly, retryable))
+			}
+			should(ecs...)
+			script := fn()
+
+			if v, err := script.Exec(context.Background(), c, k, a).ToString(); err != nil || v != "OK" {
+				t.Errorf("ret mismatch, %v %v", err, v)
+			}
+			ecs = nil
+			for _, e := range execMultiShould {
+				ecs = append(ecs, e(readOnly, retryable))
+			}
+			should(ecs...)
+			if v, err := script.ExecMulti(context.Background(), c, LuaExec{Keys: k, Args: a})[0].ToString(); err != nil || v != "OK" {
+				t.Errorf("ret mismatch, %v %v", err, v)
+			}
+		}
+		do(t)
+	}
+
+	t.Run("NewLuaScript", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScript(body)
+		}, false, false, genExpects(evalSha), genExpects(scriptLoad, evalSha))
+	})
+
+	t.Run("NewLuaScriptReadOnly", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScriptReadOnly(body)
+		}, true, false, genExpects(evalSha), genExpects(scriptLoad, evalSha))
+	})
+
+	t.Run("NewLuaScriptNoSha", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScriptNoSha(body)
+		}, false, false, genExpects(eval), genExpects(eval))
+	})
+
+	t.Run("NewLuaScriptReadOnlyNoSha", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScriptReadOnlyNoSha(body)
+		}, true, false, genExpects(eval), genExpects(eval))
+	})
+
+	t.Run("NewLuaScriptWithLoadSha1", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScript(body, WithLoadSHA1(true))
+		}, false, false, genExpects(scriptLoad, evalSha), genExpects(scriptLoad, evalSha))
+	})
+
+	t.Run("NewLuaScriptReadOnlyWithLoadSha1", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScriptReadOnly(body, WithLoadSHA1(true))
+		}, true, false, genExpects(scriptLoad, evalSha), genExpects(scriptLoad, evalSha))
+	})
+
+	t.Run("NewLuaScriptRetryable", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScriptRetryable(body)
+		}, false, true, genExpects(evalSha), genExpects(scriptLoad, evalSha))
+	})
+
+	t.Run("NewLuaScriptRetryableWithLoadSha1", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScriptRetryable(body, WithLoadSHA1(true))
+		}, false, true, genExpects(scriptLoad, evalSha), genExpects(scriptLoad, evalSha))
+	})
+
+	t.Run("NewLuaScriptNoShaRetryable", func(t *testing.T) {
+		testScript(t, func() *Lua {
+			return NewLuaScriptNoShaRetryable(body)
+		}, false, true, genExpects(eval), genExpects(eval))
+	})
+}
+
 func BenchmarkLuaScript_Exec(b *testing.B) {
 	ctx := context.Background()
 	c, err := NewClient(ClientOption{InitAddress: []string{"127.0.0.1:6379"}})