Skip to content

Commit 34eeef4

Browse files
committed
fix(multidb): honor NoRetry commands in pipeline retries
Batches containing a non-retryable command (RawWriteToCmd and friends) execute at most once, and the Watch documentation now states that a transaction stays bound to the member that was active at call time.
1 parent 3b16567 commit 34eeef4

2 files changed

Lines changed: 36 additions & 3 deletions

File tree

multidb_autopipeline_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package redis_test
33
import (
44
"context"
55
"errors"
6+
"io"
67
"sync/atomic"
78
"testing"
89
"time"
@@ -129,6 +130,30 @@ func TestMultiDBPipelinePerCommandRecording(t *testing.T) {
129130
}
130131
}
131132

133+
func TestMultiDBPipelineNoRetryCommandDisablesRetry(t *testing.T) {
134+
db1 := newTestDB("db1", "127.0.0.1:1", 2.0, true)
135+
db2 := newTestDB("db2", "127.0.0.1:2", 1.0, true)
136+
137+
opts := baseOptions()
138+
opts.CommandRetries = 3 // must be ignored when the batch has a NoRetry command
139+
opts.CircuitBreakerConfig = fastBreaker()
140+
mdb := newTestMultiDB(t, opts, db1, db2)
141+
ctx := context.Background()
142+
143+
db1.hook.fail.Store(true)
144+
145+
pipe := mdb.Pipeline()
146+
pipe.Get(ctx, "k")
147+
_ = pipe.Process(ctx, redis.NewRawWriteToCmd(ctx, io.Discard, "get", "k"))
148+
_, err := pipe.Exec(ctx)
149+
if err == nil {
150+
t.Fatal("batch with a NoRetry command should surface the failure, not retry")
151+
}
152+
if db2.hook.batches.Load() != 0 {
153+
t.Error("batch containing a NoRetry command was retried on db2")
154+
}
155+
}
156+
132157
func TestMultiDBTxPipelineNotRetried(t *testing.T) {
133158
db1 := newTestDB("db1", "127.0.0.1:1", 2.0, true)
134159
db2 := newTestDB("db2", "127.0.0.1:2", 1.0, true)

multidb_pipeline.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ func (c *multidbCore) processPipeline(ctx context.Context, cmds []Cmder) error {
5151
return nil
5252
}
5353
attempts := c.opts.CommandRetries + 1
54+
// A batch containing a non-retryable command (e.g. a streaming
55+
// RawWriteToCmd) must be executed at most once: retrying it after a
56+
// transport failure could duplicate execution or corrupt a partial write.
57+
if cmdsContainNoRetry(cmds) {
58+
attempts = 1
59+
}
5460

5561
for attempt := 0; attempt < attempts; attempt++ {
5662
if err := ctx.Err(); err != nil {
@@ -180,9 +186,11 @@ func (c *MultiDBClient) TxPipelined(ctx context.Context, fn func(Pipeliner) erro
180186
}
181187

182188
// Watch runs a WATCH/MULTI/EXEC transaction on the database that is active
183-
// when Watch is called. WATCH state is connection-bound: a failover while the
184-
// transaction is open aborts it with an error, and it is never automatically
185-
// retried on another database.
189+
// when Watch is called. The transaction is bound to that member for its whole
190+
// lifetime: it does NOT follow a MultiDB failover, and it is never
191+
// automatically retried on another database. If the bound member fails while
192+
// the transaction is open, the transaction errors like it would on a plain
193+
// client; MultiDB moves only subsequent operations to the new active member.
186194
func (c *MultiDBClient) Watch(ctx context.Context, fn func(*Tx) error, keys ...string) error {
187195
db, _ := c.core.activeSnapshot()
188196
if db == nil {

0 commit comments

Comments
 (0)