diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index 55cd618133..c7f576aad0 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -5787,6 +5787,58 @@ } ] }, + { + "Command": "ZMPOP", + "Name": "ZMPOP", + "Summary": "Returns the highest- or lowest-scoring members from one or more sorted sets after removing them. Deletes the sorted set if the last member was popped.", + "Group": "SortedSet", + "Complexity": "O(K) \u002B O(M*log(N)) where K is the number of provided keys, N being the number of elements in the sorted set, and M being the number of elements popped.", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMKEYS", + "DisplayText": "numkeys", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "ArgumentFlags": "Multiple", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "WHERE", + "Type": "OneOf", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MIN", + "DisplayText": "min", + "Type": "PureToken", + "Token": "MIN" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MAX", + "DisplayText": "max", + "Type": "PureToken", + "Token": "MAX" + } + ] + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "COUNT", + "DisplayText": "count", + "Type": "Integer", + "Token": "COUNT", + "ArgumentFlags": "Optional" + } + ] + }, { "Command": "ZMSCORE", "Name": "ZMSCORE", diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index 583ae9ec6a..0216f7f582 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -4300,6 +4300,28 @@ } ] }, + { + "Command": "ZMPOP", + "Name": "ZMPOP", + "Arity": -4, + "Flags": "MovableKeys, Write", + "AclCategories": "SortedSet, Slow, Write", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysKeyNum", + "KeyNumIdx": 0, + "FirstKey": 1, + "KeyStep": 1 + }, + "Flags": "RW, Access, Delete" + } + ] + }, { "Command": "ZMSCORE", "Name": "ZMSCORE", diff --git a/libs/server/API/GarnetApiObjectCommands.cs b/libs/server/API/GarnetApiObjectCommands.cs index 927107c3e0..670db210ee 100644 --- a/libs/server/API/GarnetApiObjectCommands.cs +++ b/libs/server/API/GarnetApiObjectCommands.cs @@ -74,6 +74,10 @@ public GarnetStatus SortedSetScores(byte[] key, ref ObjectInput input, ref Garne public GarnetStatus SortedSetPop(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) => storageSession.SortedSetPop(key, ref input, ref outputFooter, ref objectContext); + /// + public GarnetStatus SortedSetMPop(ReadOnlySpan keys, int count, bool lowScoresFirst, out ArgSlice poppedKey, out (ArgSlice member, ArgSlice score)[] pairs) + => storageSession.SortedSetMPop(keys, count, lowScoresFirst, out poppedKey, out pairs); + /// public GarnetStatus SortedSetPop(ArgSlice key, out (ArgSlice member, ArgSlice score)[] pairs, int count = 1, bool lowScoresFirst = true) => storageSession.SortedSetPop(key, count, lowScoresFirst, out pairs, ref objectContext); diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 14851c1148..15d9c2255a 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -410,6 +410,17 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi /// GarnetStatus SortedSetPop(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter); + /// + /// Removes and returns multiple elements from a sorted set. + /// + /// The keys of the sorted set. + /// The number of elements to pop. + /// If true, elements with the lowest scores are popped first; otherwise, elements with the highest scores are popped first. + /// The key of the popped element. + /// An array of tuples containing the member and score of each popped element. + /// A indicating the result of the operation. + GarnetStatus SortedSetMPop(ReadOnlySpan keys, int count, bool lowScoresFirst, out ArgSlice poppedKey, out (ArgSlice member, ArgSlice score)[] pairs); + /// /// Removes and returns up to count members with the highest or lowest scores in the sorted set stored at key. /// diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index e2c48bf690..e0e40afddf 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -120,6 +120,8 @@ static partial class CmdStrings public static ReadOnlySpan BYLEX => "BYLEX"u8; public static ReadOnlySpan REV => "REV"u8; public static ReadOnlySpan LIMIT => "LIMIT"u8; + public static ReadOnlySpan MIN => "MIN"u8; + public static ReadOnlySpan MAX => "MAX"u8; /// /// Response strings diff --git a/libs/server/Resp/Objects/SortedSetCommands.cs b/libs/server/Resp/Objects/SortedSetCommands.cs index 4c7c7f60b5..6289f23d9e 100644 --- a/libs/server/Resp/Objects/SortedSetCommands.cs +++ b/libs/server/Resp/Objects/SortedSetCommands.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; +using System.Text; using Garnet.common; using Tsavorite.core; @@ -389,6 +390,122 @@ private unsafe bool SortedSetPop(RespCommand command, ref TGarnetApi return true; } + /// + /// Removes and returns up to count members from the first non-empty sorted set key from the list of keys. + /// + private unsafe bool SortedSetMPop(ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + // ZMPOP requires at least 3 args: numkeys, key [key...], [COUNT count] + if (parseState.Count < 3) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.ZMPOP)); + } + + // Get number of keys + if (!parseState.TryGetInt(0, out var numKeys) || numKeys < 1) + { + return AbortWithErrorMessage(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER); + } + + if (numKeys < 0) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericParamShouldBeGreaterThanZero, "numkeys"))); + } + + // Validate we have enough arguments (no of keys + (MIN or MAX)) + if (parseState.Count < numKeys + 2) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + // Get MIN/MAX argument + var orderArg = parseState.GetArgSliceByRef(numKeys + 1); + var orderSpan = orderArg.ReadOnlySpan; + var lowScoresFirst = true; + + if (orderSpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.MIN)) + lowScoresFirst = true; + else if (orderSpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.MAX)) + lowScoresFirst = false; + else + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + // Parse optional COUNT argument + var count = 1; + if (parseState.Count > numKeys + 2) + { + if (parseState.Count != numKeys + 4) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + var countArg = parseState.GetArgSliceByRef(numKeys + 2); + if (!countArg.ReadOnlySpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.COUNT)) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + if (!parseState.TryGetInt(numKeys + 3, out count) || count < 1) + { + return AbortWithErrorMessage(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER); + } + + if (count < 0) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericParamShouldBeGreaterThanZero, "count"))); + } + } + + var keys = parseState.Parameters.Slice(1, numKeys); + var status = storageApi.SortedSetMPop(keys, count, lowScoresFirst, out var poppedKey, out var pairs); + + switch (status) + { + case GarnetStatus.OK: + if (pairs == null || pairs.Length == 0) + { + // No elements found + while (!RespWriteUtils.WriteNull(ref dcurr, dend)) + SendAndReset(); + } + else + { + // Write array with 2 elements: key and array of elements + while (!RespWriteUtils.WriteArrayLength(2, ref dcurr, dend)) + SendAndReset(); + + // Write key + while (!RespWriteUtils.WriteBulkString(poppedKey.ReadOnlySpan, ref dcurr, dend)) + SendAndReset(); + + // Write array of member-score pairs + while (!RespWriteUtils.WriteArrayLength(pairs.Length, ref dcurr, dend)) + SendAndReset(); + + foreach (var (member, score) in pairs) + { + while (!RespWriteUtils.WriteArrayLength(2, ref dcurr, dend)) + SendAndReset(); + while (!RespWriteUtils.WriteBulkString(member.ReadOnlySpan, ref dcurr, dend)) + SendAndReset(); + while (!RespWriteUtils.WriteBulkString(score.ReadOnlySpan, ref dcurr, dend)) + SendAndReset(); + } + } + break; + + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + } + + return true; + } + /// /// Returns the number of elements in the sorted set at key with a score between min and max. /// diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index c01ce43bae..a7916d157e 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -160,6 +160,7 @@ public enum RespCommand : ushort ZADD, ZDIFFSTORE, ZINCRBY, + ZMPOP, ZPOPMAX, ZPOPMIN, ZRANGESTORE, @@ -1044,6 +1045,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.ZSCAN; } + else if (*(ulong*)(ptr + 3) == MemoryMarshal.Read("\nZMPOP\r\n"u8)) + { + return RespCommand.ZMPOP; + } break; } break; diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index edccbb070d..fe14d4b157 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -615,6 +615,7 @@ private bool ProcessArrayCommands(RespCommand cmd, ref TGarnetApi st RespCommand.ZREMRANGEBYSCORE => SortedSetRemoveRange(cmd, ref storageApi), RespCommand.ZLEXCOUNT => SortedSetLengthByValue(cmd, ref storageApi), RespCommand.ZPOPMIN => SortedSetPop(cmd, ref storageApi), + RespCommand.ZMPOP => SortedSetMPop(ref storageApi), RespCommand.ZRANDMEMBER => SortedSetRandomMember(ref storageApi), RespCommand.ZDIFF => SortedSetDifference(ref storageApi), RespCommand.ZDIFFSTORE => SortedSetDifferenceStore(ref storageApi), diff --git a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs index 1c6df20744..72b7896be9 100644 --- a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs +++ b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs @@ -1032,5 +1032,61 @@ private GarnetStatus SortedSetDifference(ReadOnlySpan return GarnetStatus.OK; } + + /// + /// Removes and returns up to count members and their scores from the first sorted set that contains a member. + /// + public unsafe GarnetStatus SortedSetMPop(ReadOnlySpan keys, int count, bool lowScoresFirst, out ArgSlice poppedKey, out (ArgSlice member, ArgSlice score)[] pairs) + { + if (txnManager.ObjectStoreLockableContext.Session is null) + ThrowObjectStoreUninitializedException(); + + pairs = default; + poppedKey = default; + + if (keys.Length == 0) + return GarnetStatus.OK; + + var createTransaction = false; + + if (txnManager.state != TxnState.Running) + { + Debug.Assert(txnManager.state == TxnState.None); + createTransaction = true; + foreach (var key in keys) + txnManager.SaveKeyEntryToLock(key, true, LockType.Exclusive); + txnManager.Run(true); + } + + var storeLockableContext = txnManager.ObjectStoreLockableContext; + + try + { + // Try popping from each key until we find one with members + foreach (var key in keys) + { + if (key.Length == 0) continue; + + var status = SortedSetPop(key, count, lowScoresFirst, out pairs, ref storeLockableContext); + if (status == GarnetStatus.OK && pairs != null && pairs.Length > 0) + { + poppedKey = key; + return status; + } + + if (status != GarnetStatus.OK && status != GarnetStatus.NOTFOUND) + { + return status; + } + } + + return GarnetStatus.OK; + } + finally + { + if (createTransaction) + txnManager.Commit(true); + } + } } } \ No newline at end of file diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index ddd6f5172d..40d6ca496d 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -270,6 +270,7 @@ public class SupportedCommand new("ZINCRBY", RespCommand.ZINCRBY), new("ZLEXCOUNT", RespCommand.ZLEXCOUNT), new("ZMSCORE", RespCommand.ZMSCORE), + new("ZMPOP", RespCommand.ZMPOP), new("ZPOPMAX", RespCommand.ZPOPMAX), new("ZPOPMIN", RespCommand.ZPOPMIN), new("ZRANDMEMBER", RespCommand.ZRANDMEMBER), diff --git a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs index b52911236b..e256ede56c 100644 --- a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs +++ b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs @@ -2026,6 +2026,35 @@ public override ArraySegment[] SetupSingleSlotRequest() } } + internal class ZMPOP : BaseCommand + { + public override bool IsArrayCommand => true; + public override bool ArrayResponse => false; + public override string Command => nameof(ZMPOP); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return ["3", ssk[0], ssk[1], ssk[2], "MIN", "COUNT", "1"]; + } + + public override string[] GetCrossSlotRequest() + { + var csk = GetCrossSlotKeys; + return ["3", csk[0], csk[1], csk[2], "MIN", "COUNT", "1"]; + } + + public override ArraySegment[] SetupSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + var setup = new ArraySegment[3]; + setup[0] = new ArraySegment(["ZADD", ssk[1], "1", "a"]); + setup[1] = new ArraySegment(["ZADD", ssk[2], "2", "b"]); + setup[2] = new ArraySegment(["ZADD", ssk[3], "3", "c"]); + return setup; + } + } + #endregion #region HashCommands diff --git a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs index b569a0aa8b..59c6bb488a 100644 --- a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs +++ b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs @@ -70,6 +70,7 @@ public class ClusterSlotVerificationTests new LPUSH(), new LPOP(), new LMPOP(), + new ZMPOP(), new BLPOP(), new BLMOVE(), new BRPOPLPUSH(), @@ -253,6 +254,7 @@ public virtual void OneTimeTearDown() [TestCase("LPUSH")] [TestCase("LPOP")] [TestCase("LMPOP")] + [TestCase("ZMPOP")] [TestCase("BLPOP")] [TestCase("BLMOVE")] [TestCase("BRPOPLPUSH")] @@ -396,6 +398,7 @@ void GarnetClientSessionClusterDown(BaseCommand command) [TestCase("LPUSH")] [TestCase("LPOP")] [TestCase("LMPOP")] + [TestCase("ZMPOP")] [TestCase("BLPOP")] [TestCase("BLMOVE")] [TestCase("BRPOPLPUSH")] @@ -549,6 +552,7 @@ void GarnetClientSessionOK(BaseCommand command) [TestCase("LPUSH")] [TestCase("LPOP")] [TestCase("LMPOP")] + [TestCase("ZMPOP")] [TestCase("BLPOP")] [TestCase("BLMOVE")] [TestCase("BRPOPLPUSH")] @@ -694,6 +698,7 @@ void GarnetClientSessionCrossslotTest(BaseCommand command) [TestCase("LPUSH")] [TestCase("LPOP")] [TestCase("LMPOP")] + [TestCase("ZMPOP")] [TestCase("BLPOP")] [TestCase("BLMOVE")] [TestCase("BRPOPLPUSH")] @@ -846,6 +851,7 @@ void GarnetClientSessionMOVEDTest(BaseCommand command) [TestCase("LPUSH")] [TestCase("LPOP")] [TestCase("LMPOP")] + [TestCase("ZMPOP")] [TestCase("BLPOP")] [TestCase("BLMOVE")] [TestCase("BRPOPLPUSH")] @@ -1015,6 +1021,7 @@ void GarnetClientSessionASKTest(BaseCommand command) [TestCase("LPUSH")] [TestCase("LPOP")] [TestCase("LMPOP")] + [TestCase("ZMPOP")] [TestCase("BLPOP")] [TestCase("BLMOVE")] [TestCase("BRPOPLPUSH")] diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs index a4f74634ef..02c2aa37e7 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -5658,6 +5658,31 @@ static async Task DoZCardAsync(GarnetClient client) } } + + + [Test] + public async Task ZMPopACLsAsync() + { + await CheckCommandsAsync( + "ZMPOP", + [DoZMPopAsync, DoZMPopCountAsync] + ); + + static async Task DoZMPopAsync(GarnetClient client) + { + string[] val = await client.ExecuteForStringArrayResultAsync("ZMPOP", ["2", "foo", "bar", "MIN"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.IsNull(val[0]); + } + + static async Task DoZMPopCountAsync(GarnetClient client) + { + string[] val = await client.ExecuteForStringArrayResultAsync("ZMPOP", ["2", "foo", "bar", "MAX", "COUNT", "10"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.IsNull(val[0]); + } + } + [Test] public async Task ZPopMaxACLsAsync() { diff --git a/test/Garnet.test/RespSortedSetTests.cs b/test/Garnet.test/RespSortedSetTests.cs index 90ef72febc..13da291281 100644 --- a/test/Garnet.test/RespSortedSetTests.cs +++ b/test/Garnet.test/RespSortedSetTests.cs @@ -1250,6 +1250,103 @@ public void TestCheckSortedSetRangeStoreWithExistingDestinationKeySE() } } + [Test] + [TestCase("board1", 1, Description = "Pop from single key")] + [TestCase("board2", 3, Description = "Pop multiple elements")] + public void SortedSetMultiPopTest(string key, int count) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + db.SortedSetAdd(key, entries); + + var result = db.Execute("ZMPOP", 1, key, "MIN", "COUNT", count); + ClassicAssert.IsNotNull(result); + var popResult = (RedisResult[])result; + ClassicAssert.AreEqual(key, (string)popResult[0]); + + var poppedItems = (RedisResult[])popResult[1]; + ClassicAssert.AreEqual(Math.Min(count, entries.Length), poppedItems.Length); + + if (count == 1) + { + var element = poppedItems[0]; + ClassicAssert.AreEqual("a", (string)element[0]); + ClassicAssert.AreEqual("1", (string)element[1]); + } + } + + [Test] + [TestCase(new string[] { "board1" }, "MAX", 1, new string[] { "j" }, new double[] { 10.0 }, Description = "Pop maximum element from single key with count")] + [TestCase(new string[] { "board1" }, "MIN", 1, new string[] { "a" }, new double[] { 1.0 }, Description = "Pop minimum element from single key with count")] + [TestCase(new string[] { "board1" }, "MAX", 3, new string[] { "j", "i", "h" }, new double[] { 10.0, 9.0, 8.0 }, Description = "Pop multiple maximum elements from single key with count")] + [TestCase(new string[] { "board1" }, "MIN", 3, new string[] { "a", "b", "c" }, new double[] { 1.0, 2.0, 3.0 }, Description = "Pop multiple minimum elements from single key with count")] + [TestCase(new string[] { "board1", "nokey1" }, "MAX", 1, new string[] { "j" }, new double[] { 10.0 }, Description = "Pop maximum element from mixed existing and missing keys with count")] + [TestCase(new string[] { "board1", "nokey1" }, "MIN", 1, new string[] { "a" }, new double[] { 1.0 }, Description = "Pop minimum element from mixed existing and missing keys with count")] + [TestCase(new string[] { "nokey1", "nokey2" }, "MAX", 1, new string[] { }, new double[] { }, Description = "Pop maximum element from all missing keys with count")] + [TestCase(new string[] { "nokey1", "nokey2" }, "MIN", 1, new string[] { }, new double[] { }, Description = "Pop minimum element from all missing keys with count")] + [TestCase(new string[] { "board1", "nokey1" }, "MAX", null, new string[] { "j" }, new double[] { 10.0 }, Description = "Pop maximum element from mixed existing and missing keys without count")] + [TestCase(new string[] { "board1", "nokey1" }, "MIN", null, new string[] { "a" }, new double[] { 1.0 }, Description = "Pop minimum element from mixed existing and missing keys without count")] + public void SortedSetMultiPopWithOptionsTest(string[] keys, string direction, int? count, string[] expectedValues, double[] expectedScores) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + if (keys[0] == "board1") + { + db.SortedSetAdd(keys[0], entries); + } + + List commandArgs = [keys.Length, .. keys, direction]; + if (count.HasValue) + { + commandArgs.AddRange(["COUNT", count.Value]); + } + + var result = db.Execute("ZMPOP", commandArgs); + + if (keys[0] == "board1") + { + ClassicAssert.IsNotNull(result); + var popResult = (RedisResult[])result; + ClassicAssert.AreEqual(keys[0], (string)popResult[0]); + + var valuesAndScores = (RedisResult[])popResult[1]; + for (int i = 0; i < expectedValues.Length; i++) + { + var element = valuesAndScores[i]; + ClassicAssert.AreEqual(expectedValues[i], (string)element[0]); + ClassicAssert.AreEqual(expectedScores[i], (double)element[1]); + } + } + else + { + ClassicAssert.IsTrue(result.IsNull); + } + } + + [Test] + public void SortedSetMultiPopWithFirstKeyEmptyOnSecondPopTest() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + string[] keys = ["board1", "board2"]; + db.SortedSetAdd("board1", entries); + db.SortedSetAdd("board2", leaderBoard); + + // First pop + var result1 = db.Execute("ZMPOP", [keys.Length, keys[0], keys[1], "MAX", "COUNT", entries.Length]); + ClassicAssert.IsNotNull(result1); + var popResult1 = (RedisResult[])result1; + ClassicAssert.AreEqual("board1", (string)popResult1[0]); + + // Second pop + var result2 = db.Execute("ZMPOP", [keys.Length, keys[0], keys[1], "MIN"]); + ClassicAssert.IsNotNull(result2); + var popResult2 = (RedisResult[])result2; + ClassicAssert.AreEqual("board2", (string)popResult2[0]); + } + #endregion #region LightClientTests @@ -1647,6 +1744,47 @@ public void CanUseZRevRangeCitiesCommandInChunksLC(int bytesSent) actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); ClassicAssert.AreEqual(expectedResponse, actualValue); } + + [Test] + [TestCase(10, "MIN", 1, "*2\r\n$5\r\nboard\r\n*1\r\n*2\r\n$3\r\none\r\n$1\r\n1\r\n", Description = "Pop minimum with small chunk size")] + [TestCase(100, "MAX", 3, "*2\r\n$5\r\nboard\r\n*3\r\n*2\r\n$4\r\nfive\r\n$1\r\n5\r\n*2\r\n$4\r\nfour\r\n$1\r\n4\r\n*2\r\n$5\r\nthree\r\n$1\r\n3\r\n", Description = "Pop maximum with large chunk size")] + public void CanDoZMPopLC(int bytesSent, string direction, int count, string expectedResponse) + { + using var lightClientRequest = TestUtils.CreateRequest(); + lightClientRequest.SendCommand("ZADD board 1 one 2 two 3 three 4 four 5 five"); + + var response = lightClientRequest.SendCommandChunks($"ZMPOP 1 board {direction} COUNT {count}", bytesSent); + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } + + [Test] + [TestCase("COUNT", Description = "Missing count value")] + [TestCase("INVALID", Description = "Invalid direction")] + public void CanManageZMPopErrorsLC(string invalidArg) + { + using var lightClientRequest = TestUtils.CreateRequest(); + lightClientRequest.SendCommand("ZADD board 1 one 2 two 3 three"); + + var response = lightClientRequest.SendCommand($"ZMPOP 1 board MIN {invalidArg}"); + var expectedResponse = "-ERR syntax error\r\n"; + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } + + [Test] + public void CanDoZMPopWithMultipleKeysLC() + { + using var lightClientRequest = TestUtils.CreateRequest(); + lightClientRequest.SendCommand("ZADD board1 1 one 2 two"); + lightClientRequest.SendCommand("ZADD board2 3 three 4 four"); + + var response = lightClientRequest.SendCommand("ZMPOP 2 board1 board2 MIN"); + var expectedResponse = "*2\r\n$6\r\nboard1\r\n*1\r\n*2\r\n$3\r\none\r\n$1\r\n1\r\n"; + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } + #endregion #region NegativeTestsLC diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md index 944a325f46..af196f3838 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -331,7 +331,7 @@ Note that this list is subject to change as we continue to expand our API comman | | ZINTERCARD | ➖ | | | | ZINTERSTORE | ➖ | | | | [ZLEXCOUNT](data-structures.md#zlexcount) | ➕ | | -| | ZMPOP | ➖ | | +| | [ZMPOP](data-structures.md#zmpop) | ➕ | | | | [ZMSCORE](data-structures.md#zmscore) | ➕ | | | | [ZPOPMAX](data-structures.md#zpopmax) | ➕ | | | | [ZPOPMIN](data-structures.md#zpopmin) | ➕ | | diff --git a/website/docs/commands/data-structures.md b/website/docs/commands/data-structures.md index 4eeec3aaae..35df98bbb8 100644 --- a/website/docs/commands/data-structures.md +++ b/website/docs/commands/data-structures.md @@ -859,6 +859,22 @@ _Array reply:_ a list of string **member** scores as double-precision floating p --- +### ZMPOP + +#### Syntax + +```bash + ZMPOP numkeys key [key ...] [COUNT count] +``` + +Removes and returns one or more members with the lowest scores (default) or highest scores from the sorted set or sorted sets. + +- MIN: Remove elements starting with the lowest scores +- MAX: Remove elements starting with the highest scores +- COUNT: Specifies how many elements to pop (default is 1) + +--- + ### ZPOPMAX #### Syntax