diff --git a/libs/server/API/GarnetApi.cs b/libs/server/API/GarnetApi.cs index 9d1c0cf817..6c17967e17 100644 --- a/libs/server/API/GarnetApi.cs +++ b/libs/server/API/GarnetApi.cs @@ -127,8 +127,8 @@ public GarnetStatus SET_Conditional(ref SpanByte key, ref RawStringInput input) => storageSession.SET_Conditional(ref key, ref input, ref context); /// - public GarnetStatus SET_Conditional(ref SpanByte key, ref RawStringInput input, ref SpanByteAndMemory output, RespCommand cmd) - => storageSession.SET_Conditional(ref key, ref input, ref output, ref context, cmd); + public GarnetStatus SET_Conditional(ref SpanByte key, ref RawStringInput input, ref SpanByteAndMemory output) + => storageSession.SET_Conditional(ref key, ref input, ref output, ref context); /// public GarnetStatus SET(ArgSlice key, Memory value) diff --git a/libs/server/API/GarnetStatus.cs b/libs/server/API/GarnetStatus.cs index 3e9c26eb02..0972da6fb6 100644 --- a/libs/server/API/GarnetStatus.cs +++ b/libs/server/API/GarnetStatus.cs @@ -24,9 +24,5 @@ public enum GarnetStatus : byte /// Wrong type /// WRONGTYPE, - /// - /// ETAG mismatch result for an etag based command - /// - ETAGMISMATCH, } } \ No newline at end of file diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 82c9de2106..207111178c 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -41,7 +41,7 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi /// /// SET Conditional /// - GarnetStatus SET_Conditional(ref SpanByte key, ref RawStringInput input, ref SpanByteAndMemory output, RespCommand cmd); + GarnetStatus SET_Conditional(ref SpanByte key, ref RawStringInput input, ref SpanByteAndMemory output); /// /// SET diff --git a/libs/server/Resp/BasicCommands.cs b/libs/server/Resp/BasicCommands.cs index 8b81ce30d6..3422e88ebb 100644 --- a/libs/server/Resp/BasicCommands.cs +++ b/libs/server/Resp/BasicCommands.cs @@ -386,11 +386,96 @@ private bool NetworkGETIFNOTMATCH(ref TGarnetApi storageApi) private bool NetworkSETIFMATCH(ref TGarnetApi storageApi) where TGarnetApi : IGarnetApi { - Debug.Assert(parseState.Count == 3); + if (parseState.Count < 3 || parseState.Count > 5) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.SETIFMATCH)); + } - var key = parseState.GetArgSliceByRef(0).SpanByte; + // SETIFMATCH Args: KEY VAL ETAG -> [ ((EX || PX) expiration)] + int expiry = 0; + ReadOnlySpan errorMessage = default; + var expOption = ExpirationOption.None; + + var tokenIdx = 3; + Span nextOpt = default; + var optUpperCased = false; + while (tokenIdx < parseState.Count || optUpperCased) + { + if (!optUpperCased) + { + nextOpt = parseState.GetArgSliceByRef(tokenIdx++).Span; + } - NetworkSET_Conditional(RespCommand.SETIFMATCH, 0, ref key, getValue: true, highPrecision: false, withEtag: true, ref storageApi); + if (nextOpt.SequenceEqual(CmdStrings.EX)) + { + // Validate expiry + if (!parseState.TryGetInt(tokenIdx++, out expiry)) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER; + break; + } + + if (expOption != ExpirationOption.None) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_SYNTAX_ERROR; + break; + } + + expOption = ExpirationOption.EX; + if (expiry <= 0) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_INVALIDEXP_IN_SET; + break; + } + } + else if (nextOpt.SequenceEqual(CmdStrings.PX)) + { + // Validate expiry + if (!parseState.TryGetInt(tokenIdx++, out expiry)) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER; + break; + } + + if (expOption != ExpirationOption.None) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_SYNTAX_ERROR; + break; + } + + expOption = ExpirationOption.PX; + if (expiry <= 0) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_INVALIDEXP_IN_SET; + break; + } + } + else + { + if (!optUpperCased) + { + AsciiUtils.ToUpperInPlace(nextOpt); + optUpperCased = true; + continue; + } + + errorMessage = CmdStrings.RESP_ERR_GENERIC_UNK_CMD; + break; + } + + optUpperCased = false; + } + + if (!errorMessage.IsEmpty) + { + while (!RespWriteUtils.WriteError(errorMessage, ref dcurr, dend)) + SendAndReset(); + return true; + } + + SpanByte key = parseState.GetArgSliceByRef(0).SpanByte; + + NetworkSET_Conditional(RespCommand.SETIFMATCH, expiry, ref key, getValue: true, highPrecision: expOption == ExpirationOption.PX, withEtag: true, ref storageApi); return true; } @@ -797,7 +882,7 @@ private bool NetworkSET_Conditional(RespCommand cmd, int expiry, ref // anything with getValue or withEtag may choose to write to the buffer in success scenarios outputBuffer = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)); status = storageApi.SET_Conditional(ref key, - ref input, ref outputBuffer, cmd); + ref input, ref outputBuffer); } else { @@ -809,13 +894,8 @@ private bool NetworkSET_Conditional(RespCommand cmd, int expiry, ref switch ((getValue, withEtag, cmd, status)) { - case (_, _, RespCommand.SETIFMATCH, GarnetStatus.ETAGMISMATCH): // write back mismatch error - while (!RespWriteUtils.WriteDirect(CmdStrings.RESP_ETAGMISMTACH, ref dcurr, dend)) - SendAndReset(); - break; - // since SET with etag goes down RMW a not found is okay and data is on buffer - case (_, true, RespCommand.SET, GarnetStatus.NOTFOUND): + case (_, true, RespCommand.SET, GarnetStatus.NOTFOUND): // if getvalue || etag and Status is OK then the response is always on the buffer, getvalue is never used with conditionals // extra pattern matching on command below for invariant get value cannot be used with EXXX and EXNX case (true, _, RespCommand.SET or RespCommand.SETIFMATCH or RespCommand.SETKEEPTTL, GarnetStatus.OK): diff --git a/libs/server/Storage/Functions/MainStore/RMWMethods.cs b/libs/server/Storage/Functions/MainStore/RMWMethods.cs index af2d1a116a..0b08be5f45 100644 --- a/libs/server/Storage/Functions/MainStore/RMWMethods.cs +++ b/libs/server/Storage/Functions/MainStore/RMWMethods.cs @@ -292,7 +292,7 @@ private bool InPlaceUpdaterWorker(ref SpanByte key, ref RawStringInput input, re var cmd = input.header.cmd; int etagIgnoredOffset = 0; int etagIgnoredEnd = -1; - long oldEtag = -1; + long oldEtag = Constants.BaseEtag; if (recordInfo.ETag) { etagIgnoredOffset = Constants.EtagSize; @@ -322,27 +322,37 @@ private bool InPlaceUpdaterWorker(ref SpanByte key, ref RawStringInput input, re case RespCommand.SETIFMATCH: long etagFromClient = input.parseState.GetLong(1); - // No Etag is the same as having an etag of 0 - long prevEtag = recordInfo.ETag ? *(long*)value.ToPointer() : 0; - - if (prevEtag != etagFromClient) + if (oldEtag != etagFromClient) { - // Cancelling the operation and returning false is used to indicate ETAGMISMATCH - rmwInfo.Action = RMWAction.CancelOperation; - return false; + // write back array of the format [etag, value] + var valueToWrite = value.AsReadOnlySpan(etagIgnoredOffset); + var digitsInLenOfValue = NumUtils.NumDigitsInLong(valueToWrite.Length); + // *2\r\n: + + \r\n + $ + + \r\n + + \r\n + var numDigitsInEtag = NumUtils.NumDigitsInLong(oldEtag); + WriteValAndEtagToDst(4 + 1 + numDigitsInEtag + 2 + 1 + digitsInLenOfValue + 2 + valueToWrite.Length + 2, ref valueToWrite, oldEtag, ref output, writeDirect: false); + return true; } // Need Copy update if no space for new value var inputValue = input.parseState.GetArgSliceByRef(0); - if (value.Length < inputValue.length + Constants.EtagSize) + + // retain metadata unless metadata sent + int metadataSize = input.arg1 != 0 ? sizeof(long) : value.MetadataSize; + + if (value.Length < inputValue.length + Constants.EtagSize + metadataSize) return false; + if (input.arg1 != 0) + { + value.ExtraMetadata = input.arg1; + } + recordInfo.SetHasETag(); // Increment the ETag - long newEtag = prevEtag + 1; + long newEtag = oldEtag + 1; // Adjust value length if user shrinks it, how to get rid of spanbyte infront - value.ShrinkSerializedLength(inputValue.Length + Constants.EtagSize); + value.ShrinkSerializedLength(metadataSize + inputValue.Length + Constants.EtagSize); rmwInfo.SetUsedValueLength(ref recordInfo, ref value, value.TotalSize); rmwInfo.ClearExtraValueLength(ref recordInfo, ref value, value.TotalSize); @@ -379,7 +389,7 @@ private bool InPlaceUpdaterWorker(ref SpanByte key, ref RawStringInput input, re ArgSlice setValue = input.parseState.GetArgSliceByRef(0); // Need CU if no space for new value - int metadataSize = input.arg1 == 0 ? 0 : sizeof(long); + metadataSize = input.arg1 == 0 ? 0 : sizeof(long); if (setValue.Length + metadataSize > value.Length - nextUpdateEtagOffset) return false; @@ -433,7 +443,6 @@ private bool InPlaceUpdaterWorker(ref SpanByte key, ref RawStringInput input, re // this is the case where we have withetag option and no etag from before nextUpdateEtagOffset = Constants.EtagSize; nextUpdateEtagIgnoredEnd = value.LengthWithoutMetadata; - oldEtag = Constants.BaseEtag; } setValue = input.parseState.GetArgSliceByRef(0); @@ -765,20 +774,24 @@ public bool NeedCopyUpdate(ref SpanByte key, ref RawStringInput input, ref SpanB long etagToCheckWith = input.parseState.GetLong(1); // lack of an etag is the same as having a zero'd etag long existingEtag; - // No Etag is the same as having an etag of 0 + // No Etag is the same as having the base etag if (rmwInfo.RecordInfo.ETag) { existingEtag = *(long*)oldValue.ToPointer(); } else { - existingEtag = 0; + existingEtag = Constants.BaseEtag; } if (existingEtag != etagToCheckWith) { - // cancellation and return false indicates ETag mismatch - rmwInfo.Action = RMWAction.CancelOperation; + // write back array of the format [etag, value] + var valueToWrite = oldValue.AsReadOnlySpan(etagIgnoredOffset); + var digitsInLenOfValue = NumUtils.NumDigitsInLong(valueToWrite.Length); + // *2\r\n: + + \r\n + $ + + \r\n + \r\n + var numDigitsInEtag = NumUtils.NumDigitsInLong(existingEtag); + WriteValAndEtagToDst(4 + 1 + numDigitsInEtag + 2 + 1 + digitsInLenOfValue + 2 + valueToWrite.Length + 2, ref valueToWrite, existingEtag, ref output, writeDirect: false); return false; } @@ -848,7 +861,7 @@ public bool CopyUpdater(ref SpanByte key, ref RawStringInput input, ref SpanByte bool shouldUpdateEtag = true; int etagIgnoredOffset = 0; int etagIgnoredEnd = -1; - long oldEtag = -1; + long oldEtag = Constants.BaseEtag; if (recordInfo.ETag) { etagIgnoredEnd = oldValue.LengthWithoutMetadata; @@ -861,24 +874,27 @@ public bool CopyUpdater(ref SpanByte key, ref RawStringInput input, ref SpanByte case RespCommand.SETIFMATCH: shouldUpdateEtag = false; - // No Etag is the same as having an etag of 0 - if (!recordInfo.ETag) - { - oldEtag = 0; - } - - *(long*)newValue.ToPointer() = oldEtag + 1; - // Copy input to value Span dest = newValue.AsSpan(Constants.EtagSize); ReadOnlySpan src = input.parseState.GetArgSliceByRef(0).ReadOnlySpan; - Debug.Assert(src.Length + Constants.EtagSize + oldValue.MetadataSize == newValue.Length); + // retain metadata unless metadata sent + int metadataSize = input.arg1 != 0 ? sizeof(long) : oldValue.MetadataSize; + + Debug.Assert(src.Length + Constants.EtagSize + metadataSize == newValue.Length); - // retain metadata - newValue.ExtraMetadata = oldValue.ExtraMetadata; src.CopyTo(dest); + newValue.ExtraMetadata = oldValue.ExtraMetadata; + if (input.arg1 != 0) + { + newValue.ExtraMetadata = input.arg1; + } + + *(long*)newValue.ToPointer() = oldEtag + 1; + + recordInfo.SetHasETag(); + // Write Etag and Val back to Client CopyRespToWithInput(ref input, ref newValue, ref output, isFromPending: false, 0, -1, hasEtagInVal: true); break; @@ -900,7 +916,6 @@ public bool CopyUpdater(ref SpanByte key, ref RawStringInput input, ref SpanByte // this is the case where we have withetag option and no etag from before nextUpdateEtagOffset = Constants.EtagSize; nextUpdateEtagIgnoredEnd = oldValue.LengthWithoutMetadata; - oldEtag = 0; recordInfo.SetHasETag(); } @@ -914,7 +929,7 @@ public bool CopyUpdater(ref SpanByte key, ref RawStringInput input, ref SpanByte // Copy input to value var newInputValue = input.parseState.GetArgSliceByRef(0).ReadOnlySpan; - int metadataSize = input.arg1 == 0 ? 0 : sizeof(long); + metadataSize = input.arg1 == 0 ? 0 : sizeof(long); // new value when allocated should have 8 bytes more if the previous record had etag and the cmd was not SETEXXX Debug.Assert(newInputValue.Length + metadataSize + nextUpdateEtagOffset == newValue.Length); @@ -947,7 +962,6 @@ public bool CopyUpdater(ref SpanByte key, ref RawStringInput input, ref SpanByte // this is the case where we have withetag option and no etag from before nextUpdateEtagOffset = Constants.EtagSize; nextUpdateEtagIgnoredEnd = oldValue.LengthWithoutMetadata; - oldEtag = 0; recordInfo.SetHasETag(); } diff --git a/libs/server/Storage/Functions/MainStore/VarLenInputMethods.cs b/libs/server/Storage/Functions/MainStore/VarLenInputMethods.cs index 974817a59b..16cbab30c0 100644 --- a/libs/server/Storage/Functions/MainStore/VarLenInputMethods.cs +++ b/libs/server/Storage/Functions/MainStore/VarLenInputMethods.cs @@ -218,8 +218,8 @@ public int GetRMWModifiedValueLength(ref SpanByte t, ref RawStringInput input, b return sizeof(int) + t.LengthWithoutMetadata; case RespCommand.SETIFMATCH: var newValue = input.parseState.GetArgSliceByRef(0).ReadOnlySpan; - // always preserves the metadata and includes the etag - return sizeof(int) + newValue.Length + Constants.EtagSize + t.MetadataSize; + int metadataSize = input.arg1 == 0 ? t.MetadataSize : sizeof(long); + return sizeof(int) + newValue.Length + Constants.EtagSize + metadataSize; case RespCommand.EXPIRE: case RespCommand.PEXPIRE: case RespCommand.EXPIREAT: @@ -250,7 +250,7 @@ public int GetRMWModifiedValueLength(ref SpanByte t, ref RawStringInput input, b { var functions = functionsState.GetCustomCommandFunctions((ushort)cmd); // compute metadata for result - var metadataSize = input.arg1 switch + metadataSize = input.arg1 switch { -1 => 0, 0 => t.MetadataSize, diff --git a/libs/server/Storage/Session/MainStore/HyperLogLogOps.cs b/libs/server/Storage/Session/MainStore/HyperLogLogOps.cs index ac0f877754..f09d34e7c9 100644 --- a/libs/server/Storage/Session/MainStore/HyperLogLogOps.cs +++ b/libs/server/Storage/Session/MainStore/HyperLogLogOps.cs @@ -244,7 +244,7 @@ public unsafe GarnetStatus HyperLogLogMerge(ref RawStringInput input, out bool e parseState.InitializeWithArgument(mergeSlice); currInput.parseState = parseState; - SET_Conditional(ref dstKey, ref currInput, ref mergeBuffer, ref currLockableContext, input.header.cmd); + SET_Conditional(ref dstKey, ref currInput, ref mergeBuffer, ref currLockableContext); #endregion } diff --git a/libs/server/Storage/Session/MainStore/MainStoreOps.cs b/libs/server/Storage/Session/MainStore/MainStoreOps.cs index fe4ff370a8..0ec59bb6d9 100644 --- a/libs/server/Storage/Session/MainStore/MainStoreOps.cs +++ b/libs/server/Storage/Session/MainStore/MainStoreOps.cs @@ -376,7 +376,7 @@ public unsafe GarnetStatus SET_Conditional(ref SpanByte key, ref RawSt } } - public unsafe GarnetStatus SET_Conditional(ref SpanByte key, ref RawStringInput input, ref SpanByteAndMemory output, ref TContext context, RespCommand cmd) + public unsafe GarnetStatus SET_Conditional(ref SpanByte key, ref RawStringInput input, ref SpanByteAndMemory output, ref TContext context) where TContext : ITsavoriteContext { var status = context.RMW(ref key, ref input, ref output); @@ -393,11 +393,6 @@ public unsafe GarnetStatus SET_Conditional(ref SpanByte key, ref RawSt incr_session_notfound(); return GarnetStatus.NOTFOUND; } - else if (cmd == RespCommand.SETIFMATCH && status.IsCanceled) - { - // The RMW operation for SETIFMATCH upon not finding the etags match between the existing record and sent etag returns Cancelled Operation - return GarnetStatus.ETAGMISMATCH; - } else { incr_session_found(); @@ -571,7 +566,6 @@ public unsafe GarnetStatus RENAMENX(ArgSlice oldKeySlice, ArgSlice newKeySlice, return RENAME(oldKeySlice, newKeySlice, storeType, true, out result, withEtag); } - // HK TODO private unsafe GarnetStatus RENAME(ArgSlice oldKeySlice, ArgSlice newKeySlice, StoreType storeType, bool isNX, out int result, bool withEtag) { RawStringInput input = default; @@ -627,7 +621,7 @@ private unsafe GarnetStatus RENAME(ArgSlice oldKeySlice, ArgSlice newKeySlice, S var expirePtrVal = (byte*)expireMemoryHandle.Pointer; RespReadUtils.TryRead64Int(out var expireTimeMs, ref expirePtrVal, expirePtrVal + expireSpan.Length, out var _); - input = isNX ? new RawStringInput(RespCommand.SETEXNX): new RawStringInput(RespCommand.SET); + input = isNX ? new RawStringInput(RespCommand.SETEXNX) : new RawStringInput(RespCommand.SET); // If the key has an expiration, set the new key with the expiration if (expireTimeMs > 0) diff --git a/test/Garnet.test/RespEtagTests.cs b/test/Garnet.test/RespEtagTests.cs index 5a9a8ed3be..32b5138e97 100644 --- a/test/Garnet.test/RespEtagTests.cs +++ b/test/Garnet.test/RespEtagTests.cs @@ -54,14 +54,14 @@ public void SetIfMatchReturnsNewValueAndEtagWhenEtagMatches() IDatabase db = redis.GetDatabase(0); var key = "florida"; - RedisResult res = (RedisResult)db.Execute("SET", [key, "one", "WITHETAG"]); + RedisResult res = db.Execute("SET", [key, "one", "WITHETAG"]); long initalEtag = long.Parse(res.ToString()); ClassicAssert.AreEqual(1, initalEtag); - // ETAGMISMATCH test var incorrectEtag = 1738; - RedisResult etagMismatchMsg = db.Execute("SETIFMATCH", [key, "nextone", incorrectEtag]); - ClassicAssert.AreEqual("ETAGMISMATCH", etagMismatchMsg.ToString()); + RedisResult[] etagMismatchMsg = (RedisResult[])db.Execute("SETIFMATCH", [key, "nextone", incorrectEtag]); + ClassicAssert.AreEqual("1", etagMismatchMsg[0].ToString()); + ClassicAssert.AreEqual("one", etagMismatchMsg[1].ToString()); // set a bigger val RedisResult[] setIfMatchRes = (RedisResult[])db.Execute("SETIFMATCH", [key, "nextone", initalEtag]); @@ -81,8 +81,9 @@ public void SetIfMatchReturnsNewValueAndEtagWhenEtagMatches() ClassicAssert.AreEqual(value, "nextnextone"); // ETAGMISMATCH again - etagMismatchMsg = db.Execute("SETIFMATCH", [key, "lastOne", incorrectEtag]); - ClassicAssert.AreEqual("ETAGMISMATCH", etagMismatchMsg.ToString()); + res = db.Execute("SETIFMATCH", [key, "lastOne", incorrectEtag]); + ClassicAssert.AreEqual(nextEtag.ToString(), res[0].ToString()); + ClassicAssert.AreEqual("nextnextone", res[1].ToString()); // set a smaller val setIfMatchRes = (RedisResult[])db.Execute("SETIFMATCH", [key, "lastOne", nextEtag]); @@ -91,6 +92,122 @@ public void SetIfMatchReturnsNewValueAndEtagWhenEtagMatches() ClassicAssert.AreEqual(4, nextEtag); ClassicAssert.AreEqual(value, "lastOne"); + + // ETAGMISMATCH on data that never had an etag + db.KeyDelete(key); + db.StringSet(key, "one"); + res = db.Execute("SETIFMATCH", [key, "lastOne", incorrectEtag]); + ClassicAssert.AreEqual("0", res[0].ToString()); + ClassicAssert.AreEqual("one", res[1].ToString()); + } + + [Test] + public void SetIfMatchWorksWithExpiration() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + IDatabase db = redis.GetDatabase(0); + + var key = "florida"; + // Scenario: Key existed before and had no expiration + RedisResult res = db.Execute("SET", key, "one", "WITHETAG"); + long initalEtag = long.Parse(res.ToString()); + ClassicAssert.AreEqual(1, initalEtag); + + // expiration added + long updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "nextone", 1, "EX", 100)[0].ToString()); + ClassicAssert.AreEqual(2, updatedEtagRes); + + // confirm expiration added -> TTL should exist + var ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); + + updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "nextoneeexpretained", updatedEtagRes)[0].ToString()); + ClassicAssert.AreEqual(3, updatedEtagRes); + + // TTL should be retained + ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); + + db.KeyDelete(key); // cleanup + + // Scenario: Key existed before and had expiration + res = db.Execute("SET", key, "one", "WITHETAG", "PX", 100000); + + // confirm expiration added -> TTL should exist + ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); + + // change value and retain expiration + updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "nextone", 1)[0].ToString()); + ClassicAssert.AreEqual(2, updatedEtagRes); + + // TTL should be retained + ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); + + // change value and change expiration + updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "nextoneeexpretained", 2, "EX", 100)[0].ToString()); + ClassicAssert.AreEqual(3, updatedEtagRes); + + db.KeyDelete(key); // cleanup + + // Scenario: SET without etag and existing expiration when sent with setifmatch will add etag and retain the expiration too + res = db.Execute("SET", key, "one", "EX", 100000); + // when no etag then count 0 as it's existing etag + updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "nextone", 0)[0].ToString()); + ClassicAssert.AreEqual(1, updatedEtagRes); + + // confirm expiration retained -> TTL should exist + ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); + + // confirm has etag now + var etag = long.Parse(db.Execute("GETWITHETAG", key)[0].ToString()); + ClassicAssert.AreEqual(1, etag); + + db.KeyDelete(key); // cleanup + + + // Scenario: SET without etag and without expiration when sent with setifmatch will add etag and retain the expiration too + // copy update + res = db.Execute("SET", key, "one"); + // when no etag then count 0 as it's existing etag + updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "nextone", 0, "EX", 10000)[0].ToString()); + ClassicAssert.AreEqual(1, updatedEtagRes); + + // confirm expiration retained -> TTL should exist + ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); + + // confirm has etag now + etag = long.Parse(db.Execute("GETWITHETAG", key)[0].ToString()); + ClassicAssert.AreEqual(1, etag); + + // same length update + res = db.Execute("SET", key, "one"); + // when no etag then count 0 as it's existing etag + updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "two", 0, "EX", 10000)[0].ToString()); + ClassicAssert.AreEqual(1, updatedEtagRes); + + // confirm expiration retained -> TTL should exist + ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); + + // confirm has etag now + etag = long.Parse(db.Execute("GETWITHETAG", key)[0].ToString()); + ClassicAssert.AreEqual(1, etag); + + db.KeyDelete(key); // cleanup + + // Scenario: smaller length update + res = db.Execute("SET", key, "oneofusoneofus"); + // when no etag then count 0 as it's existing etag + updatedEtagRes = long.Parse(db.Execute("SETIFMATCH", key, "i", 0, "EX", 10000)[0].ToString()); + ClassicAssert.AreEqual(1, updatedEtagRes); + + // confirm expiration retained -> TTL should exist + ttl = db.KeyTimeToLive(key); + ClassicAssert.IsTrue(ttl.HasValue); } #endregion @@ -338,15 +455,16 @@ public void SETOnAlreadyExistingNonEtagDataOverridesIt() [Test] - public void SetIfMatchOnNonEtagDataReturnsEtagMismatch() + public void SetIfMatchOnNonEtagDataReturnsNewEtagAndValue() { using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); IDatabase db = redis.GetDatabase(0); var _ = db.StringSet("h", "k"); - var res = db.Execute("SETIFMATCH", ["h", "t", "2"]); - ClassicAssert.AreEqual("ETAGMISMATCH", res.ToString()); + var res = (RedisResult[])db.Execute("SETIFMATCH", ["h", "t", "2"]); + ClassicAssert.AreEqual("0", res[0].ToString()); + ClassicAssert.AreEqual("k", res[1].ToString()); } [Test]