Files
go-redis/tx_unwatch_test.go
Filipe Oliveira (Redis)andClaude Opus 4.8 5484b0b86c feat(tx): skip redundant UNWATCH in Tx.Close when no WATCH is active (#3854)
Tx.Close issued UNWATCH unconditionally before returning the connection to
the pool, adding a round trip to every Watch transaction even when nothing
was watched or when EXEC had already discarded the watched keys.

Track whether a WATCH is still active on the transaction (watchArmed) and
only issue UNWATCH from Close when it is. The flag is set on a successful
WATCH and cleared only when EXEC actually ran: a nil error (committed),
TxFailedErr (a watched key changed, EXEC returned nil), or an EXECABORT
error (a queued command was rejected, EXEC discarded the transaction) — all
of which release the watched keys server-side. Any other error may be
reported before EXEC executes (for example -LOADING on the MULTI reply) or
on a broken connection, so the flag is left set and Close still sends
UNWATCH rather than risk leaving a watch on a pooled connection.

This removes the extra UNWATCH on the common WATCH/.../EXEC path and on the
no-key Watch path, and never returns a connection to the pool with an
active WATCH. No exported API changes; the only wire difference is the
elided no-op UNWATCH.

Tests assert, on the wire (recording hook) and server-side (INFO
commandstats), that UNWATCH is elided after a committed EXEC, an aborted
EXEC, an EXECABORT, and a no-key Watch, and is still sent on every path
where no EXEC cleared the watch: an error before EXEC, a read-only decision,
a non-transactional pipeline, an empty TxPipelined, multi-key bail, a WATCH
re-armed after EXEC, a panic in fn, a failed WATCH, and a server error
reported before EXEC runs. A manual Unwatch case asserts no second UNWATCH
from Close, and a PoolSize=1 reuse case asserts no watch leaks onto a
pooled connection.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:15:40 +03:00

435 lines
14 KiB
Go

package redis_test
import (
"context"
"errors"
"strconv"
"strings"
"sync"
. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
"github.com/redis/go-redis/v9"
)
// cmdRecorder captures the command stream a client sends so tests can assert
// the presence or absence of individual commands. UNWATCH from Tx.Close flows
// through ProcessHook; the MULTI/EXEC block flows through ProcessPipelineHook.
type cmdRecorder struct {
mu sync.Mutex
sent []string
}
func (h *cmdRecorder) record(names ...string) {
h.mu.Lock()
defer h.mu.Unlock()
h.sent = append(h.sent, names...)
}
func (h *cmdRecorder) has(name string) bool {
return h.count(name) > 0
}
func (h *cmdRecorder) count(name string) int {
h.mu.Lock()
defer h.mu.Unlock()
n := 0
for _, c := range h.sent {
if c == name {
n++
}
}
return n
}
func (h *cmdRecorder) DialHook(next redis.DialHook) redis.DialHook { return next }
func (h *cmdRecorder) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
h.record(strings.ToUpper(cmd.Name()))
return next(ctx, cmd)
}
}
func (h *cmdRecorder) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error {
for _, cmd := range cmds {
h.record(strings.ToUpper(cmd.Name()))
}
return next(ctx, cmds)
}
}
// commandCalls returns the per-command call counts reported by
// INFO commandstats, keyed by command name (e.g. "unwatch").
func commandCalls(client *redis.Client) map[string]int64 {
info, err := client.Info(ctx, "commandstats").Result()
Expect(err).NotTo(HaveOccurred())
calls := make(map[string]int64)
for _, line := range strings.Split(info, "\n") {
line = strings.TrimSpace(line)
const prefix = "cmdstat_"
if !strings.HasPrefix(line, prefix) {
continue
}
name, rest, ok := strings.Cut(line[len(prefix):], ":")
if !ok {
continue
}
// rest looks like: calls=3,usec=10,usec_per_call=3.33,...
fields := strings.Split(rest, ",")
if len(fields) == 0 {
continue
}
kv := strings.SplitN(fields[0], "=", 2)
if len(kv) != 2 || kv[0] != "calls" {
continue
}
n, err := strconv.ParseInt(kv[1], 10, 64)
Expect(err).NotTo(HaveOccurred())
calls[name] = n
}
return calls
}
// pipelineErrInjector returns a fixed error for the MULTI/EXEC batch without
// executing it, simulating a server error reported before EXEC runs (such as
// -LOADING on the MULTI reply). The watched keys stay armed, so Close must
// still send UNWATCH.
type pipelineErrInjector struct{ err error }
func (h *pipelineErrInjector) DialHook(next redis.DialHook) redis.DialHook { return next }
func (h *pipelineErrInjector) ProcessHook(next redis.ProcessHook) redis.ProcessHook { return next }
func (h *pipelineErrInjector) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error {
for _, cmd := range cmds {
if strings.EqualFold(cmd.Name(), "exec") {
return h.err // do not call next: EXEC never runs
}
}
return next(ctx, cmds)
}
}
// cmdErrInjector fails a single named command without sending it, simulating a
// server error on that command (e.g. a WATCH that the server rejects).
type cmdErrInjector struct {
name string
err error
}
func (h *cmdErrInjector) DialHook(next redis.DialHook) redis.DialHook { return next }
func (h *cmdErrInjector) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
if strings.EqualFold(cmd.Name(), h.name) {
return h.err // do not call next: the command never runs
}
return next(ctx, cmd)
}
}
func (h *cmdErrInjector) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return next
}
var _ = Describe("Tx UNWATCH elision", func() {
var client *redis.Client
BeforeEach(func() {
client = redis.NewClient(redisOptions())
Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred())
})
AfterEach(func() {
Expect(client.Close()).NotTo(HaveOccurred())
})
// watchRecorded runs fn inside client.Watch (recording the wire commands)
// and returns the recorder plus the error returned by Watch.
watchRecorded := func(fn func(*redis.Tx) error, keys ...string) (*cmdRecorder, error) {
rec := &cmdRecorder{}
client.AddHook(rec)
err := client.Watch(ctx, fn, keys...)
return rec, err
}
// expectUnwatchSent asserts that fn, which must not run EXEC, leaves the
// connection with a WATCH that Close releases via UNWATCH. The error
// returned by fn is irrelevant here (a read may legitimately return
// redis.Nil), so it is not asserted.
expectUnwatchSent := func(fn func(*redis.Tx) error, keys ...string) {
rec, _ := watchRecorded(fn, keys...)
Expect(rec.has("WATCH")).To(BeTrue(), "WATCH must be sent")
Expect(rec.has("EXEC")).To(BeFalse(), "no EXEC should have run")
Expect(rec.has("UNWATCH")).To(BeTrue(), "UNWATCH must be sent when no EXEC cleared the watch")
}
// --- the watch is cleared by EXEC: UNWATCH must be elided ---
It("does not send UNWATCH after a committed EXEC", func() {
rec, err := watchRecorded(func(tx *redis.Tx) error {
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "key", "value", 0)
return nil
})
return err
}, "key")
Expect(err).NotTo(HaveOccurred())
Expect(rec.has("WATCH")).To(BeTrue())
Expect(rec.has("EXEC")).To(BeTrue())
Expect(rec.has("UNWATCH")).To(BeFalse())
})
It("does not send UNWATCH when no keys are watched", func() {
rec, err := watchRecorded(func(tx *redis.Tx) error {
return tx.Ping(ctx).Err()
})
Expect(err).NotTo(HaveOccurred())
Expect(rec.has("PING")).To(BeTrue(), "fn must have actually run")
Expect(rec.has("WATCH")).To(BeFalse(), "no WATCH should be sent for a no-key Watch")
Expect(rec.has("UNWATCH")).To(BeFalse())
})
It("does not send a second UNWATCH when fn already called Unwatch", func() {
rec, err := watchRecorded(func(tx *redis.Tx) error {
return tx.Unwatch(ctx).Err()
}, "key")
Expect(err).NotTo(HaveOccurred())
Expect(rec.count("UNWATCH")).To(Equal(1), "Close must not re-issue UNWATCH after a manual Unwatch")
})
It("does not send UNWATCH when WATCH itself fails", func() {
rec := &cmdRecorder{}
client.AddHook(rec) // must be added before the injector so it records the short-circuited command
client.AddHook(&cmdErrInjector{name: "watch", err: errors.New("watch rejected")})
err := client.Watch(ctx, func(tx *redis.Tx) error {
return nil
}, "key")
Expect(err).To(HaveOccurred())
Expect(rec.has("WATCH")).To(BeTrue())
Expect(rec.has("UNWATCH")).To(BeFalse(), "a failed WATCH armed nothing; Close must not UNWATCH")
})
It("records no UNWATCH server-side after a committed EXEC (commandstats)", Label("NonRedisEnterprise"), func() {
Expect(client.Do(ctx, "config", "resetstat").Err()).NotTo(HaveOccurred())
err := client.Watch(ctx, func(tx *redis.Tx) error {
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "key", "value", 0)
return nil
})
return err
}, "key")
Expect(err).NotTo(HaveOccurred())
calls := commandCalls(client)
Expect(calls).To(HaveKey("watch"))
Expect(calls).To(HaveKey("exec"))
Expect(calls).NotTo(HaveKey("unwatch"))
})
// --- the watch is NOT cleared (no EXEC): UNWATCH must still be sent ---
It("sends UNWATCH when fn returns an error before EXEC", func() {
expectUnwatchSent(func(tx *redis.Tx) error {
return errors.New("bail")
}, "key")
})
It("sends UNWATCH when fn reads and decides not to write", func() {
expectUnwatchSent(func(tx *redis.Tx) error {
_, err := tx.Get(ctx, "key").Result()
if err != nil && err != redis.Nil {
return err
}
return nil // no TxPipelined -> no EXEC
}, "key")
})
It("sends UNWATCH when fn uses only a non-transactional pipeline", func() {
expectUnwatchSent(func(tx *redis.Tx) error {
_, err := tx.Pipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Get(ctx, "key")
return nil
})
return err // Pipelined, not TxPipelined -> no MULTI/EXEC
}, "key")
})
It("sends UNWATCH when TxPipelined queues no commands", func() {
expectUnwatchSent(func(tx *redis.Tx) error {
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
return nil // nothing queued -> exec closure never runs -> no EXEC
})
return err
}, "key")
})
It("sends UNWATCH when bailing with multiple keys watched", func() {
expectUnwatchSent(func(tx *redis.Tx) error {
return errors.New("bail")
}, "{tag}.a", "{tag}.b", "{tag}.c")
})
It("sends UNWATCH when a WATCH is re-armed after a committed EXEC", func() {
rec, err := watchRecorded(func(tx *redis.Tx) error {
if _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "key", "value", 0)
return nil
}); err != nil {
return err
}
// EXEC cleared the watch; re-arm it and bail without a second EXEC.
return tx.Watch(ctx, "key").Err()
}, "key")
Expect(err).NotTo(HaveOccurred())
Expect(rec.has("EXEC")).To(BeTrue())
Expect(rec.has("UNWATCH")).To(BeTrue(), "re-armed WATCH must be released by Close")
})
It("sends UNWATCH when fn panics", func() {
rec := &cmdRecorder{}
client.AddHook(rec)
Expect(func() {
_ = client.Watch(ctx, func(tx *redis.Tx) error {
panic("boom")
}, "key")
}).To(Panic())
Expect(rec.has("WATCH")).To(BeTrue())
Expect(rec.has("UNWATCH")).To(BeTrue(), "deferred Close must UNWATCH during panic unwinding")
})
It("sends UNWATCH when the EXEC pipeline fails before EXEC runs", func() {
rec := &cmdRecorder{}
client.AddHook(rec) // must be added before the injector so it records the short-circuited batch
// Inject a non-TxFailedErr server error for the MULTI/EXEC batch so EXEC
// never executes and the watch stays armed (mirrors -LOADING on MULTI).
// redis.Nil is a RedisError other than TxFailedErr.
client.AddHook(&pipelineErrInjector{err: redis.Nil})
err := client.Watch(ctx, func(tx *redis.Tx) error {
_, e := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "key", "value", 0)
return nil
})
return e
}, "key")
Expect(err).To(HaveOccurred())
Expect(rec.has("WATCH")).To(BeTrue())
Expect(rec.has("UNWATCH")).To(BeTrue(), "a pre-EXEC error must not elide UNWATCH; the watch is still armed")
})
It("records UNWATCH server-side when fn returns before EXEC (commandstats)", Label("NonRedisEnterprise"), func() {
Expect(client.Do(ctx, "config", "resetstat").Err()).NotTo(HaveOccurred())
err := client.Watch(ctx, func(tx *redis.Tx) error {
return errors.New("bail")
}, "key")
Expect(err).To(MatchError("bail"))
calls := commandCalls(client)
Expect(calls).To(HaveKey("watch"))
Expect(calls).To(HaveKey("unwatch"))
Expect(calls).NotTo(HaveKey("exec"))
})
// --- optimistic abort: EXEC ran (returned TxFailedErr), watch is cleared ---
It("does not send UNWATCH after an aborted EXEC (optimistic lock)", func() {
other := redis.NewClient(redisOptions())
defer other.Close()
Expect(client.Set(ctx, "key", "0", 0).Err()).NotTo(HaveOccurred())
rec, err := watchRecorded(func(tx *redis.Tx) error {
// Dirty the watched key from another connection before EXEC.
if e := other.Set(ctx, "key", "changed", 0).Err(); e != nil {
return e
}
_, e := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "key", "new", 0)
return nil
})
return e
}, "key")
Expect(err).To(MatchError(redis.TxFailedErr))
Expect(rec.has("EXEC")).To(BeTrue(), "EXEC must have run")
Expect(rec.has("UNWATCH")).To(BeFalse(), "aborted EXEC already cleared the watch")
})
It("records no UNWATCH server-side after an aborted EXEC (commandstats)", Label("NonRedisEnterprise"), func() {
other := redis.NewClient(redisOptions())
defer other.Close()
Expect(client.Set(ctx, "key", "0", 0).Err()).NotTo(HaveOccurred())
Expect(client.Do(ctx, "config", "resetstat").Err()).NotTo(HaveOccurred())
err := client.Watch(ctx, func(tx *redis.Tx) error {
if e := other.Set(ctx, "key", "changed", 0).Err(); e != nil {
return e
}
_, e := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "key", "new", 0)
return nil
})
return e
}, "key")
Expect(err).To(MatchError(redis.TxFailedErr))
calls := commandCalls(client)
Expect(calls).To(HaveKey("watch"))
Expect(calls).To(HaveKey("exec")) // EXEC ran and aborted
Expect(calls).NotTo(HaveKey("unwatch"))
})
It("does not send UNWATCH after an EXECABORT", func() {
rec, err := watchRecorded(func(tx *redis.Tx) error {
_, e := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
// SET with a missing argument is rejected at queue time, so EXEC
// returns EXECABORT and discards the transaction (and the watch).
pipe.Do(ctx, "set", "key")
return nil
})
return e
}, "key")
Expect(err).To(HaveOccurred())
Expect(redis.IsExecAbortError(err)).To(BeTrue())
Expect(rec.has("EXEC")).To(BeTrue(), "EXEC must have run")
Expect(rec.has("UNWATCH")).To(BeFalse(), "EXECABORT already discarded the watch")
})
// --- pool safety: a bailed transaction must not leak a watch ---
It("does not leak a watch onto a pooled connection", func() {
opt := redisOptions()
opt.PoolSize = 1
opt.MinIdleConns = 0
c := redis.NewClient(opt)
defer c.Close()
Expect(c.Set(ctx, "guard", "0", 0).Err()).NotTo(HaveOccurred())
errBail := errors.New("bail")
err := c.Watch(ctx, func(tx *redis.Tx) error {
return errBail
}, "guard")
Expect(err).To(Equal(errBail))
// Mutate the watched key on the (single) reused connection.
Expect(c.Incr(ctx, "guard").Err()).NotTo(HaveOccurred())
// An unrelated transaction on the reused connection must not be aborted
// by a leftover WATCH on "guard"; a leak would surface as TxFailedErr.
err = c.Watch(ctx, func(tx *redis.Tx) error {
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "fresh", "ok", 0)
return nil
})
return err
})
Expect(err).NotTo(HaveOccurred(), "a leaked WATCH would abort this transaction with TxFailedErr")
Expect(c.Get(ctx, "fresh").Val()).To(Equal("ok"))
})
})