Skip to content

Commit bfaeaa3

Browse files
committed
fix(kvblock): avoid Redis full-scan clear
Signed-off-by: Kay Yan <kay.yan@daocloud.io>
1 parent 36e8415 commit bfaeaa3

5 files changed

Lines changed: 530 additions & 42 deletions

File tree

pkg/kvcache/kvblock/redis.go

Lines changed: 62 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ func (r *RedisIndex) Add(ctx context.Context, engineKeys, requestKeys []BlockHas
272272
return err
273273
}
274274
pipe.HSet(ctx, redisKey, field, "")
275+
pipe.SAdd(ctx, redisPodEntriesKey(entry.PodIdentifier), redisPodEntryMember(redisKey, field))
275276
}
276277
}
277278

@@ -330,6 +331,7 @@ func (r *RedisIndex) evictPodsFromRequestKey(ctx context.Context, requestKey Blo
330331
return err
331332
}
332333
pipe.HDel(ctx, redisKey, field)
334+
pipe.SRem(ctx, redisPodEntriesKey(entry.PodIdentifier), redisPodEntryMember(redisKey, field))
333335
}
334336

335337
if _, err := pipe.Exec(ctx); err != nil {
@@ -408,60 +410,79 @@ func redisEngineKey(engineKey BlockHash) string {
408410
return "engine:" + engineKey.String()
409411
}
410412

411-
// Clear removes every hash field for the pod across all request-key hashes and
412-
// device tiers. Each field is a JSON-encoded PodEntry, so matching decodes the
413-
// field and compares PodIdentifier — catching every tier, group, and speculative
414-
// variant. It pages the keyspace with SCAN (skipping engine: keys), HDELs the
415-
// pod's fields, and prunes now-empty hashes. O(keyspace), but Clear is rare and
416-
// off the Lookup/Add hot path. Because it deletes from the shared store, it is
417-
// correct for multi-replica deployments with no cross-process coordination.
413+
func redisPodEntriesKey(podIdentifier string) string {
414+
return "kvblock:pod:" + podIdentifier + ":entries"
415+
}
416+
417+
func redisPodEntryMember(requestKey, field string) string {
418+
return requestKey + "\x00" + field
419+
}
420+
421+
type redisPodEntryRef struct {
422+
requestKey string
423+
field string
424+
}
425+
426+
func parseRedisPodEntryMember(member string) (redisPodEntryRef, bool) {
427+
requestKey, field, ok := strings.Cut(member, "\x00")
428+
if !ok || requestKey == "" || field == "" {
429+
return redisPodEntryRef{}, false
430+
}
431+
return redisPodEntryRef{requestKey: requestKey, field: field}, true
432+
}
433+
434+
// Clear removes every reverse-indexed field for the pod across all request-key
435+
// hashes and device tiers. Add maintains an exact per-pod set of
436+
// "<requestKey>\x00<encodedPodField>" entries, so Clear only touches keys this
437+
// index recorded for that pod instead of scanning the whole Redis keyspace.
418438
func (r *RedisIndex) Clear(ctx context.Context, podIdentifier string) error {
419439
logger := log.FromContext(ctx).WithName("kvblock.RedisIndex.Clear")
440+
podEntriesKey := redisPodEntriesKey(podIdentifier)
420441

421442
const scanBatch int64 = 1024
422-
removed := 0
443+
processed := 0
423444
var cursor uint64
424445
for {
425-
keys, next, err := r.RedisClient.Scan(ctx, cursor, "*", scanBatch).Result()
446+
members, next, err := r.RedisClient.SScan(ctx, podEntriesKey, cursor, "", scanBatch).Result()
426447
if err != nil {
427-
return fmt.Errorf("clear scan failed: %w", err)
448+
return fmt.Errorf("clear reverse-index scan failed: %w", err)
428449
}
429-
for _, key := range keys {
430-
if strings.HasPrefix(key, "engine:") {
431-
continue // engine:<hash> ZSETs hold no pod fields
432-
}
433-
434-
fields, err := r.RedisClient.HKeys(ctx, key).Result()
435-
if err != nil {
436-
return fmt.Errorf("clear hkeys failed for %s: %w", key, err)
437-
}
438-
439-
var stale []string
440-
for _, field := range fields {
441-
if entry, ok := decodeRedisPodField(field); ok && entry.PodIdentifier == podIdentifier {
442-
stale = append(stale, field)
443-
}
444-
}
445-
if len(stale) == 0 {
446-
continue
447-
}
448-
449-
if err := r.RedisClient.HDel(ctx, key, stale...).Err(); err != nil {
450-
return fmt.Errorf("clear hdel failed for %s: %w", key, err)
451-
}
452-
removed += len(stale)
453-
454-
if err := pruneRequestKeyScript.Run(ctx, r.RedisClient, []string{key}).Err(); err != nil &&
455-
!errors.Is(err, redis.Nil) {
456-
return fmt.Errorf("clear prune failed for %s: %w", key, err)
457-
}
450+
if err := r.clearRedisPodEntryMembers(ctx, podEntriesKey, members); err != nil {
451+
return err
458452
}
453+
processed += len(members)
459454

460-
if cursor = next; cursor == 0 {
455+
if cursor = next; cursor != 0 {
456+
continue
457+
}
458+
remaining, err := r.RedisClient.SCard(ctx, podEntriesKey).Result()
459+
if err != nil {
460+
return fmt.Errorf("clear reverse-index cardinality failed: %w", err)
461+
}
462+
if remaining == 0 {
461463
break
462464
}
463465
}
464466

465-
logger.Info("cleared pod from index", "pod", podIdentifier, "removed", removed)
467+
logger.Info("cleared pod from index", "pod", podIdentifier, "members", processed)
468+
return nil
469+
}
470+
471+
func (r *RedisIndex) clearRedisPodEntryMembers(ctx context.Context, podEntriesKey string, members []string) error {
472+
if len(members) == 0 {
473+
return nil
474+
}
475+
476+
pipe := r.RedisClient.Pipeline()
477+
for _, member := range members {
478+
ref, ok := parseRedisPodEntryMember(member)
479+
if ok {
480+
pipe.HDel(ctx, ref.requestKey, ref.field)
481+
}
482+
pipe.SRem(ctx, podEntriesKey, member)
483+
}
484+
if _, err := pipe.Exec(ctx); err != nil {
485+
return fmt.Errorf("clear reverse-index pipeline failed: %w", err)
486+
}
466487
return nil
467488
}

pkg/kvcache/kvblock/redis_test.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package kvblock_test
1818

1919
import (
20+
"encoding/json"
2021
"testing"
2122

2223
"github.com/alicebob/miniredis/v2"
@@ -27,6 +28,11 @@ import (
2728

2829
// createRedisIndexForTesting creates a new RedisIndex with a mock Redis server for testing.
2930
func createRedisIndexForTesting(t *testing.T) Index {
31+
t.Helper()
32+
return createRedisIndexForInspection(t)
33+
}
34+
35+
func createRedisIndexForInspection(t *testing.T) *RedisIndex {
3036
t.Helper()
3137
server, err := miniredis.Run()
3238
require.NoError(t, err)
@@ -41,10 +47,81 @@ func createRedisIndexForTesting(t *testing.T) Index {
4147
}
4248
index, err := NewRedisIndex(redisConfig)
4349
require.NoError(t, err)
44-
return index
50+
redisIndex, ok := index.(*RedisIndex)
51+
require.True(t, ok)
52+
return redisIndex
4553
}
4654

4755
// TestRedisIndexBehavior tests the Redis index implementation using common test behaviors.
4856
func TestRedisIndexBehavior(t *testing.T) {
4957
testCommonIndexBehavior(t, createRedisIndexForTesting)
5058
}
59+
60+
func TestRedisClearPreservesUnrelatedKeys(t *testing.T) {
61+
index := createRedisIndexForInspection(t)
62+
ctx := t.Context()
63+
pod := PodEntry{PodIdentifier: "pod-clear", DeviceTier: "gpu"}
64+
requestKey := BlockHash(0xC1EA1001)
65+
unrelatedField := redisTestPodField(t, pod)
66+
67+
require.NoError(t, index.Add(ctx, nil, []BlockHash{requestKey}, []PodEntry{pod}))
68+
require.NoError(t, index.RedisClient.HSet(ctx, "unrelated-hash", unrelatedField, "keep").Err())
69+
require.NoError(t, index.RedisClient.Set(ctx, "unrelated-string", "keep", 0).Err())
70+
71+
require.NoError(t, index.Clear(ctx, pod.PodIdentifier))
72+
73+
gotHash, err := index.RedisClient.HGet(ctx, "unrelated-hash", unrelatedField).Result()
74+
require.NoError(t, err)
75+
require.Equal(t, "keep", gotHash)
76+
77+
gotString, err := index.RedisClient.Get(ctx, "unrelated-string").Result()
78+
require.NoError(t, err)
79+
require.Equal(t, "keep", gotString)
80+
81+
hits, err := index.Lookup(ctx, []BlockHash{requestKey}, nil)
82+
require.NoError(t, err)
83+
require.Empty(t, hits[requestKey])
84+
85+
exists, err := index.RedisClient.Exists(ctx, requestKey.String()).Result()
86+
require.NoError(t, err)
87+
require.Zero(t, exists)
88+
}
89+
90+
func TestRedisEvictPrunesReverseIndex(t *testing.T) {
91+
index := createRedisIndexForInspection(t)
92+
ctx := t.Context()
93+
pod := PodEntry{PodIdentifier: "pod-evict", DeviceTier: "gpu"}
94+
requestKey := BlockHash(0xC1EA1002)
95+
podEntriesKey := "kvblock:pod:" + pod.PodIdentifier + ":entries"
96+
97+
require.NoError(t, index.Add(ctx, nil, []BlockHash{requestKey}, []PodEntry{pod}))
98+
count, err := index.RedisClient.SCard(ctx, podEntriesKey).Result()
99+
require.NoError(t, err)
100+
require.EqualValues(t, 1, count)
101+
102+
require.NoError(t, index.Evict(ctx, requestKey, RequestKey, []PodEntry{pod}))
103+
104+
count, err = index.RedisClient.SCard(ctx, podEntriesKey).Result()
105+
require.NoError(t, err)
106+
require.Zero(t, count)
107+
}
108+
109+
func TestRedisClearDropsMalformedReverseEntry(t *testing.T) {
110+
index := createRedisIndexForInspection(t)
111+
ctx := t.Context()
112+
podEntriesKey := "kvblock:pod:pod-malformed:entries"
113+
114+
require.NoError(t, index.RedisClient.SAdd(ctx, podEntriesKey, "malformed").Err())
115+
require.NoError(t, index.Clear(ctx, "pod-malformed"))
116+
117+
count, err := index.RedisClient.SCard(ctx, podEntriesKey).Result()
118+
require.NoError(t, err)
119+
require.Zero(t, count)
120+
}
121+
122+
func redisTestPodField(t *testing.T, entry PodEntry) string {
123+
t.Helper()
124+
value, err := json.Marshal(entry)
125+
require.NoError(t, err)
126+
return string(value)
127+
}

tests/profiling/kv_cache_index/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,44 @@ use the -bench option to filter
3535
go test -bench=Redis -benchmem
3636
```
3737

38+
### Redis Clear benchmarks
39+
40+
The Redis Clear benchmark has two modes:
41+
42+
* Default mode uses `miniredis`, so it needs no external Redis server and is suitable for quick local checks.
43+
* Real Redis mode is guarded by the `redis_real_bench` build tag and requires `KV_CACHE_BENCH_REDIS_ADDR`.
44+
45+
Run the default `miniredis` benchmark:
46+
47+
```bash
48+
go test ./tests/profiling/kv_cache_index \
49+
-run '^$' \
50+
-bench 'BenchmarkRedisClear(SharedKeyspace|PodFanout|Scale)' \
51+
-benchmem \
52+
-benchtime=3x \
53+
-count=1
54+
```
55+
56+
Run the same Clear scenarios against a real Redis server:
57+
58+
```bash
59+
docker run --rm --name llmd-redis-bench \
60+
-p 127.0.0.1:6380:6379 \
61+
redis:7.4.1 redis-server --save '' --appendonly no
62+
```
63+
64+
In another shell:
65+
66+
```bash
67+
KV_CACHE_BENCH_REDIS_ADDR='redis://127.0.0.1:6380' \
68+
go test -tags redis_real_bench ./tests/profiling/kv_cache_index \
69+
-run '^$' \
70+
-bench 'BenchmarkRedisClearReal(Scale|SharedKeyspace|PodFanout)' \
71+
-benchmem \
72+
-benchtime=3x \
73+
-count=1
74+
```
75+
3876
### Understanding the Output
3977

4078
`BenchmarkInMemory_Add-12 192 6086106 ns/op 500 B/op 5 allocs/op`

0 commit comments

Comments
 (0)