diff --git a/libs/server/API/GarnetApiObjectCommands.cs b/libs/server/API/GarnetApiObjectCommands.cs index 208cb8af26..e72b9cfac1 100644 --- a/libs/server/API/GarnetApiObjectCommands.cs +++ b/libs/server/API/GarnetApiObjectCommands.cs @@ -452,6 +452,14 @@ public GarnetStatus HashIncrement(byte[] key, ref ObjectInput input, ref GarnetO public GarnetStatus HashScan(ArgSlice key, long cursor, string match, int count, out ArgSlice[] items) => storageSession.ObjectScan(GarnetObjectType.Hash, key, cursor, match, count, out items, ref objectContext); + /// + public GarnetStatus HashExpire(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) + => storageSession.HashExpire(key, ref input, ref outputFooter, ref objectContext); + + /// + public GarnetStatus HashTtl(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter) + => storageSession.HashTtl(key, ref input, ref outputFooter, ref objectContext); + #endregion } diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 7a84d8603d..21791b3817 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -876,6 +876,23 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi /// GarnetStatus HashIncrement(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter); + /// + /// Sets the expiration time for a hash field + /// + /// + /// + /// + /// + GarnetStatus HashExpire(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter); + + /// + /// Gets the time to live for a hash field + /// + /// + /// + /// + /// + GarnetStatus HashTtl(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter); #endregion #region BitMaps Methods diff --git a/libs/server/Objects/Hash/HashObject.cs b/libs/server/Objects/Hash/HashObject.cs index 88d5ea9c9a..ac4f372ef0 100644 --- a/libs/server/Objects/Hash/HashObject.cs +++ b/libs/server/Objects/Hash/HashObject.cs @@ -32,7 +32,13 @@ public enum HashOperation : byte HINCRBYFLOAT, HRANDFIELD, HSCAN, - HSTRLEN + HSTRLEN, + HEXPIRE, + HPEXPIRE, + HEXPIREAT, + HPEXPIREAT, + HTTL, + HPTTL } @@ -41,7 +47,22 @@ public enum HashOperation : byte /// public unsafe partial class HashObject : GarnetObjectBase { - readonly Dictionary hash; + public class HashValue + { + public byte[] Value { get; set; } + public long Expiration { get; set; } + public HashValue(byte[] value, long expiration) + { + this.Value = value; + this.Expiration = expiration; + } + + public HashValue(byte[] value) : this(value, 0) { } + + public bool IsExpired() { return Expiration > 0 && DateTimeOffset.UtcNow.Ticks > Expiration; } + } + + readonly Dictionary hash; /// /// Constructor @@ -49,7 +70,7 @@ public unsafe partial class HashObject : GarnetObjectBase public HashObject(long expiration = 0) : base(expiration, MemoryUtils.DictionaryOverhead) { - hash = new Dictionary(ByteArrayComparer.Instance); + hash = new Dictionary(ByteArrayComparer.Instance); } /// @@ -58,14 +79,14 @@ public HashObject(long expiration = 0) public HashObject(BinaryReader reader) : base(reader, MemoryUtils.DictionaryOverhead) { - hash = new Dictionary(ByteArrayComparer.Instance); + hash = new Dictionary(ByteArrayComparer.Instance); int count = reader.ReadInt32(); for (int i = 0; i < count; i++) { var item = reader.ReadBytes(reader.ReadInt32()); var value = reader.ReadBytes(reader.ReadInt32()); - hash.Add(item, value); + hash.Add(item, new HashValue(value)); this.UpdateSize(item, value); } @@ -74,7 +95,7 @@ public HashObject(BinaryReader reader) /// /// Copy constructor /// - public HashObject(Dictionary hash, long expiration, long size) + public HashObject(Dictionary hash, long expiration, long size) : base(expiration, size) { this.hash = hash; @@ -94,8 +115,8 @@ public override void DoSerialize(BinaryWriter writer) { writer.Write(kvp.Key.Length); writer.Write(kvp.Key); - writer.Write(kvp.Value.Length); - writer.Write(kvp.Value); + writer.Write(kvp.Value.Value.Length); + writer.Write(kvp.Value.Value); count--; } Debug.Assert(count == 0); @@ -183,6 +204,16 @@ public override unsafe bool Operate(ref ObjectInput input, ref SpanByteAndMemory ObjectUtils.WriteScanError(error, ref output); } break; + case HashOperation.HEXPIRE: + case HashOperation.HPEXPIRE: + case HashOperation.HEXPIREAT: + case HashOperation.HPEXPIREAT: + HashExpire(ref input, ref output); + break; + case HashOperation.HTTL: + case HashOperation.HPTTL: + HashTtl(ref input, ref output); + break; default: throw new GarnetException($"Unsupported operation {input.header.HashOp} in HashObject.Operate"); } @@ -228,7 +259,7 @@ public override unsafe void Scan(long start, out List items, out long cu if (patternLength == 0) { items.Add(item.Key); - items.Add(item.Value); + items.Add(item.Value.Value); } else { @@ -237,7 +268,7 @@ public override unsafe void Scan(long start, out List items, out long cu if (GlobUtils.Match(pattern, patternLength, keyPtr, item.Key.Length)) { items.Add(item.Key); - items.Add(item.Value); + items.Add(item.Value.Value); } } } diff --git a/libs/server/Objects/Hash/HashObjectImpl.cs b/libs/server/Objects/Hash/HashObjectImpl.cs index cc3fedd5e1..1e21d86ed6 100644 --- a/libs/server/Objects/Hash/HashObjectImpl.cs +++ b/libs/server/Objects/Hash/HashObjectImpl.cs @@ -37,7 +37,7 @@ private void HashGet(ref ObjectInput input, ref SpanByteAndMemory output) if (hash.TryGetValue(key, out var hashValue)) { - while (!RespWriteUtils.WriteBulkString(hashValue, ref curr, end)) + while (!RespWriteUtils.WriteBulkString(hashValue.Value, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } else @@ -83,7 +83,7 @@ private void HashMultipleGet(ref ObjectInput input, ref SpanByteAndMemory output if (hash.TryGetValue(key, out var hashValue)) { - while (!RespWriteUtils.WriteBulkString(hashValue, ref curr, end)) + while (!RespWriteUtils.WriteBulkString(hashValue.Value, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } else @@ -134,7 +134,7 @@ private void HashGetAll(ref ObjectInput input, ref SpanByteAndMemory output) { while (!RespWriteUtils.WriteBulkString(item.Key, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - while (!RespWriteUtils.WriteBulkString(item.Value, ref curr, end)) + while (!RespWriteUtils.WriteBulkString(item.Value.Value, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } } @@ -160,7 +160,7 @@ private void HashDelete(ref ObjectInput input, byte* output) if (hash.Remove(key, out var hashValue)) { _output->result1++; - this.UpdateSize(key, hashValue, false); + this.UpdateSize(key, hashValue.Value, false); } } } @@ -176,7 +176,7 @@ private void HashStrLength(ref ObjectInput input, byte* output) *_output = default; var key = input.parseState.GetArgSliceByRef(input.parseStateStartIdx).SpanByte.ToByteArray(); - _output->result1 = hash.TryGetValue(key, out var hashValue) ? hashValue.Length : 0; + _output->result1 = hash.TryGetValue(key, out var hashValue) ? hashValue.Value.Length : 0; } private void HashExists(ref ObjectInput input, byte* output) @@ -228,7 +228,7 @@ private void HashRandomField(ref ObjectInput input, ref SpanByteAndMemory output if (withValues) { - while (!RespWriteUtils.WriteBulkString(pair.Value, ref curr, end)) + while (!RespWriteUtils.WriteBulkString(pair.Value.Value, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } @@ -270,17 +270,17 @@ private void HashSet(ref ObjectInput input, byte* output) if (!hash.TryGetValue(key, out var hashValue)) { - hash.Add(key, value); + hash.Add(key, new HashValue(value)); this.UpdateSize(key, value); _output->result1++; } else if ((hop == HashOperation.HSET || hop == HashOperation.HMSET) && hashValue != default && - !hashValue.AsSpan().SequenceEqual(value)) + !hashValue.Value.AsSpan().SequenceEqual(value)) { - hash[key] = value; + hash[key] = new HashValue(value); // Skip overhead as existing item is getting replaced. this.Size += Utility.RoundUp(value.Length, IntPtr.Size) - - Utility.RoundUp(hashValue.Length, IntPtr.Size); + Utility.RoundUp(hashValue.Value.Length, IntPtr.Size); } } } @@ -312,7 +312,7 @@ private void HashGetKeysOrValues(ref ObjectInput input, ref SpanByteAndMemory ou } else { - while (!RespWriteUtils.WriteBulkString(item.Value, ref curr, end)) + while (!RespWriteUtils.WriteBulkString(item.Value.Value, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } _output.result1++; @@ -365,7 +365,7 @@ private void HashIncrement(ref ObjectInput input, ref SpanByteAndMemory output) if (valueExists) { - if (!NumUtils.TryParse(value, out int result)) + if (!NumUtils.TryParse(value.Value, out int result)) { while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_HASH_VALUE_IS_NOT_INTEGER, ref curr, end)) @@ -384,14 +384,14 @@ private void HashIncrement(ref ObjectInput input, ref SpanByteAndMemory output) resultSpan = resultSpan.Slice(0, bytesWritten); resultBytes = resultSpan.ToArray(); - hash[key] = resultBytes; + hash[key] = new HashValue(resultBytes); Size += Utility.RoundUp(resultBytes.Length, IntPtr.Size) - - Utility.RoundUp(value.Length, IntPtr.Size); + Utility.RoundUp(value.Value.Length, IntPtr.Size); } else { resultBytes = incrSlice.SpanByte.ToByteArray(); - hash.Add(key, resultBytes); + hash.Add(key, new HashValue(resultBytes)); UpdateSize(key, resultBytes); } @@ -413,7 +413,7 @@ private void HashIncrement(ref ObjectInput input, ref SpanByteAndMemory output) if (valueExists) { - if (!NumUtils.TryParse(value, out float result)) + if (!NumUtils.TryParse(value.Value, out float result)) { while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_HASH_VALUE_IS_NOT_FLOAT, ref curr, end)) @@ -425,14 +425,14 @@ private void HashIncrement(ref ObjectInput input, ref SpanByteAndMemory output) result += incr; resultBytes = Encoding.ASCII.GetBytes(result.ToString(CultureInfo.InvariantCulture)); - hash[key] = resultBytes; + hash[key] = new HashValue(resultBytes); Size += Utility.RoundUp(resultBytes.Length, IntPtr.Size) - - Utility.RoundUp(value.Length, IntPtr.Size); + Utility.RoundUp(value.Value.Length, IntPtr.Size); } else { resultBytes = incrSlice.SpanByte.ToByteArray(); - hash.Add(key, resultBytes); + hash.Add(key, new HashValue(resultBytes)); UpdateSize(key, resultBytes); } @@ -452,5 +452,219 @@ private void HashIncrement(ref ObjectInput input, ref SpanByteAndMemory output) output.Length = (int)(curr - ptr); } } + + private void HashExpire(ref ObjectInput input, ref SpanByteAndMemory output) + { + var hop = input.header.HashOp; + + var isMemory = false; + MemoryHandle ptrHandle = default; + var ptr = output.SpanByte.ToPointer(); + + var curr = ptr; + var end = curr + output.Length; + + ObjectOutputHeader _output = default; + + _output.result1 = int.MinValue; + try + { + var parseState = input.parseState; + var currIdx = input.parseStateStartIdx; + var expireOption = ExpireOption.None; + + if (!parseState.TryGetLong(currIdx, out var expirationValue)) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + if (parseState.TryGetEnum(++currIdx, true, out expireOption) && expireOption.IsValid(ref parseState.GetArgSliceByRef(currIdx))) + { + currIdx++; + } + else + { + expireOption = ExpireOption.None; + } + var fieldsKeyword = parseState.GetString(currIdx); + if (fieldsKeyword != "FIELDS") + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_MISSING_ARGUMENT_FIELDS, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + currIdx++; + if (!parseState.TryGetInt(currIdx, out var fieldCount)) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + + var expiryTime = hop switch + { + HashOperation.HEXPIRE => DateTimeOffset.UtcNow.AddSeconds(expirationValue), + HashOperation.HEXPIREAT => DateTimeOffset.FromUnixTimeSeconds(expirationValue), + HashOperation.HPEXPIRE => DateTimeOffset.UtcNow.AddMilliseconds(expirationValue), + HashOperation.HPEXPIREAT => DateTimeOffset.FromUnixTimeMilliseconds(expirationValue), + _ => DateTimeOffset.UtcNow.AddSeconds(expirationValue) + }; + + currIdx++; + if (fieldCount != parseState.Count - currIdx) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_MISMATCH_NUMFIELDS, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + while (!RespWriteUtils.WriteArrayLength(fieldCount, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + for (var fieldIdx = 0; fieldIdx < fieldCount; fieldIdx++, currIdx++) + { + var key = parseState.GetArgSliceByRef(currIdx).SpanByte.ToByteArray(); + + var result = 1; // Assume success + if (!hash.TryGetValue(key, out var hashValue) || hashValue.IsExpired()) + { + result = -2; + } + else + { + switch (expireOption) + { + case ExpireOption.NX: // Only set if not already set + if (hashValue.Expiration > 0) + result = 0; + break; + case ExpireOption.XX: // Only set if already set + if (hashValue.Expiration <= 0) + result = 0; + break; + case ExpireOption.GT: // Only set if greater + // Unset TTL is interpreted as infinite + if (hashValue.Expiration <= 0 || hashValue.Expiration >= expiryTime.Ticks) + result = 0; + break; + case ExpireOption.LT: // Only set if smaller + // Unset TTL is interpreted as infinite + if (hashValue.Expiration > 0 && hashValue.Expiration <= expiryTime.Ticks) + result = 0; + break; + } + if (result != 0) // Option did not reject the operation + { + if (DateTimeOffset.UtcNow >= expiryTime) + { + // If provided expiration time is before or equal to now, delete key + if (hash.Remove(key)) + { + this.UpdateSize(key, hashValue.Value, false); + } + result = 2; + } + else + { + hashValue.Expiration = expiryTime.Ticks; // Update the expiration time + hash[key] = hashValue; + } + + } + } + while (!RespWriteUtils.WriteInteger(result, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, + ref end); + _output.result1 = (_output.result1 < 0) ? 1 : _output.result1 + 1; + } + } + finally + { + while (!RespWriteUtils.WriteDirect(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 HashTtl(ref ObjectInput input, ref SpanByteAndMemory output) + { + var hop = input.header.HashOp; + + var isMemory = false; + MemoryHandle ptrHandle = default; + var ptr = output.SpanByte.ToPointer(); + + var curr = ptr; + var end = curr + output.Length; + + ObjectOutputHeader _output = default; + + _output.result1 = int.MinValue; + try + { + var parseState = input.parseState; + var currIdx = input.parseStateStartIdx; + var expireOption = ExpireOption.None; + + var fieldsKeyword = parseState.GetString(currIdx); + if (fieldsKeyword != "FIELDS") + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_MISSING_ARGUMENT_FIELDS, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + currIdx++; + if (!parseState.TryGetInt(currIdx, out var fieldCount)) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + currIdx++; + if (fieldCount != parseState.Count - currIdx) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_MISMATCH_NUMFIELDS, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + while (!RespWriteUtils.WriteArrayLength(fieldCount, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + for (var fieldIdx = 0; fieldIdx < fieldCount; fieldIdx++, currIdx++) + { + var key = parseState.GetArgSliceByRef(currIdx).SpanByte.ToByteArray(); + + var result = 1L; // Assume success + if (!hash.TryGetValue(key, out var hashValue) || hashValue.IsExpired()) + { + result = -2L; + } + else if (hashValue.Expiration <= 0) + { + result = -1L; + } + else + { + result = (hop == HashOperation.HTTL) ? + ConvertUtils.SecondsFromDiffUtcNowTicks(hashValue.Expiration > 0 ? hashValue.Expiration : -1) : + ConvertUtils.MillisecondsFromDiffUtcNowTicks(hashValue.Expiration > 0 ? hashValue.Expiration : -1); + } + while (!RespWriteUtils.WriteInteger(result, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, + ref end); + _output.result1 = (_output.result1 < 0) ? 1 : _output.result1 + 1; + } + } + finally + { + while (!RespWriteUtils.WriteDirect(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); + } + } } } \ No newline at end of file diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index 48c86c6ed0..68329796d1 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT license. using System; @@ -181,6 +181,8 @@ static partial class CmdStrings public static ReadOnlySpan RESP_ERR_XX_NX_NOT_COMPATIBLE => "ERR XX and NX options at the same time are not compatible"u8; public static ReadOnlySpan RESP_ERR_GT_LT_NX_NOT_COMPATIBLE => "ERR GT, LT, and/or NX options at the same time are not compatible"u8; public static ReadOnlySpan RESP_ERR_INCR_SUPPORTS_ONLY_SINGLE_PAIR => "ERR INCR option supports a single increment-element pair"u8; + public static ReadOnlySpan RESP_ERR_MISSING_ARGUMENT_FIELDS => "ERR Mandatory argument FIELDS is missing or not at the right position"u8; + public static ReadOnlySpan RESP_ERR_MISMATCH_NUMFIELDS => "ERR The `numfields` parameter must match the number of arguments"u8; /// /// Response string templates diff --git a/libs/server/Resp/Objects/HashCommands.cs b/libs/server/Resp/Objects/HashCommands.cs index 59a606e92a..43de1f3044 100644 --- a/libs/server/Resp/Objects/HashCommands.cs +++ b/libs/server/Resp/Objects/HashCommands.cs @@ -705,5 +705,155 @@ private unsafe bool HashIncrement(RespCommand command, ref TGarnetAp } return true; } + + /// + /// HashExpire: Sets the expiration time for the hash field in seconds + /// + /// + /// + /// + /// + private unsafe bool HashExpire(RespCommand command, ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (parseState.Count < 5) + { + return AbortWithWrongNumberOfArguments(command.ToString()); + } + + var sbKey = parseState.GetArgSliceByRef(0).SpanByte; + var keyBytes = sbKey.ToByteArray(); + + if (NetworkSingleKeySlotVerify(keyBytes, false)) + { + return true; + } + + var hop = + command switch + { + RespCommand.HEXPIRE => HashOperation.HEXPIRE, + RespCommand.HPEXPIRE => HashOperation.HPEXPIRE, + RespCommand.HEXPIREAT => HashOperation.HEXPIREAT, + RespCommand.HPEXPIREAT => HashOperation.HPEXPIREAT, + _ => throw new Exception($"Unexpected {nameof(HashOperation)}: {command}") + }; + + // Prepare input + var input = new ObjectInput + { + header = new RespInputHeader + { + type = GarnetObjectType.Hash, + HashOp = hop, + }, + parseState = parseState, + parseStateStartIdx = 1, + }; + + // Prepare GarnetObjectStore output + var outputFooter = new GarnetObjectStoreOutput { spanByteAndMemory = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)) }; + + var status = storageApi.HashExpire(keyBytes, ref input, ref outputFooter); + + switch (status) + { + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + default: + ProcessOutputWithHeader(outputFooter.spanByteAndMemory); + break; + } + return true; + } + + /// + /// HashExpire: Sets the expiration time for the hash field in seconds + /// + /// + /// + /// + /// + private unsafe bool HashTtl(RespCommand command, ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (parseState.Count < 4) + { + return AbortWithWrongNumberOfArguments(command.ToString()); + } + + // Redis parses syntax before seeing if hash exists, so we need to check for some + // bad syntax here to return the right errors if the hash does not exist - otherwise + // we'll get back an empty array instead of the correct error message. + if (parseState.GetString(1) != "FIELDS") + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_MISSING_ARGUMENT_FIELDS, ref dcurr, dend)) + SendAndReset(); + return true; + } + if (!parseState.TryGetInt(2, out var fieldCount)) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER, ref dcurr, dend)) + SendAndReset(); + return true; + } + if (fieldCount != parseState.Count - 3) + { + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_MISMATCH_NUMFIELDS, ref dcurr, dend)) + SendAndReset(); + return true; + } + + var sbKey = parseState.GetArgSliceByRef(0).SpanByte; + var keyBytes = sbKey.ToByteArray(); + + if (NetworkSingleKeySlotVerify(keyBytes, false)) + { + return true; + } + + var hop = + command switch + { + RespCommand.HTTL => HashOperation.HTTL, + RespCommand.HPTTL => HashOperation.HPTTL, + _ => throw new Exception($"Unexpected {nameof(HashOperation)}: {command}") + }; + + // Prepare input + var input = new ObjectInput + { + header = new RespInputHeader + { + type = GarnetObjectType.Hash, + HashOp = hop, + }, + parseState = parseState, + parseStateStartIdx = 1, + }; + + // Prepare GarnetObjectStore output + var outputFooter = new GarnetObjectStoreOutput { spanByteAndMemory = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)) }; + + var status = storageApi.HashTtl(keyBytes, ref input, ref outputFooter); + + switch (status) + { + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + case GarnetStatus.NOTFOUND: + while (!RespWriteUtils.WriteEmptyArray(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 83595b1cb0..eb548781bf 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -89,11 +89,17 @@ public enum RespCommand : byte GEOADD, GETDEL, HDEL, + HEXPIRE, + HEXPIREAT, HINCRBY, HINCRBYFLOAT, HMSET, + HPEXPIRE, + HPEXPIREAT, + HPTTL, HSET, HSETNX, + HTTL, INCR, INCRBY, LINSERT, @@ -741,6 +747,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HLEN; } + else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("\r\nHTTL\r\n"u8)) + { + return RespCommand.HTTL; + } break; case 'K': @@ -911,6 +921,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HSCAN; } + else if (*(ulong*)(ptr + 3) == MemoryMarshal.Read("\nHPTTL\r\n"u8)) + { + return RespCommand.HPTTL; + } break; case 'L': @@ -1184,6 +1198,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HSTRLEN; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("HEXPIRE\r"u8) && *(byte*)(ptr + 12) == '\n') + { + return RespCommand.HEXPIRE; + } break; case 'L': @@ -1244,6 +1262,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.BITFIELD; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("HPEXPIRE"u8) && *(ushort*)(ptr + 12) == MemoryMarshal.Read("\r\n"u8)) + { + return RespCommand.HPEXPIRE; + } break; case 9: if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("SUBSCRIB"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("BE\r\n"u8)) @@ -1270,6 +1292,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.RPOPLPUSH; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("HEXPIREA"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("AT\r\n"u8)) + { + return RespCommand.HEXPIREAT; + } break; } @@ -1311,6 +1337,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.SDIFFSTORE; } + else if (*(ulong*)(ptr + 1) == MemoryMarshal.Read("10\r\nHPEX"u8) && *(ulong*)(ptr + 9) == MemoryMarshal.Read("PIREAT\r\n"u8)) + { + return RespCommand.HPEXPIREAT; + } break; case 11: diff --git a/libs/server/Resp/RespCommandsInfo.json b/libs/server/Resp/RespCommandsInfo.json index 65204e8205..c310756bf7 100644 --- a/libs/server/Resp/RespCommandsInfo.json +++ b/libs/server/Resp/RespCommandsInfo.json @@ -1989,6 +1989,64 @@ ], "SubCommands": null }, + { + "Command": "HEXPIRE", + "Name": "HEXPIRE", + "IsInternal": false, + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Hash, Fast, Write", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RW, Access, Update, Delete" + } + ], + "SubCommands": null + }, + { + "Command": "HEXPIREAT", + "Name": "HEXPIREAT", + "IsInternal": false, + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Hash, Fast, Write", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RW, Access, Update, Delete" + } + ], + "SubCommands": null + }, { "Command": "HGET", "Name": "HGET", @@ -2225,6 +2283,93 @@ ], "SubCommands": null }, + { + "Command": "HPEXPIRE", + "Name": "HPEXPIRE", + "IsInternal": false, + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Hash, Fast, Write", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RW, Access, Update, Delete" + } + ], + "SubCommands": null + }, + { + "Command": "HPEXPIREAT", + "Name": "HPEXPIREAT", + "IsInternal": false, + "Arity": -6, + "Flags": "DenyOom, Fast, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Hash, Fast, Write", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RW, Access, Update, Delete" + } + ], + "SubCommands": null + }, + { + "Command": "HPTTL", + "Name": "HPTTL", + "IsInternal": false, + "Arity": 2, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Hash, Fast, Read", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RO" + } + ], + "SubCommands": null + }, { "Command": "HRANDFIELD", "Name": "HRANDFIELD", @@ -2374,6 +2519,35 @@ ], "SubCommands": null }, + { + "Command": "HTTL", + "Name": "HTTL", + "IsInternal": false, + "Arity": 2, + "Flags": "Fast, ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Hash, Fast, Read", + "Tips": null, + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Notes": null, + "Flags": "RO" + } + ], + "SubCommands": null + }, { "Command": "HVALS", "Name": "HVALS", diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index 0cc8df973f..2007c92418 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -642,13 +642,19 @@ private bool ProcessArrayCommands(RespCommand cmd, ref TGarnetApi st RespCommand.HLEN => HashLength(ref storageApi), RespCommand.HSTRLEN => HashStrLength(ref storageApi), RespCommand.HEXISTS => HashExists(ref storageApi), + RespCommand.HEXPIRE => HashExpire(cmd, ref storageApi), + RespCommand.HEXPIREAT => HashExpire(cmd, ref storageApi), RespCommand.HKEYS => HashKeys(cmd, ref storageApi), RespCommand.HVALS => HashKeys(cmd, ref storageApi), RespCommand.HINCRBY => HashIncrement(cmd, ref storageApi), RespCommand.HINCRBYFLOAT => HashIncrement(cmd, ref storageApi), + RespCommand.HPEXPIRE => HashExpire(cmd, ref storageApi), + RespCommand.HPEXPIREAT => HashExpire(cmd, ref storageApi), RespCommand.HSETNX => HashSet(cmd, ref storageApi), RespCommand.HRANDFIELD => HashRandomField(cmd, ref storageApi), RespCommand.HSCAN => ObjectScan(GarnetObjectType.Hash, ref storageApi), + RespCommand.HTTL => HashTtl(cmd, ref storageApi), + RespCommand.HPTTL => HashTtl(cmd, ref storageApi), // Set Commands RespCommand.SADD => SetAdd(ref storageApi), RespCommand.SMEMBERS => SetMembers(ref storageApi), diff --git a/libs/server/Storage/Session/ObjectStore/HashOps.cs b/libs/server/Storage/Session/ObjectStore/HashOps.cs index 3af7bf709a..0f6e2c9f9f 100644 --- a/libs/server/Storage/Session/ObjectStore/HashOps.cs +++ b/libs/server/Storage/Session/ObjectStore/HashOps.cs @@ -625,5 +625,31 @@ public GarnetStatus HashIncrement(byte[] key, ArgSlice input, ou public GarnetStatus HashIncrement(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter, ref TObjectContext objectContext) where TObjectContext : ITsavoriteContext => RMWObjectStoreOperationWithOutput(key, ref input, ref objectContext, ref outputFooter); + + /// + /// Sets the expiration time for a hash field + /// + /// + /// + /// + /// + /// + /// + public GarnetStatus HashExpire(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + => RMWObjectStoreOperationWithOutput(key, ref input, ref objectContext, ref outputFooter); + + /// + /// Gets the expiration time for a hash field + /// + /// + /// + /// + /// + /// + /// + public GarnetStatus HashTtl(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput outputFooter, ref TObjectContext objectContext) + where TObjectContext : ITsavoriteContext + => ReadObjectStoreOperationWithOutput(key, ref input, ref objectContext, ref outputFooter); } } \ No newline at end of file diff --git a/libs/server/Transaction/TxnKeyManager.cs b/libs/server/Transaction/TxnKeyManager.cs index be90499b1d..8dad0855ac 100644 --- a/libs/server/Transaction/TxnKeyManager.cs +++ b/libs/server/Transaction/TxnKeyManager.cs @@ -105,6 +105,8 @@ internal int GetKeys(RespCommand command, int inputCount, out ReadOnlySpan RespCommand.RPUSHX => ListObjectKeys((byte)ListOperation.RPUSHX), RespCommand.HDEL => HashObjectKeys((byte)HashOperation.HDEL), RespCommand.HEXISTS => HashObjectKeys((byte)HashOperation.HEXISTS), + RespCommand.HEXPIRE => HashObjectKeys((byte)HashOperation.HEXPIRE), + RespCommand.HEXPIREAT => HashObjectKeys((byte)HashOperation.HEXPIREAT), RespCommand.HGET => HashObjectKeys((byte)HashOperation.HGET), RespCommand.HGETALL => HashObjectKeys((byte)HashOperation.HGETALL), RespCommand.HINCRBY => HashObjectKeys((byte)HashOperation.HINCRBY), @@ -113,11 +115,15 @@ internal int GetKeys(RespCommand command, int inputCount, out ReadOnlySpan RespCommand.HLEN => HashObjectKeys((byte)HashOperation.HLEN), RespCommand.HMGET => HashObjectKeys((byte)HashOperation.HMGET), RespCommand.HMSET => HashObjectKeys((byte)HashOperation.HMSET), + RespCommand.HPEXPIRE => HashObjectKeys((byte)HashOperation.HPEXPIRE), + RespCommand.HPEXPIREAT => HashObjectKeys((byte)HashOperation.HPEXPIREAT), + RespCommand.HPTTL => HashObjectKeys((byte)HashOperation.HPTTL), RespCommand.HRANDFIELD => HashObjectKeys((byte)HashOperation.HRANDFIELD), RespCommand.HSCAN => HashObjectKeys((byte)HashOperation.HSCAN), RespCommand.HSET => HashObjectKeys((byte)HashOperation.HSET), RespCommand.HSETNX => HashObjectKeys((byte)HashOperation.HSETNX), RespCommand.HSTRLEN => HashObjectKeys((byte)HashOperation.HSTRLEN), + RespCommand.HTTL => HashObjectKeys((byte)HashOperation.HTTL), RespCommand.HVALS => HashObjectKeys((byte)HashOperation.HVALS), RespCommand.GET => SingleKey(1, false, LockType.Shared), RespCommand.SET => SingleKey(1, false, LockType.Exclusive), @@ -242,6 +248,12 @@ private int HashObjectKeys(byte subCommand) (byte)HashOperation.HSETNX => SingleKey(1, true, LockType.Exclusive), (byte)HashOperation.HRANDFIELD => SingleKey(1, true, LockType.Shared), (byte)HashOperation.HSTRLEN => SingleKey(1, true, LockType.Shared), + (byte)HashOperation.HEXPIRE => SingleKey(1, true, LockType.Shared), + (byte)HashOperation.HEXPIREAT => SingleKey(1, true, LockType.Shared), + (byte)HashOperation.HPEXPIRE => SingleKey(1, true, LockType.Shared), + (byte)HashOperation.HPEXPIREAT => SingleKey(1, true, LockType.Shared), + (byte)HashOperation.HTTL => SingleKey(1, true, LockType.Shared), + (byte)HashOperation.HPTTL => SingleKey(1, true, LockType.Shared), _ => -1 }; } diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index db39c684fd..8f9aac13c5 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -127,6 +127,8 @@ public class SupportedCommand new("HDEL", RespCommand.HDEL), new("HELLO", RespCommand.HELLO), new("HEXISTS", RespCommand.HEXISTS), + new("HEXPIRE", RespCommand.HPEXPIRE), + new("HEXPIREAT", RespCommand.HPEXPIREAT), new("HGET", RespCommand.HGET), new("HGETALL", RespCommand.HGETALL), new("HINCRBY", RespCommand.HINCRBY), @@ -135,11 +137,15 @@ public class SupportedCommand new("HLEN", RespCommand.HLEN), new("HMGET", RespCommand.HMGET), new("HMSET", RespCommand.HMSET), + new("HPEXPIRE", RespCommand.HPEXPIRE), + new("HPEXPIREAT", RespCommand.HPEXPIREAT), + new("HPTTL", RespCommand.HPTTL), new("HRANDFIELD", RespCommand.HRANDFIELD), new("HSCAN", RespCommand.HSCAN), new("HSET", RespCommand.HSET), new("HSETNX", RespCommand.HSETNX), new("HSTRLEN", RespCommand.HSTRLEN), + new("HTTL", RespCommand.HTTL), new("HVALS", RespCommand.HVALS), new("INCR", RespCommand.INCR), new("INCRBY", RespCommand.INCRBY), diff --git a/test/Garnet.test/RespHashTests.cs b/test/Garnet.test/RespHashTests.cs index 5991ae0b4a..8d2f43729a 100644 --- a/test/Garnet.test/RespHashTests.cs +++ b/test/Garnet.test/RespHashTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Garnet.common; using Garnet.server; using NUnit.Framework; using NUnit.Framework.Legacy; @@ -1171,6 +1172,263 @@ public void CanDoWrongNumOfParametersInHINCRBYFLOATLC() #endregion + #region HashExpireTests + + [Test] + [TestCase("HEXPIRE")] + [TestCase("HEXPIREAT")] + [TestCase("HPEXPIRE")] + [TestCase("HPEXPIREAT")] + public void HExpireCommandParameters(string command) + { + string key = "expirehash"; + var lightClientRequest = TestUtils.CreateRequest(); + var middleTtl = command switch + { + "HEXPIRE" => 120, + "HPEXPIRE" => 120000, + "HEXPIREAT" => DateTimeOffset.UtcNow.AddSeconds(120).ToUnixTimeSeconds(), + "HPEXPIREAT" => DateTimeOffset.UtcNow.AddSeconds(120).ToUnixTimeMilliseconds(), + _ => 10 + }; + var largerTtl = command switch + { + "HEXPIRE" => 240, + "HPEXPIRE" => 240000, + "HEXPIREAT" => DateTimeOffset.UtcNow.AddSeconds(240).ToUnixTimeSeconds(), + "HPEXPIREAT" => DateTimeOffset.UtcNow.AddSeconds(240).ToUnixTimeMilliseconds(), + _ => 10 + }; + var smallerTtl = command switch + { + "HEXPIRE" => 60, + "HPEXPIRE" => 60000, + "HEXPIREAT" => DateTimeOffset.UtcNow.AddSeconds(60).ToUnixTimeSeconds(), + "HPEXPIREAT" => DateTimeOffset.UtcNow.AddSeconds(60).ToUnixTimeMilliseconds(), + _ => 10 + }; + // This value should ensure the key is deleted + var zeroTtl = command switch + { + "HEXPIRE" => 0, + "HPEXPIRE" => 0, + "HEXPIREAT" => DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + "HPEXPIREAT" => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + _ => 0 + }; + + // Invalid syntax tests + + // Non-numeric value for TTL + var res = lightClientRequest.SendCommand($"{command} {key} hello FIELDS 1 field1"); + var expectedResponse = "-ERR value is not an integer or out of range.\r\n"; + var actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Non-numeric value for field count + res = lightClientRequest.SendCommand($"{command} {key} 10 FIELDS hello field1"); + expectedResponse = "-ERR value is not an integer or out of range.\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Invalid option + res = lightClientRequest.SendCommand($"{command} {key} 10 BB FIELDS 1 field1"); + expectedResponse = "-ERR Mandatory argument FIELDS is missing or not at the right position\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Missing FIELDS keyword (same error as above) + res = lightClientRequest.SendCommand($"{command} {key} 10 2 field1 field2"); + expectedResponse = "-ERR Mandatory argument FIELDS is missing or not at the right position\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Missing FIELDS keyword with valid option (same error as above) + res = lightClientRequest.SendCommand($"{command} {key} 10 NX 1 field1"); + expectedResponse = "-ERR Mandatory argument FIELDS is missing or not at the right position\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Too many fields listed + res = lightClientRequest.SendCommand($"{command} {key} 10 FIELDS 1 field1 field2"); + expectedResponse = "-ERR The `numfields` parameter must match the number of arguments\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Too few fields listed + res = lightClientRequest.SendCommand($"{command} {key} 10 FIELDS 2 field1"); + expectedResponse = "-ERR The `numfields` parameter must match the number of arguments\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Insufficient arguments + res = lightClientRequest.SendCommand($"{command} {key} 10 FIELDS 1"); + expectedResponse = $"-ERR wrong number of arguments for '{command}' command\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + res = lightClientRequest.SendCommand($"HSET {key} field1 1"); + expectedResponse = ":1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Invalid field + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} FIELDS 1 field2", 2); + expectedResponse = "*1\r\n:-2\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Successfully set + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} FIELDS 1 field1", 2); + expectedResponse = "*1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Unset (key deleted) + res = lightClientRequest.SendCommand($"{command} {key} {zeroTtl} FIELDS 1 field1", 2); + expectedResponse = "*1\r\n:2\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Ensure key was actually deleted + res = lightClientRequest.SendCommand($"HGET {key} field1"); + expectedResponse = "$-1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Add three fields (this also clears their TTLs) + res = lightClientRequest.SendCommand($"HSET {key} field1 1 field2 1 field3 1"); + expectedResponse = ":3\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Set TTL on the first two fields + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} FIELDS 2 field1 field2", 3); + expectedResponse = "*2\r\n:1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // XX on a field with no TTL will fail + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} XX FIELDS 1 field3", 2); + expectedResponse = "*1\r\n:0\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // XX on a field with TTL will succeed + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} XX FIELDS 1 field1", 2); + expectedResponse = "*1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // NX on a field with TTL will fail + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} NX FIELDS 1 field1", 2); + expectedResponse = "*1\r\n:0\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // NX on a field with no TTL will succeed + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} NX FIELDS 1 field3", 2); + expectedResponse = "*1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Set TTL on both fields again + res = lightClientRequest.SendCommand($"{command} {key} {middleTtl} FIELDS 2 field1 field2", 3); + expectedResponse = "*2\r\n:1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // LT when new TTL is larger will fail + res = lightClientRequest.SendCommand($"{command} {key} {largerTtl} LT FIELDS 1 field1", 2); + expectedResponse = "*1\r\n:0\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // LT when new TTL is smaller will succeed + res = lightClientRequest.SendCommand($"{command} {key} {smallerTtl} LT FIELDS 1 field1", 2); + expectedResponse = "*1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // GT when new TTL is smaller will fail + res = lightClientRequest.SendCommand($"{command} {key} {smallerTtl} GT FIELDS 1 field2", 2); + expectedResponse = "*1\r\n:0\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // GT when new TTL is larger will succeed + res = lightClientRequest.SendCommand($"{command} {key} {largerTtl} GT FIELDS 1 field2", 2); + expectedResponse = "*1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + } + + [Test] + [TestCase("HTTL")] + [TestCase("HPTTL")] + public void HTtlCommandParameters(string command) + { + string key = "expirehash"; + var lightClientRequest = TestUtils.CreateRequest(); + + // Invalid syntax tests + + // Missing FIELDS keyword + var res = lightClientRequest.SendCommand($"{command} {key} 2 field1 field2"); + var expectedResponse = "-ERR Mandatory argument FIELDS is missing or not at the right position\r\n"; + var actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Non-numeric value for field count + res = lightClientRequest.SendCommand($"{command} {key} FIELDS hello field1"); + expectedResponse = "-ERR value is not an integer or out of range.\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Too many fields listed + res = lightClientRequest.SendCommand($"{command} {key} FIELDS 1 field1 field2"); + expectedResponse = "-ERR The `numfields` parameter must match the number of arguments\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Insufficient arguments + res = lightClientRequest.SendCommand($"{command} {key} FIELDS 1"); + expectedResponse = $"-ERR wrong number of arguments for '{command}' command\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Set up a field + res = lightClientRequest.SendCommand($"HSET {key} field1 1"); + expectedResponse = ":1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Set a TTL + res = lightClientRequest.SendCommand($"HEXPIRE {key} 300 FIELDS 1 field1"); + expectedResponse = "*1\r\n:1\r\n"; + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + + // Get the TTL in seconds + res = lightClientRequest.SendCommand($"{command} {key} FIELDS 1 field1", 2); + expectedResponse = "*1\r\n"; // Should return a one element array + actualResponse = Encoding.ASCII.GetString(res).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualResponse); + if (command == "HTTL") + { + // Should be less or equal to 300 and within 5 seconds of it + var ttlValue = long.Parse(Encoding.ASCII.GetString(res).Substring(5, 3)); + ClassicAssert.IsTrue(ttlValue <= 300 && ttlValue >= 295); + } + else + { + // Should be less or equal to 300000 and within 5 seconds of it + var ttlValue = long.Parse(Encoding.ASCII.GetString(res).Substring(5, 6)); + ClassicAssert.IsTrue(ttlValue <= 300000 && ttlValue >= 295000); + } + } + #endregion + private static string FormatWrongNumOfArgsError(string commandName) => $"-{string.Format(CmdStrings.GenericErrWrongNumArgs, commandName)}\r\n"; }