Files
graphql-monitoring-proxy/cache/redis/redis_clear_glob_test.go
T
lukaszraczylo 67cc71bbf8 test(redis): paginate SCAN in the Clear failure test
miniredis 2.39 ignores the SCAN COUNT hint and returns every key in one
batch. The test now paginates SCAN in its own hook, so it no longer
depends on miniredis batching.
2026-10-04 10:14:04 +01:00

203 lines
7.1 KiB
Go

package libpack_cache_redis
import (
"fmt"
"sort"
"strconv"
"sync/atomic"
"testing"
"github.com/alicebob/miniredis/v2"
miniredis_server "github.com/alicebob/miniredis/v2/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// Clear — prefix containing Redis glob metacharacters
// ---------------------------------------------------------------------------
// TestClear_PrefixWithGlobMetacharacters_ClearsOnlyOwnedKeys covers the
// defect where a prefix containing Redis glob metacharacters (*, ?, [, ],
// \) corrupted the MATCH pattern passed to SCAN, so Clear silently deleted
// nothing (or the wrong keys). The prefix must now be matched literally.
func TestClear_PrefixWithGlobMetacharacters_ClearsOnlyOwnedKeys(t *testing.T) {
t.Parallel()
s, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(s.Close)
const globPrefix = "a[b]c*d?e\\f:"
owned, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: globPrefix})
require.NoError(t, err)
other, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: "unrelated:"})
require.NoError(t, err)
require.NoError(t, owned.Set("k1", []byte("v1"), 0))
require.NoError(t, owned.Set("k2", []byte("v2"), 0))
require.NoError(t, other.Set("k1", []byte("untouched"), 0))
require.NoError(t, owned.Clear())
// Keys under the glob-metacharacter prefix are gone.
_, found, err := owned.Get("k1")
assert.NoError(t, err)
assert.False(t, found, "key under glob-metacharacter prefix should be cleared")
_, found, err = owned.Get("k2")
assert.NoError(t, err)
assert.False(t, found, "key under glob-metacharacter prefix should be cleared")
// Key under the unrelated prefix survives untouched.
val, found, err := other.Get("k1")
assert.NoError(t, err)
assert.True(t, found, "key outside the cleared prefix must not be touched")
assert.Equal(t, []byte("untouched"), val)
}
// TestCountQueries_PrefixWithGlobMetacharacters_CountsOnlyOwnedKeys covers
// the same class of defect as TestClear_PrefixWithGlobMetacharacters_
// ClearsOnlyOwnedKeys, but for CountQueries: an unescaped glob-metacharacter
// prefix embedded directly in the KEYS pattern would report the wrong count
// for the admin dashboard (matching too few or unrelated keys).
func TestCountQueries_PrefixWithGlobMetacharacters_CountsOnlyOwnedKeys(t *testing.T) {
t.Parallel()
s, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(s.Close)
const globPrefix = "a[b]c*d?e\\f:"
owned, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: globPrefix})
require.NoError(t, err)
other, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: "unrelated:"})
require.NoError(t, err)
require.NoError(t, owned.Set("k1", []byte("v1"), 0))
require.NoError(t, owned.Set("k2", []byte("v2"), 0))
require.NoError(t, other.Set("k1", []byte("untouched"), 0))
count, err := owned.CountQueries()
require.NoError(t, err)
assert.Equal(t, int64(2), count, "CountQueries must count only keys under the glob-metacharacter prefix")
patternCount, err := owned.CountQueriesWithPattern("*")
require.NoError(t, err)
assert.Equal(t, 2, patternCount, "CountQueriesWithPattern must count only keys under the glob-metacharacter prefix")
}
// TestClear_NormalPrefix_StillClearsAllOwnedKeys is a regression check that
// the happy path (a prefix without glob metacharacters) still works exactly
// as before.
func TestClear_NormalPrefix_StillClearsAllOwnedKeys(t *testing.T) {
t.Parallel()
s, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(s.Close)
rc, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: "normal:"})
require.NoError(t, err)
require.NoError(t, rc.Set("k1", []byte("v1"), 0))
require.NoError(t, rc.Set("k2", []byte("v2"), 0))
require.NoError(t, rc.Set("k3", []byte("v3"), 0))
count, err := rc.CountQueries()
require.NoError(t, err)
require.Equal(t, int64(3), count)
assert.NoError(t, rc.Clear())
count, err = rc.CountQueries()
assert.NoError(t, err)
assert.Equal(t, int64(0), count)
}
// ---------------------------------------------------------------------------
// Clear — mid-scan DEL failure
// ---------------------------------------------------------------------------
// TestClear_AllBatchesDelFail_AttemptsEveryBatchAndAggregatesErrors covers
// the defect where a mid-scan DEL error aborted Clear early, leaving a
// partial clear with no error surfaced to the caller.
//
// miniredis exposes its underlying server.Server via Miniredis.Server(),
// whose SetPreHook is a documented public hook point (used by miniredis
// itself for auth/error simulation, see Miniredis.SetError). It lets a test
// intercept DEL commands without a rewrite of RedisConfig into an
// interface-backed mock, which the task's infra allows.
//
// Every DEL call is made to fail so the key set never shrinks between
// scans. SCAN is paginated by the hook itself because miniredis ignores
// COUNT and returns every key in one batch.
func TestClear_AllBatchesDelFail_AttemptsEveryBatchAndAggregatesErrors(t *testing.T) {
s, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(s.Close)
rc, err := New(&RedisClientConfig{RedisServer: s.Addr(), Prefix: "pfx:"})
require.NoError(t, err)
// 250 owned keys with a SCAN page size of 100 yield exactly 3 batches.
const totalKeys = 250
const wantBatches = 3
for i := 0; i < totalKeys; i++ {
key := "k" + zeroPad(i)
require.NoError(t, rc.Set(key, []byte("v"), 0))
}
var seen int32
const scanPage = 100
s.Server().SetPreHook(func(c *miniredis_server.Peer, cmd string, args ...string) bool {
if cmd == "SCAN" {
cursor, convErr := strconv.Atoi(args[0])
if convErr != nil {
c.WriteError("ERR invalid cursor")
return true
}
keys := s.Keys()
sort.Strings(keys)
end := min(cursor+scanPage, len(keys))
next := end
if end == len(keys) {
next = 0
}
c.WriteLen(2)
c.WriteBulk(strconv.Itoa(next))
c.WriteStrings(keys[cursor:end])
return true
}
if cmd != "DEL" {
return false // let miniredis handle everything else normally
}
n := atomic.AddInt32(&seen, 1)
c.WriteError(fmt.Sprintf("SIMULATED DEL FAILURE (batch %d)", n))
return true // command handled (as an error); miniredis skips its own DEL
})
clearErr := rc.Clear()
require.Error(t, clearErr, "Clear must surface DEL failures instead of swallowing them")
assert.Contains(t, clearErr.Error(), "SIMULATED DEL FAILURE")
// Every batch was attempted: DEL was invoked once per batch even though
// every prior call failed. The old code aborted after the first failure,
// which would leave seen == 1 here.
assert.Equal(t, int32(wantBatches), atomic.LoadInt32(&seen), "every batch must be attempted even after a DEL error")
remaining, err := rc.CountQueries()
assert.NoError(t, err)
assert.Equal(t, int64(totalKeys), remaining, "no keys are removed when every DEL fails")
}
func zeroPad(i int) string {
// Zero-pad to 3 digits so lexicographic sort (used by miniredis SCAN)
// matches numeric order, keeping batch membership predictable.
digits := [3]byte{'0', '0', '0'}
s := digits[:]
for pos := len(s) - 1; i > 0 && pos >= 0; pos-- {
s[pos] = byte('0' + i%10)
i /= 10
}
return string(s)
}