From 748ec9a208ad0ee1a220be6b1fd5573784783396 Mon Sep 17 00:00:00 2001 From: Vijay Nirmal Date: Sat, 5 Oct 2024 00:17:55 +0530 Subject: [PATCH] [Compatibility] Added EXPIREAT and PEXPIREAT command and bug fixes for EXPIRE and PEXPIRE (#666) * Added EXPIREAT and PEXPIREAT command and bug fixes for EXPIRE and PEXPIRE * Code style fix * Review comment fixes * Fixed merge conflict and fixed review comments * Changed to _unixEpochTicks --- libs/common/ConvertUtils.cs | 29 +- libs/server/API/GarnetApi.cs | 12 + libs/server/API/IGarnetApi.cs | 26 + libs/server/ExpireOption.cs | 21 +- libs/server/Resp/KeyAdminCommands.cs | 118 +++- libs/server/Resp/Parser/RespCommand.cs | 10 + libs/server/Resp/RespCommandsInfo.json | 58 ++ libs/server/Resp/RespServerSession.cs | 3 + .../Functions/MainStore/PrivateMethods.cs | 12 +- .../Functions/ObjectStore/PrivateMethods.cs | 6 +- .../Storage/Session/MainStore/MainStoreOps.cs | 51 +- .../CommandInfoUpdater/SupportedCommand.cs | 2 + test/Garnet.test/Resp/ACL/RespCommandTests.cs | 92 ++- test/Garnet.test/RespTests.cs | 587 ++++++++++++++++++ website/docs/commands/api-compatibility.md | 4 +- website/docs/commands/generic-commands.md | 57 ++ 16 files changed, 1070 insertions(+), 18 deletions(-) diff --git a/libs/common/ConvertUtils.cs b/libs/common/ConvertUtils.cs index 3228d49e55..89ef9ec763 100644 --- a/libs/common/ConvertUtils.cs +++ b/libs/common/ConvertUtils.cs @@ -2,7 +2,7 @@ // Licensed under the MIT license. using System; -using System.Diagnostics; +using System.Runtime.CompilerServices; namespace Garnet.common { @@ -11,6 +11,11 @@ namespace Garnet.common /// public static class ConvertUtils { + /// + /// Contains the number of ticks representing 1970/1/1. Value is equal to new DateTime(1970, 1, 1).Ticks + /// + private static readonly long _unixEpochTicks = DateTimeOffset.UnixEpoch.Ticks; + /// /// Convert diff ticks - utcNow.ticks to seconds. /// @@ -43,5 +48,27 @@ public static long MillisecondsFromDiffUtcNowTicks(long ticks) } return milliseconds; } + + /// + /// Converts a Unix timestamp in seconds to ticks. + /// + /// The Unix timestamp in seconds. + /// The equivalent number of ticks. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long UnixTimestampInSecondsToTicks(long unixTimestamp) + { + return unixTimestamp * TimeSpan.TicksPerSecond + _unixEpochTicks; + } + + /// + /// Converts a Unix timestamp in milliseconds to ticks. + /// + /// The Unix timestamp in milliseconds. + /// The equivalent number of ticks. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long UnixTimestampInMillisecondsToTicks(long unixTimestamp) + { + return unixTimestamp * TimeSpan.TicksPerMillisecond + _unixEpochTicks; + } } } \ No newline at end of file diff --git a/libs/server/API/GarnetApi.cs b/libs/server/API/GarnetApi.cs index 2217e662db..da6f90165c 100644 --- a/libs/server/API/GarnetApi.cs +++ b/libs/server/API/GarnetApi.cs @@ -181,6 +181,18 @@ public GarnetStatus PEXPIRE(ArgSlice key, TimeSpan expiry, out bool timeoutSet, #endregion + #region EXPIREAT + + /// + public GarnetStatus EXPIREAT(ArgSlice key, long expiryTimestamp, out bool timeoutSet, StoreType storeType = StoreType.All, ExpireOption expireOption = ExpireOption.None) + => storageSession.EXPIREAT(key, expiryTimestamp, out timeoutSet, storeType, expireOption, ref context, ref objectContext); + + /// + public GarnetStatus PEXPIREAT(ArgSlice key, long expiryTimestamp, out bool timeoutSet, StoreType storeType = StoreType.All, ExpireOption expireOption = ExpireOption.None) + => storageSession.EXPIREAT(key, expiryTimestamp, out timeoutSet, storeType, expireOption, ref context, ref objectContext, milliseconds: true); + + #endregion + #region PERSIST /// public unsafe GarnetStatus PERSIST(ArgSlice key, StoreType storeType = StoreType.All) diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 7527d35865..5b5f2997c8 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -176,6 +176,32 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi #endregion + #region EXPIREAT + + /// + /// Set a timeout on key using absolute Unix timestamp (seconds since January 1, 1970) in seconds + /// + /// Key + /// Absolute Unix timestamp in seconds + /// Whether timeout was set by the call + /// Store type: main, object, or both + /// Expire option + /// + GarnetStatus EXPIREAT(ArgSlice key, long expiryTimestamp, out bool timeoutSet, StoreType storeType = StoreType.All, ExpireOption expireOption = ExpireOption.None); + + /// + /// Set a timeout on key using absolute Unix timestamp (seconds since January 1, 1970) in milliseconds + /// + /// Key + /// Absolute Unix timestamp in milliseconds + /// Whether timeout was set by the call + /// Store type: main, object, or both + /// Expire option + /// + GarnetStatus PEXPIREAT(ArgSlice key, long expiryTimestamp, out bool timeoutSet, StoreType storeType = StoreType.All, ExpireOption expireOption = ExpireOption.None); + + #endregion + #region PERSIST /// /// PERSIST diff --git a/libs/server/ExpireOption.cs b/libs/server/ExpireOption.cs index d6eb7ef8f7..65b306e342 100644 --- a/libs/server/ExpireOption.cs +++ b/libs/server/ExpireOption.cs @@ -1,32 +1,43 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +using System; + namespace Garnet.server { /// /// Expire option /// + [Flags] public enum ExpireOption : byte { /// /// None /// - None, + None = 0, /// /// Set expiry only when the key has no expiry /// - NX, + NX = 1 << 0, /// /// Set expiry only when the key has an existing expiry /// - XX, + XX = 1 << 1, /// /// Set expiry only when the new expiry is greater than current one /// - GT, + GT = 1 << 2, /// /// Set expiry only when the new expiry is less than current one /// - LT + LT = 1 << 3, + /// + /// Set expiry only when the key has an existing expiry and the new expiry is greater than current one + /// + XXGT = XX | GT, + /// + /// Set expiry only when the key has an existing expiry and the new expiry is less than current one + /// + XXLT = XX | LT, } } \ No newline at end of file diff --git a/libs/server/Resp/KeyAdminCommands.cs b/libs/server/Resp/KeyAdminCommands.cs index 97ce43ae62..029e2ca16b 100644 --- a/libs/server/Resp/KeyAdminCommands.cs +++ b/libs/server/Resp/KeyAdminCommands.cs @@ -174,7 +174,7 @@ private bool NetworkEXPIRE(RespCommand command, ref TGarnetApi stora where TGarnetApi : IGarnetApi { var count = parseState.Count; - if (count < 2 || count > 3) + if (count < 2 || count > 4) { return AbortWithWrongNumberOfArguments(nameof(RespCommand.EXPIRE)); } @@ -205,6 +205,36 @@ private bool NetworkEXPIRE(RespCommand command, ref TGarnetApi stora } } + if (parseState.Count > 3) + { + if (!TryGetExpireOption(parseState.GetArgSliceByRef(3).ReadOnlySpan, out var additionExpireOption)) + { + var optionStr = parseState.GetString(3); + + while (!RespWriteUtils.WriteError($"ERR Unsupported option {optionStr}", ref dcurr, dend)) + SendAndReset(); + return true; + } + + if (expireOption == ExpireOption.XX && (additionExpireOption == ExpireOption.GT || additionExpireOption == ExpireOption.LT)) + { + expireOption = ExpireOption.XX | additionExpireOption; + } + else if (expireOption == ExpireOption.GT && additionExpireOption == ExpireOption.XX) + { + expireOption = ExpireOption.XXGT; + } + else if (expireOption == ExpireOption.LT && additionExpireOption == ExpireOption.XX) + { + expireOption = ExpireOption.XXLT; + } + else + { + while (!RespWriteUtils.WriteError("ERR NX and XX, GT or LT options at the same time are not compatible", ref dcurr, dend)) + SendAndReset(); + } + } + var status = command == RespCommand.EXPIRE ? storageApi.EXPIRE(key, expiryMs, out var timeoutSet, StoreType.All, expireOption) : storageApi.PEXPIRE(key, expiryMs, out timeoutSet, StoreType.All, expireOption); @@ -223,6 +253,92 @@ private bool NetworkEXPIRE(RespCommand command, ref TGarnetApi stora return true; } + /// + /// Set a timeout on a key based on unix timestamp + /// + /// + /// Indicates which command to use, expire or pexpire. + /// + /// + private bool NetworkEXPIREAT(RespCommand command, ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + var count = parseState.Count; + if (count < 2 || count > 4) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.EXPIREAT)); + } + + var key = parseState.GetArgSliceByRef(0); + if (!parseState.TryGetLong(1, out var expiryTimestamp)) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER, ref dcurr, dend)) + SendAndReset(); + return true; + } + + var expireOption = ExpireOption.None; + + if (parseState.Count > 2) + { + if (!TryGetExpireOption(parseState.GetArgSliceByRef(2).ReadOnlySpan, out expireOption)) + { + var optionStr = parseState.GetString(2); + + while (!RespWriteUtils.WriteError($"ERR Unsupported option {optionStr}", ref dcurr, dend)) + SendAndReset(); + return true; + } + } + + if (parseState.Count > 3) + { + if (!TryGetExpireOption(parseState.GetArgSliceByRef(3).ReadOnlySpan, out var additionExpireOption)) + { + var optionStr = parseState.GetString(3); + + while (!RespWriteUtils.WriteError($"ERR Unsupported option {optionStr}", ref dcurr, dend)) + SendAndReset(); + return true; + } + + if (expireOption == ExpireOption.XX && (additionExpireOption == ExpireOption.GT || additionExpireOption == ExpireOption.LT)) + { + expireOption = ExpireOption.XX | additionExpireOption; + } + else if (expireOption == ExpireOption.GT && additionExpireOption == ExpireOption.XX) + { + expireOption = ExpireOption.XXGT; + } + else if (expireOption == ExpireOption.LT && additionExpireOption == ExpireOption.XX) + { + expireOption = ExpireOption.XXLT; + } + else + { + while (!RespWriteUtils.WriteError("ERR NX and XX, GT or LT options at the same time are not compatible", ref dcurr, dend)) + SendAndReset(); + } + } + + var status = command == RespCommand.EXPIREAT ? + storageApi.EXPIREAT(key, expiryTimestamp, out var timeoutSet, StoreType.All, expireOption) : + storageApi.PEXPIREAT(key, expiryTimestamp, out timeoutSet, StoreType.All, expireOption); + + if (status == GarnetStatus.OK && timeoutSet) + { + while (!RespWriteUtils.WriteDirect(CmdStrings.RESP_RETURN_VAL_1, ref dcurr, dend)) + SendAndReset(); + } + else + { + while (!RespWriteUtils.WriteDirect(CmdStrings.RESP_RETURN_VAL_0, ref dcurr, dend)) + SendAndReset(); + } + + return true; + } + /// /// PERSIST command /// diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index ab36128081..efd8cf3df6 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -85,6 +85,7 @@ public enum RespCommand : byte DECRBY, DEL, EXPIRE, + EXPIREAT, FLUSHALL, FLUSHDB, GEOADD, @@ -114,6 +115,7 @@ public enum RespCommand : byte MSETNX, PERSIST, PEXPIRE, + PEXPIREAT, PFADD, PFMERGE, PSETEX, @@ -1252,6 +1254,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.BITFIELD; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("EXPIREAT"u8) && *(ushort*)(ptr + 12) == MemoryMarshal.Read("\r\n"u8)) + { + return RespCommand.EXPIREAT; + } break; case 9: if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("SUBSCRIB"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("BE\r\n"u8)) @@ -1278,6 +1284,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.RPOPLPUSH; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("PEXPIREA"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("AT\r\n"u8)) + { + return RespCommand.PEXPIREAT; + } break; } diff --git a/libs/server/Resp/RespCommandsInfo.json b/libs/server/Resp/RespCommandsInfo.json index aed450d1d1..b6c5265760 100644 --- a/libs/server/Resp/RespCommandsInfo.json +++ b/libs/server/Resp/RespCommandsInfo.json @@ -1594,6 +1594,35 @@ ], "SubCommands": null }, + { + "Command": "EXPIREAT", + "Name": "EXPIREAT", + "IsInternal": false, + "Arity": -3, + "Flags": "Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, KeySpace, Write", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RW, Update" + } + ], + "SubCommands": null + }, { "Command": "FAILOVER", "Name": "FAILOVER", @@ -3283,6 +3312,35 @@ ], "SubCommands": null }, + { + "Command": "PEXPIREAT", + "Name": "PEXPIREAT", + "IsInternal": false, + "Arity": -3, + "Flags": "Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Fast, KeySpace, Write", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RW, Update" + } + ], + "SubCommands": null + }, { "Command": "PFADD", "Name": "PFADD", diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index d611922a88..848a0edb3d 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -543,6 +543,9 @@ private bool ProcessBasicCommands(RespCommand cmd, ref TGarnetApi st RespCommand.RUNTXP => NetworkRUNTXP(), RespCommand.READONLY => NetworkREADONLY(), RespCommand.READWRITE => NetworkREADWRITE(), + RespCommand.EXPIREAT => NetworkEXPIREAT(RespCommand.EXPIREAT, ref storageApi), + RespCommand.PEXPIREAT => NetworkEXPIREAT(RespCommand.PEXPIREAT, ref storageApi), + _ => ProcessArrayCommands(cmd, ref storageApi) }; diff --git a/libs/server/Storage/Functions/MainStore/PrivateMethods.cs b/libs/server/Storage/Functions/MainStore/PrivateMethods.cs index c24563ef50..193a125f43 100644 --- a/libs/server/Storage/Functions/MainStore/PrivateMethods.cs +++ b/libs/server/Storage/Functions/MainStore/PrivateMethods.cs @@ -238,6 +238,7 @@ bool EvaluateExpireInPlace(ExpireOption optionType, bool expiryExists, ref SpanB o->result1 = 1; break; case ExpireOption.GT: + case ExpireOption.XXGT: bool replace = input.ExtraMetadata < value.ExtraMetadata; value.ExtraMetadata = replace ? value.ExtraMetadata : input.ExtraMetadata; if (replace) @@ -246,6 +247,7 @@ bool EvaluateExpireInPlace(ExpireOption optionType, bool expiryExists, ref SpanB o->result1 = 1; break; case ExpireOption.LT: + case ExpireOption.XXLT: replace = input.ExtraMetadata > value.ExtraMetadata; value.ExtraMetadata = replace ? value.ExtraMetadata : input.ExtraMetadata; if (replace) @@ -264,10 +266,12 @@ bool EvaluateExpireInPlace(ExpireOption optionType, bool expiryExists, ref SpanB { case ExpireOption.NX: case ExpireOption.None: + case ExpireOption.LT: // If expiry doesn't exist, LT should treat the current expiration as infinite return false; case ExpireOption.XX: case ExpireOption.GT: - case ExpireOption.LT: + case ExpireOption.XXGT: + case ExpireOption.XXLT: o->result1 = 0; return true; default: @@ -293,6 +297,7 @@ void EvaluateExpireCopyUpdate(ExpireOption optionType, bool expiryExists, ref Sp o->result1 = 1; break; case ExpireOption.GT: + case ExpireOption.XXGT: oldValue.AsReadOnlySpan().CopyTo(newValue.AsSpan()); bool replace = input.ExtraMetadata < oldValue.ExtraMetadata; newValue.ExtraMetadata = replace ? oldValue.ExtraMetadata : input.ExtraMetadata; @@ -302,6 +307,7 @@ void EvaluateExpireCopyUpdate(ExpireOption optionType, bool expiryExists, ref Sp o->result1 = 1; break; case ExpireOption.LT: + case ExpireOption.XXLT: oldValue.AsReadOnlySpan().CopyTo(newValue.AsSpan()); replace = input.ExtraMetadata > oldValue.ExtraMetadata; newValue.ExtraMetadata = replace ? oldValue.ExtraMetadata : input.ExtraMetadata; @@ -318,13 +324,15 @@ void EvaluateExpireCopyUpdate(ExpireOption optionType, bool expiryExists, ref Sp { case ExpireOption.NX: case ExpireOption.None: + case ExpireOption.LT: // If expiry doesn't exist, LT should treat the current expiration as infinite newValue.ExtraMetadata = input.ExtraMetadata; oldValue.AsReadOnlySpan().CopyTo(newValue.AsSpan()); o->result1 = 1; break; case ExpireOption.XX: case ExpireOption.GT: - case ExpireOption.LT: + case ExpireOption.XXGT: + case ExpireOption.XXLT: oldValue.AsReadOnlySpan().CopyTo(newValue.AsSpan()); o->result1 = 0; break; diff --git a/libs/server/Storage/Functions/ObjectStore/PrivateMethods.cs b/libs/server/Storage/Functions/ObjectStore/PrivateMethods.cs index ef5c82cc80..8a96900757 100644 --- a/libs/server/Storage/Functions/ObjectStore/PrivateMethods.cs +++ b/libs/server/Storage/Functions/ObjectStore/PrivateMethods.cs @@ -150,6 +150,7 @@ static bool EvaluateObjectExpireInPlace(ExpireOption optionType, bool expiryExis o->result1 = 1; break; case ExpireOption.GT: + case ExpireOption.XXGT: bool replace = expiration < value.Expiration; value.Expiration = replace ? value.Expiration : expiration; if (replace) @@ -158,6 +159,7 @@ static bool EvaluateObjectExpireInPlace(ExpireOption optionType, bool expiryExis o->result1 = 1; break; case ExpireOption.LT: + case ExpireOption.XXLT: replace = expiration > value.Expiration; value.Expiration = replace ? value.Expiration : expiration; if (replace) @@ -175,12 +177,14 @@ static bool EvaluateObjectExpireInPlace(ExpireOption optionType, bool expiryExis { case ExpireOption.NX: case ExpireOption.None: + case ExpireOption.LT: // If expiry doesn't exist, LT should treat the current expiration as infinite value.Expiration = expiration; o->result1 = 1; break; case ExpireOption.XX: case ExpireOption.GT: - case ExpireOption.LT: + case ExpireOption.XXGT: + case ExpireOption.XXLT: o->result1 = 0; break; default: diff --git a/libs/server/Storage/Session/MainStore/MainStoreOps.cs b/libs/server/Storage/Session/MainStore/MainStoreOps.cs index eed91fe0a0..12637006e5 100644 --- a/libs/server/Storage/Session/MainStore/MainStoreOps.cs +++ b/libs/server/Storage/Session/MainStore/MainStoreOps.cs @@ -788,20 +788,63 @@ public unsafe GarnetStatus EXPIRE(ArgSlice key, ArgSli /// Basic context for the main store /// Object context for the object store /// When true the command executed is PEXPIRE, expire by default. - /// + /// Return GarnetStatus.OK when key found, else GarnetStatus.NOTFOUND public unsafe GarnetStatus EXPIRE(ArgSlice key, TimeSpan expiry, out bool timeoutSet, StoreType storeType, ExpireOption expireOption, ref TContext context, ref TObjectContext objectStoreContext, bool milliseconds = false) where TContext : ITsavoriteContext where TObjectContext : ITsavoriteContext + { + return EXPIRE(key, DateTimeOffset.UtcNow.Ticks + expiry.Ticks, out timeoutSet, storeType, expireOption, ref context, ref objectStoreContext, milliseconds ? RespCommand.PEXPIRE : RespCommand.EXPIRE); + } + + /// + /// Set a timeout on key using absolute Unix timestamp (seconds since January 1, 1970). + /// + /// + /// + /// The key to set the timeout on. + /// Absolute Unix timestamp + /// True when the timeout was properly set. + /// The store to operate on. + /// Flags to use for the operation. + /// Basic context for the main store + /// Object context for the object store + /// When true, is treated as milliseconds else seconds + /// Return GarnetStatus.OK when key found, else GarnetStatus.NOTFOUND + public unsafe GarnetStatus EXPIREAT(ArgSlice key, long expiryTimestamp, out bool timeoutSet, StoreType storeType, ExpireOption expireOption, ref TContext context, ref TObjectContext objectStoreContext, bool milliseconds = false) + where TContext : ITsavoriteContext + where TObjectContext : ITsavoriteContext + { + var expiryTimestampTicks = milliseconds ? ConvertUtils.UnixTimestampInMillisecondsToTicks(expiryTimestamp) : ConvertUtils.UnixTimestampInSecondsToTicks(expiryTimestamp); + return EXPIRE(key, expiryTimestampTicks, out timeoutSet, storeType, expireOption, ref context, ref objectStoreContext, milliseconds ? RespCommand.PEXPIRE : RespCommand.EXPIRE); + } + + /// + /// Set a timeout on key using ticks. + /// + /// + /// + /// The key to set the timeout on. + /// The timestamp in ticks + /// True when the timeout was properly set. + /// The store to operate on. + /// Flags to use for the operation. + /// Basic context for the main store + /// Object context for the object store + /// Resp Command to be executed. + /// Return GarnetStatus.OK when key found, else GarnetStatus.NOTFOUND + private unsafe GarnetStatus EXPIRE(ArgSlice key, long expiryInTicks, out bool timeoutSet, StoreType storeType, ExpireOption expireOption, ref TContext context, ref TObjectContext objectStoreContext, RespCommand respCommand) + where TContext : ITsavoriteContext + where TObjectContext : ITsavoriteContext { byte* pbCmdInput = stackalloc byte[sizeof(int) + sizeof(long) + RespInputHeader.Size + sizeof(byte)]; *(int*)pbCmdInput = sizeof(long) + RespInputHeader.Size; - ((RespInputHeader*)(pbCmdInput + sizeof(int) + sizeof(long)))->cmd = milliseconds ? RespCommand.PEXPIRE : RespCommand.EXPIRE; + ((RespInputHeader*)(pbCmdInput + sizeof(int) + sizeof(long)))->cmd = respCommand; ((RespInputHeader*)(pbCmdInput + sizeof(int) + sizeof(long)))->flags = 0; *(pbCmdInput + sizeof(int) + sizeof(long) + RespInputHeader.Size) = (byte)expireOption; ref var input = ref SpanByte.Reinterpret(pbCmdInput); - input.ExtraMetadata = DateTimeOffset.UtcNow.Ticks + expiry.Ticks; + input.ExtraMetadata = expiryInTicks; var rmwOutput = stackalloc byte[ObjectOutputHeader.Size]; var output = new SpanByteAndMemory(SpanByte.FromPinnedPointer(rmwOutput, ObjectOutputHeader.Size)); @@ -843,7 +886,7 @@ public unsafe GarnetStatus EXPIRE(ArgSlice key, TimeSp { header = new RespInputHeader { - cmd = milliseconds ? RespCommand.PEXPIRE : RespCommand.EXPIRE, + cmd = respCommand, type = GarnetObjectType.Expire, }, parseState = parseState, diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index 1e00233072..48176aabc5 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -111,6 +111,7 @@ public class SupportedCommand new("EXEC", RespCommand.EXEC), new("EXISTS", RespCommand.EXISTS), new("EXPIRE", RespCommand.EXPIRE), + new("EXPIREAT", RespCommand.EXPIREAT), new("FAILOVER", RespCommand.FAILOVER), new("FLUSHALL", RespCommand.FLUSHALL), new("FLUSHDB", RespCommand.FLUSHDB), @@ -182,6 +183,7 @@ public class SupportedCommand new("MULTI", RespCommand.MULTI), new("PERSIST", RespCommand.PERSIST), new("PEXPIRE", RespCommand.PEXPIRE), + new("PEXPIREAT", RespCommand.PEXPIREAT), new("PFADD", RespCommand.PFADD), new("PFCOUNT", RespCommand.PFCOUNT), new("PFMERGE", RespCommand.PFMERGE), diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs index 8c755b0074..758ad68d30 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -2604,8 +2604,6 @@ static async Task DoExistsMultiAsync(GarnetClient client) [Test] public async Task ExpireACLsAsync() { - // TODO: expire doesn't support combinations of flags (XX GT, XX LT are legal) so those will need to be tested when implemented - await CheckCommandsAsync( "EXPIRE", [DoExpireAsync, DoExpireNXAsync, DoExpireXXAsync, DoExpireGTAsync, DoExpireLTAsync] @@ -2642,6 +2640,96 @@ static async Task DoExpireLTAsync(GarnetClient client) } } + [Test] + public async Task ExpireAtACLsAsync() + { + await CheckCommandsAsync( + "EXPIREAT", + [DoExpireAsync, DoExpireNXAsync, DoExpireXXAsync, DoExpireGTAsync, DoExpireLTAsync] + ); + + + static async Task DoExpireAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("EXPIREAT", ["foo", expireTimestamp]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireNXAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("EXPIREAT", ["foo", "10", "NX"]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireXXAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("EXPIREAT", ["foo", "10", "XX"]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireGTAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("EXPIREAT", ["foo", "10", "GT"]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireLTAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("EXPIREAT", ["foo", "10", "LT"]); + ClassicAssert.AreEqual(0, val); + } + } + + [Test] + public async Task PExpireAtACLsAsync() + { + await CheckCommandsAsync( + "PEXPIREAT", + [DoExpireAsync, DoExpireNXAsync, DoExpireXXAsync, DoExpireGTAsync, DoExpireLTAsync] + ); + + + static async Task DoExpireAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds().ToString(); + long val = await client.ExecuteForLongResultAsync("PEXPIREAT", ["foo", expireTimestamp]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireNXAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("PEXPIREAT", ["foo", "10", "NX"]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireXXAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("PEXPIREAT", ["foo", "10", "XX"]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireGTAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("PEXPIREAT", ["foo", "10", "GT"]); + ClassicAssert.AreEqual(0, val); + } + + static async Task DoExpireLTAsync(GarnetClient client) + { + var expireTimestamp = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString(); + long val = await client.ExecuteForLongResultAsync("PEXPIREAT", ["foo", "10", "LT"]); + ClassicAssert.AreEqual(0, val); + } + } + [Test] public async Task FailoverACLsAsync() { diff --git a/test/Garnet.test/RespTests.cs b/test/Garnet.test/RespTests.cs index ac5a60fff8..3c5aba514a 100644 --- a/test/Garnet.test/RespTests.cs +++ b/test/Garnet.test/RespTests.cs @@ -1912,6 +1912,593 @@ public void KeyExpireBadOptionTests(string command) } } + #region ExpireAt + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithStringAndObject(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + } + else + { + db.StringSet(key, "valueA"); + } + + var actualResult = (int)db.Execute(command, "key", expireTimeUnix); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT")] + [TestCase("PEXPIREAT")] + public void KeyExpireAtWithUnknownKey(string command) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix); + ClassicAssert.AreEqual(actualResult, 0); + } + + [Test] + [TestCase("EXPIREAT")] + [TestCase("PEXPIREAT")] + public void KeyExpireAtWithoutArgs(string command) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + + Assert.Throws(() => db.Execute(command, key)); + } + + [Test] + [TestCase("EXPIREAT")] + [TestCase("PEXPIREAT")] + public void KeyExpireAtWithUnknownArgs(string command) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + Assert.Throws(() => db.Execute(command, key, expireTimeUnix, "YY")); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithNxOptionAndKeyHasExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(1); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(10); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "Nx"); + ClassicAssert.AreEqual(actualResult, 0); + + // Test if the existing expiry time is still the same + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithNxOptionAndKeyHasNoExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var expireTimeSpan = TimeSpan.FromMinutes(10); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + } + else + { + db.StringSet(key, "valueA"); + } + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "nX"); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxOptionAndKeyHasExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(1); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(10); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "Xx"); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxOptionAndKeyHasNoExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var expireTimeSpan = TimeSpan.FromMinutes(10); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + } + else + { + db.StringSet(key, "valueA"); + } + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "xX"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsFalse(actualTtl.HasValue); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithGtOptionAndExistingKeyHasSmallerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(1); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(10); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "gT"); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithGtOptionAndExistingKeyHasLargerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(10); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "Gt"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithGtOptionAndExistingKeyNoExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + } + else + { + db.StringSet(key, "valueA"); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "GT"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsFalse(actualTtl.HasValue); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxAndGtOptionAndExistingKeyHasSmallerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(1); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(10); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "xx", "GT"); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxAndGtOptionAndExistingKeyHasLargerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(10); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "gt", "XX"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxAndGtOptionAndExistingKeyNoExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + } + else + { + db.StringSet(key, "valueA"); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "Gt", "xX"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsFalse(actualTtl.HasValue); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithLtOptionAndExistingKeyHasSmallerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(1); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(10); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "lT"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithLtOptionAndExistingKeyHasLargerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(10); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "LT"); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithLtOptionAndExistingKeyNoExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + } + else + { + db.StringSet(key, "valueA"); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "LT"); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxAndLtOptionAndExistingKeyHasSmallerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(1); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(10); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "LT", "XX"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, existingExpireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxAndLtOptionAndExistingKeyHasLargerExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + var existingExpireTimeSpan = TimeSpan.FromMinutes(10); + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + db.KeyExpire(key, existingExpireTimeSpan); + } + else + { + db.StringSet(key, "valueA", existingExpireTimeSpan); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "xX", "Lt"); + ClassicAssert.AreEqual(actualResult, 1); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(actualTtl.HasValue); + ClassicAssert.Greater(actualTtl.Value.TotalMilliseconds, 0); + ClassicAssert.LessOrEqual(actualTtl.Value.TotalMilliseconds, expireTimeSpan.TotalMilliseconds); + } + + [Test] + [TestCase("EXPIREAT", false)] + [TestCase("EXPIREAT", true)] + [TestCase("PEXPIREAT", false)] + [TestCase("PEXPIREAT", true)] + public void KeyExpireAtWithXxAndLtOptionAndExistingKeyNoExpire(string command, bool isObject) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + if (isObject) + { + db.SortedSetAdd(key, [new SortedSetEntry("element", 1.0)]); + } + else + { + db.StringSet(key, "valueA"); + } + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + var actualResult = (int)db.Execute(command, key, expireTimeUnix, "XX", "LT"); + ClassicAssert.AreEqual(actualResult, 0); + + var actualTtl = db.KeyTimeToLive(key); + ClassicAssert.IsFalse(actualTtl.HasValue); + } + + [Test] + [TestCase("EXPIREAT", "XX", "NX")] + [TestCase("EXPIREAT", "NX", "GT")] + [TestCase("EXPIREAT", "LT", "NX")] + [TestCase("PEXPIREAT", "XX", "NX")] + [TestCase("PEXPIREAT", "NX", "GT")] + [TestCase("PEXPIREAT", "LT", "NX")] + public void KeyExpireAtWithInvalidOptionCombination(string command, string optionA, string optionB) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + var key = "key"; + db.StringSet(key, "valueA"); + var expireTimeSpan = TimeSpan.FromMinutes(1); + var expireTimeUnix = command == "EXPIREAT" ? DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeSeconds() : DateTimeOffset.UtcNow.Add(expireTimeSpan).ToUnixTimeMilliseconds(); + + Assert.Throws(() => db.Execute(command, key, expireTimeUnix, optionA, optionA)); + } + + #endregion + [Test] public async Task ReAddExpiredKey() { diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md index 31eec09a7c..febf726cba 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -135,7 +135,7 @@ Note that this list is subject to change as we continue to expand our API comman | | STATS | ➖ | | **GENERIC** | [PERSIST](generic-commands.md#persist) | ➕ | | | | [PEXPIRE](generic-commands.md#pexpire) | ➕ | | -| | PEXPIREAT | ➖ | | +| | [PEXPIREAT](generic-commands.md#pexpireat) | ➕ | | | | PEXPIRETIME | ➖ | | | | [PTTL](generic-commands.md#pttl) | ➕ | | | | RANDOMKEY | ➖ | | @@ -196,7 +196,7 @@ Note that this list is subject to change as we continue to expand our API comman | | DUMP | ➖ | | | | [EXISTS](generic-commands.md#exists) | ➕ | | | | [EXPIRE](generic-commands.md#expire) | ➕ | | -| | EXPIREAT | ➖ | | +| | [EXPIREAT](generic-commands.md#expireat) | ➕ | | | | EXPIRETIME | ➖ | | | | [KEYS](generic-commands.md#keys) | ➕ | | | | [MIGRATE](generic-commands.md#migrate) | ➕ | | diff --git a/website/docs/commands/generic-commands.md b/website/docs/commands/generic-commands.md index 7b56261700..850130f84b 100644 --- a/website/docs/commands/generic-commands.md +++ b/website/docs/commands/generic-commands.md @@ -166,12 +166,41 @@ The EXPIRE command supports a set of options: * `GT` -- Set expiry only when the new expiry is greater than current one * `LT` -- Set expiry only when the new expiry is less than current one +The GT, LT and NX options are mutually exclusive. + #### Resp Reply One of the following: * Integer reply: 0 if the timeout was not set; for example, the key doesn't exist, or the operation was skipped because of the provided arguments. +* Integer reply: 1 if the timeout was set. + +--- + +### EXPIREAT + +#### Syntax + +```bash + EXPIREAT key seconds [NX | XX | GT | LT] +``` + +Set a timeout on key using absolute Unix timestamp (seconds since January 1, 1970) in seconds. After the timestamp, the key will automatically be deleted. + +The EXPIREAT command supports a set of options: +* `NX` -- Set expiry only when the key has no expiry +* `XX` -- Set expiry only when the key has an existing expiry +* `GT` -- Set expiry only when the new expiry is greater than current one +* `LT` -- Set expiry only when the new expiry is less than current one + +The GT, LT and NX options are mutually exclusive. + +#### Resp Reply + +One of the following: + +* Integer reply: 0 if the timeout was not set; for example, the key doesn't exist, or the operation was skipped because of the provided arguments. * Integer reply: 1 if the timeout was set. --- @@ -242,8 +271,36 @@ One of the following: * Integer reply: 0 if key does not exist or does not have an associated timeout. * Integer reply: 1 if the timeout has been removed. +--- + +### PEXPIREAT + +#### Syntax + +```bash + PEXPIREAT key seconds [NX | XX | GT | LT] +``` + +Set a timeout on key using absolute Unix timestamp (seconds since January 1, 1970) in milliseconds. After the timestamp, the key will automatically be deleted. + +The PEXPIREAT command supports a set of options: + +* `NX` -- Set expiry only when the key has no expiry +* `XX` -- Set expiry only when the key has an existing expiry +* `GT` -- Set expiry only when the new expiry is greater than current one +* `LT` -- Set expiry only when the new expiry is less than current one + +The GT, LT and NX options are mutually exclusive. + +#### Resp Reply + +One of the following: + +* Integer reply: 0 if the timeout was not set; for example, the key doesn't exist, or the operation was skipped because of the provided arguments. +* Integer reply: 1 if the timeout was set. --- + ### PTTL #### Syntax