Fix ZRANGESTORE aborting the RESP session on invalid range parameters - #2040
Conversation
5d15a98 to
14fc35c
Compare
There was a problem hiding this comment.
Pull request overview
Fixes a RESP-session abort in ZRANGESTORE when invalid range parameters cause SortedSetRange to emit a RESP error payload into the output buffer (previously mis-parsed as an array length), ensuring the client receives the same error as ZRANGE and the destination key is not modified on rejection.
Changes:
- Detect and surface RESP error payloads produced by
SortedSetRangebefore deleting the destination key or parsing the response as an array. - Extend the
SortedSetRangeStoreAPI surface/handler path to return and write the RESP error payload when present. - Add a regression test to verify errors are returned, the session stays alive (pipelining), and the destination key remains intact on rejected requests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/standalone/Garnet.test.collections/RespSortedSetTests.cs | Adds a LightClient-based regression test covering invalid ZRANGESTORE params, session liveness, and destination preservation. |
| libs/server/Storage/Session/ObjectStore/SortedSetOps.cs | Adds TryReadErrorAsSpan check to short-circuit on parameter errors and avoid deleting/parsing destination updates. |
| libs/server/Resp/Objects/SortedSetCommands.cs | Writes the surfaced RESP error when present, otherwise retains existing integer/WRONGTYPE responses. |
| libs/server/API/IGarnetApi.cs | Updates SortedSetRangeStore signature to include an out PinnedSpanByte error payload. |
| libs/server/API/GarnetApiObjectCommands.cs | Plumbs the new out PinnedSpanByte error parameter through the API wrapper to StorageSession. |
da89456 to
7a6648b
Compare
7a6648b to
41097c5
Compare
|
Reworked along both lines, and rebased onto current No more RESP parsing to detect the error. The error is written out rather than plumbed through a new parameter. The All 750 tests in |
9278bf4 to
e647a21
Compare
ZRANGESTORE with LIMIT in index mode, or with a non-float min/max, made SortedSetRangeStore parse the range operation's RESP error output as an array length. That threw a RESP parsing exception which aborted the connection (dropping every pipelined command after it), and because Delete(dstKey) ran before the throwing parse, a rejected ZRANGESTORE also destroyed a pre-existing destination key. SortedSetRange now flags the five paths on which it writes a RESP error instead of a range by setting ObjectOutput.result1 negative, so SortedSetRangeStore can tell an error apart from a result without inspecting the payload. On that flag it writes the error straight through to the client and leaves the destination key alone, matching what GEOSEARCHSTORE already does in SortedSetGeoOps - which is also why SortedSetRangeStore now returns its reply through a SpanByteAndMemory, like GeoSearchStore, rather than through an out parameter per reply kind. BYSCORE and BYLEX are independent options rather than mutually exclusive ones, so the BYLEX error is the one error path reachable after a reply has already been written: "ZRANGE key 1 3 BYSCORE BYLEX" runs the BYSCORE block first and leaves an array in the buffer. Rewind the writer there so the output holds the error alone. Without that, one command answers with two RESP payloads - an array followed by an error - which desynchronises the stream for every command after it.
e647a21 to
87a4f8d
Compare
|
Pushed a follow-up and rewrote the description, which still described the abandoned A defect in my own rework, found before you looked at it. One command, two RESP replies — i.e. the rework reintroduced the desync class this PR exists to fix, on a different input. The case is now in the regression test, and I confirmed it asserts red without the rewind rather than passing by luck. Three limits are stated explicitly in the description rather than left for you to find: the
|
Symptom
ZRANGESTOREaborts the whole RESP session with a protocol error when the range parameters are invalid, instead of returning the syntax error that the equivalentZRANGEreturns. Every pipelined command after it on that connection is silently dropped, and a pre-existing destination key is destroyed.Debug build of
GarnetServer, raw RESP with a trailingPINGso a missing/dropped reply is visible:server log:
Two inputs reach the fault, both unauthenticated and with no special state:
The equivalent
ZRANGEhandles both cleanly and keeps the session alive:Because the faulty path runs
Delete(dstKey)before it crashes, a rejectedZRANGESTOREagainst an existing destination also destroys that key.Root cause
libs/server/Storage/Session/ObjectStore/SortedSetOps.cs,SortedSetRangeStore. It runs the range over the source viaSortedSetRange, then treats the range output buffer as an array of members/scores toZADDinto the destination. ButSortedSetRangereports an invalid-parameter error by writing a RESP error string into that same output buffer.The status returned is not
WRONGTYPE/NOTFOUND, soSortedSetRangeStorefalls through toDelete(dstKey)andTryReadUnsignedArrayLength, which reads the'-'of-ERRand throwsRespParsingException, aborting the session.ZRANGEdoes not hit this because its handler writes the range output straight back to the client, error or not.Fix
Per kevin-montrose's review, the store path no longer inspects the RESP payload to decide whether the range succeeded, and no longer carries an extra out-parameter for the error.
SortedSetRangesetsObjectOutput.result1toSortedSetObject.RangeError(-1) on each of the five paths where it writes a RESP error instead of a range:LIMITwith fewer than two following tokens, non-integerLIMITarguments, non-float min/max,LIMITin index mode, and invalid min/max inBYLEX. A range reply never produces a negativeresult1, so the flag is unambiguous.SortedSetRangeStorechecks that flag instead of sniffing for a-prefix. On it, the range operation's own error payload is written straight through to the client and the destination key is left alone.ref SpanByteAndMemory+RespMemoryWriter, exactly like the siblingGeoSearchStoreinSortedSetGeoOps.cs. That removes theout PinnedSpanByte errorparameter and the scratch-allocator copy that went with it — the payload is written into the caller's buffer while the range output is still alive, so nothing has to outlive it. The ZRANGESTORE RESP handler is now the same shape as the GEOSEARCHSTORE one.One extra fix the flag exposed
BYSCOREandBYLEXare independent booleans rather than mutually exclusive options, so theBYLEXerror is the one error path reachable after a reply has already been written — theBYSCOREblock runs first and leaves an array in the buffer. Without handling that,ZRANGESTORE dst z 1 3 BYSCORE BYLEXwould answer one command with two RESP payloads:which desynchronises the stream for every command after it — the exact class of bug this PR exists to fix.
SortedSetRangenow rewinds the writer before that error soRangeErrormeans what its doc comment says: the output holds the error and nothing else. This also fixes plainZRANGE key 1 3 BYSCORE BYLEX, which emits the same two payloads on unpatched main today.Known, deliberate scope limits
IGarnetApi.SortedSetRangeStorechanges shape (out int result→ref SpanByteAndMemory output) rather than staying untouched. Zeroing the API delta is not reachable while preserving the five distinct error messages: the alternatives are a code→message table duplicated across the storage and RESP layers, or one generic error, which would makeZRANGESTOREreply differently fromZRANGEfor the same input.GeoSearchStorealready carriesref SpanByteAndMemory outputon this same interface, so this follows the sibling rather than inventing a shape. Happy to switch if you'd prefer one of the alternatives.ZRANGESTORE dst nosuchkey 0 -1 LIMIT 0 2still replies:0and still expires the destination. That ordering is byte-identical tomainand is not changed here; Redis validates arguments before the key lookup. Hoisting the parse into the RESP handler would changeZRANGEtoo, so it is left for a separate change.ZRANGESTORE dst src <a> <b> BYSCORE BYLEXwhere both blocks succeed still writes two arrays into the range buffer, of which the store path consumes the first. Also identical tomain; the real fix is making the two options mutually exclusive (last-wins), which belongs in its own change. Say the word if you'd rather have it here.Tests
RespSortedSetTests.ZRangeStoreInvalidParamsReturnErrorAndKeepSessionAlive(raw RESP viaLightClientRequest, each command followed byPINGso a missing reply is visible):ZRANGESTORE dst z 0 -1 LIMIT 0 2→-ERR syntax error, LIMIT ...then+PONG. On unpatched main this returns the protocol error and the session dies.ZRANGESTORE dst z notafloat 5 BYSCORE→-ERR min or max is not a floatthen+PONG.ZRANGESTORE dst z 1 3 BYSCORE BYLEX→ the error alone. Without the rewind this asserts red with the array-then-error payload quoted above, so the case is covered rather than assumed.ZADD dst 9 keep) still containskeepafter the rejected commands, and still holdsa b cafter theBYSCORE BYLEXrejection.ZRANGESTORE dst z 0 -1still returns:3and overwrites the destination.ZRANGESTOREpipelined with a second scratch-allocating command in the same network batch still produces both replies intact.Every assertion was verified to fail on the unpatched build before being added. Full
Garnet.test.collections(750 tests, 324 of them sorted-set) green locally.