Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions adapter/redis_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -709,8 +709,10 @@ func (r *RedisServer) fetchListRange(ctx context.Context, key []byte, meta store
}

func (r *RedisServer) rangeList(ctx context.Context, key []byte, startRaw, endRaw []byte) ([]string, error) {
if !r.coordinator.IsLeaderForKey(key) {
return r.proxyLRange(key, startRaw, endRaw)
if proxied, ok, err := r.fenceRangeListReadGroups(ctx, key, startRaw, endRaw); err != nil {
return nil, err
} else if ok {
return proxied, nil
}

readTS := r.readTS()
Expand All @@ -725,14 +727,6 @@ func (r *RedisServer) rangeList(ctx context.Context, key []byte, startRaw, endRa
return nil, wrongTypeError()
}

// PR #749 follow-up: pass the per-call dispatch ctx so a stalled
// VerifyLeaderForKey honours the caller's deadline rather than the
// long-lived handlerContext + verifyLeaderEngineCtx fallback. Same
// shape as keys() / FLUSHDB.
if err := r.coordinator.VerifyLeaderForKey(ctx, key); err != nil {
return nil, errors.WithStack(err)
}

meta, exists, err := r.resolveListMeta(ctx, key, readTS)
if err != nil {
return nil, err
Expand All @@ -749,6 +743,22 @@ func (r *RedisServer) rangeList(ctx context.Context, key []byte, startRaw, endRa
return r.fetchListRange(ctx, key, meta, int64(s), int64(e), readTS)
}

func (r *RedisServer) fenceRangeListReadGroups(ctx context.Context, key []byte, startRaw, endRaw []byte) ([]string, bool, error) {
groupKeys := r.redisReadFenceGroupKeys(redisTxnReadFenceKeys(key))
proxyKey, ok, err := r.readFenceProxyKey(groupKeys)
if err != nil {
return nil, false, err
}
if ok {
proxied, err := r.proxyLRange(key, proxyKey, startRaw, endRaw)
return proxied, true, err
}
if err := r.leaseRedisReadFenceGroups(ctx, groupKeys); err != nil {
return nil, false, err
}
return nil, false, nil
}

type listPushFunc func(ctx context.Context, key []byte, values [][]byte) (int64, error)
type listProxyFunc func(key []byte, values [][]byte) (int64, error)

Expand Down
30 changes: 28 additions & 2 deletions adapter/redis_proxy_leader.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ func (r *RedisServer) proxyTransactionToLeader(conn redcon.Conn, queue []redcon.
if !ok {
return
}
r.proxyTransactionToLeaderAddr(conn, queue, leaderAddr)
}

func (r *RedisServer) proxyTransactionToLeaderForKey(conn redcon.Conn, routingKey []byte, queue []redcon.Command) {
leaderAddr, ok := r.resolveLeaderRedisAddrForKey(conn, routingKey)
if !ok {
return
}
r.proxyTransactionToLeaderAddr(conn, queue, leaderAddr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve terminal EXEC errors across the proxy

When this new per-shard proxy path targets a node whose EXEC returns the newly introduced errRedisExecRouteChangedAfterAmbiguousAttempt, go-redis reports the top-level non-array EXEC error as the pipeline error and copies it onto the queued command handles. Neither proxy error handler recognizes this error, so proxyTransactionToLeaderAddr writes an EXEC result array containing per-command errors instead of preserving the top-level ambiguous-transaction failure; clients can therefore treat the transaction as having executed with ordinary command failures. Promote this EXEC-level error in the proxy handlers before writing the result array.

Useful? React with 👍 / 👎.

}

func (r *RedisServer) proxyTransactionToLeaderAddr(conn redcon.Conn, queue []redcon.Command, leaderAddr string) {
cli := r.getOrCreateLeaderClient(leaderAddr)

ctx, cancel := context.WithTimeout(r.handlerContext(), redisDispatchTimeout)
Expand Down Expand Up @@ -81,6 +93,20 @@ func (r *RedisServer) resolveLeaderRedisAddr(conn redcon.Conn) (string, bool) {
return leaderAddr, true
}

func (r *RedisServer) resolveLeaderRedisAddrForKey(conn redcon.Conn, key []byte) (string, bool) {
leader := r.coordinator.RaftLeaderForKey(key)
if leader == "" {
writeRedisError(conn, ErrLeaderNotFound)
return "", false
}
leaderAddr, ok := r.leaderRedis[leader]
if !ok || leaderAddr == "" {
conn.WriteError(fmt.Sprintf("ERR leader redis address unknown for raft address %s", leader))
return "", false
}
return leaderAddr, true
}

// execTxPipeline sends queue as a single TxPipelined batch and returns the
// per-command result handles together with any pipeline-level error.
func (r *RedisServer) execTxPipeline(ctx context.Context, cli *redis.Client, queue []redcon.Command) ([]*redis.Cmd, error) {
Expand Down Expand Up @@ -161,8 +187,8 @@ func writeProxyCmdsResult(conn redcon.Conn, cmds []*redis.Cmd) {
}
}

func (r *RedisServer) proxyLRange(key []byte, startRaw, endRaw []byte) ([]string, error) {
leader := r.coordinator.RaftLeaderForKey(key)
func (r *RedisServer) proxyLRange(key, routingKey []byte, startRaw, endRaw []byte) ([]string, error) {
leader := r.coordinator.RaftLeaderForKey(routingKey)
if leader == "" {
return nil, ErrLeaderNotFound
}
Expand Down
200 changes: 196 additions & 4 deletions adapter/redis_txn.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ var redisTxnWideSetFencePrefix = []byte("!redis|txn-wide-set|")
var redisTxnWideListFencePrefix = []byte("!redis|txn-wide-list|")
var redisTxnWideZSetFencePrefix = []byte("!redis|txn-wide-zset|")

var errRedisExecSplitShardLeaders = errors.New("ERR EXEC read fence spans multiple shard leaders")

type txnCommandHandler func(*txnContext, redcon.Command) (redisResult, error)

var txnApplyHandlers = map[string]txnCommandHandler{
Expand All @@ -38,6 +40,182 @@ var txnApplyHandlers = map[string]txnCommandHandler{
cmdPExpire: (*txnContext).applyExpireMilliseconds,
}

func redisTxnReadFenceKeys(userKey []byte) [][]byte {
keys := [][]byte{
redisStrKey(userKey),
redisHLLKey(userKey),
redisTTLKey(userKey),
listMetaKey(userKey),
store.ListMetaDeltaScanPrefix(userKey),
redisTxnWideListFenceKey(userKey),
redisHashKey(userKey),
store.HashMetaKey(userKey),
store.HashFieldScanPrefix(userKey),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fence all routes intersected by collection reads

When a hash, set, zset, or stream wide-column range is split after its scan-prefix key, this representative fences only the group owning the prefix start, although ShardStore.ScanAt visits every intersecting route (kv/shard_store.go:302-308). Point reads are also missed: for example, loadHashFieldsIntoState reads HashFieldKey(key, field), which can fall in a different route than HashFieldScanPrefix(key). The proxy prepass can therefore conclude that all required groups are local and then build its transaction snapshot from an unfenced group, allowing stale reads during leadership churn. Enumerate all routes intersecting each scanned range and include command-dependent exact storage keys before selecting the proxy target or leasing groups.

AGENTS.md reference: AGENTS.md:L24-L25

Useful? React with 👍 / 👎.

store.HashMetaDeltaScanPrefix(userKey),
redisTxnWideHashFenceKey(userKey),
redisSetKey(userKey),
store.SetMetaKey(userKey),
store.SetMemberScanPrefix(userKey),
store.SetMetaDeltaScanPrefix(userKey),
redisTxnWideSetFenceKey(userKey),
redisZSetKey(userKey),
store.ZSetMetaKey(userKey),
store.ZSetMemberScanPrefix(userKey),
store.ZSetScoreScanPrefix(userKey),
store.ZSetMetaDeltaScanPrefix(userKey),
redisTxnWideZSetFenceKey(userKey),
redisStreamKey(userKey),
store.StreamMetaKey(userKey),
store.StreamEntryScanPrefix(userKey),
}
if redisLegacyBareReadFenceAllowed(userKey) {
keys = append(keys, userKey)
}
return keys
}

func redisLegacyBareReadFenceAllowed(userKey []byte) bool {
if isKnownInternalKey(userKey) {
return false
}
return !bytes.HasPrefix(userKey, []byte("!sqs|"))
}

func redisQueuedCommandReadFenceKeys(queue []redcon.Command) [][]byte {
seen := make(map[string]struct{}, len(queue))
keys := make([][]byte, 0, len(queue))
appendKey := func(key []byte) {
keyID := string(key)
if _, ok := seen[keyID]; ok {
return
}
seen[keyID] = struct{}{}
keys = append(keys, key)
}
for _, cmd := range queue {
if len(cmd.Args) == 0 {
continue
}
meta, ok := redisCommandTable[strings.ToUpper(string(cmd.Args[0]))]
if !ok {
continue
}
for _, userKey := range redisCommandGetKeys(meta, cmd.Args) {
for _, fenceKey := range redisTxnReadFenceKeys(userKey) {
appendKey(fenceKey)
}
}
}
return keys
}

type redisTxnProxyRoute struct {
defaultLeader bool
key []byte
}

func (r *RedisServer) redisReadFenceGroupKeys(keys [][]byte) [][]byte {
if r == nil || r.coordinator == nil {
return nil
}
return kv.LeaseReadGroupKeys(r.coordinator, keys)
}

func (r *RedisServer) queuedCommandReadFenceGroupKeys(queue []redcon.Command) [][]byte {
return r.redisReadFenceGroupKeys(redisQueuedCommandReadFenceKeys(queue))
}

func (r *RedisServer) readFenceProxyKey(groupKeys [][]byte) ([]byte, bool, error) {
if r == nil || r.coordinator == nil || len(groupKeys) == 0 {
return nil, false, nil
}

var targetLeader string
var proxyKey []byte
for _, key := range groupKeys {
leader, localLeader, err := r.readFenceLeader(key)
if err != nil {
return nil, false, err
}
if err := recordReadFenceTargetLeader(&targetLeader, leader); err != nil {
return nil, false, err
}
if localLeader || len(proxyKey) > 0 {
continue
}
proxyKey = key
}
if len(proxyKey) == 0 {
return nil, false, nil
}
return proxyKey, true, nil
}

func (r *RedisServer) readFenceLeader(key []byte) (string, bool, error) {
localLeader := r.coordinator.IsLeaderForKey(key)
leader := r.coordinator.RaftLeaderForKey(key)
if !localLeader && leader == "" {
return "", false, ErrLeaderNotFound
}
return leader, localLeader, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func recordReadFenceTargetLeader(target *string, leader string) error {
if leader == "" {
return nil
}
if *target == "" {
*target = leader
return nil
}
if *target != leader {
return errRedisExecSplitShardLeaders
Comment on lines +410 to +411

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare Redis targets rather than per-group Raft endpoints

When a request reaches a node that is not the leader of either involved group, two groups led by the same remote process still normally report different Raft endpoints because each group has its own listener, while buildLeaderRedis maps both endpoints to that process's single Redis address (main.go:1027-1029,1154-1167). Comparing the raw Raft endpoint strings here therefore returns ERR EXEC read fence spans multiple shard leaders even though one Redis target can execute and fence the transaction; the same transaction only succeeds when sent directly to the co-located leader node. Resolve and compare the Redis target or stable node identity before deciding the leaders are split, and cover this with real per-group addresses.

AGENTS.md reference: AGENTS.md:L25-L27

Useful? React with 👍 / 👎.

}
return nil
}

func (r *RedisServer) transactionProxyRoute(queue []redcon.Command) (redisTxnProxyRoute, error) {
if r == nil || r.coordinator == nil {
return redisTxnProxyRoute{}, nil
}

groupKeys := r.queuedCommandReadFenceGroupKeys(queue)
if len(groupKeys) == 0 {
if !r.coordinator.IsLeader() {
return redisTxnProxyRoute{defaultLeader: true}, nil
}
return redisTxnProxyRoute{}, nil
}

proxyKey, ok, err := r.readFenceProxyKey(groupKeys)
if err != nil {
return redisTxnProxyRoute{}, err
}
if ok {
return redisTxnProxyRoute{key: proxyKey}, nil
}
return redisTxnProxyRoute{}, nil
}

func (r *RedisServer) leaseRedisReadFenceGroups(ctx context.Context, groupKeys [][]byte) error {
if r == nil || r.coordinator == nil {
return nil
}
for _, key := range groupKeys {
if _, err := kv.LeaseReadForKeyThrough(r.coordinator, ctx, key); err != nil {
return errors.WithStack(err)
}
}
return nil
}

func (r *RedisServer) leaseQueuedCommandReadGroups(ctx context.Context, queue []redcon.Command) error {
if r.coordinator == nil {
return nil
}
return r.leaseRedisReadFenceGroups(ctx, r.queuedCommandReadFenceGroupKeys(queue))
}

// MULTI/EXEC/DISCARD handling
func (r *RedisServer) multi(conn redcon.Conn, _ redcon.Command) {
state := getConnState(conn)
Expand Down Expand Up @@ -72,13 +250,19 @@ func (r *RedisServer) exec(conn redcon.Conn, _ redcon.Command) {
state.inTxn = false
state.queue = nil

// Always execute MULTI/EXEC on the leader so that reads and writes within
// the transaction see consistent, up-to-date data. Serving transactions
// on followers risks reading stale MVCC state and producing write cycles.
if !r.coordinator.IsLeader() {
route, err := r.transactionProxyRoute(queue)
if err != nil {
writeRedisError(conn, err)
return
}
if route.defaultLeader {
r.proxyTransactionToLeader(conn, queue)
return
}
if len(route.key) > 0 {
r.proxyTransactionToLeaderForKey(conn, route.key, queue)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stabilize route resolution before proxying EXEC

When transactionProxyRoute selects a remote target, the function can resolve its many range representatives across different catalog versions, but this proxy branch bypasses runTransaction, so none of the new route-version checks run. If a split or move is applied while those representatives are being collected, the mixed set can select the old Redis target and omit a newly intersecting group; that target may then execute using its still-stale catalog and return stale results or commit against the old ownership. Capture and verify the route version around proxy-route resolution before forwarding the EXEC.

Useful? React with 👍 / 👎.

return
}

results, err := r.runTransaction(queue)
if err != nil {
Expand Down Expand Up @@ -2379,6 +2563,10 @@ func (r *RedisServer) runTransactionDirect(queue []redcon.Command) ([]redisResul

var results []redisResult
err := r.retryRedisWrite(dispatchCtx, func() error {
if err := r.leaseQueuedCommandReadGroups(dispatchCtx, queue); err != nil {
return err
}

startTS := r.txnStartTS()
readPin := r.pinReadTS(startTS)
defer readPin.Release()
Expand Down Expand Up @@ -2593,6 +2781,10 @@ func (r *RedisServer) runTransactionWithDedup(queue []redcon.Command) ([]redisRe
// from runTransactionWithDedup to keep that loop under the cyclop
// budget; the dedup rationale lives there.
func (r *RedisServer) firstExecAttempt(dispatchCtx context.Context, queue []redcon.Command) ([]redisResult, *reusableExecTxn, error) {
if err := r.leaseQueuedCommandReadGroups(dispatchCtx, queue); err != nil {
return nil, nil, err
}

startTS := r.txnStartTS()
readPin := r.pinReadTS(startTS)
defer readPin.Release()
Expand Down
Loading
Loading