diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index b9701595ad..1b77ab83bb 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -3337,6 +3337,61 @@ } ] }, + { + "Command": "LCS", + "Name": "LCS", + "Summary": "Finds the longest common substring.", + "Group": "String", + "Complexity": "O(N*M) where N and M are the lengths of s1 and s2, respectively", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY1", + "DisplayText": "key1", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY2", + "DisplayText": "key2", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LEN", + "DisplayText": "len", + "Type": "PureToken", + "Token": "LEN", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "IDX", + "DisplayText": "idx", + "Type": "PureToken", + "Token": "IDX", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MIN-MATCH-LEN", + "DisplayText": "min-match-len", + "Type": "Integer", + "Token": "MINMATCHLEN", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHMATCHLEN", + "DisplayText": "withmatchlen", + "Type": "PureToken", + "Token": "WITHMATCHLEN", + "ArgumentFlags": "Optional" + } + ] + }, { "Command": "LINDEX", "Name": "LINDEX", diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index b131275ee7..52bd1123c7 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -2113,6 +2113,31 @@ } ] }, + { + "Command": "LCS", + "Name": "LCS", + "Arity": -3, + "Flags": "ReadOnly", + "FirstKey": 1, + "LastKey": 2, + "Step": 1, + "AclCategories": "Read, Slow, String", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 1, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, { "Command": "LINDEX", "Name": "LINDEX", diff --git a/libs/server/API/GarnetApi.cs b/libs/server/API/GarnetApi.cs index 34c91d00a5..9a2d797212 100644 --- a/libs/server/API/GarnetApi.cs +++ b/libs/server/API/GarnetApi.cs @@ -73,6 +73,10 @@ public unsafe GarnetStatus GET(ArgSlice key, out ArgSlice value) /// public GarnetStatus GET(byte[] key, out GarnetObjectStoreOutput value) => storageSession.GET(key, out value, ref objectContext); + + /// + public GarnetStatus LCS(ArgSlice key1, ArgSlice key2, ref SpanByteAndMemory output, bool lenOnly = false, bool withIndices = false, bool withMatchLen = false, int minMatchLen = 0) + => storageSession.LCS(key1, key2, ref output, lenOnly, withIndices, withMatchLen, minMatchLen); #endregion #region GETEX diff --git a/libs/server/API/GarnetWatchApi.cs b/libs/server/API/GarnetWatchApi.cs index 2cec273074..e6a23b6389 100644 --- a/libs/server/API/GarnetWatchApi.cs +++ b/libs/server/API/GarnetWatchApi.cs @@ -49,6 +49,14 @@ public GarnetStatus GET(byte[] key, out GarnetObjectStoreOutput value) garnetApi.WATCH(key, StoreType.Object); return garnetApi.GET(key, out value); } + + /// + public GarnetStatus LCS(ArgSlice key1, ArgSlice key2, ref SpanByteAndMemory output, bool lenOnly = false, bool withIndices = false, bool withMatchLen = false, int minMatchLen = 0) + { + garnetApi.WATCH(key1, StoreType.Object); + garnetApi.WATCH(key2, StoreType.Object); + return garnetApi.LCS(key1, key2, ref output, lenOnly, withIndices, withMatchLen, minMatchLen); + } #endregion #region GETRANGE diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index 25a028e19a..15aad7a4e7 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -1122,6 +1122,19 @@ public interface IGarnetReadApi /// /// GarnetStatus GET(byte[] key, out GarnetObjectStoreOutput value); + + /// + /// Finds the longest common subsequence (LCS) between two keys. + /// + /// The first key to compare. + /// The second key to compare. + /// The output containing the LCS result. + /// If true, only the length of the LCS is returned. + /// If true, the indices of the LCS in both keys are returned. + /// If true, the length of each match is returned. + /// The minimum length of a match to be considered. + /// The status of the operation. + GarnetStatus LCS(ArgSlice key1, ArgSlice key2, ref SpanByteAndMemory output, bool lenOnly = false, bool withIndices = false, bool withMatchLen = false, int minMatchLen = 0); #endregion #region GETRANGE diff --git a/libs/server/Resp/ArrayCommands.cs b/libs/server/Resp/ArrayCommands.cs index 22ea08989a..d1e534ae3b 100644 --- a/libs/server/Resp/ArrayCommands.cs +++ b/libs/server/Resp/ArrayCommands.cs @@ -450,5 +450,77 @@ private bool NetworkArrayPING() WriteDirectLargeRespString(message); return true; } + + private bool NetworkLCS(ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + if (parseState.Count < 2) + return AbortWithWrongNumberOfArguments(nameof(RespCommand.LCS)); + + var key1 = parseState.GetArgSliceByRef(0); + var key2 = parseState.GetArgSliceByRef(1); + + // Parse options + var lenOnly = false; + var withIndices = false; + var minMatchLen = 0; + var withMatchLen = false; + var tokenIdx = 2; + while (tokenIdx < parseState.Count) + { + var option = parseState.GetArgSliceByRef(tokenIdx++).ReadOnlySpan; + + if (option.EqualsUpperCaseSpanIgnoringCase(CmdStrings.LEN)) + { + lenOnly = true; + } + else if (option.EqualsUpperCaseSpanIgnoringCase(CmdStrings.IDX)) + { + withIndices = true; + } + else if (option.EqualsUpperCaseSpanIgnoringCase(CmdStrings.MINMATCHLEN)) + { + if (tokenIdx + 1 > parseState.Count) + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + + if (!parseState.TryGetInt(tokenIdx++, out var minLen)) + { + return AbortWithErrorMessage(CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER); + } + + if (minLen < 0) + { + minLen = 0; + } + + minMatchLen = minLen; + } + else if (option.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WITHMATCHLEN)) + { + withMatchLen = true; + } + else + { + return AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR); + } + } + + if (lenOnly && withIndices) + { + return AbortWithErrorMessage(CmdStrings.RESP_ERR_LENGTH_AND_INDEXES); + } + + var output = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)); + var status = storageApi.LCS(key1, key2, ref output, lenOnly, withIndices, withMatchLen, minMatchLen); + + if (!output.IsSpanByte) + SendAndReset(output.Memory, output.Length); + else + dcurr += output.Length; + + return true; + } } } \ No newline at end of file diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index 22141be4d4..715ebc2f29 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -125,6 +125,10 @@ static partial class CmdStrings public static ReadOnlySpan WEIGHTS => "WEIGHTS"u8; public static ReadOnlySpan AGGREGATE => "AGGREGATE"u8; public static ReadOnlySpan SUM => "SUM"u8; + public static ReadOnlySpan LEN => "LEN"u8; + public static ReadOnlySpan IDX => "IDX"u8; + public static ReadOnlySpan MINMATCHLEN => "MINMATCHLEN"u8; + public static ReadOnlySpan WITHMATCHLEN => "WITHMATCHLEN"u8; /// /// Response strings @@ -140,6 +144,8 @@ static partial class CmdStrings public static ReadOnlySpan RESP_PONG => "+PONG\r\n"u8; public static ReadOnlySpan RESP_EMPTY => "$0\r\n\r\n"u8; public static ReadOnlySpan RESP_QUEUED => "+QUEUED\r\n"u8; + public static ReadOnlySpan matches => "matches"u8; + public static ReadOnlySpan len => "len"u8; /// /// Simple error response strings, i.e. these are of the form "-errorString\r\n" @@ -215,6 +221,7 @@ static partial class CmdStrings 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_INVALID_BITFIELD_TYPE => "ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is"u8; public static ReadOnlySpan RESP_ERR_SCRIPT_FLUSH_OPTIONS => "ERR SCRIPT FLUSH only support SYNC|ASYNC option"u8; + public static ReadOnlySpan RESP_ERR_LENGTH_AND_INDEXES => "If you want both the length and indexes, please just use IDX."u8; /// /// Response string templates diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index 9e706a5859..8d288da07f 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -45,6 +45,7 @@ public enum RespCommand : ushort HSTRLEN, HVALS, KEYS, + LCS, LINDEX, LLEN, LPOS, @@ -772,6 +773,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.DEL; } + else if (*(ulong*)(ptr + 1) == MemoryMarshal.Read("3\r\nLCS\r\n"u8)) + { + return RespCommand.LCS; + } break; diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index f72be029bb..44754e75e1 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -757,6 +757,8 @@ private bool ProcessOtherCommands(RespCommand command, ref TGarnetAp RespCommand.EVAL => TryEVAL(), RespCommand.EVALSHA => TryEVALSHA(), + // Slow commands + RespCommand.LCS => NetworkLCS(ref storageApi), _ => Process(command) }; diff --git a/libs/server/Storage/Session/MainStore/MainStoreOps.cs b/libs/server/Storage/Session/MainStore/MainStoreOps.cs index f4f333963c..c1e069ba43 100644 --- a/libs/server/Storage/Session/MainStore/MainStoreOps.cs +++ b/libs/server/Storage/Session/MainStore/MainStoreOps.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. using System; +using System.Buffers; +using System.Collections.Generic; using System.Diagnostics; using System.Runtime.CompilerServices; using Garnet.common; @@ -1190,5 +1192,286 @@ public GarnetStatus MemoryUsageForKey(ArgSlice key, ou return status; } + + /// + /// Computes the Longest Common Subsequence (LCS) of two keys. + /// + /// The first key to compare. + /// The second key to compare. + /// The output span to store the result. + /// If true, only the length of the LCS is returned. + /// If true, the indices of the LCS in both keys are returned. + /// If true, the length of each match is returned. + /// The minimum length of a match to be considered. + /// The status of the operation. + public unsafe GarnetStatus LCS(ArgSlice key1, ArgSlice key2, ref SpanByteAndMemory output, bool lenOnly = false, bool withIndices = false, bool withMatchLen = false, int minMatchLen = 0) + { + var createTransaction = false; + if (txnManager.state != TxnState.Running) + { + txnManager.SaveKeyEntryToLock(key1, false, LockType.Shared); + txnManager.SaveKeyEntryToLock(key2, false, LockType.Shared); + txnManager.Run(true); + createTransaction = true; + } + + var context = txnManager.LockableContext; + try + { + var status = LCSInternal(key1, key2, ref output, ref context, lenOnly, withIndices, withMatchLen, minMatchLen); + return status; + } + finally + { + if (createTransaction) + txnManager.Commit(true); + } + } + + private unsafe GarnetStatus LCSInternal(ArgSlice key1, ArgSlice key2, ref SpanByteAndMemory output, ref TContext context, bool lenOnly = false, bool withIndices = false, bool withMatchLen = false, int minMatchLen = 0) + where TContext : ITsavoriteContext + { + var isMemory = false; + MemoryHandle ptrHandle = default; + var ptr = output.SpanByte.ToPointer(); + var curr = ptr; + var end = curr + output.Length; + + try + { + ArgSlice val1, val2; + var status1 = GET(key1, out val1, ref context); + var status2 = GET(key2, out val2, ref context); + + if (lenOnly) + { + if (status1 != GarnetStatus.OK || status2 != GarnetStatus.OK) + { + while (!RespWriteUtils.WriteInteger(0, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return GarnetStatus.OK; + } + + var len = ComputeLCSLength(val1.ReadOnlySpan, val2.ReadOnlySpan, minMatchLen); + while (!RespWriteUtils.WriteInteger(len, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } + else if (withIndices) + { + List matches; + int len; + if (status1 != GarnetStatus.OK || status2 != GarnetStatus.OK) + { + matches = new List(); + len = 0; + } + else + { + matches = ComputeLCSWithIndices(val1.ReadOnlySpan, val2.ReadOnlySpan, minMatchLen, out len); + } + + WriteLCSMatches(matches, withMatchLen, len, ref curr, end, ref output, ref isMemory, ref ptr, ref ptrHandle); + } + else + { + if (status1 != GarnetStatus.OK || status2 != GarnetStatus.OK) + { + while (!RespWriteUtils.WriteDirect(CmdStrings.RESP_EMPTY, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return GarnetStatus.OK; + } + + var lcs = ComputeLCS(val1.ReadOnlySpan, val2.ReadOnlySpan, minMatchLen); + while (!RespWriteUtils.WriteBulkString(lcs, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } + } + finally + { + if (isMemory) + ptrHandle.Dispose(); + output.Length = (int)(curr - ptr); + } + + return GarnetStatus.OK; + } + + private static int ComputeLCSLength(ReadOnlySpan str1, ReadOnlySpan str2, int minMatchLen) + { + var m = str1.Length; + var n = str2.Length; + var dp = GetLcsDpTable(str1, str2); + + return dp[m, n] >= minMatchLen ? dp[m, n] : 0; + } + + private static List ComputeLCSWithIndices(ReadOnlySpan str1, ReadOnlySpan str2, int minMatchLen, out int lcsLength) + { + var m = str1.Length; + var n = str2.Length; + var dp = GetLcsDpTable(str1, str2); + + lcsLength = dp[m, n]; + + var matches = new List(); + // Backtrack to find matches + if (dp[m, n] >= minMatchLen) + { + int i = m, j = n; + var currentMatch = new List<(int, int)>(); + + while (i > 0 && j > 0) + { + if (str1[i - 1] == str2[j - 1]) + { + currentMatch.Insert(0, (i - 1, j - 1)); + i--; j--; + } + else if (dp[i - 1, j] > dp[i, j - 1]) + i--; + else + j--; + } + + // Convert consecutive matches into LCSMatch objects + if (currentMatch.Count > 0) + { + int start = 0; + for (int k = 1; k <= currentMatch.Count; k++) + { + if (k == currentMatch.Count || + currentMatch[k].Item1 != currentMatch[k - 1].Item1 + 1 || + currentMatch[k].Item2 != currentMatch[k - 1].Item2 + 1) + { + int length = k - start; + if (length >= minMatchLen) + { + matches.Add(new LCSMatch + { + Start1 = currentMatch[start].Item1, + Start2 = currentMatch[start].Item2, + Length = length + }); + } + start = k; + } + } + } + } + + matches.Reverse(); + + return matches; + } + + private static unsafe void WriteLCSMatches(List matches, bool withMatchLen, int lcsLength, + ref byte* curr, byte* end, ref SpanByteAndMemory output, + ref bool isMemory, ref byte* ptr, ref MemoryHandle ptrHandle) + { + while (!RespWriteUtils.WriteArrayLength(4, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + // Write "matches" section identifier + while (!RespWriteUtils.WriteBulkString(CmdStrings.matches, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + // Write matches array + while (!RespWriteUtils.WriteArrayLength(matches.Count, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + foreach (var match in matches) + { + while (!RespWriteUtils.WriteArrayLength(withMatchLen ? 3 : 2, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + while (!RespWriteUtils.WriteArrayLength(2, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + while (!RespWriteUtils.WriteInteger(match.Start1, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + while (!RespWriteUtils.WriteInteger(match.Start1 + match.Length - 1, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + while (!RespWriteUtils.WriteArrayLength(2, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + while (!RespWriteUtils.WriteInteger(match.Start2, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + while (!RespWriteUtils.WriteInteger(match.Start2 + match.Length - 1, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + if (withMatchLen) + { + while (!RespWriteUtils.WriteInteger(match.Length, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } + } + + // Write "len" section identifier + while (!RespWriteUtils.WriteBulkString(CmdStrings.len, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + // Write LCS length + while (!RespWriteUtils.WriteInteger(lcsLength, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } + + private static byte[] ComputeLCS(ReadOnlySpan str1, ReadOnlySpan str2, int minMatchLen) + { + var m = str1.Length; + var n = str2.Length; + var dp = GetLcsDpTable(str1, str2); + + // If result is shorter than minMatchLen, return empty array + if (dp[m, n] < minMatchLen) + return []; + + // Backtrack to build the LCS + var result = new byte[dp[m, n]]; + int index = dp[m, n] - 1; + int k = m, l = n; + + while (k > 0 && l > 0) + { + if (str1[k - 1] == str2[l - 1]) + { + result[index] = str1[k - 1]; + k--; l--; index--; + } + else if (dp[k - 1, l] > dp[k, l - 1]) + k--; + else + l--; + } + + return result; + } + + private static int[,] GetLcsDpTable(ReadOnlySpan str1, ReadOnlySpan str2) + { + var m = str1.Length; + var n = str2.Length; + var dp = new int[m + 1, n + 1]; + for (int i = 1; i <= m; i++) + { + for (int j = 1; j <= n; j++) + { + if (str1[i - 1] == str2[j - 1]) + dp[i, j] = dp[i - 1, j - 1] + 1; + else + dp[i, j] = Math.Max(dp[i - 1, j], dp[i, j - 1]); + } + } + return dp; + } + + private struct LCSMatch + { + public int Start1; + public int Start2; + public int Length; + } } } \ No newline at end of file diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index b0cbe3dd21..f1b5d3d67f 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -158,6 +158,7 @@ public class SupportedCommand new("INCRBYFLOAT", RespCommand.INCRBYFLOAT), new("INFO", RespCommand.INFO), new("KEYS", RespCommand.KEYS), + new("LCS", RespCommand.LCS), new("LASTSAVE", RespCommand.LASTSAVE), new("LATENCY", RespCommand.LATENCY, [ diff --git a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs index 659a993f17..5d7da8fd4e 100644 --- a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs +++ b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs @@ -206,6 +206,34 @@ public DummyCommand(string commandName) } #region BasicCommands + internal class LCS : BaseCommand + { + public override bool IsArrayCommand => false; + public override bool ArrayResponse => false; + public override string Command => nameof(LCS); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + return [ssk[0], ssk[1]]; + } + + public override string[] GetCrossSlotRequest() + { + var csk = GetCrossSlotKeys; + return [csk[0], csk[1]]; + } + + public override ArraySegment[] SetupSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + var setup = new ArraySegment[2]; + setup[0] = new(["SET", ssk[0], "hello"]); + setup[1] = new(["SET", ssk[1], "world"]); + return setup; + } + } + internal class GET : BaseCommand { public override bool IsArrayCommand => false; diff --git a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs index a7912b8f84..e1ae064223 100644 --- a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs +++ b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs @@ -133,6 +133,7 @@ public class ClusterSlotVerificationTests new WATCHMS(), new WATCHOS(), new SINTERCARD(), + new LCS(), }; ClusterTestContext context; @@ -323,6 +324,7 @@ public virtual void OneTimeTearDown() [TestCase("WATCHOS")] [TestCase("SINTERCARD")] [TestCase("EVALSHA")] + [TestCase("LCS")] public void ClusterCLUSTERDOWNTest(string commandName) { var requestNodeIndex = otherIndex; @@ -474,6 +476,7 @@ void GarnetClientSessionClusterDown(BaseCommand command) [TestCase("WATCHMS")] [TestCase("WATCHOS")] [TestCase("SINTERCARD")] + [TestCase("LCS")] public void ClusterOKTest(string commandName) { var requestNodeIndex = sourceIndex; @@ -636,6 +639,7 @@ void GarnetClientSessionOK(BaseCommand command) [TestCase("WATCHMS")] [TestCase("WATCHOS")] [TestCase("SINTERCARD")] + [TestCase("LCS")] public void ClusterCROSSSLOTTest(string commandName) { var requestNodeIndex = sourceIndex; @@ -790,6 +794,7 @@ void GarnetClientSessionCrossslotTest(BaseCommand command) [TestCase("WATCHOS")] [TestCase("SINTERCARD")] [TestCase("EVALSHA")] + [TestCase("LCS")] public void ClusterMOVEDTest(string commandName) { var requestNodeIndex = targetIndex; @@ -951,6 +956,7 @@ void GarnetClientSessionMOVEDTest(BaseCommand command) [TestCase("WATCHOS")] [TestCase("SINTERCARD")] [TestCase("EVALSHA")] + [TestCase("LCS")] public void ClusterASKTest(string commandName) { var requestNodeIndex = sourceIndex; @@ -1129,6 +1135,7 @@ void GarnetClientSessionASKTest(BaseCommand command) [TestCase("WATCHMS")] [TestCase("WATCHOS")] [TestCase("SINTERCARD")] + [TestCase("LCS")] public void ClusterTRYAGAINTest(string commandName) { var requestNodeIndex = sourceIndex; diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs index bf47124bdb..c5341efa3a 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -3958,6 +3958,21 @@ static async Task DoRPushXMultiAsync(GarnetClient client) } } + [Test] + public async Task LCSACLsAsync() + { + await CheckCommandsAsync( + "LCS", + [DoLCSAsync] + ); + + static async Task DoLCSAsync(GarnetClient client) + { + string val = await client.ExecuteForStringResultAsync("LCS", ["foo", "bar"]); + ClassicAssert.AreEqual("", val); + } + } + [Test] public async Task LLenACLsAsync() { diff --git a/test/Garnet.test/RespTests.cs b/test/Garnet.test/RespTests.cs index 86c0adfa96..4ad3363534 100644 --- a/test/Garnet.test/RespTests.cs +++ b/test/Garnet.test/RespTests.cs @@ -4257,5 +4257,210 @@ public void SubStringWithOptions(string input, int start, int end, string expect } #endregion + + #region LCS + + [Test] + [TestCase("abc", "abc", "abc", Description = "Identical strings")] + [TestCase("hello", "world", "o", Description = "Different strings with common subsequence")] + [TestCase("", "abc", "", Description = "Empty first string")] + [TestCase("abc", "", "", Description = "Empty second string")] + [TestCase("", "", "", Description = "Both empty strings")] + [TestCase("abc", "def", "", Description = "No common subsequence")] + [TestCase("A string", "Another string", "A string", Description = "Strings with spaces")] + [TestCase("ABCDEF", "ACDF", "ACDF", Description = "Multiple common subsequences")] + [TestCase("ABCDEF", "ACEDF", "ACEF", Description = "Multiple common subsequences")] + public void LCSBasicTest(string key1Value, string key2Value, string expectedLCS) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("key1", key1Value); + db.StringSet("key2", key2Value); + + var result = (string)db.Execute("LCS", "key1", "key2"); + ClassicAssert.AreEqual(expectedLCS, result); + } + + [Test] + [TestCase("hello", "world", 1, Description = "Basic length check")] + [TestCase("", "world", 0, Description = "Empty first string length")] + [TestCase("hello", "", 0, Description = "Empty second string length")] + [TestCase("", "", 0, Description = "Both empty strings length")] + [TestCase("abc", "def", 0, Description = "No common subsequence length")] + [TestCase("ABCDEF", "ACEDF", 4, Description = "Multiple common subsequences")] + [TestCase("A string", "Another string", 8, Description = "Strings with spaces")] + public void LCSWithLenOption(string key1Value, string key2Value, int expectedLength) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("key1", key1Value); + db.StringSet("key2", key2Value); + + var result = (long)db.Execute("LCS", "key1", "key2", "LEN"); + ClassicAssert.AreEqual(expectedLength, result); + } + + [Test] + [TestCase("ABCDEF", "ACEDF", 4, new[] { 5, 4, 2, 0 }, new[] { 5, 4, 2, 0 }, new[] { 4, 2, 1, 0 }, new[] { 4, 2, 1, 0 }, Description = "Multiple matches")] + [TestCase("hello", "world", 1, new[] { 4 }, new[] { 4 }, new[] { 1 }, new[] { 1 }, Description = "Basic IDX test")] + [TestCase("abc", "def", 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "No matches")] + [TestCase("", "", 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "Empty strings")] + [TestCase("abc", "", 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "One empty string")] + [TestCase("Hello World!", "Hello Earth!", 8, new[] { 11, 8, 0 }, new[] { 11, 8, 5 }, new[] { 11, 8, 0 }, new[] { 11, 8, 5 }, Description = "Multiple words with punctuation")] + [TestCase("AAABBB", "AAABBB", 6, new[] { 0 }, new[] { 5 }, new[] { 0 }, new[] { 5 }, Description = "Identical strings")] + [TestCase(" abc", "abc ", 3, new[] { 4 }, new[] { 6 }, new[] { 0 }, new[] { 2 }, Description = "Strings with spaces")] + public void LCSWithIdxOption(string key1Value, string key2Value, int expectedLength, + int[] expectedKey1StartPositions, int[] expectedKey1EndPositions, + int[] expectedKey2StartPositions, int[] expectedKey2EndPositions) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("key1", key1Value); + db.StringSet("key2", key2Value); + + var result = db.Execute("LCS", "key1", "key2", "IDX"); + var resultDict = result.ToDictionary(); + + ClassicAssert.AreEqual(expectedLength, (int)resultDict["len"]); + + if (expectedLength > 0) + { + var matches = (RedisResult[])resultDict["matches"]; + for (int i = 0; i < matches.Length; i++) + { + var match = (RedisResult[])matches[i]; + var positions1 = Array.ConvertAll((RedisValue[])match[0], x => (int)x); + var positions2 = Array.ConvertAll((RedisValue[])match[1], x => (int)x); + + ClassicAssert.AreEqual(expectedKey1StartPositions[i], positions1[0]); + ClassicAssert.AreEqual(expectedKey1EndPositions[i], positions1[1]); + ClassicAssert.AreEqual(expectedKey2StartPositions[i], positions2[0]); + ClassicAssert.AreEqual(expectedKey2EndPositions[i], positions2[1]); + } + } + else + { + ClassicAssert.IsEmpty((RedisResult[])resultDict["matches"]); + } + } + + [Test] + [TestCase("ABCDEF", "ACEDF", 2, 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "Basic MINMATCHLEN test")] + [TestCase("hello world12", "hello earth12", 3, 1, new[] { 0 }, new[] { 5 }, new[] { 0 }, new[] { 5 }, Description = "Multiple matches with spaces")] + [TestCase("", "", 0, 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "Empty strings")] + [TestCase("abc", "", 0, 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "One empty string")] + [TestCase("abcdef", "abcdef", 1, 1, new[] { 0 }, new[] { 5 }, new[] { 0 }, new[] { 5 }, Description = "Identical strings")] + [TestCase("AAABBBCCC", "AAACCC", 3, 2, new[] { 6, 0 }, new[] { 8, 2 }, new[] { 3, 0 }, new[] { 5, 2 }, Description = "Repeated characters")] + [TestCase("Hello World!", "Hello Earth!", 4, 1, new[] { 0 }, new[] { 5 }, new[] { 0 }, new[] { 5 }, Description = "Words with punctuation")] + [TestCase(" abc ", "abc", 2, 1, new[] { 4 }, new[] { 6 }, new[] { 0 }, new[] { 2 }, Description = "Strings with leading/trailing spaces")] + public void LCSWithIdxAndMinMatchLen(string key1Value, string key2Value, int minMatchLen, + int expectedMatchCount, int[] expectedKey1StartPositions, int[] expectedKey1EndPositions, + int[] expectedKey2StartPositions, int[] expectedKey2EndPositions) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("key1", key1Value); + db.StringSet("key2", key2Value); + + var result = db.Execute("LCS", "key1", "key2", "IDX", "MINMATCHLEN", minMatchLen); + var resultDict = result.ToDictionary(); + var matches = (RedisResult[])resultDict["matches"]; + + ClassicAssert.AreEqual(expectedMatchCount, matches.Length); + + for (int i = 0; i < expectedMatchCount; i++) + { + var match = (RedisResult[])matches[i]; + var positions1 = Array.ConvertAll((RedisValue[])match[0], x => (int)x); + var positions2 = Array.ConvertAll((RedisValue[])match[1], x => (int)x); + + ClassicAssert.AreEqual(expectedKey1StartPositions[i], positions1[0]); + ClassicAssert.AreEqual(expectedKey1EndPositions[i], positions1[1]); + ClassicAssert.AreEqual(expectedKey2StartPositions[i], positions2[0]); + ClassicAssert.AreEqual(expectedKey2EndPositions[i], positions2[1]); + } + } + + [Test] + [TestCase("ABCDEF", "ACEDF", 0, 4, new[] { 1, 1, 1, 1 }, new[] { 5, 4, 2, 0 }, new[] { 5, 4, 2, 0 }, new[] { 4, 2, 1, 0 }, new[] { 4, 2, 1, 0 }, Description = "Basic WITHMATCHLEN test")] + [TestCase("hello world12", "hello earth12", 3, 1, new[] { 6 }, new[] { 0 }, new[] { 5 }, new[] { 0 }, new[] { 5 }, Description = "Multiple matches with lengths")] + [TestCase("", "", 0, 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "Empty strings")] + [TestCase("abc", "", 0, 0, new int[] { }, new int[] { }, new int[] { }, new int[] { }, new int[] { }, Description = "One empty string")] + [TestCase("abcdef", "abcdef", 0, 1, new[] { 6 }, new[] { 0 }, new[] { 5 }, new[] { 0 }, new[] { 5 }, Description = "Identical strings")] + [TestCase("AAABBBCCC", "AAACCC", 3, 2, new[] { 3, 3 }, new[] { 6, 0 }, new[] { 8, 2 }, new[] { 3, 0 }, new[] { 5, 2 }, Description = "Repeated characters")] + [TestCase("Hello World!", "Hello Earth!", 2, 1, new[] { 6 }, new[] { 0 }, new[] { 5 }, new[] { 0 }, new[] { 5 }, Description = "Words with punctuation")] + [TestCase(" abc ", "abc", 2, 1, new[] { 3 }, new[] { 4 }, new[] { 6 }, new[] { 0 }, new[] { 2 }, Description = "Strings with leading/trailing spaces")] + public void LCSWithIdxAndWithMatchLen(string key1Value, string key2Value, int minMatchLen, + int expectedMatchCount, int[] expectedMatchLengths, int[] expectedKey1StartPositions, + int[] expectedKey1EndPositions, int[] expectedKey2StartPositions, int[] expectedKey2EndPositions) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("key1", key1Value); + db.StringSet("key2", key2Value); + + var result = db.Execute("LCS", "key1", "key2", "IDX", "MINMATCHLEN", minMatchLen, "WITHMATCHLEN"); + var resultDict = result.ToDictionary(); + var matches = (RedisResult[])resultDict["matches"]; + + ClassicAssert.AreEqual(expectedMatchCount, matches.Length); + + for (int i = 0; i < expectedMatchCount; i++) + { + var match = (RedisResult[])matches[i]; + var positions1 = Array.ConvertAll((RedisValue[])match[0], x => (int)x); + var positions2 = Array.ConvertAll((RedisValue[])match[1], x => (int)x); + + ClassicAssert.AreEqual(expectedKey1StartPositions[i], positions1[0]); + ClassicAssert.AreEqual(expectedKey1EndPositions[i], positions1[1]); + ClassicAssert.AreEqual(expectedKey2StartPositions[i], positions2[0]); + ClassicAssert.AreEqual(expectedKey2EndPositions[i], positions2[1]); + ClassicAssert.AreEqual(expectedMatchLengths[i], (int)match[2]); + } + } + + [Test] + public void LCSWithInvalidOptions() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + db.StringSet("key1", "hello"); + db.StringSet("key2", "world"); + + // Invalid option + Assert.Throws(() => db.Execute("LCS", "key1", "key2", "INVALID")); + + // Invalid MINMATCHLEN value + Assert.Throws(() => db.Execute("LCS", "key1", "key2", "IDX", "MINMATCHLEN", "abc")); + } + + [Test] + public void LCSWithNonExistentKeys() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + // Basic LCS with non-existent keys + var result = (string)db.Execute("LCS", "nonexistent1", "nonexistent2"); + ClassicAssert.AreEqual("", result); + + // LCS LEN with non-existent keys + result = db.Execute("LCS", "nonexistent1", "nonexistent2", "LEN").ToString(); + ClassicAssert.AreEqual("0", result); + + // LCS IDX with non-existent keys + var idxResult = db.Execute("LCS", "nonexistent1", "nonexistent2", "IDX"); + var resultDict = idxResult.ToDictionary(); + ClassicAssert.AreEqual(0, (int)resultDict["len"]); + ClassicAssert.IsEmpty((RedisResult[])resultDict["matches"]); + } + + #endregion } } \ No newline at end of file diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md index 3e2e1f3d36..68a653a5fc 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -387,7 +387,7 @@ Note that this list is subject to change as we continue to expand our API comman | | [INCR](raw-string.md#incr) | ➕ | | | | [INCRBY](raw-string.md#incrby) | ➕ | | | | [INCRBYFLOAT](raw-string.md#incrbyfloat) | ➕ | | -| | LCS | ➖ | | +| | [LCS](raw-string.md#lcs) | ➕ | | | | [MGET](raw-string.md#mget) | ➕ | | | | [MSET](raw-string.md#mset) | ➕ | | | | [MSETNX](raw-string.md#msetnx) | ➕ | | diff --git a/website/docs/commands/raw-string.md b/website/docs/commands/raw-string.md index c673da5b1b..47d3314489 100644 --- a/website/docs/commands/raw-string.md +++ b/website/docs/commands/raw-string.md @@ -222,6 +222,33 @@ Bulk string reply: the value of the key after the increment. --- +### LCS + +#### Syntax + +```bash + LCS key1 key2 [LEN] [IDX] [MINMATCHLEN len] [WITHMATCHLEN] +``` + +Returns the longest common subsequence of the values stored at key1 and key2. + +The LCS command supports a set of options that modify its behavior: + +* LEN -- Return the length of the longest common subsequence. +* IDX -- Return the match positions of the longest common subsequence. +* MINMATCHLEN len -- Return only matches of length greater than or equal to len. +* WITHMATCHLEN -- Return the lengths of matches. + +#### Resp Reply + +One of the following: + +* Bulk string reply: the longest common subsequence of the values stored at key1 and key2. +* Integer reply: the length of the longest common subsequence (if LEN is specified). +* Array reply: the match positions of the longest common subsequence (if IDX is specified). + +--- + ### MGET #### Syntax