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