Files
go-redis/csc_keygate_test.go
Nedyalko Dyakov 0bc2cb8dc2 perf(csc): build the key list only on a miss (#4037)
* perf(csc): build the key list only on a miss

extractRedisKeys and the namespaced copy ran before the cache-hit
check, but only Reserve uses them and Reserve is on the miss path. At a
94-99% hit rate that allocated a key slice, a namespaced slice and one
string per key on nearly every read and discarded all three -- by
object count about 18% of everything the client allocated.

Object count is what matters here: after cache-key pooling cut
allocated bytes by 30%, memclrNoHeapPointers was still 20% of CPU,
because malloc and zeroing track the number of objects, not their size.

Deferring the "no key list, not cacheable" gate along with it is safe:
extractRedisKeys depends only on the command's shape, so a command with
no key list was never cacheable and can never be a hit. The gate still
runs before anything is cached.

* fix(csc): check key types before the cache key

Deferring the key list to the miss path also deferred the key-type
check, so buildCacheKeyNS encoded the arguments first. For a key the
cache cannot render, such as a BinaryMarshaler, MarshalBinary then ran
twice: once for the cache key and once for the request.

The key positions of every cacheable command are one contiguous span.
processCached now reads that span and checks its types before it
builds the cache key, without allocating, and builds the key strings
only on a miss. extractRedisKeys moves to the tests.

* fix(csc): reject negative key positions

SetFirstKeyPos is public and takes any int8. isCacheable accepted any
nonzero position and cscKeySpan rejected only 0, so a negative one let
cscKeysRenderable index args[-1] and panic the process. The keyArg
bounds check this replaced had treated such a command as uncacheable.
Both now require a position above 0, so the command is sent normally.
2026-10-01 15:22:20 +03:00

77 lines
2.2 KiB
Go

package redis_test
import (
"context"
"strconv"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
// countingKeyArg is a key argument the cache cannot render in wire form, so
// the command is uncacheable. It counts MarshalBinary calls.
type countingKeyArg struct {
key string
calls int
}
func (a *countingKeyArg) MarshalBinary() ([]byte, error) {
a.calls++
return []byte(a.key), nil
}
// TestCSCUncacheableKeyMarshaledOnce pins the key-type gate order in
// processCached: a command whose key the cache cannot render must fall back
// before the cache key is built. Building the cache key encodes every
// argument, so a BinaryMarshaler key would be marshaled once for the cache
// key and again for the request.
func TestCSCUncacheableKeyMarshaledOnce(t *testing.T) {
if err := probeRedis(cscNativeAddr()); err != nil {
t.Skipf("redis not available at %s: %v", cscNativeAddr(), err)
}
cache := redis.NewLocalCache(redis.CacheConfig{MaxEntries: 32})
c := redis.NewClient(&redis.Options{
Addr: cscNativeAddr(),
Protocol: 3,
ClientSideCache: cache,
PoolSize: 1,
})
t.Cleanup(func() { _ = c.Close() })
ctx := context.Background()
if err := c.Ping(ctx).Err(); err != nil {
t.Skipf("redis not available at %s: %v", cscNativeAddr(), err)
}
plain := redis.NewClient(&redis.Options{Addr: cscNativeAddr()})
t.Cleanup(func() { _ = plain.Close() })
unavailable, err := probeClientTracking(ctx, plain)
if unavailable {
t.Skipf("CLIENT TRACKING is unavailable: %v", err)
}
if err != nil {
t.Fatalf("probe CLIENT TRACKING: %v", err)
}
key := "csc-keygate:" + strconv.FormatInt(time.Now().UnixNano(), 10)
if err := plain.Set(ctx, key, "v", 0).Err(); err != nil {
t.Fatalf("SET: %v", err)
}
t.Cleanup(func() { _ = plain.Del(context.Background(), key).Err() })
arg := &countingKeyArg{key: key}
cmd := redis.NewStringCmd(ctx, "get", arg)
if err := c.Process(ctx, cmd); err != nil {
t.Fatalf("GET: %v", err)
}
if got := cmd.Val(); got != "v" {
t.Fatalf("GET = %q, want v", got)
}
if arg.calls != 1 {
t.Fatalf("MarshalBinary called %d times, want 1 (the request only)", arg.calls)
}
if n := cache.Len(); n != 0 {
t.Fatalf("cache.Len = %d, want 0: an unrenderable key must not be cached", n)
}
}