Skip to content

Commit 7a6648b

Browse files
committed
Fix ZRANGESTORE aborting the RESP session on invalid range parameters
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. Detect the error output before consuming it - as GEOSEARCHSTORE already does - and return it to the client unchanged, leaving the session alive and the destination untouched. The error payload is copied via the scratch allocator so it can coexist with other commands' scratch allocations in the same network batch.
1 parent fcdb5fc commit 7a6648b

5 files changed

Lines changed: 75 additions & 5 deletions

File tree

libs/server/API/GarnetApiObjectCommands.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ public GarnetStatus SortedSetAdd(PinnedSpanByte key, ref ObjectInput input, ref
3131
=> storageSession.SortedSetAdd(key, ref input, ref output, ref objectContext);
3232

3333
/// <inheritdoc />
34-
public GarnetStatus SortedSetRangeStore(PinnedSpanByte dstKey, PinnedSpanByte srcKey, ref ObjectInput input, out int result)
35-
=> storageSession.SortedSetRangeStore(dstKey, srcKey, ref input, out result, ref objectContext);
34+
public GarnetStatus SortedSetRangeStore(PinnedSpanByte dstKey, PinnedSpanByte srcKey, ref ObjectInput input, out int result, out PinnedSpanByte error)
35+
=> storageSession.SortedSetRangeStore(dstKey, srcKey, ref input, out result, out error, ref objectContext);
3636

3737
/// <inheritdoc />
3838
public GarnetStatus SortedSetRemove(PinnedSpanByte key, PinnedSpanByte member, out int zremCount)

libs/server/API/IGarnetApi.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,9 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi
398398
/// <param name="srcKey">The sub-key for the sorted set.</param>
399399
/// <param name="input">The input object containing the elements to store.</param>
400400
/// <param name="result">The result of the store operation.</param>
401+
/// <param name="error">When the range parameters are invalid, receives the RESP error payload (without the leading '-' or trailing CRLF); otherwise empty.</param>
401402
/// <returns>A <see cref="GarnetStatus"/> indicating the status of the operation.</returns>
402-
GarnetStatus SortedSetRangeStore(PinnedSpanByte dstKey, PinnedSpanByte srcKey, ref ObjectInput input, out int result);
403+
GarnetStatus SortedSetRangeStore(PinnedSpanByte dstKey, PinnedSpanByte srcKey, ref ObjectInput input, out int result, out PinnedSpanByte error);
403404

404405
/// <summary>
405406
/// Removes the specified member from the sorted set stored at key.

libs/server/Resp/Objects/SortedSetCommands.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,14 @@ private unsafe bool SortedSetRangeStore<TGarnetApi>(ref TGarnetApi storageApi)
220220
var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZRANGE };
221221
var input = new ObjectInput(header, ref parseState, startIdx: 2, arg1: respProtocolVersion, arg2: (int)SortedSetRangeOpts.Store);
222222

223-
var status = storageApi.SortedSetRangeStore(dstKey, srcKey, ref input, out int result);
223+
var status = storageApi.SortedSetRangeStore(dstKey, srcKey, ref input, out int result, out var error);
224+
225+
if (error.Length > 0)
226+
{
227+
while (!RespWriteUtils.TryWriteError(error.ReadOnlySpan, ref dcurr, dend))
228+
SendAndReset();
229+
return true;
230+
}
224231

225232
switch (status)
226233
{

libs/server/Storage/Session/ObjectStore/SortedSetOps.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,13 +710,14 @@ public GarnetStatus SortedSetAdd<TObjectContext>(PinnedSpanByte key, ref ObjectI
710710
/// <param name="result">The result of the operation, indicating the number of elements stored.</param>
711711
/// <param name="objectContext">The context of the object store.</param>
712712
/// <returns>Returns a GarnetStatus indicating the success or failure of the operation.</returns>
713-
public unsafe GarnetStatus SortedSetRangeStore<TObjectContext>(PinnedSpanByte dstKey, PinnedSpanByte srcKey, ref ObjectInput input, out int result, ref TObjectContext objectContext)
713+
public unsafe GarnetStatus SortedSetRangeStore<TObjectContext>(PinnedSpanByte dstKey, PinnedSpanByte srcKey, ref ObjectInput input, out int result, out PinnedSpanByte error, ref TObjectContext objectContext)
714714
where TObjectContext : ITsavoriteContext<FixedSpanByteKey, ObjectInput, ObjectOutput, long, ObjectSessionFunctions, StoreFunctions, StoreAllocator>
715715
{
716716
if (txnManager.ObjectTransactionalContext.Session is null)
717717
ThrowObjectStoreUninitializedException();
718718

719719
result = 0;
720+
error = default;
720721

721722
if (dstKey.Length == 0 || srcKey.Length == 0)
722723
return GarnetStatus.OK;
@@ -763,6 +764,23 @@ public unsafe GarnetStatus SortedSetRangeStore<TObjectContext>(PinnedSpanByte ds
763764
ref var currOutPtr = ref rangeOutPtr;
764765
var endOutPtr = rangeOutPtr + rangeOutputMem.Length;
765766

767+
// SortedSetRange signals a parameter error (LIMIT in index mode, or a
768+
// non-float min/max) by writing a RESP error into this output buffer
769+
// instead of an array. Surface it to the caller — as GEOSEARCHSTORE
770+
// already does in SortedSetGeoOps — rather than parsing the '-' error
771+
// prefix as an array length, which aborts the RESP session with a
772+
// protocol error, or clobbering the destination key.
773+
if (RespReadUtils.TryReadErrorAsSpan(out var rangeError, ref currOutPtr, endOutPtr))
774+
{
775+
// Copy the payload so it outlives the disposed range output. Use the
776+
// allocator, not scratchBufferBuilder: this slice is intentionally never
777+
// rewound (it must survive until the RESP handler writes it), and the
778+
// builder's single-outstanding-slice invariant would otherwise trip once
779+
// another command in the same network batch allocates from it.
780+
error = scratchBufferAllocator.CreateArgSlice(rangeError);
781+
return GarnetStatus.OK;
782+
}
783+
766784
var destinationKey = dstKey.ReadOnlySpan;
767785
ssUnifiedTransactionalContext.Delete((FixedSpanByteKey)destinationKey);
768786

test/standalone/Garnet.test.collections/RespSortedSetTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,50 @@ public void CanDoZMScoreLC()
828828
TestUtils.AssertEqualUpToExpectedLength(expectedResponse, response);
829829
}
830830

831+
[Test]
832+
public void ZRangeStoreInvalidParamsReturnErrorAndKeepSessionAlive()
833+
{
834+
using var lightClientRequest = TestUtils.CreateRequest();
835+
836+
lightClientRequest.SendCommands("ZADD zrs 1 a 2 b 3 c", "PING");
837+
// A pre-existing destination must survive a rejected ZRANGESTORE.
838+
lightClientRequest.SendCommands("ZADD zrsdst 9 keep", "PING");
839+
840+
// Index mode with LIMIT. On unpatched main SortedSetRange writes the
841+
// "-ERR syntax error, LIMIT ..." into the range output buffer, which the
842+
// store path then parses as an array length, aborting the RESP session
843+
// with a protocol error (a missing reply here would hang this request).
844+
var response = lightClientRequest.SendCommands("ZRANGESTORE zrsdst zrs 0 -1 LIMIT 0 2", "PING");
845+
var expectedResponse = "-ERR syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX\r\n+PONG\r\n";
846+
TestUtils.AssertEqualUpToExpectedLength(expectedResponse, response);
847+
848+
// Non-float min/max in BYSCORE mode reaches the same faulty path on main.
849+
response = lightClientRequest.SendCommands("ZRANGESTORE zrsdst zrs notafloat 5 BYSCORE", "PING");
850+
expectedResponse = "-ERR min or max is not a float\r\n+PONG\r\n";
851+
TestUtils.AssertEqualUpToExpectedLength(expectedResponse, response);
852+
853+
// The destination is left untouched by the rejected commands.
854+
response = lightClientRequest.SendCommands("ZRANGE zrsdst 0 -1", "PING", 2, 1);
855+
expectedResponse = "*1\r\n$4\r\nkeep\r\n+PONG\r\n";
856+
TestUtils.AssertEqualUpToExpectedLength(expectedResponse, response);
857+
858+
// A well-formed ZRANGESTORE still overwrites the destination.
859+
response = lightClientRequest.SendCommands("ZRANGESTORE zrsdst zrs 0 -1", "PING");
860+
expectedResponse = ":3\r\n+PONG\r\n";
861+
TestUtils.AssertEqualUpToExpectedLength(expectedResponse, response);
862+
863+
response = lightClientRequest.SendCommands("ZRANGE zrsdst 0 -1", "PING", 4, 1);
864+
expectedResponse = "*3\r\n$1\r\na\r\n$1\r\nb\r\n$1\r\nc\r\n+PONG\r\n";
865+
TestUtils.AssertEqualUpToExpectedLength(expectedResponse, response);
866+
867+
// A rejected ZRANGESTORE pipelined with another scratch-allocating command
868+
// in the same network batch must not corrupt the shared scratch buffer: the
869+
// error payload has to coexist with the following ZRANGE's own allocation.
870+
response = lightClientRequest.SendCommands("ZRANGESTORE zrsdst zrs 0 -1 LIMIT 0 2", "ZRANGE zrs (1 3 BYSCORE LIMIT 0 2", 1, 3);
871+
expectedResponse = "-ERR syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX\r\n*2\r\n$1\r\nb\r\n$1\r\nc\r\n";
872+
TestUtils.AssertEqualUpToExpectedLength(expectedResponse, response);
873+
}
874+
831875
[Test]
832876
public void CandDoZIncrby()
833877
{

0 commit comments

Comments
 (0)