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