diff --git a/libs/server/API/GarnetApi.cs b/libs/server/API/GarnetApi.cs
index 4aec2f705d..9fb3e706f4 100644
--- a/libs/server/API/GarnetApi.cs
+++ b/libs/server/API/GarnetApi.cs
@@ -154,6 +154,10 @@ public GarnetStatus APPEND(ArgSlice key, ArgSlice value, ref ArgSlice output)
///
public GarnetStatus RENAME(ArgSlice oldKey, ArgSlice newKey, StoreType storeType = StoreType.All)
=> storageSession.RENAME(oldKey, newKey, storeType);
+
+ ///
+ public GarnetStatus RENAMENX(ArgSlice oldKey, ArgSlice newKey, StoreType storeType = StoreType.All)
+ => storageSession.RENAMENX(oldKey, newKey, storeType);
#endregion
#region EXISTS
diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs
index 7a84d8603d..146c303483 100644
--- a/libs/server/API/IGarnetApi.cs
+++ b/libs/server/API/IGarnetApi.cs
@@ -118,6 +118,19 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi
///
///
GarnetStatus RENAME(ArgSlice oldKey, ArgSlice newKey, StoreType storeType = StoreType.All);
+
+ ///
+ /// Renames key to newkey if newkey does not yet exist. It returns an error when key does not exist.
+ ///
+ /// The old key to be renamed.
+ /// The new key name.
+ /// The type of store to perform the operation on.
+ ///
+ /// GarnetStatus OK means rename operation is successful
+ /// GarnetStatus NOTFOUND means old key does not exists
+ /// GarnetStatus MOVED means new key already exists
+ ///
+ GarnetStatus RENAMENX(ArgSlice oldKey, ArgSlice newKey, StoreType storeType = StoreType.All);
#endregion
#region EXISTS
diff --git a/libs/server/Resp/KeyAdminCommands.cs b/libs/server/Resp/KeyAdminCommands.cs
index 2365e9bb27..b24596ca1f 100644
--- a/libs/server/Resp/KeyAdminCommands.cs
+++ b/libs/server/Resp/KeyAdminCommands.cs
@@ -40,6 +40,46 @@ private bool NetworkRENAME(ref TGarnetApi storageApi)
return true;
}
+ ///
+ /// TryRENAMENX
+ ///
+ private bool NetworkRENAMENX(ref TGarnetApi storageApi)
+ where TGarnetApi : IGarnetApi
+ {
+ if (parseState.Count != 2)
+ {
+ return AbortWithWrongNumberOfArguments(nameof(RespCommand.RENAMENX));
+ }
+
+ var oldKeySlice = parseState.GetArgSliceByRef(0);
+ var newKeySlice = parseState.GetArgSliceByRef(1);
+ var status = storageApi.RENAMENX(oldKeySlice, newKeySlice);
+
+ // Integer reply: 1 if key was renamed to newkey.
+ // Integer reply: 0 if newkey already exists.
+ switch (status)
+ {
+ // GarnetStatus OK means new key already exists
+ case GarnetStatus.OK:
+ while (!RespWriteUtils.WriteDirect(CmdStrings.RESP_RETURN_VAL_1, ref dcurr, dend))
+ SendAndReset();
+ break;
+
+ // GarnetStatus NOTFOUND means remane operation is successful
+ case GarnetStatus.NOTFOUND:
+ while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_GENERIC_NOSUCHKEY, ref dcurr, dend))
+ SendAndReset();
+ break;
+
+ case GarnetStatus.MOVED:
+ while (!RespWriteUtils.WriteDirect(CmdStrings.RESP_RETURN_VAL_0, ref dcurr, dend))
+ SendAndReset();
+ break;
+ }
+
+ return true;
+ }
+
///
/// GETDEL command processor
///
diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs
index 83595b1cb0..cf087de3db 100644
--- a/libs/server/Resp/Parser/RespCommand.cs
+++ b/libs/server/Resp/Parser/RespCommand.cs
@@ -117,6 +117,7 @@ public enum RespCommand : byte
PFMERGE,
PSETEX,
RENAME,
+ RENAMENX,
RPOP,
RPOPLPUSH,
RPUSH,
@@ -620,6 +621,7 @@ private RespCommand FastParseCommand(out int count)
(2 << 4) | 6 when lastWord == MemoryMarshal.Read("INCRBY\r\n"u8) => RespCommand.INCRBY,
(2 << 4) | 6 when lastWord == MemoryMarshal.Read("DECRBY\r\n"u8) => RespCommand.DECRBY,
(2 << 4) | 6 when lastWord == MemoryMarshal.Read("RENAME\r\n"u8) => RespCommand.RENAME,
+ (2 << 4) | 8 when lastWord == MemoryMarshal.Read("NAMENX\r\n"u8) => RespCommand.RENAMENX,
(2 << 4) | 6 when lastWord == MemoryMarshal.Read("GETBIT\r\n"u8) => RespCommand.GETBIT,
(2 << 4) | 6 when lastWord == MemoryMarshal.Read("APPEND\r\n"u8) => RespCommand.APPEND,
(2 << 4) | 7 when lastWord == MemoryMarshal.Read("UBLISH\r\n"u8) && ptr[8] == 'P' => RespCommand.PUBLISH,
diff --git a/libs/server/Resp/RespCommandsInfo.json b/libs/server/Resp/RespCommandsInfo.json
index 65204e8205..a0f85d937e 100644
--- a/libs/server/Resp/RespCommandsInfo.json
+++ b/libs/server/Resp/RespCommandsInfo.json
@@ -3573,6 +3573,49 @@
],
"SubCommands": null
},
+ {
+ "Command": "RENAMENX",
+ "Name": "RENAMENX",
+ "IsInternal": false,
+ "Arity": 3,
+ "Flags": "Fast, Write",
+ "FirstKey": 1,
+ "LastKey": 2,
+ "Step": 1,
+ "AclCategories": "Fast, KeySpace, Write",
+ "Tips": null,
+ "KeySpecifications": [
+ {
+ "BeginSearch": {
+ "TypeDiscriminator": "BeginSearchIndex",
+ "Index": 1
+ },
+ "FindKeys": {
+ "TypeDiscriminator": "FindKeysRange",
+ "LastKey": 0,
+ "KeyStep": 1,
+ "Limit": 0
+ },
+ "Notes": null,
+ "Flags": "RW, Access, Delete"
+ },
+ {
+ "BeginSearch": {
+ "TypeDiscriminator": "BeginSearchIndex",
+ "Index": 2
+ },
+ "FindKeys": {
+ "TypeDiscriminator": "FindKeysRange",
+ "LastKey": 0,
+ "KeyStep": 1,
+ "Limit": 0
+ },
+ "Notes": null,
+ "Flags": "OW, Insert"
+ }
+ ],
+ "SubCommands": null
+ },
{
"Command": "REPLICAOF",
"Name": "REPLICAOF",
diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs
index 0cc8df973f..6379873979 100644
--- a/libs/server/Resp/RespServerSession.cs
+++ b/libs/server/Resp/RespServerSession.cs
@@ -512,6 +512,7 @@ private bool ProcessBasicCommands(RespCommand cmd, ref TGarnetApi st
RespCommand.SETEXNX => NetworkSETEXNX(ref storageApi),
RespCommand.DEL => NetworkDEL(ref storageApi),
RespCommand.RENAME => NetworkRENAME(ref storageApi),
+ RespCommand.RENAMENX => NetworkRENAMENX(ref storageApi),
RespCommand.EXISTS => NetworkEXISTS(ref storageApi),
RespCommand.EXPIRE => NetworkEXPIRE(RespCommand.EXPIRE, ref storageApi),
RespCommand.PEXPIRE => NetworkEXPIRE(RespCommand.PEXPIRE, ref storageApi),
diff --git a/libs/server/Storage/Session/MainStore/MainStoreOps.cs b/libs/server/Storage/Session/MainStore/MainStoreOps.cs
index 151f36567d..4520bb4867 100644
--- a/libs/server/Storage/Session/MainStore/MainStoreOps.cs
+++ b/libs/server/Storage/Session/MainStore/MainStoreOps.cs
@@ -618,32 +618,171 @@ public unsafe GarnetStatus RENAME(ArgSlice oldKeySlice, ArgSlice newKeySlice, St
var valObj = value.garnetObject;
byte[] newKeyArray = newKeySlice.ToArray();
+ // valObj already has expiration time, so no need to write expiration logic here
+ SET(newKeyArray, valObj, ref objectContext);
+
+ // Delete the old key
+ DELETE(oldKeyArray, StoreType.Object, ref context, ref objectContext);
+
+ returnStatus = GarnetStatus.OK;
+ }
+ }
+ finally
+ {
+ if (createTransaction)
+ txnManager.Commit(true);
+ }
+ }
+
+ return returnStatus;
+ }
+
+ ///
+ /// Renames key to newkey if newkey does not yet exist. It returns an error when key does not exist.
+ ///
+ /// The old key to be renamed.
+ /// The new key name.
+ /// The type of store to perform the operation on.
+ ///
+ /// GarnetStatus OK means rename operation is successful
+ /// GarnetStatus NOTFOUND means old key does not exists
+ /// GarnetStatus MOVED means new key already exists
+ ///
+ public unsafe GarnetStatus RENAMENX(ArgSlice oldKeySlice, ArgSlice newKeySlice, StoreType storeType)
+ {
+ GarnetStatus returnStatus = GarnetStatus.NOTFOUND;
+
+ // If same name check return early.
+ if (oldKeySlice.ReadOnlySpan.SequenceEqual(newKeySlice.ReadOnlySpan))
+ return GarnetStatus.OK;
+
+ bool createTransaction = false;
+ if (txnManager.state != TxnState.Running)
+ {
+ createTransaction = true;
+ txnManager.SaveKeyEntryToLock(oldKeySlice, false, LockType.Exclusive);
+ txnManager.SaveKeyEntryToLock(newKeySlice, false, LockType.Exclusive);
+ txnManager.Run(true);
+ }
+
+ var context = txnManager.LockableContext;
+ var objectContext = txnManager.ObjectStoreLockableContext;
+ SpanByte oldKey = oldKeySlice.SpanByte;
+
+ if (storeType == StoreType.Main || storeType == StoreType.All)
+ {
+ try
+ {
+ SpanByte input = default;
+ var o = new SpanByteAndMemory();
+ var status = GET(ref oldKey, ref input, ref o, ref context);
+
+ if (status == GarnetStatus.OK)
+ {
+ Debug.Assert(!o.IsSpanByte);
+ var memoryHandle = o.Memory.Memory.Pin();
+ var ptrVal = (byte*)memoryHandle.Pointer;
+
+ RespReadUtils.ReadUnsignedLengthHeader(out var headerLength, ref ptrVal, ptrVal + o.Length);
+
+ // Find expiration time of the old key
var expireSpan = new SpanByteAndMemory();
- var ttlStatus = TTL(ref oldKey, StoreType.Object, ref expireSpan, ref context, ref objectContext, true);
+ var ttlStatus = TTL(ref oldKey, storeType, ref expireSpan, ref context, ref objectContext, true);
if (ttlStatus == GarnetStatus.OK && !expireSpan.IsSpanByte)
{
using var expireMemoryHandle = expireSpan.Memory.Memory.Pin();
var expirePtrVal = (byte*)expireMemoryHandle.Pointer;
RespReadUtils.TryRead64Int(out var expireTimeMs, ref expirePtrVal, expirePtrVal + expireSpan.Length, out var _);
- expireSpan.Memory.Dispose();
+ // If the key has an expiration, set the new key with the expiration
if (expireTimeMs > 0)
{
- SET(newKeyArray, valObj, ref objectContext);
- EXPIRE(newKeySlice, TimeSpan.FromMilliseconds(expireTimeMs), out _, StoreType.Object, ExpireOption.None, ref context, ref objectContext, true);
+ // Move payload forward to make space for RespInputHeader and Metadata
+ var setValue = scratchBufferManager.FormatScratch(RespInputHeader.Size + sizeof(long), new ArgSlice(ptrVal, headerLength));
+ var setValueSpan = setValue.SpanByte;
+ var setValuePtr = setValueSpan.ToPointerWithMetadata();
+ setValueSpan.ExtraMetadata = DateTimeOffset.UtcNow.Ticks + TimeSpan.FromMilliseconds(expireTimeMs).Ticks;
+ ((RespInputHeader*)(setValuePtr + sizeof(long)))->cmd = RespCommand.SETEXNX;
+ ((RespInputHeader*)(setValuePtr + sizeof(long)))->flags = 0;
+ var newKey = newKeySlice.SpanByte;
+ var setStatus = SET_Conditional(ref newKey, ref setValueSpan, ref context);
+
+ // For SET NX `NOTFOUND` means the operation succeeded
+ returnStatus = setStatus == GarnetStatus.NOTFOUND ? GarnetStatus.OK : GarnetStatus.MOVED;
}
- else if (expireTimeMs == -1) // Its possible to have expire as 0 or -2, in those cases we don't SET the new key
+ else if (expireTimeMs == -1) // Its possible to have expireTimeMs as 0 (Key expired or will be expired now) or -2 (Key does not exist), in those cases we don't SET the new key
+ {
+ // Move payload forward to make space for RespInputHeader
+ var setValue = scratchBufferManager.FormatScratch(RespInputHeader.Size, new ArgSlice(ptrVal, headerLength));
+ var setValueSpan = setValue.SpanByte;
+ var setValuePtr = setValueSpan.ToPointerWithMetadata();
+ ((RespInputHeader*)setValuePtr)->cmd = RespCommand.SETEXNX;
+ ((RespInputHeader*)setValuePtr)->flags = 0;
+ var newKey = newKeySlice.SpanByte;
+ var setStatus = SET_Conditional(ref newKey, ref setValueSpan, ref context);
+
+ // For SET NX `NOTFOUND` means the operation succeeded
+ returnStatus = setStatus == GarnetStatus.NOTFOUND ? GarnetStatus.OK : GarnetStatus.MOVED;
+ }
+
+ expireSpan.Memory.Dispose();
+ memoryHandle.Dispose();
+ o.Memory.Dispose();
+
+ // Delete the old key only when SET NX succeeded
+ if (returnStatus == GarnetStatus.OK)
{
- SET(newKeyArray, valObj, ref objectContext);
+ DELETE(ref oldKey, StoreType.Main, ref context, ref objectContext);
}
+ }
+ }
+ }
+ finally
+ {
+ if (createTransaction)
+ txnManager.Commit(true);
+ }
+ }
+
+ if ((storeType == StoreType.Object || storeType == StoreType.All) && !objectStoreBasicContext.IsNull)
+ {
+ createTransaction = false;
+ if (txnManager.state != TxnState.Running)
+ {
+ txnManager.SaveKeyEntryToLock(oldKeySlice, true, LockType.Exclusive);
+ txnManager.SaveKeyEntryToLock(newKeySlice, true, LockType.Exclusive);
+ txnManager.Run(true);
+ createTransaction = true;
+ }
+
+ try
+ {
+ byte[] oldKeyArray = oldKeySlice.ToArray();
+ var status = GET(oldKeyArray, out var value, ref objectContext);
+
+ if (status == GarnetStatus.OK)
+ {
+ var valObj = value.garnetObject;
+ byte[] newKeyArray = newKeySlice.ToArray();
+
+ // Not using EXISTS method to avoid new allocation of Array for key
+ var getNewStatus = GET(newKeyArray, out _, ref objectContext);
+
+ if (getNewStatus == GarnetStatus.NOTFOUND)
+ {
+ // valObj already has expiration time, so no need to write expiration logic here
+ SET(newKeyArray, valObj, ref objectContext);
// Delete the old key
DELETE(oldKeyArray, StoreType.Object, ref context, ref objectContext);
returnStatus = GarnetStatus.OK;
}
-
+ else
+ {
+ returnStatus = GarnetStatus.MOVED;
+ }
}
}
finally
@@ -652,7 +791,6 @@ public unsafe GarnetStatus RENAME(ArgSlice oldKeySlice, ArgSlice newKeySlice, St
txnManager.Commit(true);
}
}
-
return returnStatus;
}
diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs
index db39c684fd..81115f74a6 100644
--- a/playground/CommandInfoUpdater/SupportedCommand.cs
+++ b/playground/CommandInfoUpdater/SupportedCommand.cs
@@ -194,6 +194,7 @@ public class SupportedCommand
new("READONLY", RespCommand.READONLY),
new("READWRITE", RespCommand.READWRITE),
new("RENAME", RespCommand.RENAME),
+ new("RENAMENX", RespCommand.RENAMENX),
new("REPLICAOF", RespCommand.REPLICAOF),
new("RPOP", RespCommand.RPOP),
new("RPOPLPUSH", RespCommand.RPOPLPUSH),
diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs
index e616dd5888..c79e075f68 100644
--- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs
+++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs
@@ -4278,10 +4278,10 @@ public async Task RenameACLsAsync()
{
await CheckCommandsAsync(
"RENAME",
- [DoPTTLAsync]
+ [DoRENAMEAsync]
);
- static async Task DoPTTLAsync(GarnetClient client)
+ static async Task DoRENAMEAsync(GarnetClient client)
{
try
{
@@ -4300,6 +4300,33 @@ static async Task DoPTTLAsync(GarnetClient client)
}
}
+ [Test]
+ public async Task RenameNxACLsAsync()
+ {
+ await CheckCommandsAsync(
+ "RENAMENX",
+ [DoRENAMENXAsync]
+ );
+
+ static async Task DoRENAMENXAsync(GarnetClient client)
+ {
+ try
+ {
+ await client.ExecuteForStringResultAsync("RENAMENX", ["foo", "bar"]);
+ Assert.Fail("Shouldn't succeed, key doesn't exist");
+ }
+ catch (Exception e)
+ {
+ if (e.Message == "ERR no such key")
+ {
+ return;
+ }
+
+ throw;
+ }
+ }
+ }
+
[Test]
public async Task ReplicaOfACLsAsync()
{
diff --git a/test/Garnet.test/RespTests.cs b/test/Garnet.test/RespTests.cs
index 2061c41ff2..ac5a60fff8 100644
--- a/test/Garnet.test/RespTests.cs
+++ b/test/Garnet.test/RespTests.cs
@@ -1178,7 +1178,7 @@ public void SingleRenameWithExpiry()
var ttl = db.KeyTimeToLive("key2");
ClassicAssert.IsTrue(ttl.HasValue);
ClassicAssert.Greater(ttl.Value.TotalMilliseconds, 0);
- ClassicAssert.Less(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
+ ClassicAssert.LessOrEqual(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
}
[Test]
@@ -1266,9 +1266,248 @@ public void SingleRenameObjectStoreWithExpiry()
var ttl = db.KeyTimeToLive("lkey2");
ClassicAssert.IsTrue(ttl.HasValue);
ClassicAssert.Greater(ttl.Value.TotalMilliseconds, 0);
- ClassicAssert.Less(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
+ ClassicAssert.LessOrEqual(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
}
+ [Test]
+ public void SingleRenameWithOldKeyAndNewKeyAsSame()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+ var origValue = "test1";
+ var key = "key1";
+ db.StringSet(key, origValue);
+
+ var result = db.KeyRename(key, key);
+
+ ClassicAssert.IsTrue(result);
+ string retValue = db.StringGet(key);
+ ClassicAssert.AreEqual(origValue, retValue);
+ }
+
+ #region RENAMENX
+
+ [Test]
+ public void SingleRenameNx()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ string origValue = "test1";
+ db.StringSet("key1", origValue);
+
+ var result = db.KeyRename("key1", "key2", When.NotExists);
+ ClassicAssert.IsTrue(result);
+
+ string retValue = db.StringGet("key2");
+ ClassicAssert.AreEqual(origValue, retValue);
+
+ origValue = db.StringGet("key1");
+ ClassicAssert.AreEqual(null, origValue);
+ }
+
+ [Test]
+ public void SingleRenameNxWithNewKeyAlreadyExist()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ string origValue = "test1";
+ string origValue2 = "test2";
+ db.StringSet("key1", origValue);
+ db.StringSet("key2", origValue2);
+
+ var result = db.KeyRename("key1", "key2", When.NotExists);
+ ClassicAssert.IsFalse(result);
+
+ string retValue2 = db.StringGet("key2");
+ ClassicAssert.AreEqual(origValue2, retValue2);
+
+ string retValue1 = db.StringGet("key1");
+ ClassicAssert.AreEqual(origValue, retValue1);
+ }
+
+ [Test]
+ public void SingleRenameNxWithExpiry()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ var origValue = "test1";
+ db.StringSet("key1", origValue, TimeSpan.FromMinutes(1));
+
+ var result = db.KeyRename("key1", "key2", When.NotExists);
+ ClassicAssert.IsTrue(result);
+
+ string retValue = db.StringGet("key2");
+ ClassicAssert.AreEqual(origValue, retValue);
+
+ var ttl = db.KeyTimeToLive("key2");
+ ClassicAssert.IsTrue(ttl.HasValue);
+ ClassicAssert.Greater(ttl.Value.TotalMilliseconds, 0);
+ ClassicAssert.LessOrEqual(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
+ }
+
+ [Test]
+ public void SingleRenameNxWithExpiryAndNewKeyAlreadyExist()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ var origValue = "test1";
+ string origValue2 = "test2";
+ db.StringSet("key1", origValue, TimeSpan.FromMinutes(1));
+ db.StringSet("key2", origValue2, TimeSpan.FromMinutes(1));
+
+ var result = db.KeyRename("key1", "key2", When.NotExists);
+ ClassicAssert.IsFalse(result);
+
+ string retValue = db.StringGet("key2");
+ ClassicAssert.AreEqual(origValue2, retValue);
+
+ var ttl = db.KeyTimeToLive("key2");
+ ClassicAssert.IsTrue(ttl.HasValue);
+ ClassicAssert.Greater(ttl.Value.TotalMilliseconds, 0);
+ ClassicAssert.LessOrEqual(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
+
+ string retValue1 = db.StringGet("key1");
+ CollectionAssert.AreEqual(origValue, retValue1);
+ }
+
+ [Test]
+ public void SingleRenameNxObjectStore()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ var origList = new RedisValue[] { "a", "b", "c", "d" };
+ var key1 = "lkey1";
+ var count = db.ListRightPush(key1, origList);
+ var result = db.ListRange(key1);
+ var key2 = "lkey2";
+
+ var rb = db.KeyRename(key1, key2, When.NotExists);
+ ClassicAssert.IsTrue(rb);
+
+ result = db.ListRange(key1);
+ CollectionAssert.AreEqual(Array.Empty(), result);
+
+ result = db.ListRange(key2);
+ CollectionAssert.AreEqual(origList, result);
+ }
+
+ [Test]
+ public void SingleRenameNxObjectStoreWithNewKeyAlreadyExist()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ var origList = new RedisValue[] { "a", "b", "c", "d" };
+ var origList2 = new RedisValue[] { "z", "y", "z" };
+ var key1 = "lkey1";
+ var key2 = "lkey2";
+ db.ListRightPush(key1, origList);
+ db.ListRightPush(key2, origList2);
+
+ var rb = db.KeyRename(key1, key2, When.NotExists);
+ ClassicAssert.IsFalse(rb);
+
+ var result = db.ListRange(key1);
+ ClassicAssert.AreEqual(origList, result);
+
+ result = db.ListRange(key2);
+ ClassicAssert.AreEqual(origList2, result);
+ }
+
+ [Test]
+ public void SingleRenameNxObjectStoreWithExpiry()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ var origList = new RedisValue[] { "a", "b", "c", "d" };
+ var key1 = "lkey1";
+ var count = db.ListRightPush(key1, origList);
+ var result = db.ListRange(key1);
+ var expirySet = db.KeyExpire("lkey1", TimeSpan.FromMinutes(1));
+ var key2 = "lkey2";
+
+ var rb = db.KeyRename(key1, key2, When.NotExists);
+ ClassicAssert.IsTrue(rb);
+
+ result = db.ListRange(key1);
+ ClassicAssert.AreEqual(Array.Empty(), result);
+
+ result = db.ListRange(key2);
+ ClassicAssert.AreEqual(origList, result);
+
+ var ttl = db.KeyTimeToLive(key2);
+ ClassicAssert.IsTrue(ttl.HasValue);
+ ClassicAssert.Greater(ttl.Value.TotalMilliseconds, 0);
+ ClassicAssert.LessOrEqual(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
+ }
+
+ [Test]
+ public void SingleRenameNxObjectStoreWithExpiryAndNewKeyAlreadyExist()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ var origList = new RedisValue[] { "a", "b", "c", "d" };
+ var origList2 = new RedisValue[] { "x", "y", "z" };
+ var key1 = "lkey1";
+ var key2 = "lkey2";
+ db.ListRightPush(key1, origList);
+ db.ListRightPush(key2, origList2);
+ var result = db.ListRange(key1);
+ var expirySet = db.KeyExpire(key1, TimeSpan.FromMinutes(1));
+
+ var rb = db.KeyRename(key1, key2, When.NotExists);
+ ClassicAssert.IsFalse(rb);
+
+ result = db.ListRange(key1);
+ ClassicAssert.AreEqual(origList, result);
+
+ result = db.ListRange(key2);
+ ClassicAssert.AreEqual(origList2, result);
+
+ var ttl = db.KeyTimeToLive(key1);
+ ClassicAssert.IsTrue(ttl.HasValue);
+ ClassicAssert.Greater(ttl.Value.TotalMilliseconds, 0);
+ ClassicAssert.LessOrEqual(ttl.Value.TotalMilliseconds, TimeSpan.FromMinutes(1).TotalMilliseconds);
+
+ var ttl2 = db.KeyTimeToLive(key2);
+ ClassicAssert.IsFalse(ttl2.HasValue);
+ }
+
+ [Test]
+ public void SingleRenameNxWithKeyNotExist()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+
+ var exception = Assert.Throws(() => db.KeyRename("key1", "key2", When.NotExists));
+ ClassicAssert.AreEqual("ERR no such key", exception.Message);
+ }
+
+ [Test]
+ public void SingleRenameNxWithOldKeyAndNewKeyAsSame()
+ {
+ using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
+ var db = redis.GetDatabase(0);
+ var origValue = "test1";
+ var key = "key1";
+ db.StringSet(key, origValue);
+
+ var result = db.KeyRename(key, key, When.NotExists);
+
+ ClassicAssert.IsTrue(result);
+ string retValue = db.StringGet(key);
+ ClassicAssert.AreEqual(origValue, retValue);
+ }
+
+ #endregion
+
[Test]
public void CanSelectCommand()
{
diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md
index af95155613..00ed34db2d 100644
--- a/website/docs/commands/api-compatibility.md
+++ b/website/docs/commands/api-compatibility.md
@@ -99,7 +99,7 @@ Note that this list is subject to change as we continue to expand our API comman
| | [PTTL](generic-commands.md#pttl) | ➕ | |
| | RANDOMKEY | ➖ | |
| | [RENAME](generic-commands.md#rename) | ➕ | |
-| | RENAMENX | ➖ | |
+| | [RENAMENX](generic-commands.md#renamenx) | ➕ | |
| | RESTORE | ➖ | |
| | [SCAN](generic-commands.md#scan) | ➕ | |
| | SORT | ➖ | |
diff --git a/website/docs/commands/generic-commands.md b/website/docs/commands/generic-commands.md
index 8eecbfdede..2e4a330047 100644
--- a/website/docs/commands/generic-commands.md
+++ b/website/docs/commands/generic-commands.md
@@ -263,6 +263,26 @@ Renames key to newkey. It returns an error when key does not exist. If newkey al
Simple string reply: OK.
---
+
+### RENAMENX
+
+#### Syntax
+
+```bash
+ RENAME key newkey
+```
+
+Renames key to newkey if newkey does not yet exist. It returns an error when key does not exist.
+
+#### Resp Reply
+
+One of the following:
+
+* Integer reply: 1 if key was renamed to newkey.
+* Integer reply: 0 if newkey already exists.
+
+---
+
### SCAN
#### Syntax