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";
}