From d965f8ac2afed49be01eddca82a88923aa013f69 Mon Sep 17 00:00:00 2001 From: Vijay Nirmal Date: Wed, 18 Dec 2024 06:24:36 +0530 Subject: [PATCH] [Compatibility] Added ZUNION, ZUNIONSTORE commands (#833) * Added ZUNION, ZUNIONSTORE commands * Format fix * Fixed test failure * Fixed review commands * Build fix * Review command fixes * Buid fix --- libs/resources/RespCommandsDocs.json | 137 ++++++++++ libs/resources/RespCommandsInfo.json | 60 +++++ libs/server/API/GarnetApiObjectCommands.cs | 7 + libs/server/API/GarnetWatchApi.cs | 10 + libs/server/API/IGarnetApi.cs | 21 ++ libs/server/Resp/CmdStrings.cs | 6 +- libs/server/Resp/Objects/SortedSetCommands.cs | 220 ++++++++++++++++ libs/server/Resp/Parser/RespCommand.cs | 10 + libs/server/Resp/RespServerSession.cs | 2 + .../Session/ObjectStore/SortedSetOps.cs | 156 +++++++++++ .../CommandInfoUpdater/SupportedCommand.cs | 2 + .../RedirectTests/BaseCommand.cs | 48 ++++ .../ClusterSlotVerificationTests.cs | 14 + test/Garnet.test/Resp/ACL/RespCommandTests.cs | 30 +++ test/Garnet.test/RespSortedSetTests.cs | 247 ++++++++++++++++++ website/docs/commands/api-compatibility.md | 4 +- website/docs/commands/data-structures.md | 36 +++ 17 files changed, 1005 insertions(+), 5 deletions(-) diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index d9e9fa0b4f..b9701595ad 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -6778,5 +6778,142 @@ "Type": "String" } ] + }, + { + "Command": "ZUNION", + "Name": "ZUNION", + "Summary": "Returns the union of multiple sorted sets.", + "Group": "SortedSet", + "Complexity": "O(N)\u002BO(M*log(M)) with N being the sum of the sizes of the input sorted sets, and M being the number of elements in the resulting sorted set.", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMKEYS", + "DisplayText": "numkeys", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "ArgumentFlags": "Multiple", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WEIGHT", + "DisplayText": "weight", + "Type": "Integer", + "Token": "WEIGHTS", + "ArgumentFlags": "Optional, Multiple" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "AGGREGATE", + "Type": "OneOf", + "Token": "AGGREGATE", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "SUM", + "DisplayText": "sum", + "Type": "PureToken", + "Token": "SUM" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MIN", + "DisplayText": "min", + "Type": "PureToken", + "Token": "MIN" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MAX", + "DisplayText": "max", + "Type": "PureToken", + "Token": "MAX" + } + ] + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHSCORES", + "DisplayText": "withscores", + "Type": "PureToken", + "Token": "WITHSCORES", + "ArgumentFlags": "Optional" + } + ] + }, + { + "Command": "ZUNIONSTORE", + "Name": "ZUNIONSTORE", + "Summary": "Stores the union of multiple sorted sets in a key.", + "Group": "SortedSet", + "Complexity": "O(N)\u002BO(M log(M)) with N being the sum of the sizes of the input sorted sets, and M being the number of elements in the resulting sorted set.", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "DESTINATION", + "DisplayText": "destination", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMKEYS", + "DisplayText": "numkeys", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "ArgumentFlags": "Multiple", + "KeySpecIndex": 1 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WEIGHT", + "DisplayText": "weight", + "Type": "Integer", + "Token": "WEIGHTS", + "ArgumentFlags": "Optional, Multiple" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "AGGREGATE", + "Type": "OneOf", + "Token": "AGGREGATE", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "SUM", + "DisplayText": "sum", + "Type": "PureToken", + "Token": "SUM" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MIN", + "DisplayText": "min", + "Type": "PureToken", + "Token": "MIN" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MAX", + "DisplayText": "max", + "Type": "PureToken", + "Token": "MAX" + } + ] + } + ] } ] \ No newline at end of file diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index 15307b1e9d..b131275ee7 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -4881,5 +4881,65 @@ "Flags": "RO, Access" } ] + }, + { + "Command": "ZUNION", + "Name": "ZUNION", + "Arity": -3, + "Flags": "MovableKeys, ReadOnly", + "AclCategories": "Read, SortedSet, Slow", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysKeyNum", + "KeyNumIdx": 0, + "FirstKey": 1, + "KeyStep": 1 + }, + "Flags": "RO, Access" + } + ] + }, + { + "Command": "ZUNIONSTORE", + "Name": "ZUNIONSTORE", + "Arity": -4, + "Flags": "DenyOom, MovableKeys, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Slow, Write", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "OW, Update" + }, + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 2 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysKeyNum", + "KeyNumIdx": 0, + "FirstKey": 1, + "KeyStep": 1 + }, + "Flags": "RO, Access" + } + ] } ] \ No newline at end of file diff --git a/libs/server/API/GarnetApiObjectCommands.cs b/libs/server/API/GarnetApiObjectCommands.cs index 8fd44473dd..42cf34f17d 100644 --- a/libs/server/API/GarnetApiObjectCommands.cs +++ b/libs/server/API/GarnetApiObjectCommands.cs @@ -138,10 +138,17 @@ public GarnetStatus SortedSetRange(ArgSlice key, ArgSlice min, ArgSlice max, Sor public GarnetStatus SortedSetDifference(ArgSlice[] keys, out Dictionary pairs) => storageSession.SortedSetDifference(keys, out pairs); + /// + public GarnetStatus SortedSetUnion(ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out Dictionary pairs) + => storageSession.SortedSetUnion(keys, weights, aggregateType, out pairs); + /// public GarnetStatus SortedSetDifferenceStore(ArgSlice destinationKey, ReadOnlySpan keys, out int count) => storageSession.SortedSetDifferenceStore(destinationKey, keys, out count); + public GarnetStatus SortedSetUnionStore(ArgSlice destinationKey, ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out int count) + => storageSession.SortedSetUnionStore(destinationKey, keys, weights, aggregateType, out count); + /// public GarnetStatus SortedSetScan(ArgSlice key, long cursor, string match, int count, out ArgSlice[] items) => storageSession.ObjectScan(GarnetObjectType.SortedSet, key, cursor, match, count, out items, ref objectContext); diff --git a/libs/server/API/GarnetWatchApi.cs b/libs/server/API/GarnetWatchApi.cs index 1b359c3d46..2cec273074 100644 --- a/libs/server/API/GarnetWatchApi.cs +++ b/libs/server/API/GarnetWatchApi.cs @@ -184,6 +184,16 @@ public GarnetStatus SortedSetDifference(ArgSlice[] keys, out Dictionary + public GarnetStatus SortedSetUnion(ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out Dictionary pairs) + { + foreach (var key in keys) + { + garnetApi.WATCH(key, StoreType.Object); + } + return garnetApi.SortedSetUnion(keys, weights, aggregateType, out pairs); + } + /// public GarnetStatus GeoCommands(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) { diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 0ae7353622..25a028e19a 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -532,6 +532,17 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi /// A indicating the status of the operation. GarnetStatus SortedSetIntersectStore(ArgSlice destinationKey, ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out int count); + /// + /// Performs a union of multiple sorted sets and stores the result in the destination key. + /// + /// The key where the result will be stored. + /// The keys of the sorted sets to union. + /// The number of elements in the resulting sorted set. + /// Optional weights to apply to each sorted set. + /// The type of aggregation to perform (e.g., Sum, Min, Max). + /// A indicating the status of the operation. + GarnetStatus SortedSetUnionStore(ArgSlice destinationKey, ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out int count); + #endregion #region Set Methods @@ -1294,6 +1305,16 @@ public interface IGarnetReadApi /// GarnetStatus SortedSetDifference(ArgSlice[] keys, out Dictionary pairs); + /// + /// Performs a union of multiple sorted sets and stores the result in a dictionary. + /// + /// A read-only span of ArgSlice representing the keys of the sorted sets to union. + /// An output dictionary where the result of the union will be stored, with byte arrays as keys and doubles as values. + /// An optional array of doubles representing the weights to apply to each sorted set during the union. + /// The type of aggregation to use when combining scores from the sorted sets. Defaults to . + /// A indicating the status of the operation. + GarnetStatus SortedSetUnion(ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out Dictionary pairs); + /// /// Iterates members of SortedSet key and their associated scores using a cursor, /// a match pattern and count parameters diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index 82634932f6..7ec2967e95 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -119,12 +119,12 @@ static partial class CmdStrings public static ReadOnlySpan LEFT => "LEFT"u8; 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; public static ReadOnlySpan WEIGHTS => "WEIGHTS"u8; public static ReadOnlySpan AGGREGATE => "AGGREGATE"u8; public static ReadOnlySpan SUM => "SUM"u8; - public static ReadOnlySpan MIN => "MIN"u8; - public static ReadOnlySpan MAX => "MAX"u8; - public static ReadOnlySpan LIMIT => "LIMIT"u8; /// /// Response strings diff --git a/libs/server/Resp/Objects/SortedSetCommands.cs b/libs/server/Resp/Objects/SortedSetCommands.cs index 6cdd92d5b0..4aa313d4cc 100644 --- a/libs/server/Resp/Objects/SortedSetCommands.cs +++ b/libs/server/Resp/Objects/SortedSetCommands.cs @@ -1303,5 +1303,225 @@ private unsafe bool SortedSetIntersectStore(ref TGarnetApi storageAp return true; } + + /// + /// Computes a union operation between multiple sorted sets and returns the result to the client. + /// The total number of input keys is specified. + /// + /// + /// + private unsafe bool SortedSetUnion(ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (parseState.Count < 2) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.ZUNION)); + } + + // Number of keys + if (!parseState.TryGetInt(0, out var nKeys)) + { + return AbortWithErrorMessage(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER); + } + + if (nKeys < 1) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZUNION)))); + } + + if (parseState.Count < nKeys + 1) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + var currentArg = nKeys + 1; + var includeWithScores = false; + double[] weights = null; + var aggregateType = SortedSetAggregateType.Sum; + + // Parse optional arguments + while (currentArg < parseState.Count) + { + var arg = parseState.GetArgSliceByRef(currentArg).ReadOnlySpan; + if (arg.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WITHSCORES)) + { + includeWithScores = true; + currentArg++; + } + else if (arg.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WEIGHTS)) + { + currentArg++; + if (currentArg + nKeys > parseState.Count) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + weights = new double[nKeys]; + for (var i = 0; i < nKeys; i++) + { + if (!parseState.TryGetDouble(currentArg + i, out weights[i])) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrNotAFloat, "weight"))); + } + } + currentArg += nKeys; + } + else if (arg.EqualsUpperCaseSpanIgnoringCase(CmdStrings.AGGREGATE)) + { + currentArg++; + if (currentArg >= parseState.Count) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + if (!parseState.TryGetSortedSetAggregateType(currentArg, out aggregateType)) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + currentArg++; + } + else + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + } + + // Read all the keys + var keys = parseState.Parameters.Slice(1, nKeys); + + var status = storageApi.SortedSetUnion(keys, weights, aggregateType, out var result); + + switch (status) + { + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + default: + if (result == null || result.Count == 0) + { + while (!RespWriteUtils.WriteEmptyArray(ref dcurr, dend)) + SendAndReset(); + break; + } + + // write the size of the array reply + while (!RespWriteUtils.WriteArrayLength(includeWithScores ? result.Count * 2 : result.Count, ref dcurr, dend)) + SendAndReset(); + + foreach (var (element, score) in result) + { + while (!RespWriteUtils.WriteBulkString(element, ref dcurr, dend)) + SendAndReset(); + + if (includeWithScores) + { + while (!RespWriteUtils.TryWriteDoubleBulkString(score, ref dcurr, dend)) + SendAndReset(); + } + } + break; + } + + return true; + } + + /// + /// Computes a union operation between multiple sorted sets and stores the result in destination. + /// Returns the number of elements in the resulting sorted set at destination. + /// + /// + /// + private unsafe bool SortedSetUnionStore(ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (parseState.Count < 3) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.ZUNIONSTORE)); + } + + // Number of keys + if (!parseState.TryGetInt(1, out var nKeys)) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER, ref dcurr, dend)) + SendAndReset(); + return true; + } + + if (nKeys < 1) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZUNIONSTORE)))); + } + + if (parseState.Count < nKeys + 2) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + var currentArg = nKeys + 2; + double[] weights = null; + var aggregateType = SortedSetAggregateType.Sum; + + // Parse optional arguments + while (currentArg < parseState.Count) + { + var arg = parseState.GetArgSliceByRef(currentArg).ReadOnlySpan; + if (arg.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WEIGHTS)) + { + currentArg++; + if (currentArg + nKeys > parseState.Count) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + weights = new double[nKeys]; + for (var i = 0; i < nKeys; i++) + { + if (!parseState.TryGetDouble(currentArg + i, out weights[i])) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrNotAFloat, "weight"))); + } + } + currentArg += nKeys; + } + else if (arg.EqualsUpperCaseSpanIgnoringCase(CmdStrings.AGGREGATE)) + { + currentArg++; + if (currentArg + 1 > parseState.Count) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + if (!parseState.TryGetSortedSetAggregateType(currentArg, out aggregateType)) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + currentArg++; + } + else + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + } + + var destination = parseState.GetArgSliceByRef(0); + var keys = parseState.Parameters.Slice(2, nKeys); + + var status = storageApi.SortedSetUnionStore(destination, keys, weights, aggregateType, out var count); + + switch (status) + { + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + default: + while (!RespWriteUtils.WriteInteger(count, ref dcurr, dend)) + SendAndReset(); + break; + } + + return true; + } } } \ No newline at end of file diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index abd425c901..9e706a5859 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -89,6 +89,7 @@ public enum RespCommand : ushort ZREVRANK, ZSCAN, ZSCORE, // Note: Last read command should immediately precede FirstWriteCommand + ZUNION, // Write commands APPEND, // Note: Update FirstWriteCommand if adding new write commands before this @@ -172,6 +173,7 @@ public enum RespCommand : ushort ZREMRANGEBYLEX, ZREMRANGEBYRANK, ZREMRANGEBYSCORE, + ZUNIONSTORE, // BITOP is the true command, AND|OR|XOR|NOT are pseudo-subcommands BITOP, @@ -1219,6 +1221,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.ZRANGE; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("ZUNION\r\n"u8)) + { + return RespCommand.ZUNION; + } if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("ZSCORE\r\n"u8)) { return RespCommand.ZSCORE; @@ -1483,6 +1489,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.ZINTERSTORE; } + else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("1\r\nZUNIO"u8) && *(ulong*)(ptr + 10) == MemoryMarshal.Read("NSTORE\r\n"u8)) + { + return RespCommand.ZUNIONSTORE; + } break; case 12: diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index 50b781d122..138c8a14b4 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -626,6 +626,8 @@ private bool ProcessArrayCommands(RespCommand cmd, ref TGarnetApi st RespCommand.ZINTER => SortedSetIntersect(ref storageApi), RespCommand.ZINTERCARD => SortedSetIntersectLength(ref storageApi), RespCommand.ZINTERSTORE => SortedSetIntersectStore(ref storageApi), + RespCommand.ZUNION => SortedSetUnion(ref storageApi), + RespCommand.ZUNIONSTORE => SortedSetUnionStore(ref storageApi), //SortedSet for Geo Commands RespCommand.GEOADD => GeoAdd(ref storageApi), RespCommand.GEOHASH => GeoCommands(cmd, ref storageApi), diff --git a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs index 7f542e3bf0..a2840d4b82 100644 --- a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs +++ b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs @@ -991,6 +991,162 @@ public GarnetStatus SortedSetScan(byte[] key, ref ObjectInput in where TObjectContext : ITsavoriteContext => ReadObjectStoreOperationWithOutput(key, ref input, ref objectStoreContext, ref outputFooter); + public GarnetStatus SortedSetUnion(ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out Dictionary pairs) + { + pairs = 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 item in keys) + txnManager.SaveKeyEntryToLock(item, true, LockType.Shared); + txnManager.Run(true); + } + + var objectContext = txnManager.ObjectStoreLockableContext; + + try + { + return SortedSetUnion(keys, ref objectContext, out pairs, weights, aggregateType); + } + finally + { + if (createTransaction) + txnManager.Commit(true); + } + } + + public GarnetStatus SortedSetUnionStore(ArgSlice destinationKey, ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out int count) + { + count = default; + + if (keys.Length == 0) + return GarnetStatus.OK; + + var createTransaction = false; + + if (txnManager.state != TxnState.Running) + { + Debug.Assert(txnManager.state == TxnState.None); + createTransaction = true; + txnManager.SaveKeyEntryToLock(destinationKey, true, LockType.Exclusive); + foreach (var item in keys) + txnManager.SaveKeyEntryToLock(item, true, LockType.Shared); + _ = txnManager.Run(true); + } + + var objectContext = txnManager.ObjectStoreLockableContext; + + try + { + var status = SortedSetUnion(keys, ref objectContext, out var pairs, weights, aggregateType); + + if (status == GarnetStatus.WRONGTYPE) + { + return GarnetStatus.WRONGTYPE; + } + + count = pairs?.Count ?? 0; + + if (count > 0) + { + SortedSetObject newSortedSetObject = new(); + foreach (var (element, score) in pairs) + { + newSortedSetObject.Add(element, score); + } + _ = SET(destinationKey.ToArray(), newSortedSetObject, ref objectContext); + } + else + { + _ = EXPIRE(destinationKey, TimeSpan.Zero, out _, StoreType.Object, ExpireOption.None, + ref lockableContext, ref objectContext); + } + + return status; + } + finally + { + if (createTransaction) + txnManager.Commit(true); + } + } + + private GarnetStatus SortedSetUnion(ReadOnlySpan keys, ref TObjectContext objectContext, + out Dictionary pairs, double[] weights = null, SortedSetAggregateType aggregateType = SortedSetAggregateType.Sum) + where TObjectContext : ITsavoriteContext + { + pairs = default; + + if (keys.Length == 0) + return GarnetStatus.OK; + + // Get the first sorted set + var status = GET(keys[0].ToArray(), out var firstObj, ref objectContext); + if (status == GarnetStatus.OK) + { + if (firstObj.garnetObject is not SortedSetObject firstSortedSet) + { + return GarnetStatus.WRONGTYPE; + } + + // Initialize pairs with the first set + if (weights is null) + { + pairs = new Dictionary(firstSortedSet.Dictionary, ByteArrayComparer.Instance); + } + else + { + pairs = new Dictionary(ByteArrayComparer.Instance); + foreach (var (key, score) in firstSortedSet.Dictionary) + { + pairs[key] = weights[0] * score; + } + } + + // Process remaining sets + for (var i = 1; i < keys.Length; i++) + { + status = GET(keys[i].ToArray(), out var nextObj, ref objectContext); + if (status != GarnetStatus.OK) + continue; + + if (nextObj.garnetObject is not SortedSetObject nextSortedSet) + { + pairs = default; + return GarnetStatus.WRONGTYPE; + } + + foreach (var (key, score) in nextSortedSet.Dictionary) + { + var weightedScore = weights is null ? score : score * weights[i]; + if (pairs.TryGetValue(key, out var existingScore)) + { + pairs[key] = aggregateType switch + { + SortedSetAggregateType.Sum => existingScore + weightedScore, + SortedSetAggregateType.Min => Math.Min(existingScore, weightedScore), + SortedSetAggregateType.Max => Math.Max(existingScore, weightedScore), + _ => existingScore + weightedScore // Default to SUM + }; + } + else + { + pairs[key] = weightedScore; + } + } + } + } + + return GarnetStatus.OK; + } + private GarnetStatus SortedSetDifference(ReadOnlySpan keys, ref TObjectContext objectContext, out Dictionary pairs) where TObjectContext : ITsavoriteContext { diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index b7dfee8e76..b0cbe3dd21 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -292,6 +292,8 @@ public class SupportedCommand new("ZREVRANK", RespCommand.ZREVRANK), new("ZSCAN", RespCommand.ZSCAN), new("ZSCORE", RespCommand.ZSCORE), + new("ZUNION", RespCommand.ZUNION), + new("ZUNIONSTORE", RespCommand.ZUNIONSTORE), new("EVAL", RespCommand.EVAL), new("EVALSHA", RespCommand.EVALSHA), new("SCRIPT", RespCommand.SCRIPT, diff --git a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs index e323355f15..659a993f17 100644 --- a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs +++ b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs @@ -2194,6 +2194,54 @@ public override ArraySegment[] SetupSingleSlotRequest() } } + internal class ZUNION : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZUNION); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + // ZUNION 2 a b + return ["2", ssk[0], ssk[1]]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZUNIONSTORE : BaseCommand + { + public override bool IsArrayCommand => true; + public override bool ArrayResponse => false; + public override string Command => nameof(ZUNIONSTORE); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + // ZUNIONSTORE c 2 a b + return [ssk[0], "2", ssk[1], ssk[2]]; + } + + public override string[] GetCrossSlotRequest() + { + var csk = GetCrossSlotKeys; + return [csk[0], "2", csk[1], csk[2]]; + } + + 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 2a82ff4757..a7912b8f84 100644 --- a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs +++ b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs @@ -114,6 +114,8 @@ public class ClusterSlotVerificationTests new ZINTER(), new ZINTERCARD(), new ZINTERSTORE(), + new ZUNION(), + new ZUNIONSTORE(), new HSET(), new HGET(), new HGETALL(), @@ -301,6 +303,8 @@ public virtual void OneTimeTearDown() [TestCase("ZINTER")] [TestCase("ZINTERCARD")] [TestCase("ZINTERSTORE")] + [TestCase("ZUNION")] + [TestCase("ZUNIONSTORE")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -452,6 +456,8 @@ void GarnetClientSessionClusterDown(BaseCommand command) [TestCase("ZINTER")] [TestCase("ZINTERCARD")] [TestCase("ZINTERSTORE")] + [TestCase("ZUNION")] + [TestCase("ZUNIONSTORE")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -612,6 +618,8 @@ void GarnetClientSessionOK(BaseCommand command) [TestCase("ZINTER")] [TestCase("ZINTERCARD")] [TestCase("ZINTERSTORE")] + [TestCase("ZUNION")] + [TestCase("ZUNIONSTORE")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -763,6 +771,8 @@ void GarnetClientSessionCrossslotTest(BaseCommand command) [TestCase("ZINTER")] [TestCase("ZINTERCARD")] [TestCase("ZINTERSTORE")] + [TestCase("ZUNION")] + [TestCase("ZUNIONSTORE")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -922,6 +932,8 @@ void GarnetClientSessionMOVEDTest(BaseCommand command) [TestCase("ZINTER")] [TestCase("ZINTERCARD")] [TestCase("ZINTERSTORE")] + [TestCase("ZUNION")] + [TestCase("ZUNIONSTORE")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -1099,6 +1111,8 @@ void GarnetClientSessionASKTest(BaseCommand command) [TestCase("ZINTER")] [TestCase("ZINTERCARD")] [TestCase("ZINTERSTORE")] + [TestCase("ZUNION")] + [TestCase("ZUNIONSTORE")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs index ec98b645ce..bf47124bdb 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -6193,6 +6193,36 @@ static async Task DoZInterStoreAsync(GarnetClient client) } } + [Test] + public async Task ZUnionACLsAsync() + { + await CheckCommandsAsync( + "ZUNION", + [DoZUnionAsync] + ); + + static async Task DoZUnionAsync(GarnetClient client) + { + string[] val = await client.ExecuteForStringArrayResultAsync("ZUNION", ["2", "foo", "bar"]); + ClassicAssert.AreEqual(0, val.Length); + } + } + + [Test] + public async Task ZUnionStoreACLsAsync() + { + await CheckCommandsAsync( + "ZUNIONSTORE", + [DoZUnionStoreAsync] + ); + + static async Task DoZUnionStoreAsync(GarnetClient client) + { + var val = await client.ExecuteForLongResultAsync("ZUNIONSTORE", ["keyZ", "2", "foo", "bar"]); + ClassicAssert.AreEqual(0, val); + } + } + [Test] public async Task ZScanACLsAsync() { diff --git a/test/Garnet.test/RespSortedSetTests.cs b/test/Garnet.test/RespSortedSetTests.cs index 1742d6d107..04311a99aa 100644 --- a/test/Garnet.test/RespSortedSetTests.cs +++ b/test/Garnet.test/RespSortedSetTests.cs @@ -1559,6 +1559,192 @@ public void CanDoZInterStoreWithSE() ClassicAssert.AreEqual(string.Format(CmdStrings.GenericErrWrongNumArgs, "ZINTERSTORE"), ex.Message); } + [Test] + [TestCase("SUM", new double[] { 5, 7, 3, 6 }, new string[] { "a", "b", "c", "d" }, Description = "Tests ZUNION with SUM aggregate")] + [TestCase("MIN", new double[] { 1, 2, 3, 6 }, new string[] { "a", "b", "c", "d" }, Description = "Tests ZUNION with MIN aggregate")] + [TestCase("MAX", new double[] { 4, 5, 3, 6 }, new string[] { "a", "b", "c", "d" }, Description = "Tests ZUNION with MAX aggregate")] + public void CanUseZUnionWithAggregateOption(string aggregateType, double[] expectedScores, string[] expectedElements) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Setup test data + db.SortedSetAdd("zset1", + [ + new SortedSetEntry("a", 1), + new SortedSetEntry("b", 2), + new SortedSetEntry("c", 3) + ]); + db.SortedSetAdd("zset2", + [ + new SortedSetEntry("a", 4), + new SortedSetEntry("b", 5), + new SortedSetEntry("d", 6) + ]); + + var result = db.SortedSetCombineWithScores(SetOperation.Union, ["zset1", "zset2"], + weights: null, aggregate: aggregateType switch + { + "SUM" => Aggregate.Sum, + "MIN" => Aggregate.Min, + "MAX" => Aggregate.Max, + _ => throw new ArgumentException("Invalid aggregate type") + }); + + ClassicAssert.AreEqual(expectedScores.Length, result.Length); + for (int i = 0; i < result.Length; i++) + { + ClassicAssert.AreEqual(expectedScores[i], result[i].Score); + ClassicAssert.AreEqual(expectedElements[i], result[i].Element.ToString()); + } + } + + [Test] + [TestCase(new double[] { 2, 3 }, new double[] { 14, 19, 6, 18 }, new string[] { "a", "b", "c", "d" }, Description = "Tests ZUNION with multiple weights")] + public void CanUseZUnionWithWeights(double[] weights, double[] expectedScores, string[] expectedElements) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Setup test data + db.SortedSetAdd("zset1", [ + new("a", 1), + new("b", 2), + new("c", 3) + ]); + db.SortedSetAdd("zset2", [ + new("a", 4), + new("b", 5), + new("d", 6) + ]); + + var result = db.SortedSetCombineWithScores(SetOperation.Union, + ["zset1", "zset2"], + weights: weights); + + ClassicAssert.AreEqual(expectedScores.Length, result.Length); + for (int i = 0; i < result.Length; i++) + { + ClassicAssert.AreEqual(expectedScores[i], result[i].Score); + ClassicAssert.AreEqual(expectedElements[i], result[i].Element.ToString()); + } + } + + [Test] + [TestCase("SUM", new double[] { 3, 5, 6, 7 }, new string[] { "c", "a", "d", "b" }, Description = "Tests ZUNIONSTORE with SUM aggregate")] + [TestCase("MIN", new double[] { 1, 2, 3, 6 }, new string[] { "a", "b", "c", "d" }, Description = "Tests ZUNIONSTORE with MIN aggregate")] + [TestCase("MAX", new double[] { 3, 4, 5, 6 }, new string[] { "c", "a", "b", "d" }, Description = "Tests ZUNIONSTORE with MAX aggregate")] + public void CanUseZUnionStoreWithAggregateOption(string aggregateType, double[] expectedScores, string[] expectedElements) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Setup test data + db.SortedSetAdd("zset1", + [ + new SortedSetEntry("a", 1), + new SortedSetEntry("b", 2), + new SortedSetEntry("c", 3) + ]); + db.SortedSetAdd("zset2", + [ + new SortedSetEntry("a", 4), + new SortedSetEntry("b", 5), + new SortedSetEntry("d", 6) + ]); + + db.SortedSetCombineAndStore(SetOperation.Union, "zset3", ["zset1", "zset2"], + weights: null, aggregate: aggregateType switch + { + "SUM" => Aggregate.Sum, + "MIN" => Aggregate.Min, + "MAX" => Aggregate.Max, + _ => throw new ArgumentException("Invalid aggregate type") + }); + + var result = db.SortedSetRangeByRankWithScores("zset3"); + + ClassicAssert.AreEqual(expectedScores.Length, result.Length); + for (int i = 0; i < result.Length; i++) + { + ClassicAssert.AreEqual(expectedScores[i], result[i].Score); + ClassicAssert.AreEqual(expectedElements[i], result[i].Element.ToString()); + } + } + + [Test] + public void CanUseZUnionStoreWithNonEmptyDestinationKey() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Setup test data + db.SortedSetAdd("zset1", + [ + new SortedSetEntry("a", 1), + new SortedSetEntry("b", 2), + new SortedSetEntry("c", 3) + ]); + db.SortedSetAdd("zset2", + [ + new SortedSetEntry("a", 4), + new SortedSetEntry("b", 5), + new SortedSetEntry("d", 6) + ]); + + // Add some data to the destination key + db.SortedSetAdd("zset3", + [ + new SortedSetEntry("x", 10), + new SortedSetEntry("y", 20) + ]); + + db.SortedSetCombineAndStore(SetOperation.Union, "zset3", ["zset1", "zset2"], weights: null, aggregate: Aggregate.Sum); + + var result = db.SortedSetRangeByRankWithScores("zset3"); + + var expectedScores = new double[] { 3, 5, 6, 7 }; + var expectedElements = new string[] { "c", "a", "d", "b" }; + + ClassicAssert.AreEqual(expectedScores.Length, result.Length); + for (int i = 0; i < result.Length; i++) + { + ClassicAssert.AreEqual(expectedScores[i], result[i].Score); + ClassicAssert.AreEqual(expectedElements[i], result[i].Element.ToString()); + } + } + + [Test] + [TestCase(new double[] { 2, 3 }, new double[] { 6, 14, 18, 19 }, new string[] { "c", "a", "d", "b" }, Description = "Tests ZUNIONSTORE with multiple weights")] + public void CanUseZUnionStoreWithWeights(double[] weights, double[] expectedScores, string[] expectedElements) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Setup test data + db.SortedSetAdd("zset1", [ + new("a", 1), + new("b", 2), + new("c", 3) + ]); + db.SortedSetAdd("zset2", [ + new("a", 4), + new("b", 5), + new("d", 6) + ]); + + db.SortedSetCombineAndStore(SetOperation.Union, "zset3", ["zset1", "zset2"], weights: weights); + + var result = db.SortedSetRangeByRankWithScores("zset3"); + + ClassicAssert.AreEqual(expectedScores.Length, result.Length); + for (int i = 0; i < result.Length; i++) + { + ClassicAssert.AreEqual(expectedScores[i], result[i].Score); + ClassicAssert.AreEqual(expectedElements[i], result[i].Element.ToString()); + } + } + #endregion #region LightClientTests @@ -1957,6 +2143,52 @@ public void CanUseZRevRangeCitiesCommandInChunksLC(int bytesSent) ClassicAssert.AreEqual(expectedResponse, actualValue); } + [Test] + [TestCase(10)] + [TestCase(30)] + [TestCase(100)] + public void CanUseZUnion(int bytesSent) + { + using var lightClientRequest = TestUtils.CreateRequest(); + lightClientRequest.SendCommand("ZADD zset1 1 uno 2 due 3 tre 4 quattro"); + lightClientRequest.SendCommand("ZADD zset2 1 uno 2 due 3 tre 4 quattro 5 cinque 6 sei"); + + // Basic ZUNION + var response = lightClientRequest.SendCommandChunks("ZUNION 2 zset1 zset2", bytesSent, 7); + var expectedResponse = "*6\r\n$3\r\nuno\r\n$3\r\ndue\r\n$3\r\ntre\r\n$7\r\nquattro\r\n$6\r\ncinque\r\n$3\r\nsei\r\n"; + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + + // ZUNION with WITHSCORES + response = lightClientRequest.SendCommandChunks("ZUNION 2 zset1 zset2 WITHSCORES", bytesSent, 13); + expectedResponse = "*12\r\n$3\r\nuno\r\n$1\r\n2\r\n$3\r\ndue\r\n$1\r\n4\r\n$3\r\ntre\r\n$1\r\n6\r\n$7\r\nquattro\r\n$1\r\n8\r\n$6\r\ncinque\r\n$1\r\n5\r\n$3\r\nsei\r\n$1\r\n6\r\n"; + actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } + + [Test] + [TestCase(10)] + [TestCase(30)] + [TestCase(100)] + public void CanUseZUnionStore(int bytesSent) + { + using var lightClientRequest = TestUtils.CreateRequest(); + lightClientRequest.SendCommand("ZADD zset1 1 uno 2 due 3 tre 4 quattro"); + lightClientRequest.SendCommand("ZADD zset2 1 uno 2 due 3 tre 4 quattro 5 cinque 6 sei"); + + // Basic ZUNIONSTORE + var response = lightClientRequest.SendCommandChunks("ZUNIONSTORE destset 2 zset1 zset2", bytesSent); + var expectedResponse = ":6\r\n"; + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + + // Verify stored result + response = lightClientRequest.SendCommandChunks("ZRANGE destset 0 -1 WITHSCORES", bytesSent, 13); + expectedResponse = "*12\r\n$3\r\nuno\r\n$1\r\n2\r\n$3\r\ndue\r\n$1\r\n4\r\n$6\r\ncinque\r\n$1\r\n5\r\n$3\r\nsei\r\n$1\r\n6\r\n$3\r\ntre\r\n$1\r\n6\r\n$7\r\nquattro\r\n$1\r\n8\r\n"; + 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")] @@ -1997,6 +2229,21 @@ public void CanDoZMPopWithMultipleKeysLC() ClassicAssert.AreEqual(expectedResponse, actualValue); } + + [Test] + public void CanUseZUnionWithMultipleOptions() + { + using var lightClientRequest = TestUtils.CreateRequest(); + lightClientRequest.SendCommand("ZADD zset1 1 uno 2 due 3 tre"); + lightClientRequest.SendCommand("ZADD zset2 4 uno 5 due 6 quattro"); + + // Test WEIGHTS and AGGREGATE together + var response = lightClientRequest.SendCommand("ZUNION 2 zset1 zset2 WEIGHTS 2 3 AGGREGATE MAX WITHSCORES"); + var expectedResponse = "*8\r\n$3\r\nuno\r\n$2\r\n12\r\n$3\r\ndue\r\n$2\r\n15\r\n$3\r\ntre\r\n$1\r\n6\r\n$7\r\nquattro\r\n$2\r\n18\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 033eee5270..3e2e1f3d36 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -351,8 +351,8 @@ Note that this list is subject to change as we continue to expand our API comman | | [ZREVRANK](data-structures.md#zrevrank) | ➕ | | | | [ZSCAN](data-structures.md#zscan) | ➕ | | | | [ZSCORE](data-structures.md#zscore) | ➕ | | -| | ZUNION | ➖ | | -| | ZUNIONSTORE | ➖ | | +| | [ZUNION](data-structures.md#zunion) | ➕ | | +| | [ZUNIONSTORE](data-structures.md#zunionstore) | ➕ | | | **STREAM** | XACK | ➖ | | | | XADD | ➖ | | | | XAUTOCLAIM | ➖ | | diff --git a/website/docs/commands/data-structures.md b/website/docs/commands/data-structures.md index 426cabb293..fce8c59ceb 100644 --- a/website/docs/commands/data-structures.md +++ b/website/docs/commands/data-structures.md @@ -1192,6 +1192,42 @@ Stores the specified range of elements in the sorted set stored at **src** into --- +### ZUNION + +#### Syntax + +```bash + ZUNION numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] [WITHSCORES] +``` + +Returns the union of the input sorted sets specified by the keys. The total number of input keys is specified by numkeys. + +Keys that do not exist are considered to be empty sets. + +#### Resp Reply + +Array reply: the result of the union with, optionally, their scores when WITHSCORES is used. + +--- + +### ZUNIONSTORE + +#### Syntax + +```bash + ZUNIONSTORE destination numkeys key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE ] +``` + +Computes the union of the input sorted sets specified by the keys and stores the result in destination. The total number of input keys is specified by numkeys. + +Keys that do not exist are considered to be empty sets. + +#### Resp Reply + +Integer reply: the number of members in the resulting sorted set at destination. + +--- + ## Geospatial indices ### GEOADD