Skip to content

Fix ZREMRANGEBYRANK missing reply and unnormalized negative ranks - #2028

Merged
kevin-montrose merged 3 commits into
microsoft:mainfrom
hexonal:fix-zremrangebyrank-rank-normalization
Aug 7, 2026
Merged

kevin-montrose merged 3 commits into
microsoft:mainfrom
hexonal:fix-zremrangebyrank-rank-normalization

Conversation

@hexonal

Copy link
Copy Markdown
Contributor

Symptom

ZREMRANGEBYRANK mishandles every rank range that Redis treats as empty or out of bounds. On a three-element set:

$ redis-cli ZADD zk 1 a 2 b 3 c
(integer) 3

# start past the end of the set: Garnet writes zero bytes, redis-cli blocks on the reply
$ redis-cli ZREMRANGEBYRANK zk 3 5
^C                          # Redis: (integer) 0

# start > stop: negative count
$ redis-cli ZREMRANGEBYRANK zk 2 0
(integer) -1                # Redis: (integer) 0

# negative ranks that fall before the first element: removes the whole set
$ redis-cli ZREMRANGEBYRANK zk -100 -50
(integer) 51                # Redis: (integer) 0
$ redis-cli ZCARD zk
(integer) 0                 # Redis: (integer) 3

The -100 -50 case is silent data loss. The 3 5 case desynchronizes the connection: on GarnetStatus.OK the RESP layer forwards whatever the object wrote (libs/server/Resp/Objects/SortedSetCommands.cs:785, ProcessOutput), and the object wrote nothing, so a pipelining client reads the next command's reply as the answer to ZREMRANGEBYRANK.

Root cause

libs/server/Objects/SortedSet/SortedSetObjectImpl.cs:584-594

if (start > sortedSetDict.Count - 1)
    return;                                  // no reply written

// Shift from the end of the set
start = start < 0 ? sortedSetDict.Count + start : start;
stop = stop < 0
    ? sortedSetDict.Count + stop
    : stop >= sortedSetDict.Count ? sortedSetDict.Count - 1 : stop;

var elementCount = stop - start + 1;

Negative indexes are shifted by the set size but never floored at 0, start > stop is never rejected, and the early return leaves the RespMemoryWriter empty. elementCount is then computed from unclamped indexes, so it no longer describes what Skip(start).Take(elementCount) actually removed: -100 -50 gives start = -97, stop = -47, elementCount = 51, and Skip(-97) is a no-op, so all three members go.

Fix

Normalize the indexes before use, in the order ListObjectImpl.ListRange already uses for LRANGE (libs/server/Objects/List/ListObjectImpl.cs:143-150): shift negatives by the count, floor start at 0, reply :0 for an empty range, then clamp stop to count - 1. It is also the order zremrangeGenericCommand uses in Redis, which is worth naming here because the reply values themselves are the compatibility question.

After clamping, 0 <= start <= stop <= count - 1 always holds, so elementCount equals the number of members actually removed and every path writes exactly one reply. The count == 0 case (a set left empty by DeleteExpiredItems) now also replies :0 instead of nothing.

Test

CanDoZRemRangeByRank in test/standalone/Garnet.test.collections/RespSortedSetTests.cs, using raw RESP through LightClientRequest — a missing or duplicated reply is invisible to a StackExchange.Redis assertion.

Precisely what is red today, and what is not:

  • ZREMRANGEBYRANK board 2 0 expecting :0 — fails on main (:-1). It is the first new assertion, so it is what a revert reports.
  • ZREMRANGEBYRANK board -100 -50 expecting :0 — also wrong on main (:51, and board is emptied), but the run aborts at the assertion above before reaching it.
  • ZCARD board expecting :3 — regression guard.
  • SendCommands("ZREMRANGEBYRANK board 3 5", "PING") expecting :0\r\n+PONG\r\n — regression guard, not a red signal. On main that command writes nothing, so LightClient.CompletePendingRequests spins on a token that never arrives and the test hangs rather than fails. That is why it is ordered last, behind an assertion that fails cleanly.

None of the new commands remove anything, so the pre-existing assertions after them are unaffected.

Copilot AI lite review requested due to automatic review settings August 6, 2026 09:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes ZREMRANGEBYRANK compatibility and protocol correctness by normalizing rank ranges (including negative and out-of-bounds values) and guaranteeing a single integer reply is always written, preventing client desynchronization and avoiding unintended deletions.

Changes:

  • Normalize start/stop ranks (shift negatives, floor/clamp, detect empty ranges) and always write :0 for empty/out-of-bounds ranges.
  • Add regression tests covering start > stop, overly-negative ranks, and the “no reply” pipeline desync case.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
test/standalone/Garnet.test.collections/RespSortedSetTests.cs Adds raw-RESP regression coverage for empty/out-of-bounds rank ranges and pipelining reply correctness.
libs/server/Objects/SortedSet/SortedSetObjectImpl.cs Fixes rank normalization and ensures ZREMRANGEBYRANK always writes exactly one integer reply, matching Redis empty-range behavior.

@kevin-montrose kevin-montrose self-assigned this Aug 6, 2026
ZREMRANGEBYRANK returned early without writing anything when start was
past the end of the set, leaving the RespMemoryWriter empty. The command
emitted zero bytes and desynchronized the connection, so the next reply
was consumed as the answer to ZREMRANGEBYRANK.

Negative ranks were also shifted by the set size without being floored
at 0, and start > stop was never rejected, so the count written back was
computed from unclamped indexes. ZREMRANGEBYRANK board 2 0 answered :-1
and ZREMRANGEBYRANK board -100 -50 answered :51 while emptying a 3
element set.

Normalize the indexes the way Redis does in zremrangeGenericCommand:
shift negatives by the set size, floor start at 0, reply :0 for an empty
range, then clamp stop to count - 1. elementCount now always matches the
number of elements actually removed.
@hexonal
hexonal (hexonal) force-pushed the fix-zremrangebyrank-rank-normalization branch from 3b69a89 to 8757fa5 Compare August 7, 2026 05:25
@kevin-montrose
kevin-montrose merged commit c151b67 into microsoft:main Aug 7, 2026
161 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants