Skip to content

Commit 61a14d0

Browse files
committed
adapter/redis: apply valid review suggestions while preserving correct pubsub
- Make hset, hincrby, BZPOPMIN atomic via retryRedisWrite pattern - Replace reflect/unsafe pubsub introspection with self-contained redisPubSub - Pass startTS through dispatchElems for MVCC consistency in retry paths - Guard snapshotTS MaxUint64 sentinel in dispatchElems - Replace dynamoSelectCount with redisKeywordCount in xread/xrange - Fix raftstore pebble error wrapping for ErrLogNotFound - Document known implementation issues in REDIS_KNOWN_ISSUES.md
1 parent 5c3b2a4 commit 61a14d0

7 files changed

Lines changed: 494 additions & 173 deletions

File tree

adapter/REDIS_KNOWN_ISSUES.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Redis Adapter: Known Implementation Issues
2+
3+
This document tracks architectural limitations identified during PR #350 review
4+
that require larger refactoring to address.
5+
6+
## Performance
7+
8+
### LPUSH / list mutations are O(N)
9+
- **Location**: `redis_compat_commands.go` (lpush, ltrim, rpush)
10+
- **Problem**: List modification commands read the entire list, modify in memory,
11+
and rewrite the whole list. This is O(N) where N is the number of elements.
12+
- **Fix**: Push list manipulation down into the storage layer so individual
13+
elements can be appended/removed without full rewrite.
14+
15+
### SCAN materializes all keys
16+
- **Location**: `redis_compat_commands.go` (scan)
17+
- **Problem**: SCAN builds and sorts the full set of visible keys on every call,
18+
using the cursor as an array index. This is O(N) per call and diverges from
19+
Redis's incremental cursor semantics.
20+
- **Fix**: Implement SCAN as an incremental range scan over the underlying store,
21+
returning the next cursor based on the last returned key.
22+
23+
### FLUSHDB iterates all keys
24+
- **Location**: `redis_compat_commands.go` (flushdb)
25+
- **Problem**: FLUSHDB/FLUSHALL iterates through all visible keys and deletes
26+
them one by one. This can be very slow for large datasets.
27+
- **Fix**: Implement a range deletion capability at the storage layer.
28+
29+
### visibleKeys N x M per-key type checks
30+
- **Location**: `redis_compat_helpers.go` (visibleKeys)
31+
- **Problem**: `visibleKeys` calls `logicalExistsAt` per key, which performs
32+
multiple `ExistsAt` checks across namespaces (list, hash, set, zset, stream,
33+
ttl, etc.). For KEYS, DBSIZE, and SCAN this becomes O(N*M).
34+
- **Fix**: Filter internal namespaces during scanning and derive logical
35+
existence in a single pass.
36+
37+
### localKeysPattern redundant scans
38+
- **Location**: `redis.go` (localKeysPattern)
39+
- **Problem**: `localKeysPattern` performs additional scans over list and Redis
40+
internal namespaces even when `start/end` are nil (full keyspace). This causes
41+
multiple redundant full scans per call.
42+
- **Fix**: Only do extra namespace scans when the pattern has a bounded user
43+
prefix, or skip when `start == nil && end == nil`.
44+
45+
### BZPOPMIN / XREAD busy-polling
46+
- **Location**: `redis_compat_commands.go` (bzpopmin, xread)
47+
- **Problem**: Both use `time.Sleep` polling loops to wait for data. This wastes
48+
CPU cycles when there are no new elements.
49+
- **Fix**: Implement a wait/notify pattern where ZADD/XADD signal waiting
50+
BZPOPMIN/XREAD goroutines via condition variables or channels.
51+
52+
## Consistency
53+
54+
### EXISTS / GET follower reads
55+
- **Location**: `redis.go` (exists, get)
56+
- **Problem**: EXISTS checks local MVCC state without leader proxying, which can
57+
return stale results on followers. GET's pre-check via `keyType()` also uses
58+
local state, potentially returning false negatives when the follower lags.
59+
- **Fix**: Proxy EXISTS reads to the leader (or verify leader status), and
60+
perform the GET type check via the leader/proxy path as well.
61+
62+
## Design
63+
64+
### PFCOUNT is exact cardinality, not HyperLogLog
65+
- **Location**: `redis_compat_commands.go` (pfadd, pfcount)
66+
- **Problem**: PFADD/PFCOUNT stores all unique elements in a set and returns its
67+
exact size, rather than using a probabilistic HyperLogLog structure.
68+
For large datasets this consumes significantly more memory than a real HLL.
69+
- **Note**: For Misskey's use case the exact implementation is acceptable. Only
70+
relevant if elastickv is used as a general-purpose Redis replacement with
71+
large cardinality sets.

adapter/redis.go

Lines changed: 30 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,7 @@ type RedisServer struct {
243243
store store.MVCCStore
244244
coordinator kv.Coordinator
245245
redisTranscoder *redisTranscoder
246-
pubsub redcon.PubSub
247-
pubsubMu sync.RWMutex
248-
pubsubChannels map[string]int
246+
pubsub *redisPubSub
249247
scriptMu sync.RWMutex
250248
scriptCache map[string]string
251249
traceCommands bool
@@ -260,9 +258,8 @@ type RedisServer struct {
260258
}
261259

262260
type connState struct {
263-
inTxn bool
264-
queue []redcon.Command
265-
subscriptions map[string]struct{}
261+
inTxn bool
262+
queue []redcon.Command
266263
}
267264

268265
type resultType int
@@ -297,7 +294,7 @@ func NewRedisServer(listen net.Listener, redisAddr string, store store.MVCCStore
297294
redisAddr: redisAddr,
298295
relay: relay,
299296
leaderRedis: leaderRedis,
300-
pubsubChannels: map[string]int{},
297+
pubsub: newRedisPubSub(),
301298
scriptCache: map[string]string{},
302299
traceCommands: os.Getenv("ELASTICKV_REDIS_TRACE") == "1",
303300
}
@@ -433,17 +430,18 @@ func (r *RedisServer) Run() error {
433430

434431
traceID, traceStart := r.traceCommandStart(conn, name, cmd.Args[1:])
435432
f(conn, cmd)
436-
r.traceCommandFinish(traceID, conn, name, time.Since(traceStart))
433+
if r.traceCommands {
434+
r.traceCommandFinish(traceID, conn, name, time.Since(traceStart))
435+
}
437436
},
438437
func(conn redcon.Conn) bool {
439438
// Use this function to accept or deny the connection.
440439
// log.Printf("accept: %s", conn.RemoteAddr())
441440
return true
442441
},
443442
func(conn redcon.Conn, err error) {
444-
// This is called when the connection has been closed
445-
// log.Printf("closed: %s, err: %v", conn.RemoteAddr(), err)
446-
r.cleanupConnSubscriptions(conn)
443+
// This is called when the connection has been closed.
444+
// PubSub connections clean up their own subscriptions via bgrunner.
447445
})
448446

449447
return errors.WithStack(err)
@@ -609,7 +607,7 @@ func (r *RedisServer) replaceWithString(ctx context.Context, key, value []byte,
609607
} else {
610608
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(key)})
611609
}
612-
return r.dispatchElems(ctx, true, elems)
610+
return r.dispatchElems(ctx, true, 0, elems)
613611
}
614612

615613
func (r *RedisServer) executeSet(ctx context.Context, key, value []byte, opts redisSetOptions) (redisSetExecution, error) {
@@ -632,40 +630,6 @@ func (r *RedisServer) executeSet(ctx context.Context, key, value []byte, opts re
632630
return redisSetExecution{state: state, wroteOldBulk: opts.returnOld}, nil
633631
}
634632

635-
func (r *RedisServer) trackSubscription(conn redcon.Conn, channel string) {
636-
state := getConnState(conn)
637-
if state.subscriptions == nil {
638-
state.subscriptions = map[string]struct{}{}
639-
}
640-
if _, ok := state.subscriptions[channel]; ok {
641-
return
642-
}
643-
state.subscriptions[channel] = struct{}{}
644-
645-
r.pubsubMu.Lock()
646-
defer r.pubsubMu.Unlock()
647-
r.pubsubChannels[channel]++
648-
}
649-
650-
func (r *RedisServer) cleanupConnSubscriptions(conn redcon.Conn) {
651-
ctx := conn.Context()
652-
state, ok := ctx.(*connState)
653-
if !ok || len(state.subscriptions) == 0 {
654-
return
655-
}
656-
657-
r.pubsubMu.Lock()
658-
defer r.pubsubMu.Unlock()
659-
for channel := range state.subscriptions {
660-
if n := r.pubsubChannels[channel]; n <= 1 {
661-
delete(r.pubsubChannels, channel)
662-
} else {
663-
r.pubsubChannels[channel] = n - 1
664-
}
665-
}
666-
state.subscriptions = nil
667-
}
668-
669633
func (r *RedisServer) Stop() {
670634
_ = r.relayConnCache.Close()
671635
_ = r.listen.Close()
@@ -838,7 +802,7 @@ func (r *RedisServer) del(conn redcon.Conn, cmd redcon.Command) {
838802
}
839803
elems = append(elems, keyElems...)
840804
}
841-
if err := r.dispatchElems(ctx, true, elems); err != nil {
805+
if err := r.dispatchElems(ctx, true, readTS, elems); err != nil {
842806
return err
843807
}
844808
removed = nextRemoved
@@ -1471,12 +1435,31 @@ func (t *txnContext) applyExpire(cmd redcon.Command, unit time.Duration) (redisR
14711435
return redisResult{typ: resultInt, integer: 0}, nil
14721436
}
14731437

1438+
if ttl <= 0 {
1439+
return t.stageKeyDeletion(cmd.Args[1])
1440+
}
1441+
14741442
expireAt := time.Now().Add(time.Duration(ttl) * unit)
14751443
state.value = &expireAt
14761444
state.dirty = true
14771445
return redisResult{typ: resultInt, integer: 1}, nil
14781446
}
14791447

1448+
func (t *txnContext) stageKeyDeletion(key []byte) (redisResult, error) {
1449+
st, err := t.loadListState(key)
1450+
if err != nil {
1451+
return redisResult{}, err
1452+
}
1453+
stageListDelete(st)
1454+
tv, err := t.load(key)
1455+
if err != nil {
1456+
return redisResult{}, err
1457+
}
1458+
tv.deleted = true
1459+
tv.dirty = true
1460+
return redisResult{typ: resultInt, integer: 1}, nil
1461+
}
1462+
14801463
func parseRangeBounds(startRaw, endRaw []byte, total int) (int, int, error) {
14811464
start, err := parseInt(startRaw)
14821465
if err != nil {

0 commit comments

Comments
 (0)