diff --git a/libs/host/Configuration/Options.cs b/libs/host/Configuration/Options.cs index ba85044b82..ae476f45f7 100644 --- a/libs/host/Configuration/Options.cs +++ b/libs/host/Configuration/Options.cs @@ -237,9 +237,10 @@ internal sealed class Options [IntRangeValidation(0, int.MaxValue)] [Option("compaction-freq", Required = false, HelpText = "Background hybrid log compaction frequency in seconds. 0 = disabled (compaction performed before checkpointing instead)")] public int CompactionFrequencySecs { get; set; } + [IntRangeValidation(0, int.MaxValue)] - [Option("hcollect-freq", Required = false, HelpText = "Frequency in seconds for the background task to perform Hash collection. 0 = disabled. Hash collect is used to delete expired fields from hash without waiting for a write operation. Use the HCOLLECT API to collect on-demand.")] - public int HashCollectFrequencySecs { get; set; } + [Option("expired-object-collection-freq", Required = false, HelpText = "Frequency in seconds for the background task to perform object collection which removes expired members within object from memory. 0 = disabled. Use the HCOLLECT and ZCOLLECT API to collect on-demand.")] + public int ExpiredObjectCollectionFrequencySecs { get; set; } [Option("compaction-type", Required = false, HelpText = "Hybrid log compaction type. Value options: None - no compaction, Shift - shift begin address without compaction (data loss), Scan - scan old pages and move live records to tail (no data loss), Lookup - lookup each record in compaction range, for record liveness checking using hash chain (no data loss)")] public LogCompactionType CompactionType { get; set; } @@ -745,7 +746,7 @@ public GarnetServerOptions GetServerOptions(ILogger logger = null) WaitForCommit = WaitForCommit.GetValueOrDefault(), AofSizeLimit = AofSizeLimit, CompactionFrequencySecs = CompactionFrequencySecs, - HashCollectFrequencySecs = HashCollectFrequencySecs, + ExpiredObjectCollectionFrequencySecs = ExpiredObjectCollectionFrequencySecs, CompactionType = CompactionType, CompactionForceDelete = CompactionForceDelete.GetValueOrDefault(), CompactionMaxSegments = CompactionMaxSegments, diff --git a/libs/host/defaults.conf b/libs/host/defaults.conf index 4741f65e63..4331522709 100644 --- a/libs/host/defaults.conf +++ b/libs/host/defaults.conf @@ -162,8 +162,8 @@ /* Background hybrid log compaction frequency in seconds. 0 = disabled (compaction performed before checkpointing instead) */ "CompactionFrequencySecs" : 0, - /* Frequency in seconds for the background task to perform Hash collection. 0 = disabled. Hash collect is used to delete expired fields from hash without waiting for a write operation. Use the HCOLLECT API to collect on-demand. */ - "HashCollectFrequencySecs" : 0, + /* Frequency in seconds for the background task to perform object collection which removes expired members within object from memory. 0 = disabled. Use the HCOLLECT and ZCOLLECT API to collect on-demand. */ + "ExpiredObjectCollectionFrequencySecs" : 0, /* Hybrid log compaction type. Value options: */ /* None - no compaction */ diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index f42b5bcccc..404577b7f0 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -7202,6 +7202,12 @@ } ] }, + { + "Command": "ZCOLLECT", + "Name": "ZCOLLECT", + "Summary": "Manually trigger deletion of expired members from memory for SortedSet", + "Group": "Hash" + }, { "Command": "ZDIFF", "Name": "ZDIFF", @@ -7263,6 +7269,201 @@ } ] }, + { + "Command": "ZEXPIRE", + "Name": "ZEXPIRE", + "Summary": "Set expiry for sorted set member using relative time to expire (seconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "SECONDS", + "DisplayText": "seconds", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZEXPIREAT", + "Name": "ZEXPIREAT", + "Summary": "Set expiry for sorted set member using an absolute Unix timestamp (seconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "UNIX-TIME-SECONDS", + "DisplayText": "unix-time-seconds", + "Type": "UnixTime" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZEXPIRETIME", + "Name": "ZEXPIRETIME", + "Summary": "Returns the expiration time of a sorted set member as a Unix timestamp, in seconds.", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "FIELDS", + "Type": "Block", + "Token": "FIELDS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMFIELDS", + "DisplayText": "numfields", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "FIELD", + "DisplayText": "field", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, { "Command": "ZINCRBY", "Name": "ZINCRBY", @@ -7562,6 +7763,275 @@ } ] }, + { + "Command": "ZPERSIST", + "Name": "ZPERSIST", + "Summary": "Removes the expiration time for each specified sorted set member", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPEXPIRE", + "Name": "ZPEXPIRE", + "Summary": "Set expiry for sorted set member using relative time to expire (milliseconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MILLISECONDS", + "DisplayText": "milliseconds", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPEXPIREAT", + "Name": "ZPEXPIREAT", + "Summary": "Set expiry for sorted set member using an absolute Unix timestamp (milliseconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "UNIX-TIME-MILLISECONDS", + "DisplayText": "unix-time-milliseconds", + "Type": "UnixTime" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPEXPIRETIME", + "Name": "ZPEXPIRETIME", + "Summary": "Returns the expiration time of a sorted set member as a Unix timestamp, in msec.", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPTTL", + "Name": "ZPTTL", + "Summary": "Returns the TTL in milliseconds of a sorted set member.", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, { "Command": "ZPOPMAX", "Name": "ZPOPMAX", @@ -8304,6 +8774,43 @@ } ] }, + { + "Command": "ZTTL", + "Name": "ZTTL", + "Summary": "Returns the TTL in seconds of a sorted set member.", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, { "Command": "ZUNION", "Name": "ZUNION", diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index dd1499f05d..4ebc4dff52 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -4899,6 +4899,31 @@ } ] }, + { + "Command": "ZCOLLECT", + "Name": "ZCOLLECT", + "Arity": 2, + "Flags": "Admin, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Write, Admin, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Access, Update" + } + ] + }, { "Command": "ZDIFF", "Name": "ZDIFF", @@ -4959,6 +4984,81 @@ } ] }, + { + "Command": "ZEXPIRE", + "Name": "ZEXPIRE", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZEXPIREAT", + "Name": "ZEXPIREAT", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZEXPIRETIME", + "Name": "ZEXPIRETIME", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, { "Command": "ZINCRBY", "Name": "ZINCRBY", @@ -5138,6 +5238,131 @@ } ] }, + { + "Command": "ZPERSIST", + "Name": "ZPERSIST", + "Arity": -5, + "Flags": "Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZPEXPIRE", + "Name": "ZPEXPIRE", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZPEXPIREAT", + "Name": "ZPEXPIREAT", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZPEXPIRETIME", + "Name": "ZPEXPIRETIME", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, + { + "Command": "ZPTTL", + "Name": "ZPTTL", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, { "Command": "ZPOPMAX", "Name": "ZPOPMAX", @@ -5582,6 +5807,31 @@ } ] }, + { + "Command": "ZTTL", + "Name": "ZTTL", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, { "Command": "ZSCORE", "Name": "ZSCORE", diff --git a/libs/server/API/GarnetApiObjectCommands.cs b/libs/server/API/GarnetApiObjectCommands.cs index d07988a211..8213a3fb1e 100644 --- a/libs/server/API/GarnetApiObjectCommands.cs +++ b/libs/server/API/GarnetApiObjectCommands.cs @@ -169,6 +169,42 @@ public GarnetStatus SortedSetIntersectLength(ReadOnlySpan keys, int? l public GarnetStatus SortedSetIntersectStore(ArgSlice destinationKey, ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out int count) => storageSession.SortedSetIntersectStore(destinationKey, keys, weights, aggregateType, out count); + /// + public GarnetStatus SortedSetExpire(ArgSlice key, long expireAt, bool isMilliseconds, ExpireOption expireOption, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) + => storageSession.SortedSetExpire(key, expireAt, isMilliseconds, expireOption, ref input, ref outputFooter, ref objectContext); + + /// + public GarnetStatus SortedSetExpire(ArgSlice key, ReadOnlySpan members, DateTimeOffset expireAt, ExpireOption expireOption, out int[] results) + => storageSession.SortedSetExpire(key, members, expireAt, expireOption, out results, ref objectContext); + + /// + public GarnetStatus SortedSetPersist(ArgSlice key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) + => storageSession.SortedSetPersist(key, ref input, ref outputFooter, ref objectContext); + + /// + public GarnetStatus SortedSetPersist(ArgSlice key, ReadOnlySpan members, out int[] results) + => storageSession.SortedSetPersist(key, members, out results, ref objectContext); + + /// + public GarnetStatus SortedSetTimeToLive(ArgSlice key, bool isMilliseconds, bool isTimestamp, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) + => storageSession.SortedSetTimeToLive(key, isMilliseconds, isTimestamp, ref input, ref outputFooter, ref objectContext); + + /// + public GarnetStatus SortedSetTimeToLive(ArgSlice key, ReadOnlySpan members, out TimeSpan[] expireIn) + => storageSession.SortedSetTimeToLive(key, members, out expireIn, ref objectContext); + + /// + public GarnetStatus SortedSetCollect(ReadOnlySpan keys, ref ObjectInput input) + => storageSession.SortedSetCollect(keys, ref input, ref objectContext); + + /// + public GarnetStatus SortedSetCollect() + => storageSession.SortedSetCollect(ref objectContext); + + /// + public GarnetStatus SortedSetCollect(ReadOnlySpan keys) + => storageSession.SortedSetCollect(keys, ref objectContext); + #endregion #region Geospatial commands diff --git a/libs/server/API/GarnetWatchApi.cs b/libs/server/API/GarnetWatchApi.cs index e95c491e34..a511623615 100644 --- a/libs/server/API/GarnetWatchApi.cs +++ b/libs/server/API/GarnetWatchApi.cs @@ -243,6 +243,20 @@ public GarnetStatus SortedSetIntersectLength(ReadOnlySpan keys, int? l return garnetApi.SortedSetIntersectLength(keys, limit, out count); } + /// + public GarnetStatus SortedSetTimeToLive(ArgSlice key, bool isMilliseconds, bool isTimestamp, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) + { + garnetApi.WATCH(key, StoreType.Object); + return garnetApi.SortedSetTimeToLive(key, isMilliseconds, isTimestamp, ref input, ref outputFooter); + } + + /// + public GarnetStatus SortedSetTimeToLive(ArgSlice key, ReadOnlySpan members, out TimeSpan[] expireIn) + { + garnetApi.WATCH(key, StoreType.Object); + return garnetApi.SortedSetTimeToLive(key, members, out expireIn); + } + #endregion #region List Methods diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 903619fde1..36734a4d92 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -552,6 +552,67 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi /// A indicating the status of the operation. GarnetStatus SortedSetUnionStore(ArgSlice destinationKey, ReadOnlySpan keys, double[] weights, SortedSetAggregateType aggregateType, out int count); + /// + /// Sets an expiration time on a sorted set member. + /// + /// The key of the sorted set. + /// The expiration time in Unix timestamp format. + /// The expiration option to apply. + /// The input object containing additional parameters. + /// The output object to store the result. + /// The status of the operation. + GarnetStatus SortedSetExpire(ArgSlice key, long expireAt, bool isMilliseconds, ExpireOption expireOption, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter); + + /// + /// Sets an expiration time on a sorted set member. + /// + /// The key of the sorted set. + /// The members to set expiration for. + /// The expiration time. + /// The expiration option to apply. + /// The results of the operation. + /// The status of the operation. + GarnetStatus SortedSetExpire(ArgSlice key, ReadOnlySpan members, DateTimeOffset expireAt, ExpireOption expireOption, out int[] results); + + /// + /// Persists the specified sorted set member, removing any expiration time set on it. + /// + /// The key of the sorted set to persist. + /// The input object containing additional parameters. + /// The output object to store the result. + /// The status of the operation. + GarnetStatus SortedSetPersist(ArgSlice key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter); + + /// + /// Persists the specified sorted set members, removing any expiration time set on them. + /// + /// The key of the sorted set. + /// The members to persist. + /// The results of the operation. + /// The status of the operation. + GarnetStatus SortedSetPersist(ArgSlice key, ReadOnlySpan members, out int[] results); + + /// + /// Deletes already expired members from the sorted set. + /// + /// The keys of the sorted set members to check for expiration. + /// The input object containing additional parameters. + /// The status of the operation. + GarnetStatus SortedSetCollect(ReadOnlySpan keys, ref ObjectInput input); + + /// + /// Collects expired elements from the sorted set. + /// + /// The status of the operation. + GarnetStatus SortedSetCollect(); + + /// + /// Collects expired elements from the sorted set for the specified keys. + /// + /// The keys of the sorted sets to collect expired elements from. + /// The status of the operation. + GarnetStatus SortedSetCollect(ReadOnlySpan keys); + #endregion #region Set Methods @@ -1406,6 +1467,26 @@ public interface IGarnetReadApi /// Operation status GarnetStatus SortedSetIntersectLength(ReadOnlySpan keys, int? limit, out int count); + /// + /// Returns the time to live for a sorted set members. + /// + /// The key of the sorted set. + /// Indicates if the time to live is in milliseconds. + /// Indicates if the time to live is a timestamp. + /// The input object containing additional parameters. + /// The output object to store the result. + /// The status of the operation. + GarnetStatus SortedSetTimeToLive(ArgSlice key, bool isMilliseconds, bool isTimestamp, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter); + + /// + /// Returns the time to live for a sorted set members. + /// + /// The key of the sorted set. + /// The members to get the time to live for. + /// The output array containing the time to live for each member. + /// The status of the operation. + GarnetStatus SortedSetTimeToLive(ArgSlice key, ReadOnlySpan members, out TimeSpan[] expireIn); + #endregion #region Geospatial Methods diff --git a/libs/server/Objects/ItemBroker/CollectionItemBroker.cs b/libs/server/Objects/ItemBroker/CollectionItemBroker.cs index 044a698bda..f33fc61a4b 100644 --- a/libs/server/Objects/ItemBroker/CollectionItemBroker.cs +++ b/libs/server/Objects/ItemBroker/CollectionItemBroker.cs @@ -399,11 +399,11 @@ private static bool TryMoveNextListItem(ListObject srcListObj, ListObject dstLis /// BZPOPMIN and BZPOPMAX share same implementation since Dictionary.First() and Last() /// handle the ordering automatically based on sorted set scores /// - private static unsafe bool TryGetNextSetObjects(byte[] key, SortedSetObject sortedSetObj, RespCommand command, ArgSlice[] cmdArgs, out CollectionItemResult result) + private static unsafe bool TryGetNextSetObjects(byte[] key, SortedSetObject sortedSetObj, int count, RespCommand command, ArgSlice[] cmdArgs, out CollectionItemResult result) { result = default; - if (sortedSetObj.Dictionary.Count == 0) return false; + if (count == 0) return false; switch (command) { @@ -416,7 +416,7 @@ private static unsafe bool TryGetNextSetObjects(byte[] key, SortedSetObject sort case RespCommand.BZMPOP: var lowScoresFirst = *(bool*)cmdArgs[0].ptr; var popCount = *(int*)cmdArgs[1].ptr; - popCount = Math.Min(popCount, sortedSetObj.Dictionary.Count); + popCount = Math.Min(popCount, count); var scores = new double[popCount]; var items = new byte[popCount][]; @@ -546,13 +546,13 @@ private unsafe bool TryGetResult(byte[] key, StorageSession storageSession, Resp return false; } case SortedSetObject setObj: - currCount = setObj.Dictionary.Count; + currCount = setObj.Count(); if (objectType != GarnetObjectType.SortedSet) return false; if (currCount == 0) return false; - return TryGetNextSetObjects(key, setObj, command, cmdArgs, out result); + return TryGetNextSetObjects(key, setObj, currCount, command, cmdArgs, out result); default: return false; diff --git a/libs/server/Objects/SortedSet/SortedSetObject.cs b/libs/server/Objects/SortedSet/SortedSetObject.cs index aa0e520f46..e2a583e822 100644 --- a/libs/server/Objects/SortedSet/SortedSetObject.cs +++ b/libs/server/Objects/SortedSet/SortedSetObject.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; using Garnet.common; using Tsavorite.core; @@ -42,7 +44,11 @@ public enum SortedSetOperation : byte ZRANDMEMBER, ZDIFF, ZSCAN, - ZMSCORE + ZMSCORE, + ZEXPIRE, + ZTTL, + ZPERSIST, + ZCOLLECT } /// @@ -136,6 +142,11 @@ public partial class SortedSetObject : GarnetObjectBase { private readonly SortedSet<(double Score, byte[] Element)> sortedSet; private readonly Dictionary sortedSetDict; + private Dictionary expirationTimes; + private PriorityQueue expirationQueue; + + // Byte #31 is used to denote if key has expiration (1) or not (0) + private const int ExpirationBitMask = 1 << 31; /// /// Constructor @@ -159,32 +170,75 @@ public SortedSetObject(BinaryReader reader) int count = reader.ReadInt32(); for (int i = 0; i < count; i++) { - var item = reader.ReadBytes(reader.ReadInt32()); + var keyLength = reader.ReadInt32(); + var hasExpiration = (keyLength & ExpirationBitMask) != 0; + keyLength &= ~ExpirationBitMask; + var item = reader.ReadBytes(keyLength); var score = reader.ReadDouble(); - sortedSet.Add((score, item)); - sortedSetDict.Add(item, score); + var canAddItem = true; + long expiration = 0; + + if (hasExpiration) + { + expiration = reader.ReadInt64(); + canAddItem = expiration >= DateTimeOffset.UtcNow.Ticks; + } + + if (canAddItem) + { + sortedSetDict.Add(item, score); + sortedSet.Add((score, item)); + this.UpdateSize(item); - this.UpdateSize(item); + if (expiration > 0) + { + InitializeExpirationStructures(); + expirationTimes.Add(item, expiration); + expirationQueue.Enqueue(item, expiration); + UpdateExpirationSize(item, true); + } + } } } /// /// Copy constructor /// - public SortedSetObject(SortedSet<(double, byte[])> sortedSet, Dictionary sortedSetDict, long expiration, long size) - : base(expiration, size) + public SortedSetObject(SortedSetObject sortedSetObject) + : base(sortedSetObject.Expiration, sortedSetObject.Size) { - this.sortedSet = sortedSet; - this.sortedSetDict = sortedSetDict; + this.sortedSet = sortedSetObject.sortedSet; + this.sortedSetDict = sortedSetObject.sortedSetDict; + this.expirationTimes = sortedSetObject.expirationTimes; + this.expirationQueue = sortedSetObject.expirationQueue; } /// public override byte Type => (byte)GarnetObjectType.SortedSet; /// - /// Get sorted set as a dictionary + /// Get sorted set as a dictionary. /// - public Dictionary Dictionary => sortedSetDict; + public Dictionary Dictionary + { + get + { + if (!HasExpirableItems() || (expirationQueue.TryPeek(out _, out var expiration) && expiration > DateTimeOffset.UtcNow.Ticks)) + { + return sortedSetDict; + } + + var result = new Dictionary(ByteArrayComparer.Instance); + foreach (var kvp in sortedSetDict) + { + if (!IsExpired(kvp.Key)) + { + result.Add(kvp.Key, kvp.Value); + } + } + return result; + } + } /// /// Serialize @@ -193,10 +247,22 @@ public override void DoSerialize(BinaryWriter writer) { base.DoSerialize(writer); - int count = sortedSetDict.Count; + DeleteExpiredItems(); + + int count = sortedSetDict.Count; // Since expired items are already deleted, no need to worry about expiring items writer.Write(count); foreach (var kvp in sortedSetDict) { + if (expirationTimes is not null && expirationTimes.TryGetValue(kvp.Key, out var expiration)) + { + writer.Write(kvp.Key.Length | ExpirationBitMask); + writer.Write(kvp.Key); + writer.Write(kvp.Value); + writer.Write(expiration); + count--; + continue; + } + writer.Write(kvp.Key.Length); writer.Write(kvp.Key); writer.Write(kvp.Value); @@ -212,6 +278,8 @@ public override void DoSerialize(BinaryWriter writer) /// public void Add(byte[] item, double score) { + DeleteExpiredItems(); + sortedSetDict.Add(item, score); sortedSet.Add((score, item)); @@ -223,11 +291,23 @@ public void Add(byte[] item, double score) /// public bool Equals(SortedSetObject other) { - if (sortedSetDict.Count != other.sortedSetDict.Count) return false; + if (sortedSetDict.Count() != other.sortedSetDict.Count()) return false; foreach (var key in sortedSetDict) + { + if (IsExpired(key.Key) && IsExpired(key.Key)) + { + continue; + } + + if (IsExpired(key.Key) || IsExpired(key.Key)) + { + return false; + } + if (!other.sortedSetDict.TryGetValue(key.Key, out var otherValue) || key.Value != otherValue) return false; + } return true; } @@ -236,7 +316,7 @@ public bool Equals(SortedSetObject other) public override void Dispose() { } /// - public override GarnetObjectBase Clone() => new SortedSetObject(sortedSet, sortedSetDict, Expiration, Size); + public override GarnetObjectBase Clone() => new SortedSetObject(this); /// public override unsafe bool Operate(ref ObjectInput input, ref GarnetObjectStoreOutput output, out long sizeChange) @@ -285,6 +365,18 @@ public override unsafe bool Operate(ref ObjectInput input, ref GarnetObjectStore case SortedSetOperation.ZRANK: SortedSetRank(ref input, ref output.SpanByteAndMemory); break; + case SortedSetOperation.ZEXPIRE: + SortedSetExpire(ref input, ref output.SpanByteAndMemory); + break; + case SortedSetOperation.ZTTL: + SortedSetTimeToLive(ref input, ref output.SpanByteAndMemory); + break; + case SortedSetOperation.ZPERSIST: + SortedSetPersist(ref input, ref output.SpanByteAndMemory); + break; + case SortedSetOperation.ZCOLLECT: + SortedSetCollect(ref input, outputSpan); + break; case SortedSetOperation.GEOADD: GeoAdd(ref input, ref output.SpanByteAndMemory); break; @@ -365,14 +457,21 @@ public override unsafe void Scan(long start, out List items, out long cu int index = 0; - if (Dictionary.Count < start) + if (sortedSetDict.Count < start) { cursor = 0; return; } - foreach (var item in Dictionary) + var expiredKeysCount = 0; + foreach (var item in sortedSetDict) { + if (IsExpired(item.Key)) + { + expiredKeysCount++; + continue; + } + if (index < start) { index++; @@ -414,7 +513,7 @@ public override unsafe void Scan(long start, out List items, out long cu } // Indicates end of collection has been reached. - if (cursor == Dictionary.Count) + if (cursor + expiredKeysCount == sortedSetDict.Count) cursor = 0; } @@ -424,18 +523,32 @@ public override unsafe void Scan(long start, out List items, out long cu /// /// Compute difference of two dictionaries, with new result /// - public static Dictionary CopyDiff(Dictionary dict1, Dictionary dict2) + public static Dictionary CopyDiff(SortedSetObject sortedSetObject1, SortedSetObject sortedSetObject2) { - if (dict1 == null) - return []; + if (sortedSetObject1 == null) + return new Dictionary(ByteArrayComparer.Instance); - if (dict2 == null) - return new Dictionary(dict1, dict1.Comparer); + if (sortedSetObject2 == null) + { + if (sortedSetObject1.expirationTimes is null) + { + return new Dictionary(sortedSetObject1.sortedSetDict, ByteArrayComparer.Instance); + } + else + { + var directResult = new Dictionary(ByteArrayComparer.Instance); + foreach (var item in sortedSetObject1.sortedSetDict) + { + if (!sortedSetObject1.IsExpired(item.Key)) + directResult.Add(item.Key, item.Value); + } + } + } - var result = new Dictionary(dict1.Comparer); - foreach (var item in dict1) + var result = new Dictionary(ByteArrayComparer.Instance); + foreach (var item in sortedSetObject1.sortedSetDict) { - if (!dict2.ContainsKey(item.Key)) + if (!sortedSetObject1.IsExpired(item.Key) && !sortedSetObject2.IsExpired(item.Key) && !sortedSetObject2.sortedSetDict.ContainsKey(item.Key)) result.Add(item.Key, item.Value); } return result; @@ -444,21 +557,245 @@ public static Dictionary CopyDiff(Dictionary dic /// /// Remove keys existing in second dictionary, from the first dictionary, if they exist /// - public static void InPlaceDiff(Dictionary dict1, Dictionary dict2) + public static void InPlaceDiff(Dictionary dict1, SortedSetObject sortedSetObject2) { Debug.Assert(dict1 != null); - if (dict2 != null) + if (sortedSetObject2 != null) { foreach (var item in dict1) { - if (dict2.ContainsKey(item.Key)) + if (!sortedSetObject2.IsExpired(item.Key) && sortedSetObject2.sortedSetDict.ContainsKey(item.Key)) dict1.Remove(item.Key); } } } + /// + /// Tries to get the score of the specified key. + /// + /// The key to get the score for. + /// The score of the key if found. + /// True if the key is found and not expired; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryGetScore(byte[] key, out double value) + { + value = default; + if (IsExpired(key)) + { + return false; + } + + return sortedSetDict.TryGetValue(key, out value); + } + + /// + /// Gets the count of elements in the sorted set. + /// + /// The count of elements in the sorted set. + public int Count() + { + if (!HasExpirableItems()) + { + return sortedSetDict.Count; + } + var expiredKeysCount = 0; + + foreach (var item in expirationTimes) + { + if (IsExpired(item.Key)) + { + expiredKeysCount++; + } + } + return sortedSetDict.Count - expiredKeysCount; + } + + /// + /// Determines whether the specified key is expired. + /// + /// The key to check for expiration. + /// True if the key is expired; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsExpired(byte[] key) => expirationTimes is not null && expirationTimes.TryGetValue(key, out var expiration) && expiration < DateTimeOffset.UtcNow.Ticks; + + /// + /// Determines whether the sorted set has expirable items. + /// + /// True if the sorted set has expirable items; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool HasExpirableItems() + { + return expirationTimes is not null; + } + #endregion + private void InitializeExpirationStructures() + { + if (expirationTimes is null) + { + expirationTimes = new Dictionary(ByteArrayComparer.Instance); + expirationQueue = new PriorityQueue(); + this.Size += MemoryUtils.DictionaryOverhead + MemoryUtils.PriorityQueueOverhead; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateExpirationSize(ReadOnlySpan key, bool add = true) + { + var size = IntPtr.Size + sizeof(long) + MemoryUtils.DictionaryEntryOverhead + + IntPtr.Size + sizeof(long) + MemoryUtils.PriorityQueueEntryOverhead; + this.Size += add ? size : -size; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void CleanupExpirationStructures() + { + if (expirationTimes.Count == 0) + { + this.Size -= (IntPtr.Size + sizeof(long) + MemoryUtils.PriorityQueueOverhead) * expirationQueue.Count; + this.Size -= MemoryUtils.DictionaryOverhead + MemoryUtils.PriorityQueueOverhead; + expirationTimes = null; + expirationQueue = null; + } + } + + private void DeleteExpiredItems() + { + if (expirationTimes is null) + return; + + while (expirationQueue.TryPeek(out var key, out var expiration) && expiration < DateTimeOffset.UtcNow.Ticks) + { + if (expirationTimes.TryGetValue(key, out var actualExpiration) && actualExpiration == expiration) + { + expirationTimes.Remove(key); + expirationQueue.Dequeue(); + UpdateExpirationSize(key, false); + if (sortedSetDict.TryGetValue(key, out var value)) + { + sortedSetDict.Remove(key); + sortedSet.Remove((value, key)); + UpdateSize(key, false); + } + } + else + { + expirationQueue.Dequeue(); + this.Size -= MemoryUtils.PriorityQueueEntryOverhead + IntPtr.Size + sizeof(long); + } + } + + CleanupExpirationStructures(); + } + + private int SetExpiration(byte[] key, long expiration, ExpireOption expireOption) + { + if (!sortedSetDict.ContainsKey(key)) + { + return (int)SortedSetExpireResult.KeyNotFound; + } + + if (expiration <= DateTimeOffset.UtcNow.Ticks) + { + sortedSetDict.Remove(key, out var value); + sortedSet.Remove((value, key)); + UpdateSize(key, false); + return (int)SortedSetExpireResult.KeyAlreadyExpired; + } + + InitializeExpirationStructures(); + + if (expirationTimes.TryGetValue(key, out var currentExpiration)) + { + if (expireOption.HasFlag(ExpireOption.NX) || + (expireOption.HasFlag(ExpireOption.GT) && expiration <= currentExpiration) || + (expireOption.HasFlag(ExpireOption.LT) && expiration >= currentExpiration)) + { + return (int)SortedSetExpireResult.ExpireConditionNotMet; + } + + expirationTimes[key] = expiration; + expirationQueue.Enqueue(key, expiration); + this.Size += IntPtr.Size + sizeof(long) + MemoryUtils.PriorityQueueEntryOverhead; + } + else + { + if (expireOption.HasFlag(ExpireOption.XX) || expireOption.HasFlag(ExpireOption.GT)) + { + return (int)SortedSetExpireResult.ExpireConditionNotMet; + } + + expirationTimes[key] = expiration; + expirationQueue.Enqueue(key, expiration); + UpdateExpirationSize(key); + } + + return (int)SortedSetExpireResult.ExpireUpdated; + } + + private int Persist(byte[] key) + { + if (!sortedSetDict.ContainsKey(key)) + { + return -2; + } + + return TryRemoveExpiration(key) ? 1 : -1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryRemoveExpiration(byte[] key) + { + if (expirationTimes is null || !expirationTimes.TryGetValue(key, out _)) + { + return false; + } + + expirationTimes.Remove(key); + this.Size -= IntPtr.Size + sizeof(long) + MemoryUtils.DictionaryEntryOverhead; + CleanupExpirationStructures(); + return true; + } + + private long GetExpiration(byte[] key) + { + if (!sortedSetDict.ContainsKey(key)) + { + return -2; + } + + if (expirationTimes is not null && expirationTimes.TryGetValue(key, out var expiration)) + { + return expiration; + } + + return -1; + } + + private KeyValuePair ElementAt(int index) + { + if (HasExpirableItems()) + { + var currIndex = 0; + foreach (var item in sortedSetDict) + { + if (IsExpired(item.Key)) + { + continue; + } + + if (currIndex++ == index) + { + return item; + } + } + + throw new ArgumentOutOfRangeException("index is outside the bounds of the source sequence."); + } + + return sortedSetDict.ElementAt(index); + } private void UpdateSize(ReadOnlySpan item, bool add = true) { @@ -468,5 +805,31 @@ private void UpdateSize(ReadOnlySpan item, bool add = true) this.Size += add ? size : -size; Debug.Assert(this.Size >= MemoryUtils.SortedSetOverhead + MemoryUtils.DictionaryOverhead); } + + /// + /// Result of an expiration operation. + /// + enum SortedSetExpireResult + { + /// + /// The key was not found. + /// + KeyNotFound = -2, + + /// + /// The expiration condition was not met. + /// + ExpireConditionNotMet = 0, + + /// + /// The expiration was updated. + /// + ExpireUpdated = 1, + + /// + /// The key was already expired. + /// + KeyAlreadyExpired = 2, + } } } \ No newline at end of file diff --git a/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs b/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs index 0c21272e5e..952b80945c 100644 --- a/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs +++ b/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs @@ -85,6 +85,8 @@ bool GetOptions(ref ObjectInput input, ref int currTokenIdx, out SortedSetAddOpt private void SortedSetAdd(ref ObjectInput input, ref SpanByteAndMemory output) { + DeleteExpiredItems(); + var isMemory = false; MemoryHandle ptrHandle = default; var ptr = output.SpanByte.ToPointer(); @@ -155,7 +157,10 @@ private void SortedSetAdd(ref ObjectInput input, ref SpanByteAndMemory output) // No need for update if (score == scoreStored) + { + Persist(member); continue; + } // Don't update existing member if NX flag is set // or if GT/LT flag is set and existing score is higher/lower than new score, respectively @@ -167,6 +172,7 @@ private void SortedSetAdd(ref ObjectInput input, ref SpanByteAndMemory output) var success = sortedSet.Remove((scoreStored, member)); Debug.Assert(success); success = sortedSet.Add((score, member)); + Persist(member); Debug.Assert(success); // If CH flag is set, add changed member to final count @@ -198,6 +204,8 @@ private void SortedSetAdd(ref ObjectInput input, ref SpanByteAndMemory output) private void SortedSetRemove(ref ObjectInput input, byte* output) { + DeleteExpiredItems(); + var _output = (ObjectOutputHeader*)output; *_output = default; @@ -212,6 +220,7 @@ private void SortedSetRemove(ref ObjectInput input, byte* output) _output->result1++; sortedSetDict.Remove(valueArray); sortedSet.Remove((key, valueArray)); + TryRemoveExpiration(valueArray); this.UpdateSize(value, false); } @@ -221,7 +230,7 @@ private void SortedSetLength(byte* output) { // Check both objects Debug.Assert(sortedSetDict.Count == sortedSet.Count, "SortedSet object is not in sync."); - ((ObjectOutputHeader*)output)->result1 = sortedSetDict.Count; + ((ObjectOutputHeader*)output)->result1 = Count(); } private void SortedSetScore(ref ObjectInput input, ref SpanByteAndMemory output) @@ -239,7 +248,7 @@ private void SortedSetScore(ref ObjectInput input, ref SpanByteAndMemory output) ObjectOutputHeader outputHeader = default; try { - if (!sortedSetDict.TryGetValue(member, out var score)) + if (!TryGetScore(member, out var score)) { while (!RespWriteUtils.TryWriteNull(ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); @@ -294,7 +303,7 @@ private void SortedSetScores(ref ObjectInput input, ref SpanByteAndMemory output { var member = input.parseState.GetArgSliceByRef(i).SpanByte.ToByteArray(); - if (!sortedSetDict.TryGetValue(member, out var score)) + if (!TryGetScore(member, out var score)) { while (!RespWriteUtils.TryWriteNull(ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); @@ -349,6 +358,7 @@ private void SortedSetCount(ref ObjectInput input, ref SpanByteAndMemory output) { foreach (var item in sortedSet.GetViewBetween((minValue, null), sortedSet.Max)) { + if (IsExpired(item.Element)) continue; if (item.Item1 > maxValue || (maxExclusive && item.Item1 == maxValue)) break; if (minExclusive && item.Item1 == minValue) continue; count++; @@ -370,6 +380,8 @@ private void SortedSetCount(ref ObjectInput input, ref SpanByteAndMemory output) private void SortedSetIncrement(ref ObjectInput input, ref SpanByteAndMemory output) { + DeleteExpiredItems(); + // ZINCRBY key increment member var isMemory = false; MemoryHandle ptrHandle = default; @@ -519,7 +531,9 @@ private void SortedSetRange(ref ObjectInput input, ref SpanByteAndMemory output) WriteSortedSetResult(options.WithScores, scoredElements.Count, respProtocolVersion, scoredElements, ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } else - { // byIndex + { + // byIndex + var setCount = Count(); int minIndex = (int)minValue, maxIndex = (int)maxValue; if (options.ValidLimit) { @@ -527,7 +541,7 @@ private void SortedSetRange(ref ObjectInput input, ref SpanByteAndMemory output) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); return; } - else if (minValue > sortedSetDict.Count - 1) + else if (minValue > setCount - 1) { // return empty list while (!RespWriteUtils.TryWriteEmptyArray(ref curr, end)) @@ -539,15 +553,15 @@ private void SortedSetRange(ref ObjectInput input, ref SpanByteAndMemory output) //shift from the end of the set if (minIndex < 0) { - minIndex = sortedSetDict.Count + minIndex; + minIndex = setCount + minIndex; } if (maxIndex < 0) { - maxIndex = sortedSetDict.Count + maxIndex; + maxIndex = setCount + maxIndex; } - else if (maxIndex >= sortedSetDict.Count) + else if (maxIndex >= setCount) { - maxIndex = sortedSetDict.Count - 1; + maxIndex = setCount - 1; } // No elements to return if both indexes fall outside the range or min is higher than max @@ -565,6 +579,12 @@ private void SortedSetRange(ref ObjectInput input, ref SpanByteAndMemory output) // calculate number of elements var n = maxIndex - minIndex + 1; var iterator = options.Reverse ? sortedSet.Reverse() : sortedSet; + + if (expirationTimes is not null) + { + iterator = iterator.Where(x => !IsExpired(x.Element)); + } + iterator = iterator.Skip(minIndex).Take(n); WriteSortedSetResult(options.WithScores, n, respProtocolVersion, iterator, ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); @@ -639,6 +659,8 @@ void WriteSortedSetResult(bool withScores, int count, int respProtocolVersion, I private void SortedSetRemoveRangeByRank(ref ObjectInput input, ref SpanByteAndMemory output) { + DeleteExpiredItems(); + // ZREMRANGEBYRANK key start stop var isMemory = false; MemoryHandle ptrHandle = default; @@ -680,6 +702,7 @@ private void SortedSetRemoveRangeByRank(ref ObjectInput input, ref SpanByteAndMe this.UpdateSize(item.Item2, false); } + TryRemoveExpiration(item.Item2); } // Write the number of elements @@ -698,6 +721,8 @@ private void SortedSetRemoveRangeByRank(ref ObjectInput input, ref SpanByteAndMe private void SortedSetRemoveRangeByScore(ref ObjectInput input, ref SpanByteAndMemory output) { + DeleteExpiredItems(); + // ZREMRANGEBYSCORE key min max var isMemory = false; MemoryHandle ptrHandle = default; @@ -745,9 +770,10 @@ private void SortedSetRandomMember(ref ObjectInput input, ref SpanByteAndMemory var withScores = (input.arg1 & 1) == 1; var includedCount = ((input.arg1 >> 1) & 1) == 1; var seed = input.arg2; + var sortedSetCount = Count(); - if (count > 0 && count > sortedSet.Count) - count = sortedSet.Count; + if (count > 0 && count > sortedSetCount) + count = sortedSetCount; var isMemory = false; MemoryHandle ptrHandle = default; @@ -767,11 +793,11 @@ private void SortedSetRandomMember(ref ObjectInput input, ref SpanByteAndMemory ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } - var indexes = RandomUtils.PickKRandomIndexes(sortedSetDict.Count, Math.Abs(count), seed, count > 0); + var indexes = RandomUtils.PickKRandomIndexes(sortedSetCount, Math.Abs(count), seed, count > 0); foreach (var item in indexes) { - var (element, score) = sortedSetDict.ElementAt(item); + var (element, score) = ElementAt(item); while (!RespWriteUtils.TryWriteBulkString(element, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); @@ -809,7 +835,14 @@ private void SortedSetRemoveOrCountRangeByLex(ref ObjectInput input, byte* outpu var minParamBytes = input.parseState.GetArgSliceByRef(0).ReadOnlySpan; var maxParamBytes = input.parseState.GetArgSliceByRef(1).ReadOnlySpan; - var rem = GetElementsInRangeByLex(minParamBytes, maxParamBytes, false, false, op != SortedSetOperation.ZLEXCOUNT, out int errorCode); + var isRemove = op == SortedSetOperation.ZREMRANGEBYLEX; + + if (isRemove) + { + DeleteExpiredItems(); + } + + var rem = GetElementsInRangeByLex(minParamBytes, maxParamBytes, false, false, isRemove, out int errorCode); _output->result1 = errorCode; if (errorCode == 0) @@ -840,7 +873,7 @@ private void SortedSetRank(ref ObjectInput input, ref SpanByteAndMemory output, { var member = input.parseState.GetArgSliceByRef(0).SpanByte.ToByteArray(); - if (!sortedSetDict.TryGetValue(member, out var score)) + if (!TryGetScore(member, out var score)) { while (!RespWriteUtils.TryWriteNull(ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); @@ -850,13 +883,18 @@ private void SortedSetRank(ref ObjectInput input, ref SpanByteAndMemory output, var rank = 0; foreach (var item in sortedSet) { + if (IsExpired(item.Element)) + { + continue; + } + if (item.Item2.SequenceEqual(member)) break; rank++; } if (!ascending) - rank = sortedSet.Count - rank - 1; + rank = Count() - rank - 1; if (withScore) { @@ -894,12 +932,15 @@ private void SortedSetRank(ref ObjectInput input, ref SpanByteAndMemory output, /// A tuple containing the score and the element as a byte array. public (double Score, byte[] Element) PopMinOrMax(bool popMaxScoreElement = false) { + DeleteExpiredItems(); + if (sortedSet.Count == 0) return default; var element = popMaxScoreElement ? sortedSet.Max : sortedSet.Min; sortedSet.Remove(element); sortedSetDict.Remove(element.Element); + TryRemoveExpiration(element.Element); this.UpdateSize(element.Element, false); return element; @@ -913,6 +954,8 @@ private void SortedSetRank(ref ObjectInput input, ref SpanByteAndMemory output, /// private void SortedSetPopMinOrMaxCount(ref ObjectInput input, ref SpanByteAndMemory output, SortedSetOperation op) { + DeleteExpiredItems(); + var count = input.arg1; var countDone = 0; @@ -938,6 +981,7 @@ private void SortedSetPopMinOrMaxCount(ref ObjectInput input, ref SpanByteAndMem var max = op == SortedSetOperation.ZPOPMAX ? sortedSet.Max : sortedSet.Min; sortedSet.Remove(max); sortedSetDict.Remove(max.Element); + TryRemoveExpiration(max.Element); this.UpdateSize(max.Element, false); @@ -963,6 +1007,150 @@ private void SortedSetPopMinOrMaxCount(ref ObjectInput input, ref SpanByteAndMem } } + + private void SortedSetPersist(ref ObjectInput input, ref SpanByteAndMemory output) + { + var isMemory = false; + MemoryHandle ptrHandle = default; + var ptr = output.SpanByte.ToPointer(); + + var curr = ptr; + var end = curr + output.Length; + + ObjectOutputHeader _output = default; + try + { + DeleteExpiredItems(); + + var numFields = input.parseState.Count; + while (!RespWriteUtils.TryWriteArrayLength(numFields, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + foreach (var item in input.parseState.Parameters) + { + var result = Persist(item.ToArray()); + while (!RespWriteUtils.TryWriteInt32(result, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + _output.result1++; + } + } + finally + { + while (!RespWriteUtils.TryWriteDirect(ref _output, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + if (isMemory) ptrHandle.Dispose(); + output.Length = (int)(curr - ptr); + } + } + + private void SortedSetTimeToLive(ref ObjectInput input, ref SpanByteAndMemory output) + { + var isMemory = false; + MemoryHandle ptrHandle = default; + var ptr = output.SpanByte.ToPointer(); + + var curr = ptr; + var end = curr + output.Length; + + ObjectOutputHeader _output = default; + try + { + DeleteExpiredItems(); + + var isMilliseconds = input.arg1 == 1; + var isTimestamp = input.arg2 == 1; + var numFields = input.parseState.Count; + while (!RespWriteUtils.TryWriteArrayLength(numFields, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + foreach (var item in input.parseState.Parameters) + { + var result = GetExpiration(item.ToArray()); + + if (result >= 0) + { + if (isTimestamp && isMilliseconds) + { + result = ConvertUtils.UnixTimeInMillisecondsFromTicks(result); + } + else if (isTimestamp && !isMilliseconds) + { + result = ConvertUtils.UnixTimeInSecondsFromTicks(result); + } + else if (!isTimestamp && isMilliseconds) + { + result = ConvertUtils.MillisecondsFromDiffUtcNowTicks(result); + } + else if (!isTimestamp && !isMilliseconds) + { + result = ConvertUtils.SecondsFromDiffUtcNowTicks(result); + } + } + + while (!RespWriteUtils.TryWriteInt64(result, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + _output.result1++; + } + } + finally + { + while (!RespWriteUtils.TryWriteDirect(ref _output, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + if (isMemory) ptrHandle.Dispose(); + output.Length = (int)(curr - ptr); + } + } + + private void SortedSetExpire(ref ObjectInput input, ref SpanByteAndMemory output) + { + var isMemory = false; + MemoryHandle ptrHandle = default; + var ptr = output.SpanByte.ToPointer(); + + var curr = ptr; + var end = curr + output.Length; + + ObjectOutputHeader _output = default; + try + { + DeleteExpiredItems(); + + var expireOption = (ExpireOption)input.arg1; + var expiration = input.parseState.GetLong(0); + var numFields = input.parseState.Count - 1; + while (!RespWriteUtils.TryWriteArrayLength(numFields, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + foreach (var item in input.parseState.Parameters.Slice(1)) + { + var result = SetExpiration(item.ToArray(), expiration, expireOption); + while (!RespWriteUtils.TryWriteInt32(result, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + _output.result1++; + } + } + finally + { + while (!RespWriteUtils.TryWriteDirect(ref _output, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + if (isMemory) ptrHandle.Dispose(); + output.Length = (int)(curr - ptr); + } + } + + private void SortedSetCollect(ref ObjectInput input, byte* output) + { + var _output = (ObjectOutputHeader*)output; + *_output = default; + + DeleteExpiredItems(); + + _output->result1 = 1; + } + #region CommonMethods /// @@ -1009,6 +1197,11 @@ private void SortedSetPopMinOrMaxCount(ref ObjectInput input, ref SpanByteAndMem // using ToList method so we avoid the Invalid operation ex. when removing foreach (var item in iterator.ToList()) { + if (IsExpired(item.Element)) + { + continue; + } + var inRange = new ReadOnlySpan(item.Item2).SequenceCompareTo(minValueChars); if (inRange < 0 || (inRange == 0 && minValueExclusive)) continue; @@ -1023,6 +1216,7 @@ private void SortedSetPopMinOrMaxCount(ref ObjectInput input, ref SpanByteAndMem { sortedSetDict.Remove(item.Item2); sortedSet.Remove((_key, item.Item2)); + TryRemoveExpiration(item.Element); this.UpdateSize(item.Item2, false); } @@ -1079,6 +1273,7 @@ private void SortedSetPopMinOrMaxCount(ref ObjectInput input, ref SpanByteAndMem foreach (var item in sortedSet.GetViewBetween((minValue, null), sortedSet.Max)) { + if (IsExpired(item.Element)) continue; if (item.Item1 > maxValue || (maxExclusive && item.Item1 == maxValue)) break; if (minExclusive && item.Item1 == minValue) continue; scoredElements.Add(item); @@ -1100,6 +1295,7 @@ private void SortedSetPopMinOrMaxCount(ref ObjectInput input, ref SpanByteAndMem { sortedSetDict.Remove(item.Item2); sortedSet.Remove((_key, item.Item2)); + TryRemoveExpiration(item.Item2); this.UpdateSize(item.Item2, false); } diff --git a/libs/server/Objects/Types/GarnetObject.cs b/libs/server/Objects/Types/GarnetObject.cs index 7474d547e7..85167c197b 100644 --- a/libs/server/Objects/Types/GarnetObject.cs +++ b/libs/server/Objects/Types/GarnetObject.cs @@ -43,6 +43,8 @@ internal static bool NeedToCreate(RespInputHeader header) SortedSetOperation.ZREMRANGEBYLEX => false, SortedSetOperation.ZREMRANGEBYSCORE => false, SortedSetOperation.ZREMRANGEBYRANK => false, + SortedSetOperation.ZEXPIRE => false, + SortedSetOperation.ZCOLLECT => false, _ => true, }, GarnetObjectType.List => header.ListOp switch diff --git a/libs/server/Resp/AdminCommands.cs b/libs/server/Resp/AdminCommands.cs index 5905b61eee..3894e09eee 100644 --- a/libs/server/Resp/AdminCommands.cs +++ b/libs/server/Resp/AdminCommands.cs @@ -59,6 +59,7 @@ RespCommand.MIGRATE or RespCommand.COMMITAOF => NetworkCOMMITAOF(), RespCommand.FORCEGC => NetworkFORCEGC(), RespCommand.HCOLLECT => NetworkHCOLLECT(ref storageApi), + RespCommand.ZCOLLECT => NetworkZCOLLECT(ref storageApi), RespCommand.MONITOR => NetworkMonitor(), RespCommand.ACL_DELUSER => NetworkAclDelUser(), RespCommand.ACL_GETUSER => NetworkAclGetUser(), @@ -642,6 +643,36 @@ private bool NetworkHCOLLECT(ref TGarnetApi storageApi) return true; } + private bool NetworkZCOLLECT(ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (parseState.Count < 1) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.ZCOLLECT)); + } + + var keys = parseState.Parameters; + + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZCOLLECT }; + var input = new ObjectInput(header); + + var status = storageApi.SortedSetCollect(keys, ref input); + + switch (status) + { + case GarnetStatus.OK: + while (!RespWriteUtils.TryWriteDirect(CmdStrings.RESP_OK, ref dcurr, dend)) + SendAndReset(); + break; + default: + while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_ZCOLLECT_ALREADY_IN_PROGRESS, ref dcurr, dend)) + SendAndReset(); + break; + } + + return true; + } + private bool NetworkProcessClusterCommand(RespCommand command) { if (clusterSession == null) diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index d5b135bce0..7c4df4bef9 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -117,6 +117,7 @@ static partial class CmdStrings public static ReadOnlySpan maxlen => "maxlen"u8; public static ReadOnlySpan PUBSUB => "PUBSUB"u8; public static ReadOnlySpan HCOLLECT => "HCOLLECT"u8; + public static ReadOnlySpan ZCOLLECT => "ZCOLLECT"u8; public static ReadOnlySpan CHANNELS => "CHANNELS"u8; public static ReadOnlySpan NUMPAT => "NUMPAT"u8; public static ReadOnlySpan NUMSUB => "NUMSUB"u8; @@ -146,6 +147,7 @@ static partial class CmdStrings public static ReadOnlySpan SETIFMATCH => "SETIFMATCH"u8; public static ReadOnlySpan SETIFGREATER => "SETIFGREATER"u8; public static ReadOnlySpan FIELDS => "FIELDS"u8; + public static ReadOnlySpan MEMBERS => "MEMBERS"u8; public static ReadOnlySpan TIMEOUT => "TIMEOUT"u8; public static ReadOnlySpan ERROR => "ERROR"u8; public static ReadOnlySpan INCRBY => "INCRBY"u8; @@ -252,6 +254,7 @@ static partial class CmdStrings public static ReadOnlySpan RESP_ERR_LENGTH_AND_INDEXES => "If you want both the length and indexes, please just use IDX."u8; public static ReadOnlySpan RESP_ERR_INVALID_EXPIRE_TIME => "ERR invalid expire time, must be >= 0"u8; public static ReadOnlySpan RESP_ERR_HCOLLECT_ALREADY_IN_PROGRESS => "ERR HCOLLECT scan already in progress"u8; + public static ReadOnlySpan RESP_ERR_ZCOLLECT_ALREADY_IN_PROGRESS => "ERR ZCOLLECT scan already in progress"u8; public static ReadOnlySpan RESP_INVALID_COMMAND_SPECIFIED => "Invalid command specified"u8; public static ReadOnlySpan RESP_COMMAND_HAS_NO_KEY_ARGS => "The command has no key arguments"u8; public static ReadOnlySpan RESP_ERR_INVALID_CLIENT_UNBLOCK_REASON => "ERR CLIENT UNBLOCK reason should be TIMEOUT or ERROR"u8; diff --git a/libs/server/Resp/Objects/ObjectStoreUtils.cs b/libs/server/Resp/Objects/ObjectStoreUtils.cs index 407ebf5ebc..4264b1c189 100644 --- a/libs/server/Resp/Objects/ObjectStoreUtils.cs +++ b/libs/server/Resp/Objects/ObjectStoreUtils.cs @@ -39,6 +39,53 @@ private bool AbortWithErrorMessage(ReadOnlySpan errorMessage) return true; } + /// + /// Aborts the execution of the current object store command and outputs a given error message. + /// + /// The format string for the error message. + /// The first argument to format. + /// true if the command was completely consumed, false if the input on the receive buffer was incomplete. + private bool AbortWithErrorMessage(string format, object arg0) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(format, arg0))); + } + + /// + /// Aborts the execution of the current object store command and outputs a given error message. + /// + /// The format string for the error message. + /// The first argument to format. + /// The second argument to format. + /// true if the command was completely consumed, false if the input on the receive buffer was incomplete. + private bool AbortWithErrorMessage(string format, object arg0, object arg1) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(format, arg0, arg1))); + } + + /// + /// Aborts the execution of the current object store command and outputs a given error message. + /// + /// The format string for the error message. + /// The first argument to format. + /// The second argument to format. + /// The third argument to format. + /// true if the command was completely consumed, false if the input on the receive buffer was incomplete. + private bool AbortWithErrorMessage(string format, object arg0, object arg1, object arg2) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(format, arg0, arg1, arg2))); + } + + /// + /// Aborts the execution of the current object store command and outputs a given error message. + /// + /// The format string for the error message. + /// The arguments to format. + /// true if the command was completely consumed, false if the input on the receive buffer was incomplete. + private bool AbortWithErrorMessage(string format, params object[] args) + { + return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(format, args))); + } + /// /// Tries to parse the input as "LEFT" or "RIGHT" and returns the corresponding OperationDirection. /// If parsing fails, returns OperationDirection.Unknown. diff --git a/libs/server/Resp/Objects/SortedSetCommands.cs b/libs/server/Resp/Objects/SortedSetCommands.cs index e42100eb95..e9e9979875 100644 --- a/libs/server/Resp/Objects/SortedSetCommands.cs +++ b/libs/server/Resp/Objects/SortedSetCommands.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. using System; -using System.Text; using Garnet.common; using Tsavorite.core; @@ -425,7 +424,7 @@ private unsafe bool SortedSetMPop(ref TGarnetApi storageApi) if (numKeys < 0) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericParamShouldBeGreaterThanZero, "numkeys"))); + return AbortWithErrorMessage(CmdStrings.GenericParamShouldBeGreaterThanZero, "numkeys"); } // Validate we have enough arguments (no of keys + (MIN or MAX)) @@ -470,7 +469,7 @@ private unsafe bool SortedSetMPop(ref TGarnetApi storageApi) if (count < 0) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericParamShouldBeGreaterThanZero, "count"))); + return AbortWithErrorMessage(CmdStrings.GenericParamShouldBeGreaterThanZero, "count"); } } @@ -1066,7 +1065,7 @@ private unsafe bool SortedSetIntersect(ref TGarnetApi storageApi) if (nKeys < 1) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZINTER)))); + return AbortWithErrorMessage(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZINTER)); } if (parseState.Count < nKeys + 1) @@ -1105,7 +1104,7 @@ private unsafe bool SortedSetIntersect(ref TGarnetApi storageApi) { if (!parseState.TryGetDouble(currentArg + i, out weights[i])) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrNotAFloat, "weight"))); + return AbortWithErrorMessage(CmdStrings.GenericErrNotAFloat, "weight"); } } currentArg += nKeys; @@ -1181,7 +1180,7 @@ private unsafe bool SortedSetIntersectLength(ref TGarnetApi storageA if (nKeys < 1) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZINTERCARD)))); + return AbortWithErrorMessage(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZINTERCARD)); } if (parseState.Count < nKeys + 1) @@ -1208,7 +1207,7 @@ private unsafe bool SortedSetIntersectLength(ref TGarnetApi storageA if (limitVal < 0) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrCantBeNegative, "LIMIT"))); + return AbortWithErrorMessage(CmdStrings.GenericErrCantBeNegative, "LIMIT"); } limit = limitVal; @@ -1277,7 +1276,7 @@ private unsafe bool SortedSetIntersectStore(ref TGarnetApi storageAp { if (!parseState.TryGetDouble(currentArg + i, out weights[i])) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrNotAFloat, "weight"))); + return AbortWithErrorMessage(CmdStrings.GenericErrNotAFloat, "weight"); } } currentArg += nKeys; @@ -1341,7 +1340,7 @@ private unsafe bool SortedSetUnion(ref TGarnetApi storageApi) if (nKeys < 1) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZUNION)))); + return AbortWithErrorMessage(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZUNION)); } if (parseState.Count < nKeys + 1) @@ -1376,7 +1375,7 @@ private unsafe bool SortedSetUnion(ref TGarnetApi storageApi) { if (!parseState.TryGetDouble(currentArg + i, out weights[i])) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrNotAFloat, "weight"))); + return AbortWithErrorMessage(CmdStrings.GenericErrNotAFloat, "weight"); } } currentArg += nKeys; @@ -1465,7 +1464,7 @@ private unsafe bool SortedSetUnionStore(ref TGarnetApi storageApi) if (nKeys < 1) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZUNIONSTORE)))); + return AbortWithErrorMessage(CmdStrings.GenericErrAtLeastOneKey, nameof(RespCommand.ZUNIONSTORE)); } if (parseState.Count < nKeys + 2) @@ -1494,7 +1493,7 @@ private unsafe bool SortedSetUnionStore(ref TGarnetApi storageApi) { if (!parseState.TryGetDouble(currentArg + i, out weights[i])) { - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrNotAFloat, "weight"))); + return AbortWithErrorMessage(CmdStrings.GenericErrNotAFloat, "weight"); } } currentArg += nKeys; @@ -1613,8 +1612,7 @@ private unsafe bool SortedSetBlockingMPop() // Read count of keys if (!parseState.TryGetInt(currTokenId++, out var numKeys)) { - var err = string.Format(CmdStrings.GenericParamShouldBeGreaterThanZero, "numkeys"); - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(err)); + return AbortWithErrorMessage(CmdStrings.GenericParamShouldBeGreaterThanZero, "numkeys"); } // Should have MAX|MIN or it should contain COUNT + value @@ -1659,8 +1657,7 @@ private unsafe bool SortedSetBlockingMPop() if (!parseState.TryGetInt(currTokenId, out popCount) || popCount < 1) { - var err = string.Format(CmdStrings.GenericParamShouldBeGreaterThanZero, "count"); - return AbortWithErrorMessage(Encoding.ASCII.GetBytes(err)); + return AbortWithErrorMessage(CmdStrings.GenericParamShouldBeGreaterThanZero, "count"); } } @@ -1697,5 +1694,274 @@ private unsafe bool SortedSetBlockingMPop() return true; } + + /// + /// Sets an expiration time for a member in the SortedSet stored at key. + /// ZEXPIRE key seconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] + /// ZPEXPIRE key milliseconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] + /// ZEXPIREAT key unix-time-seconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] + /// ZPEXPIREAT key unix-time-milliseconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] + /// + /// + /// + /// + /// + private unsafe bool SortedSetExpire(RespCommand command, ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (storeWrapper.itemBroker == null) + throw new GarnetException("Object store is disabled"); + + if (parseState.Count <= 4) + { + return AbortWithWrongNumberOfArguments(command.ToString()); + } + + var key = parseState.GetArgSliceByRef(0); + + long expireAt = 0; + var isMilliseconds = false; + if (!parseState.TryGetLong(1, out expireAt)) + { + return AbortWithErrorMessage(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER); + } + + if (expireAt < 0) + { + return AbortWithErrorMessage(CmdStrings.RESP_ERR_INVALID_EXPIRE_TIME); + } + + switch (command) + { + case RespCommand.ZEXPIRE: + expireAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + expireAt; + isMilliseconds = false; + break; + case RespCommand.ZPEXPIRE: + expireAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + expireAt; + isMilliseconds = true; + break; + case RespCommand.ZPEXPIREAT: + isMilliseconds = true; + break; + default: // RespCommand.ZEXPIREAT + break; + } + + var currIdx = 2; + if (parseState.TryGetExpireOption(currIdx, out var expireOption)) + { + currIdx++; // If expire option is present, move to next argument else continue with the current argument + } + + var fieldOption = parseState.GetArgSliceByRef(currIdx++); + if (!fieldOption.ReadOnlySpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.MEMBERS)) + { + return AbortWithErrorMessage(CmdStrings.GenericErrMandatoryMissing, "MEMBERS"); + } + + if (!parseState.TryGetInt(currIdx++, out var numMembers)) + { + return AbortWithErrorMessage(CmdStrings.GenericParamShouldBeGreaterThanZero, "numMembers"); + } + + if (parseState.Count != currIdx + numMembers) + { + return AbortWithErrorMessage(CmdStrings.GenericErrMustMatchNoOfArgs, "numMembers"); + } + + var membersParseState = parseState.Slice(currIdx, numMembers); + + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZEXPIRE }; + var input = new ObjectInput(header, ref membersParseState); + + var outputFooter = new GarnetObjectStoreOutput { SpanByteAndMemory = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)) }; + + var status = storageApi.SortedSetExpire(key, expireAt, isMilliseconds, expireOption, ref input, ref outputFooter); + + switch (status) + { + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + case GarnetStatus.NOTFOUND: + while (!RespWriteUtils.TryWriteArrayLength(numMembers, ref dcurr, dend)) + SendAndReset(); + for (var i = 0; i < numMembers; i++) + { + while (!RespWriteUtils.TryWriteInt32(-2, ref dcurr, dend)) + SendAndReset(); + } + break; + default: + ProcessOutputWithHeader(outputFooter.SpanByteAndMemory); + break; + } + + return true; + } + + /// + /// Returns the time to live (TTL) for the specified members in the SortedSet stored at the given key. + /// ZTTL key MEMBERS nummembers member [member ...] + /// ZPTTL key MEMBERS nummembers member [member ...] + /// ZEXPIRETIME key MEMBERS nummembers member [member ...] + /// ZPEXPIRETIME key MEMBERS nummembers member [member ...] + /// + /// The type of the storage API. + /// The RESP command indicating the type of TTL operation. + /// The storage API instance to interact with the underlying storage. + /// True if the operation was successful; otherwise, false. + /// Thrown when the object store is disabled. + private unsafe bool SortedSetTimeToLive(RespCommand command, ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (storeWrapper.itemBroker == null) + throw new GarnetException("Object store is disabled"); + + if (parseState.Count <= 3) + { + return AbortWithWrongNumberOfArguments(command.ToString()); + } + + var key = parseState.GetArgSliceByRef(0); + + var fieldOption = parseState.GetArgSliceByRef(1); + if (!fieldOption.ReadOnlySpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.MEMBERS)) + { + return AbortWithErrorMessage(CmdStrings.GenericErrMandatoryMissing, "MEMBERS"); + } + + if (!parseState.TryGetInt(2, out var numMembers)) + { + return AbortWithErrorMessage(CmdStrings.GenericParamShouldBeGreaterThanZero, "numMembers"); + } + + if (parseState.Count != 3 + numMembers) + { + return AbortWithErrorMessage(CmdStrings.GenericErrMustMatchNoOfArgs, "numMembers"); + } + + var isMilliseconds = false; + var isTimestamp = false; + switch (command) + { + case RespCommand.ZPTTL: + isMilliseconds = true; + isTimestamp = false; + break; + case RespCommand.ZEXPIRETIME: + isMilliseconds = false; + isTimestamp = true; + break; + case RespCommand.ZPEXPIRETIME: + isMilliseconds = true; + isTimestamp = true; + break; + default: // RespCommand.ZTTL + break; + } + + var membersParseState = parseState.Slice(3, numMembers); + + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZTTL }; + var input = new ObjectInput(header, ref membersParseState); + + var outputFooter = new GarnetObjectStoreOutput { SpanByteAndMemory = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)) }; + + var status = storageApi.SortedSetTimeToLive(key, isMilliseconds, isTimestamp, ref input, ref outputFooter); + + switch (status) + { + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + case GarnetStatus.NOTFOUND: + while (!RespWriteUtils.TryWriteArrayLength(numMembers, ref dcurr, dend)) + SendAndReset(); + for (var i = 0; i < numMembers; i++) + { + while (!RespWriteUtils.TryWriteInt32(-2, ref dcurr, dend)) + SendAndReset(); + } + break; + default: + ProcessOutputWithHeader(outputFooter.SpanByteAndMemory); + break; + } + + return true; + } + + /// + /// Removes the expiration time from the specified members in the sorted set stored at the given key. + /// ZPERSIST key MEMBERS nummembers member [member ...] + /// + /// The type of the storage API. + /// The storage API instance to interact with the underlying storage. + /// True if the operation was successful; otherwise, false. + /// Thrown when the object store is disabled. + private unsafe bool SortedSetPersist(ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (storeWrapper.itemBroker == null) + throw new GarnetException("Object store is disabled"); + + if (parseState.Count <= 3) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.ZPERSIST)); + } + + var key = parseState.GetArgSliceByRef(0); + + var fieldOption = parseState.GetArgSliceByRef(1); + if (!fieldOption.ReadOnlySpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.MEMBERS)) + { + return AbortWithErrorMessage(CmdStrings.GenericErrMandatoryMissing, "MEMBERS"); + } + + if (!parseState.TryGetInt(2, out var numMembers)) + { + return AbortWithErrorMessage(CmdStrings.GenericParamShouldBeGreaterThanZero, "numMembers"); + } + + if (parseState.Count != 3 + numMembers) + { + return AbortWithErrorMessage(CmdStrings.GenericErrMustMatchNoOfArgs, "numMembers"); + } + + var membersParseState = parseState.Slice(3, numMembers); + + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZPERSIST }; + var input = new ObjectInput(header, ref membersParseState); + + var outputFooter = new GarnetObjectStoreOutput { SpanByteAndMemory = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)) }; + + var status = storageApi.SortedSetPersist(key, ref input, ref outputFooter); + + switch (status) + { + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + case GarnetStatus.NOTFOUND: + while (!RespWriteUtils.TryWriteArrayLength(numMembers, ref dcurr, dend)) + SendAndReset(); + for (var i = 0; i < numMembers; i++) + { + while (!RespWriteUtils.TryWriteInt32(-2, ref dcurr, dend)) + SendAndReset(); + } + break; + default: + ProcessOutputWithHeader(outputFooter.SpanByteAndMemory); + 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 1c74fd8ac1..9f99ae5135 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -98,6 +98,10 @@ public enum RespCommand : ushort ZREVRANGEBYLEX, ZREVRANGEBYSCORE, ZREVRANK, + ZTTL, + ZPTTL, + ZEXPIRETIME, + ZPEXPIRETIME, ZSCAN, ZSCORE, // Note: Last read command should immediately precede FirstWriteCommand ZUNION, @@ -185,7 +189,13 @@ public enum RespCommand : ushort SUNIONSTORE, UNLINK, ZADD, + ZCOLLECT, ZDIFFSTORE, + ZEXPIRE, + ZPEXPIRE, + ZEXPIREAT, + ZPEXPIREAT, + ZPERSIST, ZINCRBY, ZMPOP, ZINTERSTORE, @@ -947,6 +957,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.ZREM; } + else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("\r\nZTTL\r\n"u8)) + { + return RespCommand.ZTTL; + } break; } break; @@ -1122,6 +1136,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.ZMPOP; } + else if (*(ulong*)(ptr + 3) == MemoryMarshal.Read("\nZPTTL\r\n"u8)) + { + return RespCommand.ZPTTL; + } break; } break; @@ -1391,6 +1409,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.ZPOPMIN; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("ZEXPIRE\r"u8) && *(byte*)(ptr + 12) == '\n') + { + return RespCommand.ZEXPIRE; + } else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("ZPOPMAX\r"u8) && *(byte*)(ptr + 12) == '\n') { return RespCommand.ZPOPMAX; @@ -1431,6 +1453,14 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HPERSIST; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("ZPEXPIRE"u8) && *(ushort*)(ptr + 12) == MemoryMarshal.Read("\r\n"u8)) + { + return RespCommand.ZPEXPIRE; + } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("ZPERSIST"u8) && *(ushort*)(ptr + 12) == MemoryMarshal.Read("\r\n"u8)) + { + return RespCommand.ZPERSIST; + } else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("BZPOPMAX"u8) && *(ushort*)(ptr + 12) == MemoryMarshal.Read("\r\n"u8)) { return RespCommand.BZPOPMAX; @@ -1473,6 +1503,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HEXPIREAT; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("ZEXPIREA"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("AT\r\n"u8)) + { + return RespCommand.ZEXPIREAT; + } break; case 10: if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("SSUBSCRI"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("BE\r\n"u8)) @@ -1548,6 +1582,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HPEXPIREAT; } + else if (*(ulong*)(ptr + 1) == MemoryMarshal.Read("10\r\nZPEX"u8) && *(uint*)(ptr + 9) == MemoryMarshal.Read("PIREAT\r\n"u8)) + { + return RespCommand.ZPEXPIREAT; + } break; case 11: if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("1\r\nUNSUB"u8) && *(ulong*)(ptr + 10) == MemoryMarshal.Read("SCRIBE\r\n"u8)) @@ -1578,6 +1616,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.PEXPIRETIME; } + else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("1\r\nHEXPI"u8) && *(ulong*)(ptr + 10) == MemoryMarshal.Read("RETIME\r\n"u8)) + { + return RespCommand.HEXPIRETIME; + } else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("1\r\nINCRB"u8) && *(ulong*)(ptr + 10) == MemoryMarshal.Read("YFLOAT\r\n"u8)) { return RespCommand.INCRBYFLOAT; @@ -1598,9 +1640,9 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.ZUNIONSTORE; } - else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("1\r\nHEXPI"u8) && *(ulong*)(ptr + 10) == MemoryMarshal.Read("RETIME\r\n"u8)) + else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("1\r\nZEXPI"u8) && *(ulong*)(ptr + 10) == MemoryMarshal.Read("RETIME\r\n"u8)) { - return RespCommand.HEXPIRETIME; + return RespCommand.ZEXPIRETIME; } break; @@ -1617,6 +1659,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HPEXPIRETIME; } + else if (*(ulong*)(ptr + 3) == MemoryMarshal.Read("\r\nZPEXPI"u8) && *(ulong*)(ptr + 11) == MemoryMarshal.Read("RETIME\r\n"u8)) + { + return RespCommand.ZPEXPIRETIME; + } break; case 13: @@ -2436,6 +2482,10 @@ private RespCommand SlowParseCommand(ref int count, ref ReadOnlySpan speci { return RespCommand.DEBUG; } + else if (command.SequenceEqual(CmdStrings.ZCOLLECT)) + { + return RespCommand.ZCOLLECT; + } else { // Custom commands should have never been set when we reach this point diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index b401f75282..d0c7dcafc2 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -721,6 +721,15 @@ private bool ProcessArrayCommands(RespCommand cmd, ref TGarnetApi st RespCommand.ZINTERSTORE => SortedSetIntersectStore(ref storageApi), RespCommand.ZUNION => SortedSetUnion(ref storageApi), RespCommand.ZUNIONSTORE => SortedSetUnionStore(ref storageApi), + RespCommand.ZEXPIRE => SortedSetExpire(cmd, ref storageApi), + RespCommand.ZPEXPIRE => SortedSetExpire(cmd, ref storageApi), + RespCommand.ZEXPIREAT => SortedSetExpire(cmd, ref storageApi), + RespCommand.ZPEXPIREAT => SortedSetExpire(cmd, ref storageApi), + RespCommand.ZTTL => SortedSetTimeToLive(cmd, ref storageApi), + RespCommand.ZPTTL => SortedSetTimeToLive(cmd, ref storageApi), + RespCommand.ZEXPIRETIME => SortedSetTimeToLive(cmd, ref storageApi), + RespCommand.ZPEXPIRETIME => SortedSetTimeToLive(cmd, ref storageApi), + RespCommand.ZPERSIST => SortedSetPersist(ref storageApi), //SortedSet for Geo Commands RespCommand.GEOADD => GeoAdd(ref storageApi), RespCommand.GEOHASH => GeoCommands(cmd, ref storageApi), diff --git a/libs/server/Servers/GarnetServerOptions.cs b/libs/server/Servers/GarnetServerOptions.cs index 399578e82d..024cfcb04b 100644 --- a/libs/server/Servers/GarnetServerOptions.cs +++ b/libs/server/Servers/GarnetServerOptions.cs @@ -137,9 +137,9 @@ public class GarnetServerOptions : ServerOptions public int CompactionFrequencySecs = 0; /// - /// Hash collection frequency in seconds. 0 = disabled. Hash collect is used to delete expired fields from hash without waiting for a write operation. + /// Frequency in seconds for the background task to perform object collection which removes expired members within object from memory. 0 = disabled. Use the HCOLLECT and ZCOLLECT API to collect on-demand. /// - public int HashCollectFrequencySecs = 0; + public int ExpiredObjectCollectionFrequencySecs = 0; /// /// Hybrid log compaction type. diff --git a/libs/server/Storage/Session/ObjectStore/Common.cs b/libs/server/Storage/Session/ObjectStore/Common.cs index ff993dabf3..b4284adaad 100644 --- a/libs/server/Storage/Session/ObjectStore/Common.cs +++ b/libs/server/Storage/Session/ObjectStore/Common.cs @@ -4,8 +4,10 @@ using System; using System.Diagnostics; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using Garnet.common; +using Garnet.common.Parsing; using Tsavorite.core; namespace Garnet.server @@ -334,11 +336,24 @@ unsafe int[] ProcessRespIntegerArrayOutput(GarnetObjectStoreOutput outputFooter, elements = new int[arraySize]; for (int i = 0; i < elements.Length; i++) { + if (*refPtr != ':') + { + RespParsingException.ThrowUnexpectedToken(*refPtr); + } + refPtr++; + element = null; if (RespReadUtils.TryReadInt32(ref refPtr, outputPtr + outputSpan.Length, out var number, out var _)) { elements[i] = number; } + + if (*(ushort*)refPtr != MemoryMarshal.Read("\r\n"u8)) + { + RespParsingException.ThrowUnexpectedToken(*refPtr); + } + + refPtr += 2; } } } diff --git a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs index 050178bc40..5907e67ef4 100644 --- a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs +++ b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.Linq; using System.Text; using Garnet.common; using Tsavorite.core; @@ -16,6 +17,7 @@ namespace Garnet.server sealed partial class StorageSession : IDisposable { + private SingleWriterMultiReaderLock _zcollectTaskLock; /// /// Adds the specified member and score to the sorted set stored at key. @@ -1215,9 +1217,9 @@ private GarnetStatus SortedSetDifference(ReadOnlySpan } if (pairs == default) - pairs = SortedSetObject.CopyDiff(firstSortedSet.Dictionary, nextSortedSet.Dictionary); + pairs = SortedSetObject.CopyDiff(firstSortedSet, nextSortedSet); else - SortedSetObject.InPlaceDiff(pairs, nextSortedSet.Dictionary); + SortedSetObject.InPlaceDiff(pairs, nextSortedSet); } } @@ -1415,16 +1417,10 @@ private GarnetStatus SortedSetIntersection(ReadOnlySpan(firstSortedSet.Dictionary, ByteArrayComparer.Instance); + pairs = keys.Length == 1 ? firstSortedSet.Dictionary : new Dictionary(firstSortedSet.Dictionary, ByteArrayComparer.Instance); } else { @@ -1435,6 +1431,11 @@ private GarnetStatus SortedSetIntersection(ReadOnlySpan(ReadOnlySpan(ReadOnlySpan + /// Sets the expiration time for the specified key. + /// + /// The type of the object context. + /// The key for which to set the expiration time. + /// The expiration time in ticks. + /// Indicates whether the expiration time is in milliseconds. + /// The expiration option to use. + /// The input object containing the operation details. + /// The output footer object to store the result. + /// The object context for the operation. + /// The status of the operation. + public GarnetStatus SortedSetExpire(ArgSlice key, long expireAt, bool isMilliseconds, ExpireOption expireOption, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + var expireAtUtc = isMilliseconds ? ConvertUtils.UnixTimestampInMillisecondsToTicks(expireAt) : ConvertUtils.UnixTimestampInSecondsToTicks(expireAt); + var expiryLength = NumUtils.CountDigits(expireAtUtc); + var expirySlice = scratchBufferManager.CreateArgSlice(expiryLength); + var expirySpan = expirySlice.Span; + NumUtils.WriteInt64(expireAtUtc, expirySpan); + + parseState.Initialize(1 + input.parseState.Count); + parseState.SetArgument(0, expirySlice); + parseState.SetArguments(1, input.parseState.Parameters); + + var innerInput = new ObjectInput(input.header, ref parseState, startIdx: 0, arg1: (int)expireOption); + + return RMWObjectStoreOperationWithOutput(key.ToArray(), ref innerInput, ref objectContext, ref outputFooter); + } + + /// + /// Sets the expiration time for the specified key and fields in a sorted set. + /// + /// The type of the object context. + /// The key of the sorted set. + /// The members within the sorted set to set the expiration time for. + /// The expiration time as a DateTimeOffset. + /// The expiration option to use. + /// The results of the operation, indicating the number of fields that were successfully set to expire. + /// The context of the object store. + /// Returns a GarnetStatus indicating the success or failure of the operation. + public GarnetStatus SortedSetExpire(ArgSlice key, ReadOnlySpan members, DateTimeOffset expireAt, ExpireOption expireOption, out int[] results, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + results = default; + var expireAtUtc = expireAt.UtcTicks; + var expiryLength = NumUtils.CountDigits(expireAtUtc); + var expirySlice = scratchBufferManager.CreateArgSlice(expiryLength); + var expirySpan = expirySlice.Span; + NumUtils.WriteInt64(expireAtUtc, expirySpan); + + parseState.Initialize(1 + members.Length); + parseState.SetArgument(0, expirySlice); + parseState.SetArguments(1, members); + + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZEXPIRE }; + var innerInput = new ObjectInput(header, ref parseState, startIdx: 0, arg1: (int)expireOption); + + var outputFooter = new GarnetObjectStoreOutput { SpanByteAndMemory = new SpanByteAndMemory(null) }; + var status = RMWObjectStoreOperationWithOutput(key.ToArray(), ref innerInput, ref objectContext, ref outputFooter); + + if (status == GarnetStatus.OK) + { + results = ProcessRespIntegerArrayOutput(outputFooter, out _); + } + + return status; + } + + /// + /// Returns the time-to-live (TTL) of a SortedSet member. + /// + /// The type of the object context. + /// The key of the hash. + /// Indicates whether the TTL is in milliseconds. + /// Indicates whether the TTL is a timestamp. + /// The input object containing the operation details. + /// The output footer object to store the result. + /// The object context for the operation. + /// The status of the operation. + public GarnetStatus SortedSetTimeToLive(ArgSlice key, bool isMilliseconds, bool isTimestamp, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + var innerInput = new ObjectInput(input.header, ref input.parseState, arg1: isMilliseconds ? 1 : 0, arg2: isTimestamp ? 1 : 0); + + return ReadObjectStoreOperationWithOutput(key.ToArray(), ref innerInput, ref objectContext, ref outputFooter); + } + + /// + /// Returns the time-to-live (TTL) of a SortedSet member. + /// + /// The type of the object context. + /// The key of the sorted set. + /// The members within the sorted set to get the TTL for. + /// The array of TimeSpan representing the TTL for each member. + /// The context of the object store. + /// Returns a GarnetStatus indicating the success or failure of the operation. + public GarnetStatus SortedSetTimeToLive(ArgSlice key, ReadOnlySpan members, out TimeSpan[] expireIn, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + expireIn = default; + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZTTL }; + parseState.Initialize(members.Length); + parseState.SetArguments(0, members); + var isMilliseconds = 1; + var isTimestamp = 0; + var innerInput = new ObjectInput(header, ref parseState, arg1: isMilliseconds, arg2: isTimestamp); + + var outputFooter = new GarnetObjectStoreOutput { SpanByteAndMemory = new SpanByteAndMemory(null) }; + var status = ReadObjectStoreOperationWithOutput(key.ToArray(), ref innerInput, ref objectContext, ref outputFooter); + + if (status == GarnetStatus.OK) + { + expireIn = ProcessRespIntegerArrayOutput(outputFooter, out _).Select(x => TimeSpan.FromMilliseconds(x < 0 ? 0 : x)).ToArray(); + } + + return status; + } + + /// + /// Removes the expiration time from a SortedSet member, making it persistent. + /// + /// The type of the object context. + /// The key of the SortedSet. + /// The input object containing the operation details. + /// The output footer object to store the result. + /// The object context for the operation. + /// The status of the operation. + public GarnetStatus SortedSetPersist(ArgSlice key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + => RMWObjectStoreOperationWithOutput(key.ToArray(), ref input, ref objectContext, ref outputFooter); + + /// + /// Removes the expiration time from the specified members in the sorted set stored at the given key. + /// + /// The type of the object context. + /// The key of the sorted set. + /// The members whose expiration time will be removed. + /// The results of the operation, indicating the number of members whose expiration time was successfully removed. + /// The context of the object store. + /// Returns a GarnetStatus indicating the success or failure of the operation. + public GarnetStatus SortedSetPersist(ArgSlice key, ReadOnlySpan members, out int[] results, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + results = default; + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZPERSIST }; + parseState.Initialize(members.Length); + parseState.SetArguments(0, members); + var innerInput = new ObjectInput(header, ref parseState); + var outputFooter = new GarnetObjectStoreOutput { SpanByteAndMemory = new SpanByteAndMemory(null) }; + + var status = RMWObjectStoreOperationWithOutput(key.ToArray(), ref innerInput, ref objectContext, ref outputFooter); + + if (status == GarnetStatus.OK) + { + results = ProcessRespIntegerArrayOutput(outputFooter, out _); + } + + return status; + } + + /// + /// Collects SortedSet keys and performs a specified operation on them. + /// + /// The type of the object context. + /// The keys to collect. + /// The input object containing the operation details. + /// The object context for the operation. + /// The status of the operation. + /// + /// If the first key is "*", all SortedSet keys are scanned in batches and the operation is performed on each key. + /// Otherwise, the operation is performed on the specified keys. + /// + public GarnetStatus SortedSetCollect(ReadOnlySpan keys, ref ObjectInput input, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + if (!_zcollectTaskLock.TryWriteLock()) + { + return GarnetStatus.NOTFOUND; + } + + try + { + if (keys[0].ReadOnlySpan.SequenceEqual("*"u8)) + { + long cursor = 0; + long storeCursor = 0; + + // Scan all SortedSet keys in batches + do + { + if (!DbScan(keys[0], true, cursor, out storeCursor, out var hashKeys, 100, CmdStrings.ZSET)) + { + return GarnetStatus.OK; + } + + // Process each SortedSet key + foreach (var hashKey in hashKeys) + { + RMWObjectStoreOperation(hashKey, ref input, out _, ref objectContext); + } + + cursor = storeCursor; + } while (storeCursor != 0); + + return GarnetStatus.OK; + } + + foreach (var key in keys) + { + RMWObjectStoreOperation(key.ToArray(), ref input, out _, ref objectContext); + } + + return GarnetStatus.OK; + } + finally + { + _zcollectTaskLock.WriteUnlock(); + } + } + + /// + /// Collects SortedSet keys and performs a specified operation on them. + /// + /// The type of the object context. + /// The object context for the operation. + /// The status of the operation. + /// + /// If the first key is "*", all SortedSet keys are scanned in batches and the operation is performed on each key. + /// Otherwise, the operation is performed on the specified keys. + /// + public GarnetStatus SortedSetCollect(ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + return SortedSetCollect([], ref objectContext); + } + + /// + /// Collects SortedSet keys and performs a specified operation on them. + /// + /// The type of the object context. + /// The keys to collect. + /// The object context for the operation. + /// The status of the operation. + /// + /// If the first key is "*", all SortedSet keys are scanned in batches and the operation is performed on each key. + /// Otherwise, the operation is performed on the specified keys. + /// + public GarnetStatus SortedSetCollect(ReadOnlySpan keys, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + { + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZCOLLECT }; + var innerInput = new ObjectInput(header); + + if (keys.IsEmpty) + { + return SortedSetCollect([ArgSlice.FromPinnedSpan("*"u8)], ref innerInput, ref objectContext); + } + + return SortedSetCollect(keys, ref innerInput, ref objectContext); + } } } \ No newline at end of file diff --git a/libs/server/StoreWrapper.cs b/libs/server/StoreWrapper.cs index 0def6ff075..de73119b43 100644 --- a/libs/server/StoreWrapper.cs +++ b/libs/server/StoreWrapper.cs @@ -441,9 +441,9 @@ async Task CompactionTask(int compactionFrequencySecs, CancellationToken token = } } - async Task HashCollectTask(int hashCollectFrequencySecs, CancellationToken token = default) + async Task ObjectCollectTask(int objectCollectFrequencySecs, CancellationToken token = default) { - Debug.Assert(hashCollectFrequencySecs > 0); + Debug.Assert(objectCollectFrequencySecs > 0); try { var scratchBufferManager = new ScratchBufferManager(); @@ -451,7 +451,7 @@ async Task HashCollectTask(int hashCollectFrequencySecs, CancellationToken token if (objectStore is null) { - logger?.LogWarning("HashCollectFrequencySecs option is configured but Object store is disabled. Stopping the background hash collect task."); + logger?.LogWarning("ExpiredObjectCollectionFrequencySecs option is configured but Object store is disabled. Stopping the background hash collect task."); return; } @@ -460,8 +460,9 @@ async Task HashCollectTask(int hashCollectFrequencySecs, CancellationToken token if (token.IsCancellationRequested) return; ExecuteHashCollect(scratchBufferManager, storageSession); + ExecuteSortedSetCollect(scratchBufferManager, storageSession); - await Task.Delay(TimeSpan.FromSeconds(hashCollectFrequencySecs), token); + await Task.Delay(TimeSpan.FromSeconds(objectCollectFrequencySecs), token); } } catch (TaskCanceledException) when (token.IsCancellationRequested) @@ -470,7 +471,7 @@ async Task HashCollectTask(int hashCollectFrequencySecs, CancellationToken token } catch (Exception ex) { - logger?.LogCritical(ex, "Unknown exception received for background hash collect task. Hash collect task won't be resumed."); + logger?.LogCritical(ex, "Unknown exception received for background hash collect task. Object collect task won't be resumed."); } static void ExecuteHashCollect(ScratchBufferManager scratchBufferManager, StorageSession storageSession) @@ -482,6 +483,12 @@ static void ExecuteHashCollect(ScratchBufferManager scratchBufferManager, Storag storageSession.HashCollect(key, ref input, ref storageSession.objectStoreBasicContext); scratchBufferManager.Reset(); } + + static void ExecuteSortedSetCollect(ScratchBufferManager scratchBufferManager, StorageSession storageSession) + { + storageSession.SortedSetCollect(ref storageSession.objectStoreBasicContext); + scratchBufferManager.Reset(); + } } void DoCompaction() @@ -639,9 +646,9 @@ internal void Start() Task.Run(async () => await CompactionTask(serverOptions.CompactionFrequencySecs, ctsCommit.Token)); } - if (serverOptions.HashCollectFrequencySecs > 0) + if (serverOptions.ExpiredObjectCollectionFrequencySecs > 0) { - Task.Run(async () => await HashCollectTask(serverOptions.HashCollectFrequencySecs, ctsCommit.Token)); + Task.Run(async () => await ObjectCollectTask(serverOptions.ExpiredObjectCollectionFrequencySecs, ctsCommit.Token)); } if (serverOptions.AdjustedIndexMaxCacheLines > 0 || serverOptions.AdjustedObjectStoreIndexMaxCacheLines > 0) diff --git a/playground/CommandInfoUpdater/GarnetCommandsDocs.json b/playground/CommandInfoUpdater/GarnetCommandsDocs.json index bebf4f591a..1e564a04c2 100644 --- a/playground/CommandInfoUpdater/GarnetCommandsDocs.json +++ b/playground/CommandInfoUpdater/GarnetCommandsDocs.json @@ -858,5 +858,475 @@ "Complexity": "O(1)" } ] + }, + { + "Command": "ZCOLLECT", + "Name": "ZCOLLECT", + "Summary": "Manually trigger deletion of expired members from memory for SortedSet", + "Group": "Hash" + }, + { + "Command": "ZEXPIRE", + "Name": "ZEXPIRE", + "Summary": "Set expiry for sorted set member using relative time to expire (seconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "SECONDS", + "DisplayText": "seconds", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZEXPIREAT", + "Name": "ZEXPIREAT", + "Summary": "Set expiry for sorted set member using an absolute Unix timestamp (seconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "UNIX-TIME-SECONDS", + "DisplayText": "unix-time-seconds", + "Type": "UnixTime" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZEXPIRETIME", + "Name": "ZEXPIRETIME", + "Summary": "Returns the expiration time of a sorted set member as a Unix timestamp, in seconds.", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "FIELDS", + "Type": "Block", + "Token": "FIELDS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMFIELDS", + "DisplayText": "numfields", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "FIELD", + "DisplayText": "field", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPERSIST", + "Name": "ZPERSIST", + "Summary": "Removes the expiration time for each specified sorted set member", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPEXPIRE", + "Name": "ZPEXPIRE", + "Summary": "Set expiry for sorted set member using relative time to expire (milliseconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MILLISECONDS", + "DisplayText": "milliseconds", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPEXPIREAT", + "Name": "ZPEXPIREAT", + "Summary": "Set expiry for sorted set member using an absolute Unix timestamp (milliseconds)", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "UNIX-TIME-MILLISECONDS", + "DisplayText": "unix-time-milliseconds", + "Type": "UnixTime" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "CONDITION", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NX", + "DisplayText": "nx", + "Type": "PureToken", + "Token": "NX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "XX", + "DisplayText": "xx", + "Type": "PureToken", + "Token": "XX" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "GT", + "DisplayText": "gt", + "Type": "PureToken", + "Token": "GT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LT", + "DisplayText": "lt", + "Type": "PureToken", + "Token": "LT" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPEXPIRETIME", + "Name": "ZPEXPIRETIME", + "Summary": "Returns the expiration time of a sorted set member as a Unix timestamp, in msec.", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] + }, + { + "Command": "ZPTTL", + "Name": "ZPTTL", + "Summary": "Returns the TTL in milliseconds of a sorted set member.", + "Group": "SortedSet", + "Complexity": "O(N) where N is the number of specified members", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "DisplayText": "key", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "MEMBERS", + "Type": "Block", + "Token": "MEMBERS", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "NUMMEMBERS", + "DisplayText": "nummembers", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "DisplayText": "member", + "Type": "String", + "ArgumentFlags": "Multiple" + } + ] + } + ] } ] \ No newline at end of file diff --git a/playground/CommandInfoUpdater/GarnetCommandsInfo.json b/playground/CommandInfoUpdater/GarnetCommandsInfo.json index c62c8b278d..b623dd4fa9 100644 --- a/playground/CommandInfoUpdater/GarnetCommandsInfo.json +++ b/playground/CommandInfoUpdater/GarnetCommandsInfo.json @@ -855,5 +855,255 @@ "Flags": "RO" } ] + }, + { + "Command": "ZCOLLECT", + "Name": "ZCOLLECT", + "Arity": 2, + "Flags": "Admin, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Write, Admin, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Access, Update" + } + ] + }, + { + "Command": "ZEXPIRE", + "Name": "ZEXPIRE", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZEXPIREAT", + "Name": "ZEXPIREAT", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZEXPIRETIME", + "Name": "ZEXPIRETIME", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, + { + "Command": "ZPERSIST", + "Name": "ZPERSIST", + "Arity": -5, + "Flags": "Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZPEXPIRE", + "Name": "ZPEXPIRE", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZPEXPIREAT", + "Name": "ZPEXPIREAT", + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Write, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RW, Update" + } + ] + }, + { + "Command": "ZPEXPIRETIME", + "Name": "ZPEXPIRETIME", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, + { + "Command": "ZPTTL", + "Name": "ZPTTL", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, + { + "Command": "ZTTL", + "Name": "ZTTL", + "Arity": -5, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "SortedSet, Fast, Read, Garnet", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] } ] \ No newline at end of file diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index 2d59d8be80..5d67f9a7b7 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -331,6 +331,16 @@ public class SupportedCommand new("ZREVRANK", RespCommand.ZREVRANK), new("ZSCAN", RespCommand.ZSCAN), new("ZSCORE", RespCommand.ZSCORE), + new("ZEXPIRE", RespCommand.HEXPIRE), + new("ZPEXPIRE", RespCommand.HPEXPIRE), + new("ZEXPIREAT", RespCommand.HEXPIREAT), + new("ZPEXPIREAT", RespCommand.HPEXPIREAT), + new("ZTTL", RespCommand.HTTL), + new("ZPTTL", RespCommand.HPTTL), + new("ZEXPIRETIME", RespCommand.HEXPIRETIME), + new("ZPEXPIRETIME", RespCommand.HPEXPIRETIME), + new("ZPERSIST", RespCommand.HPERSIST), + new("ZCOLLECT", RespCommand.HPERSIST), new("ZUNION", RespCommand.ZUNION), new("ZUNIONSTORE", RespCommand.ZUNIONSTORE), new("EVAL", RespCommand.EVAL), diff --git a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs index ee2f31bb68..296364f1f1 100644 --- a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs +++ b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs @@ -2317,6 +2317,184 @@ public override ArraySegment[] SetupSingleSlotRequest() } } + internal class ZEXPIRE : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZEXPIRE); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], "3", "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZPEXPIRE : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZPEXPIRE); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], "3000", "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZEXPIREAT : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZEXPIREAT); + + public override string[] GetSingleSlotRequest() + { + var timestamp = DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeSeconds().ToString(); + var ssk = GetSingleSlotKeys; + return [ssk[0], timestamp, "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZPEXPIREAT : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZPEXPIREAT); + + public override string[] GetSingleSlotRequest() + { + var timestamp = DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeMilliseconds().ToString(); + var ssk = GetSingleSlotKeys; + return [ssk[0], timestamp, "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZTTL : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZTTL); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZPTTL : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZPTTL); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZEXPIRETIME : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZEXPIRETIME); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZPEXPIRETIME : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZPEXPIRETIME); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZPERSIST : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => true; + public override string Command => nameof(ZPERSIST); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], "MEMBERS", "1", "member1"]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() => throw new NotImplementedException(); + } + + internal class ZCOLLECT : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => false; + public override string Command => nameof(ZCOLLECT); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0]]; + } + + public override string[] GetCrossSlotRequest() => throw new NotImplementedException(); + + public override ArraySegment[] SetupSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + var setup = new ArraySegment[1]; + setup[0] = new ArraySegment(["ZADD", ssk[0], "1", "a", "2", "b", "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 938d54cf44..2fa5925b75 100644 --- a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs +++ b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs @@ -118,6 +118,17 @@ public class ClusterSlotVerificationTests new ZINTERSTORE(), new ZUNION(), new ZUNIONSTORE(), + new HEXPIRE(), + new ZEXPIRE(), + new ZPEXPIRE(), + new ZEXPIREAT(), + new ZPEXPIREAT(), + new ZTTL(), + new ZPTTL(), + new ZEXPIRETIME(), + new ZPEXPIRETIME(), + new ZPERSIST(), + new ZCOLLECT(), new HSET(), new HGET(), new HGETALL(), @@ -320,6 +331,16 @@ public virtual void OneTimeTearDown() [TestCase("ZINTERSTORE")] [TestCase("ZUNION")] [TestCase("ZUNIONSTORE")] + [TestCase("ZEXPIRE")] + [TestCase("ZPEXPIRE")] + [TestCase("ZEXPIREAT")] + [TestCase("ZPEXPIREAT")] + [TestCase("ZTTL")] + [TestCase("ZPTTL")] + [TestCase("ZEXPIRETIME")] + [TestCase("ZPEXPIRETIME")] + [TestCase("ZPERSIST")] + [TestCase("ZCOLLECT")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -486,6 +507,16 @@ void GarnetClientSessionClusterDown(BaseCommand command) [TestCase("ZINTERSTORE")] [TestCase("ZUNION")] [TestCase("ZUNIONSTORE")] + [TestCase("ZEXPIRE")] + [TestCase("ZPEXPIRE")] + [TestCase("ZEXPIREAT")] + [TestCase("ZPEXPIREAT")] + [TestCase("ZTTL")] + [TestCase("ZPTTL")] + [TestCase("ZEXPIRETIME")] + [TestCase("ZPEXPIRETIME")] + [TestCase("ZPERSIST")] + [TestCase("ZCOLLECT")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -661,6 +692,16 @@ void GarnetClientSessionOK(BaseCommand command) [TestCase("ZINTERSTORE")] [TestCase("ZUNION")] [TestCase("ZUNIONSTORE")] + [TestCase("ZEXPIRE")] + [TestCase("ZPEXPIRE")] + [TestCase("ZEXPIREAT")] + [TestCase("ZPEXPIREAT")] + [TestCase("ZTTL")] + [TestCase("ZPTTL")] + [TestCase("ZEXPIRETIME")] + [TestCase("ZPEXPIRETIME")] + [TestCase("ZPERSIST")] + [TestCase("ZCOLLECT")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -825,6 +866,16 @@ void GarnetClientSessionCrossslotTest(BaseCommand command) [TestCase("ZINTERSTORE")] [TestCase("ZUNION")] [TestCase("ZUNIONSTORE")] + [TestCase("ZEXPIRE")] + [TestCase("ZPEXPIRE")] + [TestCase("ZEXPIREAT")] + [TestCase("ZPEXPIREAT")] + [TestCase("ZTTL")] + [TestCase("ZPTTL")] + [TestCase("ZEXPIRETIME")] + [TestCase("ZPEXPIRETIME")] + [TestCase("ZPERSIST")] + [TestCase("ZCOLLECT")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -999,6 +1050,16 @@ void GarnetClientSessionMOVEDTest(BaseCommand command) [TestCase("ZINTERSTORE")] [TestCase("ZUNION")] [TestCase("ZUNIONSTORE")] + [TestCase("ZEXPIRE")] + [TestCase("ZPEXPIRE")] + [TestCase("ZEXPIREAT")] + [TestCase("ZPEXPIREAT")] + [TestCase("ZTTL")] + [TestCase("ZPTTL")] + [TestCase("ZEXPIRETIME")] + [TestCase("ZPEXPIRETIME")] + [TestCase("ZPERSIST")] + [TestCase("ZCOLLECT")] [TestCase("HSET")] [TestCase("HGET")] [TestCase("HGETALL")] @@ -1191,6 +1252,16 @@ void GarnetClientSessionASKTest(BaseCommand command) [TestCase("ZINTERSTORE")] [TestCase("ZUNION")] [TestCase("ZUNIONSTORE")] + [TestCase("ZEXPIRE")] + [TestCase("ZPEXPIRE")] + [TestCase("ZEXPIREAT")] + [TestCase("ZPEXPIREAT")] + [TestCase("ZTTL")] + [TestCase("ZPTTL")] + [TestCase("ZEXPIRETIME")] + [TestCase("ZPEXPIRETIME")] + [TestCase("ZPERSIST")] + [TestCase("ZCOLLECT")] [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 d721f9103b..2bd3b9bbde 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -6962,6 +6962,165 @@ static async Task DoZDiffMultiAsync(GarnetClient client) } } + [Test] + public async Task ZExpireACLsAsync() + { + await CheckCommandsAsync( + "ZEXPIRE", + [DoZExpireAsync] + ); + + static async Task DoZExpireAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZEXPIRE", ["foo", "1", "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZPExpireACLsAsync() + { + await CheckCommandsAsync( + "ZPEXPIRE", + [DoZPExpireAsync] + ); + + static async Task DoZPExpireAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZPEXPIRE", ["foo", "1", "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZExpireAtACLsAsync() + { + await CheckCommandsAsync( + "ZEXPIREAT", + [DoZExpireAtAsync] + ); + + static async Task DoZExpireAtAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZEXPIREAT", ["foo", DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeSeconds().ToString(), "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZPExpireAtACLsAsync() + { + await CheckCommandsAsync( + "ZPEXPIREAT", + [DoZPExpireAtAsync] + ); + + static async Task DoZPExpireAtAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZPEXPIREAT", ["foo", DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeMilliseconds().ToString(), "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZExpireTimeACLsAsync() + { + await CheckCommandsAsync( + "ZEXPIRETIME", + [DoZExpireTimeAsync] + ); + + static async Task DoZExpireTimeAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZEXPIRETIME", ["foo", "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZPExpireTimeACLsAsync() + { + await CheckCommandsAsync( + "ZPEXPIRETIME", + [DoZPExpireTimeAsync] + ); + + static async Task DoZPExpireTimeAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZPEXPIRETIME", ["foo", "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZTTLACLsAsync() + { + await CheckCommandsAsync( + "ZTTL", + [DoZETTLAsync] + ); + + static async Task DoZETTLAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZTTL", ["foo", "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZPTTLACLsAsync() + { + await CheckCommandsAsync( + "ZPTTL", + [DoZPETTLAsync] + ); + + static async Task DoZPETTLAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZPTTL", ["foo", "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZPersistACLsAsync() + { + await CheckCommandsAsync( + "ZPERSIST", + [DoZPersistAsync] + ); + + static async Task DoZPersistAsync(GarnetClient client) + { + var val = await client.ExecuteForStringArrayResultAsync("ZPERSIST", ["foo", "MEMBERS", "1", "bar"]); + ClassicAssert.AreEqual(1, val.Length); + ClassicAssert.AreEqual("-2", val[0]); + } + } + + [Test] + public async Task ZCollectACLsAsync() + { + await CheckCommandsAsync( + "ZCOLLECT", + [DoZCollectAsync] + ); + + static async Task DoZCollectAsync(GarnetClient client) + { + var val = await client.ExecuteForStringResultAsync("ZCOLLECT", ["foo"]); + ClassicAssert.AreEqual("OK", val); + } + } + [Test] public async Task TimeACLsAsync() { diff --git a/test/Garnet.test/RespBlockingCollectionTests.cs b/test/Garnet.test/RespBlockingCollectionTests.cs index 6292038e48..c9d4692f7e 100644 --- a/test/Garnet.test/RespBlockingCollectionTests.cs +++ b/test/Garnet.test/RespBlockingCollectionTests.cs @@ -413,6 +413,24 @@ public void BasicBzmpopTest(string mode, string expectedValue, double expectedSc ClassicAssert.AreEqual(expectedResponse, actualValue); } + [Test] + [TestCase("MIN", Description = "Pop minimum score with expired items")] + [TestCase("MAX", Description = "Pop maximum score with expired items")] + public async Task BasicBzmpopWithExpireItemsTest(string mode) + { + var key = "mykey"; + using var lightClientRequest = TestUtils.CreateRequest(); + + lightClientRequest.SendCommand($"ZADD {key} 1.5 value1 2.5 value2 3.5 value3"); + lightClientRequest.SendCommand($"ZPEXPIRE {key} 200 MEMBERS 3 value1 value2 value3"); + await Task.Delay(300); + using var lcr = TestUtils.CreateRequest(); + var response = lcr.SendCommand($"BZMPOP 1 1 {key} {mode}"); + var expectedResponse = "$-1\r\n"; + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } + [Test] [TestCase(1, "key1", "value1", 1.5, Description = "First key has minimum value")] [TestCase(2, "key2", "value2", 2.5, Description = "Second key has minimum value")] @@ -482,6 +500,24 @@ public void BasicBzpopMinMaxTest(string command, string expectedValue, double ex ClassicAssert.AreEqual(expectedResponse, actualValue); } + [Test] + [TestCase("BZPOPMIN", Description = "Pop minimum score with expired items")] + [TestCase("BZPOPMAX", Description = "Pop maximum score with expired items")] + public async Task BasicBzpopMinMaxWithExpireItemsTest(string command) + { + var key = "zsettestkey"; + using var lightClientRequest = TestUtils.CreateRequest(); + + lightClientRequest.SendCommand($"ZADD {key} 1.5 value1 2.5 value2 3.5 value3"); + lightClientRequest.SendCommand($"ZPEXPIRE {key} 200 MEMBERS 3 value1 value2 value3"); + await Task.Delay(300); + using var lcr = TestUtils.CreateRequest(); + var response = lcr.SendCommand($"{command} {key} 1"); + var expectedResponse = "$-1\r\n"; + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } + [Test] [TestCase("BZPOPMIN", 1, "key1", "value1", 1.5, Description = "First key has minimum")] [TestCase("BZPOPMAX", 2, "key2", "value2", 3.5, Description = "Second key has maximum")] diff --git a/test/Garnet.test/RespSortedSetTests.cs b/test/Garnet.test/RespSortedSetTests.cs index 48819d416e..1bedf6e67c 100644 --- a/test/Garnet.test/RespSortedSetTests.cs +++ b/test/Garnet.test/RespSortedSetTests.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Embedded.server; using Garnet.common; @@ -19,11 +20,11 @@ namespace Garnet.test { using TestBasicGarnetApi = GarnetApi, - SpanByteAllocator>>, - BasicContext>, - GenericAllocator>>>>; + /* MainStoreFunctions */ StoreFunctions, + SpanByteAllocator>>, + BasicContext>, + GenericAllocator>>>>; [TestFixture] public class RespSortedSetTests @@ -77,7 +78,7 @@ public class RespSortedSetTests public void Setup() { TestUtils.DeleteDirectory(TestUtils.MethodTestDir, wait: true); - server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, lowMemory: true); + server = TestUtils.CreateGarnetServer(TestUtils.MethodTestDir, enableReadCache: true, enableObjectStoreReadCache: true, lowMemory: true); server.Start(); } @@ -115,6 +116,77 @@ public unsafe void SortedSetPopTest() } } + [Test] + public unsafe void SortedSetPopWithExpire() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + + // Set expiration for the minimum and maximum items + db.Execute("ZPEXPIRE", "key1", "100", "MEMBERS", "2", "a", "c"); + + // Wait for expiration + Thread.Sleep(200); + + var session = new RespServerSession(0, new EmbeddedNetworkSender(), server.Provider.StoreWrapper, null, null, false); + var api = new TestBasicGarnetApi(session.storageSession, session.storageSession.basicContext, session.storageSession.objectStoreBasicContext); + var key = Encoding.ASCII.GetBytes("key1"); + fixed (byte* keyPtr = key) + { + var result = api.SortedSetPop(new ArgSlice(keyPtr, key.Length), out var items); + ClassicAssert.AreEqual(1, items.Length); + ClassicAssert.AreEqual("b", Encoding.ASCII.GetString(items[0].member.ReadOnlySpan)); + ClassicAssert.AreEqual("2", Encoding.ASCII.GetString(items[0].score.ReadOnlySpan)); + + var count = (int)db.SortedSetLength("key1"); + ClassicAssert.AreEqual(0, count); + } + } + + [Test] + public async Task SortedSetAddWithExpire() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "1", "c"); + + await Task.Delay(300); + + var ttl = db.Execute("ZPTTL", "key1", "MEMBERS", "1", "c"); + ClassicAssert.AreEqual(-2, (long)ttl); + + db.SortedSetAdd("key1", "c", 3); + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "b", "c"); + + var ttls = db.Execute("ZPTTL", "key1", "MEMBERS", "2", "b", "c"); + ClassicAssert.LessOrEqual((long)ttls[0], 200); + ClassicAssert.Greater((long)ttls[0], 0); + ClassicAssert.LessOrEqual((long)ttls[1], 200); + ClassicAssert.Greater((long)ttls[1], 0); + + // Add the expiring item "c" again, which should remove the expiration. Score is not changed + db.SortedSetAdd("key1", "c", 3); + // Add the expiring item "b" again, which should remove the expiration. Score is changed + db.SortedSetAdd("key1", "b", 1); + + ttls = db.Execute("ZPTTL", "key1", "MEMBERS", "2", "b", "c"); + ClassicAssert.AreEqual(-1, (long)ttls[0]); + ClassicAssert.AreEqual(-1, (long)ttls[1]); + + var items = db.SortedSetRangeByRankWithScores("key1"); + ClassicAssert.AreEqual(2, items.Length); + ClassicAssert.AreEqual("b", items[0].Element.ToString()); + ClassicAssert.AreEqual(1, items[0].Score); + ClassicAssert.AreEqual("c", items[1].Element.ToString()); + ClassicAssert.AreEqual(3, items[1].Score); + } + [Test] public void AddAndLength() { @@ -363,6 +435,34 @@ public void CanGetScoresZCount() ClassicAssert.IsTrue(10 == card); } + [Test] + public async Task ZCountAndZCardWithExpiredAndExpiringItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for the minimum, maximum and middle items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "3", "a", "e", "c"); + + await Task.Delay(300); + + // ZCARD + var count = db.SortedSetLength("key1"); + ClassicAssert.AreEqual(2, count); // Only "b" and "d" should remain + + // Check the count of items within a score range + // ZCOUNT + var rangeCount = db.SortedSetLength("key1", 3, 5); + ClassicAssert.AreEqual(1, rangeCount); // Only "d" should remain within the range + } + [Test] public void AddRemove() { @@ -1757,6 +1857,1268 @@ public void CanUseZUnionStoreWithWeights(double[] weights, double[] expectedScor } } + [Test] + public async Task CanDoSortedSetCollect() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(allowAdmin: true)); + var db = redis.GetDatabase(0); + var server = redis.GetServers().First(); + db.SortedSetAdd("mysortedset", + [ + new SortedSetEntry("member1", 1), + new SortedSetEntry("member2", 2), + new SortedSetEntry("member3", 3), + new SortedSetEntry("member4", 4), + new SortedSetEntry("member5", 5), + new SortedSetEntry("member6", 6) + ]); + + var result = db.Execute("ZPEXPIRE", "mysortedset", "500", "MEMBERS", "2", "member1", "member2"); + var results = (RedisResult[])result; + ClassicAssert.AreEqual(2, results.Length); + ClassicAssert.AreEqual(1, (long)results[0]); + ClassicAssert.AreEqual(1, (long)results[1]); + + result = db.Execute("ZPEXPIRE", "mysortedset", "1500", "MEMBERS", "2", "member3", "member4"); + results = (RedisResult[])result; + ClassicAssert.AreEqual(2, results.Length); + ClassicAssert.AreEqual(1, (long)results[0]); + ClassicAssert.AreEqual(1, (long)results[1]); + + var orginalMemory = (long)db.Execute("MEMORY", "USAGE", "mysortedset"); + + await Task.Delay(600); + + var newMemory = (long)db.Execute("MEMORY", "USAGE", "mysortedset"); + ClassicAssert.AreEqual(newMemory, orginalMemory); + + var collectResult = (string)db.Execute("ZCOLLECT", "mysortedset"); + ClassicAssert.AreEqual("OK", collectResult); + + newMemory = (long)db.Execute("MEMORY", "USAGE", "mysortedset"); + ClassicAssert.Less(newMemory, orginalMemory); + orginalMemory = newMemory; + + await Task.Delay(1100); + + newMemory = (long)db.Execute("MEMORY", "USAGE", "mysortedset"); + ClassicAssert.AreEqual(newMemory, orginalMemory); + + collectResult = (string)db.Execute("ZCOLLECT", "*"); + ClassicAssert.AreEqual("OK", collectResult); + + newMemory = (long)db.Execute("MEMORY", "USAGE", "mysortedset"); + ClassicAssert.Less(newMemory, orginalMemory); + } + + [Test] + public async Task CanDoSortedSetExpire() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + db.SortedSetAdd("mysortedset", [new SortedSetEntry("member1", 1), new SortedSetEntry("member2", 2), new SortedSetEntry("member3", 3), new SortedSetEntry("member4", 4), new SortedSetEntry("member5", 5), new SortedSetEntry("member6", 6)]); + + var result = db.Execute("ZEXPIRE", "mysortedset", "3", "MEMBERS", "3", "member1", "member5", "nonexistmember"); + var results = (RedisResult[])result; + ClassicAssert.AreEqual(3, results.Length); + ClassicAssert.AreEqual(1, (long)results[0]); + ClassicAssert.AreEqual(1, (long)results[1]); + ClassicAssert.AreEqual(-2, (long)results[2]); + + result = db.Execute("ZPEXPIRE", "mysortedset", "3000", "MEMBERS", "2", "member2", "nonexistmember"); + results = (RedisResult[])result; + ClassicAssert.AreEqual(2, results.Length); + ClassicAssert.AreEqual(1, (long)results[0]); + ClassicAssert.AreEqual(-2, (long)results[1]); + + result = db.Execute("ZEXPIREAT", "mysortedset", DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeSeconds().ToString(), "MEMBERS", "2", "member3", "nonexistmember"); + results = (RedisResult[])result; + ClassicAssert.AreEqual(2, results.Length); + ClassicAssert.AreEqual(1, (long)results[0]); + ClassicAssert.AreEqual(-2, (long)results[1]); + + result = db.Execute("ZPEXPIREAT", "mysortedset", DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeMilliseconds().ToString(), "MEMBERS", "2", "member4", "nonexistmember"); + results = (RedisResult[])result; + ClassicAssert.AreEqual(2, results.Length); + ClassicAssert.AreEqual(1, (long)results[0]); + ClassicAssert.AreEqual(-2, (long)results[1]); + + var ttl = (RedisResult[])db.Execute("ZTTL", "mysortedset", "MEMBERS", "2", "member1", "nonexistmember"); + ClassicAssert.AreEqual(2, ttl.Length); + ClassicAssert.LessOrEqual((long)ttl[0], 3); + ClassicAssert.Greater((long)ttl[0], 1); + ClassicAssert.AreEqual(-2, (long)results[1]); + + ttl = (RedisResult[])db.Execute("ZPTTL", "mysortedset", "MEMBERS", "2", "member1", "nonexistmember"); + ClassicAssert.AreEqual(2, ttl.Length); + ClassicAssert.LessOrEqual((long)ttl[0], 3000); + ClassicAssert.Greater((long)ttl[0], 1000); + ClassicAssert.AreEqual(-2, (long)results[1]); + + ttl = (RedisResult[])db.Execute("ZEXPIRETIME", "mysortedset", "MEMBERS", "2", "member1", "nonexistmember"); + ClassicAssert.AreEqual(2, ttl.Length); + ClassicAssert.LessOrEqual((long)ttl[0], DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeSeconds()); + ClassicAssert.Greater((long)ttl[0], DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeSeconds()); + ClassicAssert.AreEqual(-2, (long)results[1]); + + ttl = (RedisResult[])db.Execute("ZPEXPIRETIME", "mysortedset", "MEMBERS", "2", "member1", "nonexistmember"); + ClassicAssert.AreEqual(2, ttl.Length); + ClassicAssert.LessOrEqual((long)ttl[0], DateTimeOffset.UtcNow.AddSeconds(3).ToUnixTimeMilliseconds()); + ClassicAssert.Greater((long)ttl[0], DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeMilliseconds()); + ClassicAssert.AreEqual(-2, (long)results[1]); + + results = (RedisResult[])db.Execute("ZPERSIST", "mysortedset", "MEMBERS", "3", "member5", "member6", "nonexistmember"); + ClassicAssert.AreEqual(3, results.Length); + ClassicAssert.AreEqual(1, (long)results[0]); // 1 the expiration was removed. + ClassicAssert.AreEqual(-1, (long)results[1]); // -1 if the member exists but has no associated expiration set. + ClassicAssert.AreEqual(-2, (long)results[2]); + + await Task.Delay(3500); + + var items = db.SortedSetRangeByRankWithScores("mysortedset"); + ClassicAssert.AreEqual(2, items.Length); + ClassicAssert.AreEqual("member5", items[0].Element.ToString()); + ClassicAssert.AreEqual(5, items[0].Score); + ClassicAssert.AreEqual("member6", items[1].Element.ToString()); + ClassicAssert.AreEqual(6, items[1].Score); + + result = db.Execute("ZEXPIRE", "mysortedset", "0", "MEMBERS", "1", "member5"); + results = (RedisResult[])result; + ClassicAssert.AreEqual(1, results.Length); + ClassicAssert.AreEqual(2, (long)results[0]); + + result = db.Execute("ZEXPIREAT", "mysortedset", DateTimeOffset.UtcNow.AddSeconds(-1).ToUnixTimeSeconds().ToString(), "MEMBERS", "1", "member6"); + results = (RedisResult[])result; + ClassicAssert.AreEqual(1, results.Length); + ClassicAssert.AreEqual(2, (long)results[0]); + + items = db.SortedSetRangeByRankWithScores("mysortedset"); + ClassicAssert.AreEqual(0, items.Length); + } + + [Test] + public async Task CanDoSortedSetExpireLTM() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig(allowAdmin: true)); + var db = redis.GetDatabase(0); + var server = redis.GetServer(TestUtils.EndPoint); + + string[] smallExpireKeys = ["user:user0", "user:user1"]; + string[] largeExpireKeys = ["user:user2", "user:user3"]; + + foreach (var key in smallExpireKeys) + { + db.SortedSetAdd(key, + [ + new SortedSetEntry("Field1", 1), + new SortedSetEntry("Field2", 2) + ]); + db.Execute("ZEXPIRE", key, "2", "MEMBERS", "1", "Field1"); + } + + foreach (var key in largeExpireKeys) + { + db.SortedSetAdd(key, + [ + new SortedSetEntry("Field1", 1), + new SortedSetEntry("Field2", 2) + ]); + db.Execute("ZEXPIRE", key, "4", "MEMBERS", "1", "Field1"); + } + + // Create LTM (larger than memory) DB by inserting 100 keys + for (int i = 4; i < 100; i++) + { + var key = "user:user" + i; + db.SortedSetAdd(key, + [ + new SortedSetEntry("Field1", 1), + new SortedSetEntry("Field2", 2) + ]); + } + + var info = TestUtils.GetStoreAddressInfo(server, includeReadCache: true, isObjectStore: true); + // Ensure data has spilled to disk + ClassicAssert.Greater(info.HeadAddress, info.BeginAddress); + + await Task.Delay(2000); + + var result = db.SortedSetScore(smallExpireKeys[0], "Field1"); + ClassicAssert.IsNull(result); + result = db.SortedSetScore(smallExpireKeys[1], "Field1"); + ClassicAssert.IsNull(result); + result = db.SortedSetScore(largeExpireKeys[0], "Field1"); + ClassicAssert.IsNotNull(result); + result = db.SortedSetScore(largeExpireKeys[1], "Field1"); + ClassicAssert.IsNotNull(result); + var ttl = db.SortedSetRangeByScoreWithScores(largeExpireKeys[0], 1, 1); + ClassicAssert.AreEqual(ttl.Length, 1); + ClassicAssert.Greater(ttl[0].Score, 0); + ClassicAssert.LessOrEqual(ttl[0].Score, 2000); + ttl = db.SortedSetRangeByScoreWithScores(largeExpireKeys[1], 1, 1); + ClassicAssert.AreEqual(ttl.Length, 1); + ClassicAssert.Greater(ttl[0].Score, 0); + ClassicAssert.LessOrEqual(ttl[0].Score, 2000); + + await Task.Delay(2000); + + result = db.SortedSetScore(largeExpireKeys[0], "Field1"); + ClassicAssert.IsNull(result); + result = db.SortedSetScore(largeExpireKeys[1], "Field1"); + ClassicAssert.IsNull(result); + + var data = db.SortedSetRangeByRankWithScores("user:user4"); + ClassicAssert.AreEqual(2, data.Length); + ClassicAssert.AreEqual("Field1", data[0].Element.ToString()); + ClassicAssert.AreEqual(1, data[0].Score); + ClassicAssert.AreEqual("Field2", data[1].Element.ToString()); + ClassicAssert.AreEqual(2, data[1].Score); + } + + [Test] + public void CanDoSortedSetExpireWithNonExistKey() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + var result = db.Execute("ZEXPIRE", "mysortedset", "3", "MEMBERS", "1", "member1"); + var results = (RedisResult[])result; + ClassicAssert.AreEqual(1, results.Length); + ClassicAssert.AreEqual(-2, (long)results[0]); + } + + [Test] + [TestCase("ZEXPIRE", "NX", Description = "Set expiry only when no expiration exists")] + [TestCase("ZEXPIRE", "XX", Description = "Set expiry only when expiration exists")] + [TestCase("ZEXPIRE", "GT", Description = "Set expiry only when new TTL is greater")] + [TestCase("ZEXPIRE", "LT", Description = "Set expiry only when new TTL is less")] + [TestCase("ZPEXPIRE", "NX", Description = "Set expiry only when no expiration exists")] + [TestCase("ZPEXPIRE", "XX", Description = "Set expiry only when expiration exists")] + [TestCase("ZPEXPIRE", "GT", Description = "Set expiry only when new TTL is greater")] + [TestCase("ZPEXPIRE", "LT", Description = "Set expiry only when new TTL is less")] + [TestCase("ZEXPIREAT", "NX", Description = "Set expiry only when no expiration exists")] + [TestCase("ZEXPIREAT", "XX", Description = "Set expiry only when expiration exists")] + [TestCase("ZEXPIREAT", "GT", Description = "Set expiry only when new TTL is greater")] + [TestCase("ZEXPIREAT", "LT", Description = "Set expiry only when new TTL is less")] + [TestCase("ZPEXPIREAT", "NX", Description = "Set expiry only when no expiration exists")] + [TestCase("ZPEXPIREAT", "XX", Description = "Set expiry only when expiration exists")] + [TestCase("ZPEXPIREAT", "GT", Description = "Set expiry only when new TTL is greater")] + [TestCase("ZPEXPIREAT", "LT", Description = "Set expiry only when new TTL is less")] + public void CanDoSortedSetExpireWithOptions(string command, string option) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.SortedSetAdd("mysortedset", + [ + new SortedSetEntry("member1", 1), + new SortedSetEntry("member2", 2), + new SortedSetEntry("member3", 3), + new SortedSetEntry("member4", 4) + ]); + + (var expireTimeMember1, var expireTimeMember3, var newExpireTimeMember) = command switch + { + "ZEXPIRE" => ("2", "6", "4"), + "ZPEXPIRE" => ("2000", "6000", "4000"), + "ZEXPIREAT" => (DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeSeconds().ToString(), DateTimeOffset.UtcNow.AddSeconds(6).ToUnixTimeSeconds().ToString(), DateTimeOffset.UtcNow.AddSeconds(4).ToUnixTimeSeconds().ToString()), + "ZPEXPIREAT" => (DateTimeOffset.UtcNow.AddSeconds(2).ToUnixTimeMilliseconds().ToString(), DateTimeOffset.UtcNow.AddSeconds(6).ToUnixTimeMilliseconds().ToString(), DateTimeOffset.UtcNow.AddSeconds(4).ToUnixTimeMilliseconds().ToString()), + _ => throw new ArgumentException("Invalid command") + }; + + // First set TTL for member1 only + db.Execute(command, "mysortedset", expireTimeMember1, "MEMBERS", "1", "member1"); + db.Execute(command, "mysortedset", expireTimeMember3, "MEMBERS", "1", "member3"); + + // Try setting TTL with option + var result = (RedisResult[])db.Execute(command, "mysortedset", newExpireTimeMember, option, "MEMBERS", "3", "member1", "member2", "member3"); + + switch (option) + { + case "NX": + ClassicAssert.AreEqual(0, (long)result[0]); // member1 has TTL + ClassicAssert.AreEqual(1, (long)result[1]); // member2 no TTL + ClassicAssert.AreEqual(0, (long)result[2]); // member3 has TTL + break; + case "XX": + ClassicAssert.AreEqual(1, (long)result[0]); // member1 has TTL + ClassicAssert.AreEqual(0, (long)result[1]); // member2 no TTL + ClassicAssert.AreEqual(1, (long)result[2]); // member3 has TTL + break; + case "GT": + ClassicAssert.AreEqual(1, (long)result[0]); // 4 > 2 + ClassicAssert.AreEqual(0, (long)result[1]); // no TTL = infinite + ClassicAssert.AreEqual(0, (long)result[2]); // 4 !> 6 + break; + case "LT": + ClassicAssert.AreEqual(0, (long)result[0]); // 4 !< 2 + ClassicAssert.AreEqual(1, (long)result[1]); // no TTL = infinite + ClassicAssert.AreEqual(1, (long)result[2]); // 4 < 6 + break; + } + } + + [Test] + public async Task ZDiffWithExpiredAndExpiringItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted sets + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.SortedSetAdd("key2", "a", 1); + db.SortedSetAdd("key2", "b", 2); + db.SortedSetAdd("key2", "c", 3); + + // Set expiration for some items in key1 + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "c"); + db.Execute("ZPEXPIRE", "key1", "500", "MEMBERS", "1", "d"); + + // Set expiration for matching items in key2 + db.Execute("ZPEXPIRE", "key2", "200", "MEMBERS", "1", "a"); + + await Task.Delay(300); + + // Perform ZDIFF + var diff = db.SortedSetCombine(SetOperation.Difference, ["key1", "key2"]); + ClassicAssert.AreEqual(2, diff.Length); // Only "d" and "e" should remain + + // Perform ZDIFF with scores + var diffWithScores = db.SortedSetCombineWithScores(SetOperation.Difference, ["key1", "key2"]); + ClassicAssert.AreEqual(2, diffWithScores.Length); + ClassicAssert.AreEqual("d", diffWithScores[0].Element.ToString()); + ClassicAssert.AreEqual(4, diffWithScores[0].Score); + ClassicAssert.AreEqual("e", diffWithScores[1].Element.ToString()); + ClassicAssert.AreEqual(5, diffWithScores[1].Score); + + await Task.Delay(300); + + // Perform ZDIFF again after more items have expired + diff = db.SortedSetCombine(SetOperation.Difference, ["key1", "key2"]); + ClassicAssert.AreEqual(1, diff.Length); // Only "e" should remain + + // Perform ZDIFF with scores again + diffWithScores = db.SortedSetCombineWithScores(SetOperation.Difference, ["key1", "key2"]); + ClassicAssert.AreEqual(1, diffWithScores.Length); + ClassicAssert.AreEqual("e", diffWithScores[0].Element.ToString()); + ClassicAssert.AreEqual(5, diffWithScores[0].Score); + } + + [Test] + public async Task ZDiffStoreWithExpiredAndExpiringItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted sets + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.SortedSetAdd("key2", "a", 1); + db.SortedSetAdd("key2", "b", 2); + db.SortedSetAdd("key2", "c", 3); + + // Set expiration for some items in key1 + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "c"); + db.Execute("ZPEXPIRE", "key1", "500", "MEMBERS", "1", "d"); + + // Set expiration for matching items in key2 + db.Execute("ZPEXPIRE", "key2", "200", "MEMBERS", "1", "a"); + + await Task.Delay(300); + + // Perform ZDIFFSTORE + var diffStoreCount = db.SortedSetCombineAndStore(SetOperation.Difference, "key3", ["key1", "key2"]); + ClassicAssert.AreEqual(2, diffStoreCount); // Only "d" and "e" should remain + + // Verify the stored result + var diffStoreResult = db.SortedSetRangeByRankWithScores("key3"); + ClassicAssert.AreEqual(2, diffStoreResult.Length); + ClassicAssert.AreEqual("d", diffStoreResult[0].Element.ToString()); + ClassicAssert.AreEqual(4, diffStoreResult[0].Score); + ClassicAssert.AreEqual("e", diffStoreResult[1].Element.ToString()); + ClassicAssert.AreEqual(5, diffStoreResult[1].Score); + + await Task.Delay(300); + + // Perform ZDIFFSTORE again after more items have expired + diffStoreCount = db.SortedSetCombineAndStore(SetOperation.Difference, "key3", ["key1", "key2"]); + ClassicAssert.AreEqual(1, diffStoreCount); // Only "e" should remain + + // Verify the stored result again + diffStoreResult = db.SortedSetRangeByRankWithScores("key3"); + ClassicAssert.AreEqual(1, diffStoreResult.Length); + ClassicAssert.AreEqual("e", diffStoreResult[0].Element.ToString()); + ClassicAssert.AreEqual(5, diffStoreResult[0].Score); + } + + [Test] + public async Task ZIncrByWithExpiringAndExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + + // Set expiration for some items in key1 + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "1", "a"); + + await Task.Delay(10); + + // Try to increment the score of an expiring item + var newScore = db.SortedSetIncrement("key1", "a", 5); + ClassicAssert.AreEqual(6, newScore); + + // Check the TTL of the expiring item + var ttl = db.Execute("ZPTTL", "key1", "MEMBERS", "1", "a"); + ClassicAssert.LessOrEqual((long)ttl, 200); + ClassicAssert.Greater((long)ttl, 0); + + await Task.Delay(200); + + // Check the item has expired + ttl = db.Execute("ZPTTL", "key1", "MEMBERS", "1", "a"); + ClassicAssert.AreEqual(-2, (long)ttl); + + // Try to increment the score of an already expired item + newScore = db.SortedSetIncrement("key1", "a", 5); + ClassicAssert.AreEqual(5, newScore); + + // Verify the item is added back with the new score + var score = db.SortedSetScore("key1", "a"); + ClassicAssert.AreEqual(5, score); + } + + [Test] + public async Task ZInterWithExpiredAndExpiringItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted sets + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.SortedSetAdd("key2", "a", 1); + db.SortedSetAdd("key2", "b", 2); + db.SortedSetAdd("key2", "c", 3); + + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "1", "c"); + db.Execute("ZPEXPIRE", "key1", "500", "MEMBERS", "1", "b"); + db.Execute("ZPEXPIRE", "key2", "200", "MEMBERS", "1", "a"); + + var inter = db.SortedSetCombine(SetOperation.Intersect, ["key1", "key2"]); + ClassicAssert.AreEqual(3, inter.Length); + + await Task.Delay(300); + + var interWithScores = db.SortedSetCombineWithScores(SetOperation.Intersect, ["key1", "key2"]); + ClassicAssert.AreEqual(1, interWithScores.Length); // Only "b" should remain + ClassicAssert.AreEqual("b", interWithScores[0].Element.ToString()); + ClassicAssert.AreEqual(4, interWithScores[0].Score); // Sum of scores + + await Task.Delay(300); + + inter = db.SortedSetCombine(SetOperation.Intersect, ["key1", "key2"]); + ClassicAssert.AreEqual(0, inter.Length); + } + + [Test] + public async Task ZInterCardWithExpiredAndExpiringItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.SortedSetAdd("key2", "a", 1); + db.SortedSetAdd("key2", "b", 2); + db.SortedSetAdd("key2", "c", 3); + + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "1", "c"); + db.Execute("ZPEXPIRE", "key1", "500", "MEMBERS", "1", "b"); + db.Execute("ZPEXPIRE", "key2", "200", "MEMBERS", "1", "a"); + + await Task.Delay(300); + + var interCardCount = (long)db.Execute("ZINTERCARD", "2", "key1", "key2"); + ClassicAssert.AreEqual(1, interCardCount); // Only "b" should remain + + await Task.Delay(300); + + interCardCount = (long)db.Execute("ZINTERCARD", "2", "key1", "key2"); + ClassicAssert.AreEqual(0, interCardCount); // No items should remain + } + + [Test] + public async Task ZInterStoreWithExpiredAndExpiringItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.SortedSetAdd("key2", "a", 1); + db.SortedSetAdd("key2", "b", 2); + db.SortedSetAdd("key2", "c", 3); + + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "1", "c"); + db.Execute("ZPEXPIRE", "key1", "500", "MEMBERS", "1", "b"); + db.Execute("ZPEXPIRE", "key2", "200", "MEMBERS", "1", "a"); + + await Task.Delay(300); + + var interStoreCount = db.SortedSetCombineAndStore(SetOperation.Intersect, "key3", ["key1", "key2"]); + ClassicAssert.AreEqual(1, interStoreCount); // Only "b" should remain + + var interStoreResult = db.SortedSetRangeByRankWithScores("key3"); + ClassicAssert.AreEqual(1, interStoreResult.Length); + ClassicAssert.AreEqual("b", interStoreResult[0].Element.ToString()); + ClassicAssert.AreEqual(4, interStoreResult[0].Score); // Sum of scores + + await Task.Delay(300); + + interStoreCount = db.SortedSetCombineAndStore(SetOperation.Intersect, "key3", ["key1", "key2"]); + ClassicAssert.AreEqual(0, interStoreCount); // No items should remain + + interStoreResult = db.SortedSetRangeByRankWithScores("key3"); + ClassicAssert.AreEqual(0, interStoreResult.Length); + } + + [Test] + public async Task ZLexCountWithExpiredAndExpiringItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "3", "a", "e", "c"); + db.Execute("ZPEXPIRE", "key1", "500", "MEMBERS", "1", "b"); + + await Task.Delay(300); + + var lexCount = (int)db.Execute("ZLEXCOUNT", "key1", "-", "+"); // SortedSetLengthByValue will check - and + to [- and [+ + ClassicAssert.AreEqual(2, lexCount); // Only "b" and "d" should remain + + var lexCountRange = db.SortedSetLengthByValue("key1", "b", "d", Exclude.Stop); + ClassicAssert.AreEqual(1, lexCountRange); // Only "b" should remain within the range + + await Task.Delay(300); + + lexCount = (int)db.Execute("ZLEXCOUNT", "key1", "-", "+"); + ClassicAssert.AreEqual(1, lexCount); // Only "d" should remain + + lexCountRange = db.SortedSetLengthByValue("key1", "b", "d"); + ClassicAssert.AreEqual(1, lexCountRange); // Only "d" should remain within the range + } + + [Test] + public async Task ZMPopWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.SortedSetAdd("key0", "x", 1); + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for the minimum and maximum items + db.Execute("ZPEXPIRE", "key0", "200", "MEMBERS", "1", "x"); + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZMPOP with MIN option + var result = db.Execute("ZMPOP", 2, "key0", "key1", "MIN", "COUNT", 2); + ClassicAssert.IsNotNull(result); + var popResult = (RedisResult[])result; + ClassicAssert.AreEqual("key1", (string)popResult[0]); + + var poppedItems = (RedisResult[])popResult[1]; + ClassicAssert.AreEqual(2, poppedItems.Length); + ClassicAssert.AreEqual("b", (string)poppedItems[0][0]); + ClassicAssert.AreEqual("2", (string)poppedItems[0][1]); + ClassicAssert.AreEqual("c", (string)poppedItems[1][0]); + ClassicAssert.AreEqual("3", (string)poppedItems[1][1]); + + // Perform ZMPOP with MAX option + result = db.Execute("ZMPOP", 2, "key0", "key1", "MAX", "COUNT", 2); + ClassicAssert.IsNotNull(result); + popResult = (RedisResult[])result; + ClassicAssert.AreEqual("key1", (string)popResult[0]); + + poppedItems = (RedisResult[])popResult[1]; + ClassicAssert.AreEqual(1, poppedItems.Length); + ClassicAssert.AreEqual("d", (string)poppedItems[0][0]); + ClassicAssert.AreEqual("4", (string)poppedItems[0][1]); + } + + [Test] + public async Task ZMScoreWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for the minimum and maximum items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + var scores = db.SortedSetScores("key1", ["a", "b", "c", "d", "e"]); + ClassicAssert.AreEqual(5, scores.Length); + ClassicAssert.IsNull(scores[0]); // "a" should be expired + ClassicAssert.AreEqual(2, scores[1]); // "b" should remain + ClassicAssert.AreEqual(3, scores[2]); // "c" should remain + ClassicAssert.AreEqual(4, scores[3]); // "d" should remain + ClassicAssert.IsNull(scores[4]); // "e" should be expired + } + + [Test] + public async Task ZPopMaxWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for the minimum and maximum items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "c", "e"); + + await Task.Delay(300); + + // Perform ZPOPMAX + var result = db.SortedSetPop("key1", Order.Descending); + ClassicAssert.IsNotNull(result); + ClassicAssert.AreEqual("d", result.Value.Element.ToString()); + ClassicAssert.AreEqual(4, result.Value.Score); + + // Perform ZPOPMAX with COUNT option + var results = db.SortedSetPop("key1", 2, Order.Descending); + ClassicAssert.AreEqual(2, results.Length); + ClassicAssert.AreEqual("b", results[0].Element.ToString()); + ClassicAssert.AreEqual(2, results[0].Score); + ClassicAssert.AreEqual("a", results[1].Element.ToString()); + ClassicAssert.AreEqual(1, results[1].Score); + } + + [Test] + public async Task ZPopMinWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for the minimum and middle items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "c"); + + await Task.Delay(300); + + // Perform ZPOPMIN + var result = db.SortedSetPop("key1", Order.Ascending); + ClassicAssert.IsNotNull(result); + ClassicAssert.AreEqual("b", result.Value.Element.ToString()); + ClassicAssert.AreEqual(2, result.Value.Score); + + // Perform ZPOPMIN with COUNT option + var results = db.SortedSetPop("key1", 2, Order.Ascending); + ClassicAssert.AreEqual(2, results.Length); + ClassicAssert.AreEqual("d", results[0].Element.ToString()); + ClassicAssert.AreEqual(4, results[0].Score); + ClassicAssert.AreEqual("e", results[1].Element.ToString()); + ClassicAssert.AreEqual(5, results[1].Score); + } + + [Test] + public async Task ZRandMemberWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZRANDMEMBER + var randMember = db.SortedSetRandomMember("key1"); + + ClassicAssert.IsFalse(randMember.IsNull); + ClassicAssert.IsTrue(new[] { "b", "c", "d" }.Contains(randMember.ToString())); + + // Perform ZRANDMEMBER with count + var randMembers = db.SortedSetRandomMembers("key1", 4); + ClassicAssert.AreEqual(3, randMembers.Length); + CollectionAssert.AreEquivalent(new[] { "b", "c", "d" }, randMembers.Select(member => member.ToString()).ToList()); + + // Perform ZRANDMEMBER with count and WITHSCORES + var randMembersWithScores = db.SortedSetRandomMembersWithScores("key1", 4); + ClassicAssert.AreEqual(3, randMembersWithScores.Length); + CollectionAssert.AreEquivalent(new[] { "b", "c", "d" }, randMembersWithScores.Select(member => member.Element.ToString()).ToList()); + } + + [Test] + public async Task ZRangeWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZRANGE with BYSCORE option + var result = (RedisValue[])db.Execute("ZRANGE", "key1", "1", "5", "BYSCORE"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + + // Perform ZRANGE with BYLEX option + result = (RedisValue[])db.Execute("ZRANGE", "key1", "[b", "[d", "BYLEX"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + + // Perform ZRANGE with REV option + result = (RedisValue[])db.Execute("ZRANGE", "key1", "5", "1", "BYSCORE", "REV"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "d", "c", "b" }, result.Select(r => r.ToString()).ToList()); + + // Perform ZRANGE with LIMIT option + result = (RedisValue[])db.Execute("ZRANGE", "key1", "1", "5", "BYSCORE", "LIMIT", "1", "2"); + ClassicAssert.AreEqual(2, result.Length); + CollectionAssert.AreEqual(new[] { "c", "d" }, result.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRangeByLexWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 1); + db.SortedSetAdd("key1", "c", 1); + db.SortedSetAdd("key1", "d", 1); + db.SortedSetAdd("key1", "e", 1); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZRANGEBYLEX with expired items + var result = (RedisResult[])db.Execute("ZRANGEBYLEX", "key1", "[a", "[e"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRangeByScoreWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZRANGEBYSCORE + var result = (RedisValue[])db.Execute("ZRANGEBYSCORE", "key1", "1", "5"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + + // Perform ZRANGEBYSCORE with expired items + result = (RedisValue[])db.Execute("ZRANGEBYSCORE", "key1", "1", "5"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRangeStoreWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZRANGESTORE with BYSCORE option + db.Execute("ZRANGESTORE", "key2", "key1", "1", "5", "BYSCORE"); + var result = db.SortedSetRangeByRank("key2"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + + // Perform ZRANGESTORE with BYLEX option + db.Execute("ZRANGESTORE", "key2", "key1", "[b", "[d", "BYLEX"); + result = db.SortedSetRangeByRank("key2"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + + // Perform ZRANGESTORE with REV option + db.Execute("ZRANGESTORE", "key2", "key1", "5", "1", "BYSCORE", "REV"); + result = db.SortedSetRangeByRank("key2"); + ClassicAssert.AreEqual(3, result.Length); + CollectionAssert.AreEqual(new[] { "b", "c", "d" }, result.Select(r => r.ToString()).ToList()); + + // Perform ZRANGESTORE with LIMIT option + db.Execute("ZRANGESTORE", "key2", "key1", "1", "5", "BYSCORE", "LIMIT", "1", "2"); + result = db.SortedSetRangeByRank("key2"); + ClassicAssert.AreEqual(2, result.Length); + CollectionAssert.AreEqual(new[] { "c", "d" }, result.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRankWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZRANK + var rank = db.SortedSetRank("key1", "a"); + ClassicAssert.IsNull(rank); // "a" should be expired + + rank = db.SortedSetRank("key1", "b"); + ClassicAssert.AreEqual(0, rank); // "b" should be at rank 0 + } + + [Test] + public async Task ZRemWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREM on expired and non-expired items + var removedCount = db.SortedSetRemove("key1", ["a", "b", "e"]); + ClassicAssert.AreEqual(1, removedCount); // "a" and "e" should be expired, "b" should be removed + + // Verify remaining items in the sorted set + var remainingItems = db.SortedSetRangeByRank("key1"); + ClassicAssert.AreEqual(2, remainingItems.Length); + CollectionAssert.AreEqual(new[] { "c", "d" }, remainingItems.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRemRangeByLexWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 1); + db.SortedSetAdd("key1", "c", 1); + db.SortedSetAdd("key1", "d", 1); + db.SortedSetAdd("key1", "e", 1); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREMRANGEBYLEX with expired items + var removedCount = db.Execute("ZREMRANGEBYLEX", "key1", "[a", "[e"); + ClassicAssert.AreEqual(3, (int)removedCount); // Only "b", "c", and "d" should be removed + + // Verify remaining items in the sorted set + var remainingItems = db.SortedSetRangeByRank("key1"); + ClassicAssert.AreEqual(0, remainingItems.Length); // All items should be removed + } + + [Test] + public async Task ZRemRangeByRankWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREMRANGEBYRANK with expired items + var removedCount = db.Execute("ZREMRANGEBYRANK", "key1", 0, 1); + ClassicAssert.AreEqual(2, (int)removedCount); // Only "b" and "c" should be removed + + // Verify remaining items in the sorted set + var remainingItems = db.SortedSetRangeByRank("key1"); + ClassicAssert.AreEqual(1, remainingItems.Length); + CollectionAssert.AreEqual(new[] { "d" }, remainingItems.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRemRangeByScoreWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREMRANGEBYSCORE with expired items + var removedCount = db.Execute("ZREMRANGEBYSCORE", "key1", 1, 5); + ClassicAssert.AreEqual(3, (int)removedCount); // Only "b", "c", and "d" should be removed + + // Verify remaining items in the sorted set + var remainingItems = db.SortedSetRangeByRank("key1"); + ClassicAssert.AreEqual(0, remainingItems.Length); // All items should be removed + } + + [Test] + public async Task ZRevRangeWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREVRANGE with expired items + var result = db.Execute("ZREVRANGE", "key1", 0, -1); + var items = (RedisValue[])result; + ClassicAssert.AreEqual(3, items.Length); // Only "b", "c", and "d" should remain + CollectionAssert.AreEqual(new[] { "d", "c", "b" }, items.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRevRangeByLexWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 1); + db.SortedSetAdd("key1", "c", 1); + db.SortedSetAdd("key1", "d", 1); + db.SortedSetAdd("key1", "e", 1); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREVRANGEBYLEX with expired items + var result = db.Execute("ZREVRANGEBYLEX", "key1", "[e", "[a"); + var items = (RedisValue[])result; + ClassicAssert.AreEqual(3, items.Length); // Only "b", "c", and "d" should remain + CollectionAssert.AreEqual(new[] { "d", "c", "b" }, items.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRevRangeByScoreWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREVRANGEBYSCORE with expired items + var result = db.Execute("ZREVRANGEBYSCORE", "key1", 5, 1); + var items = (RedisValue[])result; + ClassicAssert.AreEqual(3, items.Length); // Only "b", "c", and "d" should remain + CollectionAssert.AreEqual(new[] { "d", "c", "b" }, items.Select(r => r.ToString()).ToList()); + } + + [Test] + public async Task ZRevRankWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + + await Task.Delay(300); + + // Perform ZREVRANK on expired and non-expired items + var result = db.Execute("ZREVRANK", "key1", "a"); + ClassicAssert.True(result.IsNull); // "a" should be expired + + result = db.Execute("ZREVRANK", "key1", "b"); + ClassicAssert.AreEqual(2, (int)result); // "b" should be at reverse rank 2 + } + + [Test] + public async Task ZScanWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + // Set expiration for some items + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "e"); + db.Execute("ZPEXPIRE", "key1", "1000", "MEMBERS", "1", "c"); + + await Task.Delay(300); + + // Perform ZSCAN + var result = db.Execute("ZSCAN", "key1", "0"); + var items = (RedisResult[])result; + var cursor = (long)items[0]; + var elements = (RedisValue[])items[1]; + + // Verify that expired items are not returned + ClassicAssert.AreEqual(6, elements.Length); // Only "b", "c", and "d" should remain + CollectionAssert.AreEqual(new[] { "b", "2", "c", "3", "d", "4" }, elements.Select(r => r.ToString()).ToList()); + ClassicAssert.AreEqual(0, cursor); // Ensure the cursor indicates the end of the collection + } + + [Test] + public async Task ZScoreWithExpiringAndExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted set + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + + // Set expiration for some items in key1 + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "1", "a"); + + await Task.Delay(10); + + // Check the score of an expiring item + var score = db.SortedSetScore("key1", "a"); + ClassicAssert.AreEqual(1, score); + + // Check the TTL of the expiring item + var ttl = db.Execute("ZPTTL", "key1", "MEMBERS", "1", "a"); + ClassicAssert.LessOrEqual((long)ttl, 200); + ClassicAssert.Greater((long)ttl, 0); + + await Task.Delay(200); + + // Check the item has expired + ttl = db.Execute("ZPTTL", "key1", "MEMBERS", "1", "a"); + ClassicAssert.AreEqual(-2, (long)ttl); + + // Check the score of an already expired item + score = db.SortedSetScore("key1", "a"); + ClassicAssert.IsNull(score); + + // Check the score of a non-expiring item + score = db.SortedSetScore("key1", "b"); + ClassicAssert.AreEqual(2, score); + } + + [Test] + public async Task ZUnionWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted sets + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.SortedSetAdd("key2", "a", 1); + db.SortedSetAdd("key2", "b", 2); + db.SortedSetAdd("key2", "c", 3); + + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "c"); + db.Execute("ZPEXPIRE", "key1", "500", "MEMBERS", "1", "b"); + db.Execute("ZPEXPIRE", "key2", "200", "MEMBERS", "1", "a"); + + var union = db.SortedSetCombine(SetOperation.Union, ["key1", "key2"]); + ClassicAssert.AreEqual(5, union.Length); + + await Task.Delay(300); + + var unionWithScores = db.SortedSetCombineWithScores(SetOperation.Union, ["key1", "key2"]); + ClassicAssert.AreEqual(4, unionWithScores.Length); + } + + [Test] + public async Task ZUnionStoreWithExpiredItems() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Add items to the sorted sets + db.SortedSetAdd("key1", "a", 1); + db.SortedSetAdd("key1", "b", 2); + db.SortedSetAdd("key1", "c", 3); + db.SortedSetAdd("key1", "d", 4); + db.SortedSetAdd("key1", "e", 5); + + db.SortedSetAdd("key2", "a", 1); + db.SortedSetAdd("key2", "b", 2); + db.SortedSetAdd("key2", "c", 3); + + db.Execute("ZPEXPIRE", "key1", "200", "MEMBERS", "2", "a", "c"); + db.Execute("ZPEXPIRE", "key1", "1000", "MEMBERS", "1", "b"); + db.Execute("ZPEXPIRE", "key2", "200", "MEMBERS", "1", "a"); + + var unionStoreCount = db.SortedSetCombineAndStore(SetOperation.Union, "key3", ["key1", "key2"]); + ClassicAssert.AreEqual(5, unionStoreCount); + var unionStoreResult = db.SortedSetRangeByRankWithScores("key3"); + ClassicAssert.AreEqual(5, unionStoreResult.Length); + + await Task.Delay(300); + + unionStoreCount = db.SortedSetCombineAndStore(SetOperation.Union, "key3", ["key1", "key2"]); + ClassicAssert.AreEqual(4, unionStoreCount); + unionStoreResult = db.SortedSetRangeByRankWithScores("key3"); + ClassicAssert.AreEqual(4, unionStoreResult.Length); + CollectionAssert.AreEquivalent(new[] { "b", "c", "d", "e" }, unionStoreResult.Select(x => x.Element.ToString())); + } + #endregion #region LightClientTests diff --git a/test/Garnet.test/TestProcedureSortedSets.cs b/test/Garnet.test/TestProcedureSortedSets.cs index 1a905a2699..c249d8ae91 100644 --- a/test/Garnet.test/TestProcedureSortedSets.cs +++ b/test/Garnet.test/TestProcedureSortedSets.cs @@ -114,6 +114,22 @@ private static bool TestAPI(TGarnetApi api, ref CustomProcedureInput if (status != GarnetStatus.OK || newScore != 12345) return false; + status = api.SortedSetExpire(ssA, [.. ssItems.Skip(4).Take(1).Select(x => x.member), ArgSlice.FromPinnedSpan(Encoding.UTF8.GetBytes("nonExist"))], DateTimeOffset.UtcNow.AddMinutes(10), ExpireOption.None, out var expireResults); + if (status != GarnetStatus.OK || expireResults.Length != 2 || expireResults[0] != 1 || expireResults[1] != -2) + return false; + + status = api.SortedSetTimeToLive(ssA, [.. ssItems.Skip(4).Take(1).Select(x => x.member), ArgSlice.FromPinnedSpan(Encoding.UTF8.GetBytes("nonExist"))], out var expireIn); + if (status != GarnetStatus.OK || expireIn.Length != 2 || expireIn[0].TotalMicroseconds == 0 || expireIn[1].TotalMicroseconds != 0) + return false; + + status = api.SortedSetPersist(ssA, [.. ssItems.Skip(4).Take(1).Select(x => x.member), ArgSlice.FromPinnedSpan(Encoding.UTF8.GetBytes("nonExist"))], out var persistResults); + if (status != GarnetStatus.OK || persistResults.Length != 2 || persistResults[0] != 1 || persistResults[1] != -2) + return false; + + status = api.SortedSetCollect([ssA]); + if (status != GarnetStatus.OK) + return false; + // Exercise SortedSetRemoveRangeByScore status = api.SortedSetRemoveRangeByScore(ssA, "12345", "12345", out var countRemoved); if (status != GarnetStatus.OK || countRemoved != 1) diff --git a/website/docs/commands/data-structures.md b/website/docs/commands/data-structures.md index 3992413920..5a59451023 100644 --- a/website/docs/commands/data-structures.md +++ b/website/docs/commands/data-structures.md @@ -1523,6 +1523,241 @@ Integer reply: the number of members in the resulting sorted set at destination. --- +### ZEXPIRE + +#### Syntax + +```bash + ZEXPIRE key seconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Sets a timeout on one or more members of a sorted set key. After the timeout has expired, the members will automatically be deleted. The timeout is specified in seconds. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZEXPIREAT + +#### Syntax + +```bash + ZEXPIREAT key unix-time-seconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Sets an absolute expiration time (Unix timestamp in seconds) for one or more sorted set members. After the timestamp has passed, the members will automatically be deleted. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZPEXPIRE + +#### Syntax + +```bash + ZPEXPIRE key milliseconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Similar to HEXPIRE but the timeout is specified in milliseconds instead of seconds. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZPEXPIREAT + +#### Syntax + +```bash + ZPEXPIREAT key unix-time-milliseconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Similar to HEXPIREAT but uses Unix timestamp in milliseconds instead of seconds. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZTTL + +#### Syntax + +```bash + ZTTL key MEMBERS nummembers member [member ...] +``` + +Returns the remaining time to live in seconds for one or more sorted set members that have a timeout set. + +#### Resp Reply + +Array reply: For each member, returns: + +* TTL in seconds if the member exists and has an expiry set +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZPTTL + +#### Syntax + +```bash + ZPTTL key MEMBERS nummembers member [member ...] +``` + +Similar to HTTL but returns the remaining time to live in milliseconds instead of seconds. + +#### Resp Reply + +Array reply: For each member, returns: + +* TTL in milliseconds if the member exists and has an expiry set +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZEXPIRETIME + +#### Syntax + +```bash + ZEXPIRETIME key MEMBERS nummembers member [member ...] +``` + +Returns the absolute Unix timestamp (in seconds) at which the specified sorted set members will expire. + +#### Resp Reply + +Array reply: For each member, returns: + +* Unix timestamp in seconds when the member will expire +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZPEXPIRETIME + +#### Syntax + +```bash + ZPEXPIRETIME key MEMBERS nummembers member [member ...] +``` + +Similar to HEXPIRETIME but returns the expiry timestamp in milliseconds instead of seconds. + +#### Resp Reply + +Array reply: For each member, returns: + +* Unix timestamp in milliseconds when the member will expire +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZPERSIST + +#### Syntax + +```bash + ZPERSIST key MEMBERS nummembers member [member ...] +``` + +Removes the expiration from the specified sorted set members, making them persistent. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was removed +* 0 if the member exists but has no timeout +* -1 if the member does not exist + +--- + +### ZCOLLECT + +#### Syntax + +```bash + ZCOLLECT key [key ...] +``` + +Manualy trigger cleanup of expired member from memory for a given Hash set key. + +Use `*` as the key to collect it from all sorted set keys. + +#### Resp Reply + +Simple reply: OK response +Error reply: ERR ZCOLLECT scan already in progress + +--- + ## Geospatial indices ### GEOADD diff --git a/website/docs/commands/garnet-specific.md b/website/docs/commands/garnet-specific.md index 699db52412..fed2353d53 100644 --- a/website/docs/commands/garnet-specific.md +++ b/website/docs/commands/garnet-specific.md @@ -61,6 +61,25 @@ Error reply: ERR HCOLLECT scan already in progress --- +### ZCOLLECT + +#### Syntax + +```bash + ZCOLLECT key [key ...] +``` + +Manualy trigger cleanup of expired member from memory for a given Hash set key. + +Use `*` as the key to collect it from all sorted set keys. + +#### Resp Reply + +Simple reply: OK response +Error reply: ERR ZCOLLECT scan already in progress + +--- + ### COSCAN #### Syntax @@ -277,3 +296,238 @@ Below is the expected behavior of ETag-associated key-value pairs when non-ETag All other commands will update the etag internally if they modify the underlying data, and any responses from them will not expose the etag to the client. To the users the etag and it's updates remain hidden in non-etag commands. --- + +### ZEXPIRE + +#### Syntax + +```bash + ZEXPIRE key seconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Sets a timeout on one or more members of a sorted set key. After the timeout has expired, the members will automatically be deleted. The timeout is specified in seconds. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZEXPIREAT + +#### Syntax + +```bash + ZEXPIREAT key unix-time-seconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Sets an absolute expiration time (Unix timestamp in seconds) for one or more sorted set members. After the timestamp has passed, the members will automatically be deleted. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZPEXPIRE + +#### Syntax + +```bash + ZPEXPIRE key milliseconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Similar to HEXPIRE but the timeout is specified in milliseconds instead of seconds. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZPEXPIREAT + +#### Syntax + +```bash + ZPEXPIREAT key unix-time-milliseconds [NX | XX | GT | LT] MEMBERS nummembers member [member ...] +``` + +Similar to HEXPIREAT but uses Unix timestamp in milliseconds instead of seconds. + +The command supports several options to control when the expiration should be set: + +* **NX:** Only set expiry on members that have no existing expiry +* **XX:** Only set expiry on members that already have an expiry set +* **GT:** Only set expiry when it's greater than the current expiry +* **LT:** Only set expiry when it's less than the current expiry + +The **NX**, **XX**, **GT**, and **LT** options are mutually exclusive. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was set +* 0 if the member doesn't exist +* -1 if timeout was not set due to condition not being met + +--- + +### ZTTL + +#### Syntax + +```bash + ZTTL key MEMBERS nummembers member [member ...] +``` + +Returns the remaining time to live in seconds for one or more sorted set members that have a timeout set. + +#### Resp Reply + +Array reply: For each member, returns: + +* TTL in seconds if the member exists and has an expiry set +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZPTTL + +#### Syntax + +```bash + ZPTTL key MEMBERS nummembers member [member ...] +``` + +Similar to HTTL but returns the remaining time to live in milliseconds instead of seconds. + +#### Resp Reply + +Array reply: For each member, returns: + +* TTL in milliseconds if the member exists and has an expiry set +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZEXPIRETIME + +#### Syntax + +```bash + ZEXPIRETIME key MEMBERS nummembers member [member ...] +``` + +Returns the absolute Unix timestamp (in seconds) at which the specified sorted set members will expire. + +#### Resp Reply + +Array reply: For each member, returns: + +* Unix timestamp in seconds when the member will expire +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZPEXPIRETIME + +#### Syntax + +```bash + ZPEXPIRETIME key MEMBERS nummembers member [member ...] +``` + +Similar to HEXPIRETIME but returns the expiry timestamp in milliseconds instead of seconds. + +#### Resp Reply + +Array reply: For each member, returns: + +* Unix timestamp in milliseconds when the member will expire +* -1 if the member exists but has no expiry set +* -2 if the member does not exist + +--- + +### ZPERSIST + +#### Syntax + +```bash + ZPERSIST key MEMBERS nummembers member [member ...] +``` + +Removes the expiration from the specified sorted set members, making them persistent. + +#### Resp Reply + +Array reply: For each member, returns: + +* 1 if the timeout was removed +* 0 if the member exists but has no timeout +* -1 if the member does not exist + +--- + +### ZCOLLECT + +#### Syntax + +```bash + ZCOLLECT key [key ...] +``` + +Manualy trigger cleanup of expired member from memory for a given Hash set key. + +Use `*` as the key to collect it from all sorted set keys. + +#### Resp Reply + +Simple reply: OK response +Error reply: ERR ZCOLLECT scan already in progress + +--- \ No newline at end of file diff --git a/website/docs/getting-started/configuration.md b/website/docs/getting-started/configuration.md index ac0d130154..6a6cc4951c 100644 --- a/website/docs/getting-started/configuration.md +++ b/website/docs/getting-started/configuration.md @@ -119,7 +119,7 @@ For all available command line settings, run `GarnetServer.exe -h` or `GarnetSer | **WaitForCommit** | ```--aof-commit-wait``` | ```bool``` | | Wait for AOF to flush the commit before returning results to client. Warning: will greatly increase operation latency. | | **AofSizeLimit** | ```--aof-size-limit``` | ```string``` | Memory size | Maximum size of AOF (rounds down to power of 2) after which unsafe truncation will be applied. Left empty AOF will grow without bound unless a checkpoint is taken | | **CompactionFrequencySecs** | ```--compaction-freq``` | ```int``` | Integer in range:
[0, MaxValue] | Background hybrid log compaction frequency in seconds. 0 = disabled (compaction performed before checkpointing instead) | -| **HashCollectFrequencySecs** | ```--hcollect-freq``` | ```int``` | Integer in range:
[0, MaxValue] | Frequency in seconds for the background task to perform Hash collection. 0 = disabled. Hash collect is used to delete expired fields from hash without waiting for a write operation. Use the HCOLLECT API to collect on-demand. | +| **ExpiredObjectCollectionFrequencySecs** | ```--expired-object-collection-freq``` | ```int``` | Integer in range:
[0, MaxValue] | Frequency in seconds for the background task to perform object collection which removes expired members within object from memory. 0 = disabled. Use the HCOLLECT and ZCOLLECT API to collect on-demand. | | **CompactionType** | ```--compaction-type``` | ```LogCompactionType``` | None, Shift, Scan, Lookup | Hybrid log compaction type. Value options: None - No compaction, Shift - shift begin address without compaction (data loss), Scan - scan old pages and move live records to tail (no data loss), Lookup - lookup each record in compaction range, for record liveness checking using hash chain (no data loss) | | **CompactionForceDelete** | ```--compaction-force-delete``` | ```bool``` | | Forcefully delete the inactive segments immediately after the compaction strategy (type) is applied. If false, take a checkpoint to actually delete the older data files from disk. | | **CompactionMaxSegments** | ```--compaction-max-segments``` | ```int``` | Integer in range:
[0, MaxValue] | Number of log segments created on disk before compaction triggers. |