Skip to content

Commit 055fa86

Browse files
authored
Merge pull request #373 from bootjp/copilot/sub-pr-372
proxy: fix EVALSHA_RO fallback, guard script cache on primary error, bounded eviction
2 parents 7f3f917 + f26bcd3 commit 055fa86

File tree

4 files changed

+142
-8
lines changed

4 files changed

+142
-8
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/emirpasic/gods v1.18.1
2020
github.com/getsentry/sentry-go v0.27.0
2121
github.com/hashicorp/go-hclog v1.6.3
22+
github.com/hashicorp/go-msgpack/v2 v2.1.2
2223
github.com/hashicorp/raft v1.7.3
2324
github.com/pkg/errors v0.9.1
2425
github.com/prometheus/client_golang v1.23.2
@@ -66,7 +67,6 @@ require (
6667
github.com/hashicorp/errwrap v1.0.0 // indirect
6768
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
6869
github.com/hashicorp/go-metrics v0.5.4 // indirect
69-
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
7070
github.com/hashicorp/go-multierror v1.1.1 // indirect
7171
github.com/hashicorp/golang-lru v1.0.2 // indirect
7272
github.com/klauspost/compress v1.18.0 // indirect

proxy/dualwrite.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type DualWriter struct {
3737
closed bool
3838
scriptMu sync.RWMutex
3939
scripts map[string]string
40+
// scriptOrder tracks insertion order for FIFO eviction of the bounded script cache.
41+
scriptOrder []string
4042
}
4143

4244
// NewDualWriter creates a DualWriter with the given backends.
@@ -193,13 +195,13 @@ func (d *DualWriter) Script(ctx context.Context, cmd string, args [][]byte) (any
193195
result := d.primary.Do(ctx, iArgs...)
194196
resp, err := result.Result()
195197
d.metrics.CommandDuration.WithLabelValues(cmd, d.primary.Name()).Observe(time.Since(start).Seconds())
196-
d.rememberScript(cmd, args)
197198

198199
if err != nil && !errors.Is(err, redis.Nil) {
199200
d.metrics.CommandTotal.WithLabelValues(cmd, d.primary.Name(), "error").Inc()
200201
return nil, fmt.Errorf("primary script %s: %w", cmd, err)
201202
}
202203
d.metrics.CommandTotal.WithLabelValues(cmd, d.primary.Name(), "ok").Inc()
204+
d.rememberScript(cmd, args)
203205

204206
if d.hasSecondaryWrite() {
205207
d.goWrite(func() { d.writeSecondary(cmd, iArgs) })

proxy/proxy_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package proxy
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"io"
78
"log/slog"
89
"sync"
@@ -829,6 +830,107 @@ func TestDualWriter_Script_CachesEvalForEvalSHAFallback(t *testing.T) {
829830
assert.InDelta(t, 0, testutil.ToFloat64(metrics.SecondaryWriteErrors), 0.001)
830831
}
831832

833+
func TestDualWriter_Script_EvalSHARO_FallsBackToEvalRO(t *testing.T) {
834+
primary := newMockBackend("primary")
835+
primary.doFunc = makeCmd("OK", nil)
836+
837+
secondary := newMockBackend("secondary")
838+
script := "return KEYS[1]"
839+
sha := scriptSHA(script)
840+
var calls int
841+
secondary.doFunc = func(ctx context.Context, args ...any) *redis.Cmd {
842+
calls++
843+
cmd := redis.NewCmd(ctx, args...)
844+
switch calls {
845+
case 1:
846+
assert.Equal(t, []byte("EVALSHA_RO"), args[0])
847+
cmd.SetErr(testRedisErr("NOSCRIPT No matching script. Please use EVAL."))
848+
case 2:
849+
// Must fall back to EVAL_RO, not EVAL, to preserve read-only semantics.
850+
assert.Equal(t, []byte("EVAL_RO"), args[0])
851+
assert.Equal(t, []byte(script), args[1])
852+
cmd.SetVal("mykey")
853+
default:
854+
t.Fatalf("unexpected secondary call %d", calls)
855+
}
856+
return cmd
857+
}
858+
859+
metrics := newTestMetrics()
860+
d := NewDualWriter(primary, secondary, ProxyConfig{Mode: ModeRedisOnly, SecondaryTimeout: time.Second}, metrics, newTestSentry(), testLogger)
861+
862+
// Register the script body via EVAL_RO so the proxy can fall back.
863+
_, err := d.Script(context.Background(), "EVAL_RO", [][]byte{[]byte("EVAL_RO"), []byte(script), []byte("1"), []byte("mykey")})
864+
assert.NoError(t, err)
865+
866+
d.cfg.Mode = ModeDualWrite
867+
d.writeSecondary("EVALSHA_RO", []any{[]byte("EVALSHA_RO"), []byte(sha), []byte("1"), []byte("mykey")})
868+
869+
assert.Equal(t, 2, calls)
870+
assert.InDelta(t, 0, testutil.ToFloat64(metrics.SecondaryWriteErrors), 0.001)
871+
}
872+
873+
func TestDualWriter_Script_NoRememberOnPrimaryError(t *testing.T) {
874+
// Verify that a failed SCRIPT FLUSH on the primary does NOT clear the proxy
875+
// script cache, so that subsequent EVALSHA → EVAL fallbacks still work.
876+
primary := newMockBackend("primary")
877+
primary.doFunc = makeCmd(nil, testRedisErr("ERR flush failed"))
878+
879+
secondary := newMockBackend("secondary")
880+
secondary.doFunc = makeCmd("OK", nil)
881+
882+
metrics := newTestMetrics()
883+
d := NewDualWriter(primary, secondary, ProxyConfig{Mode: ModeRedisOnly, SecondaryTimeout: time.Second}, metrics, newTestSentry(), testLogger)
884+
885+
// Seed the script cache directly.
886+
script := "return 1"
887+
d.storeScript(script)
888+
sha := scriptSHA(script)
889+
_, cached := d.lookupScript(sha)
890+
assert.True(t, cached, "script should be cached before flush attempt")
891+
892+
// Attempt SCRIPT FLUSH — primary returns an error so the cache must be untouched.
893+
_, err := d.Script(context.Background(), "SCRIPT", [][]byte{[]byte("SCRIPT"), []byte("FLUSH")})
894+
assert.Error(t, err)
895+
896+
_, stillCached := d.lookupScript(sha)
897+
assert.True(t, stillCached, "cache must not be cleared when primary SCRIPT FLUSH fails")
898+
}
899+
900+
func TestDualWriter_ScriptCache_BoundedEviction(t *testing.T) {
901+
// Fill the cache beyond maxScriptCacheSize and verify it stays bounded.
902+
primary := newMockBackend("primary")
903+
primary.doFunc = makeCmd("OK", nil)
904+
905+
metrics := newTestMetrics()
906+
d := NewDualWriter(primary, nil, ProxyConfig{Mode: ModeRedisOnly, SecondaryTimeout: time.Second}, metrics, newTestSentry(), testLogger)
907+
908+
// Insert maxScriptCacheSize+10 unique scripts.
909+
total := maxScriptCacheSize + 10
910+
for i := range total {
911+
d.storeScript(fmt.Sprintf("return %d", i))
912+
}
913+
914+
d.scriptMu.RLock()
915+
size := len(d.scripts)
916+
d.scriptMu.RUnlock()
917+
918+
assert.Equal(t, maxScriptCacheSize, size, "cache must not exceed maxScriptCacheSize")
919+
920+
// The first 10 scripts (insertion order) must have been evicted.
921+
for i := range 10 {
922+
sha := scriptSHA(fmt.Sprintf("return %d", i))
923+
_, ok := d.lookupScript(sha)
924+
assert.False(t, ok, "script %d should have been evicted", i)
925+
}
926+
// The last maxScriptCacheSize scripts must still be present.
927+
for i := 10; i < total; i++ {
928+
sha := scriptSHA(fmt.Sprintf("return %d", i))
929+
_, ok := d.lookupScript(sha)
930+
assert.True(t, ok, "script %d should still be cached", i)
931+
}
932+
}
933+
832934
// ========== writeRedisValue tests ==========
833935

834936
// testRedisErr satisfies the redis.Error interface for testing.

proxy/script_cache.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,27 @@ import (
1010
)
1111

1212
const (
13-
cmdEval = "EVAL"
14-
cmdEvalSHA = "EVALSHA"
15-
cmdScript = "SCRIPT"
13+
cmdEval = "EVAL"
14+
cmdEvalRO = "EVAL_RO"
15+
cmdEvalSHA = "EVALSHA"
16+
cmdEvalSHARO = "EVALSHA_RO"
17+
cmdScript = "SCRIPT"
1618

1719
minScriptSubcommandArgs = 2
1820
scriptLoadArgIndex = 2
1921
minEvalSHAArgs = 2
22+
23+
// maxScriptCacheSize is the maximum number of scripts retained in the
24+
// proxy-side script cache. When the limit is reached, the oldest entry
25+
// (by insertion order) is evicted to prevent unbounded memory growth.
26+
maxScriptCacheSize = 512
2027
)
2128

2229
func (d *DualWriter) rememberScript(cmd string, args [][]byte) {
2330
upper := strings.ToUpper(cmd)
2431

2532
switch upper {
26-
case cmdEval, "EVAL_RO":
33+
case cmdEval, cmdEvalRO:
2734
if len(args) > 1 {
2835
d.storeScript(string(args[1]))
2936
}
@@ -47,13 +54,30 @@ func (d *DualWriter) storeScript(script string) {
4754

4855
d.scriptMu.Lock()
4956
defer d.scriptMu.Unlock()
57+
58+
if _, exists := d.scripts[sha]; !exists {
59+
// Evict the oldest entry when at capacity.
60+
// scriptOrder and scripts always have the same length, so the guard is
61+
// sufficient; the additional len(scriptOrder) check is not needed.
62+
if len(d.scripts) >= maxScriptCacheSize {
63+
oldest := d.scriptOrder[0]
64+
d.scriptOrder = d.scriptOrder[1:]
65+
delete(d.scripts, oldest)
66+
}
67+
// Append to the end so that eviction follows strict FIFO insertion order.
68+
// Re-storing an already-cached script does not move its position; the
69+
// entry retains its original eviction priority, which is acceptable for
70+
// this use-case because script bodies are immutable (same SHA ⇒ same body).
71+
d.scriptOrder = append(d.scriptOrder, sha)
72+
}
5073
d.scripts[sha] = script
5174
}
5275

5376
func (d *DualWriter) clearScripts() {
5477
d.scriptMu.Lock()
5578
defer d.scriptMu.Unlock()
5679
clear(d.scripts)
80+
d.scriptOrder = d.scriptOrder[:0]
5781
}
5882

5983
func (d *DualWriter) lookupScript(sha string) (string, bool) {
@@ -65,7 +89,7 @@ func (d *DualWriter) lookupScript(sha string) (string, bool) {
6589

6690
func (d *DualWriter) evalFallbackArgs(cmd string, iArgs []any) ([]any, bool) {
6791
upper := strings.ToUpper(cmd)
68-
if upper != cmdEvalSHA && upper != "EVALSHA_RO" {
92+
if upper != cmdEvalSHA && upper != cmdEvalSHARO {
6993
return nil, false
7094
}
7195
if len(iArgs) < minEvalSHAArgs {
@@ -78,8 +102,14 @@ func (d *DualWriter) evalFallbackArgs(cmd string, iArgs []any) ([]any, bool) {
78102
return nil, false
79103
}
80104

105+
// Preserve the read-only semantics: EVALSHA_RO falls back to EVAL_RO.
106+
fallbackCmd := cmdEval
107+
if upper == cmdEvalSHARO {
108+
fallbackCmd = cmdEvalRO
109+
}
110+
81111
fallback := make([]any, len(iArgs))
82-
fallback[0] = []byte(cmdEval)
112+
fallback[0] = []byte(fallbackCmd)
83113
fallback[1] = []byte(script)
84114
copy(fallback[2:], iArgs[2:])
85115
return fallback, true

0 commit comments

Comments
 (0)