@@ -3,6 +3,7 @@ package proxy
33import (
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.
0 commit comments