diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0370e1b3..597081600f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ #### Changes +* Node: Added BLMOVE command ([#2027](https://github.com/valkey-io/valkey-glide/pull/2027)) * Node: Exported client configuration types ([#2023](https://github.com/valkey-io/valkey-glide/pull/2023)) * Java, Python: Update docs for GEOSEARCH command ([#2017](https://github.com/valkey-io/valkey-glide/pull/2017)) * Node: Added FUNCTION LIST command ([#2019](https://github.com/valkey-io/valkey-glide/pull/2019)) diff --git a/node/src/BaseClient.ts b/node/src/BaseClient.ts index 3d0a6c284c..dc17a5eec1 100644 --- a/node/src/BaseClient.ts +++ b/node/src/BaseClient.ts @@ -81,6 +81,7 @@ import { createLInsert, createLLen, createLMove, + createBLMove, createLPop, createLPos, createLPush, @@ -1623,6 +1624,53 @@ export class BaseClient { ); } + /** + * Blocks the connection until it pops atomically and removes the left/right-most element to the + * list stored at `source` depending on `whereFrom`, and pushes the element at the first/last element + * of the list stored at `destination` depending on `whereTo`. + * `BLMOVE` is the blocking variant of {@link lmove}. + * + * @remarks + * 1. When in cluster mode, both `source` and `destination` must map to the same hash slot. + * 2. `BLMOVE` is a client blocking command, see https://github.com/aws/glide-for-redis/wiki/General-Concepts#blocking-commands for more details and best practices. + * + * See https://valkey.io/commands/blmove/ for details. + * + * @param source - The key to the source list. + * @param destination - The key to the destination list. + * @param whereFrom - The {@link ListDirection} to remove the element from. + * @param whereTo - The {@link ListDirection} to add the element to. + * @param timeout - The number of seconds to wait for a blocking operation to complete. A value of `0` will block indefinitely. + * @returns The popped element, or `null` if `source` does not exist or if the operation timed-out. + * + * since Valkey version 6.2.0. + * + * @example + * ```typescript + * await client.lpush("testKey1", ["two", "one"]); + * await client.lpush("testKey2", ["four", "three"]); + * const result = await client.blmove("testKey1", "testKey2", ListDirection.LEFT, ListDirection.LEFT, 0.1); + * console.log(result); // Output: "one" + * + * const result2 = await client.lrange("testKey1", 0, -1); + * console.log(result2); // Output: "two" + * + * const updated_array2 = await client.lrange("testKey2", 0, -1); + * console.log(updated_array2); // Output: ["one", "three", "four"] + * ``` + */ + public async blmove( + source: string, + destination: string, + whereFrom: ListDirection, + whereTo: ListDirection, + timeout: number, + ): Promise { + return this.createWritePromise( + createBLMove(source, destination, whereFrom, whereTo, timeout), + ); + } + /** * Sets the list element at `index` to `element`. * The index is zero-based, so `0` means the first element, `1` the second element and so on. diff --git a/node/src/Commands.ts b/node/src/Commands.ts index e8377c9e47..7d4c7cca19 100644 --- a/node/src/Commands.ts +++ b/node/src/Commands.ts @@ -615,6 +615,25 @@ export function createLMove( ]); } +/** + * @internal + */ +export function createBLMove( + source: string, + destination: string, + whereFrom: ListDirection, + whereTo: ListDirection, + timeout: number, +): command_request.Command { + return createCommand(RequestType.BLMove, [ + source, + destination, + whereFrom, + whereTo, + timeout.toString(), + ]); +} + /** * @internal */ diff --git a/node/src/Transaction.ts b/node/src/Transaction.ts index 24be54d0a6..b6a5eca3c2 100644 --- a/node/src/Transaction.ts +++ b/node/src/Transaction.ts @@ -94,6 +94,7 @@ import { createLInsert, createLLen, createLMove, + createBLMove, createLPop, createLPos, createLPush, @@ -819,6 +820,41 @@ export class BaseTransaction> { ); } + /** + * + * Blocks the connection until it pops atomically and removes the left/right-most element to the + * list stored at `source` depending on `whereFrom`, and pushes the element at the first/last element + * of the list stored at `destination` depending on `whereTo`. + * `BLMOVE` is the blocking variant of {@link lmove}. + * + * @remarks + * 1. When in cluster mode, both `source` and `destination` must map to the same hash slot. + * 2. `BLMOVE` is a client blocking command, see https://github.com/aws/glide-for-redis/wiki/General-Concepts#blocking-commands for more details and best practices. + * + * See https://valkey.io/commands/blmove/ for details. + * + * @param source - The key to the source list. + * @param destination - The key to the destination list. + * @param whereFrom - The {@link ListDirection} to remove the element from. + * @param whereTo - The {@link ListDirection} to add the element to. + * @param timeout - The number of seconds to wait for a blocking operation to complete. A value of `0` will block indefinitely. + * + * Command Response - The popped element, or `null` if `source` does not exist or if the operation timed-out. + * + * since Valkey version 6.2.0. + */ + public blmove( + source: string, + destination: string, + whereFrom: ListDirection, + whereTo: ListDirection, + timeout: number, + ): T { + return this.addAndReturn( + createBLMove(source, destination, whereFrom, whereTo, timeout), + ); + } + /** * Sets the list element at `index` to `element`. * The index is zero-based, so `0` means the first element, `1` the second element and so on. diff --git a/node/tests/RedisClient.test.ts b/node/tests/RedisClient.test.ts index fb4b3e4bd8..4105876521 100644 --- a/node/tests/RedisClient.test.ts +++ b/node/tests/RedisClient.test.ts @@ -30,6 +30,7 @@ import { transactionTest, validateTransactionResponse, } from "./TestUtilities"; +import { ListDirection } from ".."; /* eslint-disable @typescript-eslint/no-var-requires */ @@ -126,6 +127,38 @@ describe("GlideClient", () => { }, ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + "check that blocking commands returns never timeout_%p", + async (protocol) => { + client = await GlideClient.createClient( + getClientConfigurationOption( + cluster.getAddresses(), + protocol, + 300, + ), + ); + + const blmovePromise = client.blmove( + "source", + "destination", + ListDirection.LEFT, + ListDirection.LEFT, + 0.1, + ); + const timeoutPromise = new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + try { + await Promise.race([blmovePromise, timeoutPromise]); + } finally { + Promise.resolve(blmovePromise); + client.close(); + } + }, + 5000, + ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( "select dbsize flushdb test %p", async (protocol) => { diff --git a/node/tests/RedisClusterClient.test.ts b/node/tests/RedisClusterClient.test.ts index 9b29d6196a..4b401a1234 100644 --- a/node/tests/RedisClusterClient.test.ts +++ b/node/tests/RedisClusterClient.test.ts @@ -18,6 +18,7 @@ import { FunctionListResponse, GlideClusterClient, InfoOptions, + ListDirection, ProtocolVersion, Routes, ScoreFilter, @@ -324,6 +325,13 @@ describe("GlideClusterClient", () => { if (gte(cluster.getVersion(), "6.2.0")) { promises.push( + client.blmove( + "abc", + "def", + ListDirection.LEFT, + ListDirection.LEFT, + 0.2, + ), client.zdiff(["abc", "zxy", "lkn"]), client.zdiffWithScores(["abc", "zxy", "lkn"]), client.zdiffstore("abc", ["zxy", "lkn"]), diff --git a/node/tests/SharedTests.ts b/node/tests/SharedTests.ts index 203136507b..ee4a2453eb 100644 --- a/node/tests/SharedTests.ts +++ b/node/tests/SharedTests.ts @@ -1362,6 +1362,133 @@ export function runBaseTests(config: { config.timeout, ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + `blmove list_%p`, + async (protocol) => { + await runTest(async (client: BaseClient, cluster) => { + if (cluster.checkIfServerVersionLessThan("6.2.0")) { + return; + } + + const key1 = "{key}-1" + uuidv4(); + const key2 = "{key}-2" + uuidv4(); + const lpushArgs1 = ["2", "1"]; + const lpushArgs2 = ["4", "3"]; + + // Initialize the tests + expect(await client.lpush(key1, lpushArgs1)).toEqual(2); + expect(await client.lpush(key2, lpushArgs2)).toEqual(2); + + // Move from LEFT to LEFT with blocking + checkSimple( + await client.blmove( + key1, + key2, + ListDirection.LEFT, + ListDirection.LEFT, + 0.1, + ), + ).toEqual("1"); + + // Move from LEFT to RIGHT with blocking + checkSimple( + await client.blmove( + key1, + key2, + ListDirection.LEFT, + ListDirection.RIGHT, + 0.1, + ), + ).toEqual("2"); + + checkSimple(await client.lrange(key2, 0, -1)).toEqual([ + "1", + "3", + "4", + "2", + ]); + checkSimple(await client.lrange(key1, 0, -1)).toEqual([]); + + // Move from RIGHT to LEFT non-existing destination with blocking + checkSimple( + await client.blmove( + key2, + key1, + ListDirection.RIGHT, + ListDirection.LEFT, + 0.1, + ), + ).toEqual("2"); + + checkSimple(await client.lrange(key2, 0, -1)).toEqual([ + "1", + "3", + "4", + ]); + checkSimple(await client.lrange(key1, 0, -1)).toEqual(["2"]); + + // Move from RIGHT to RIGHT with blocking + checkSimple( + await client.blmove( + key2, + key1, + ListDirection.RIGHT, + ListDirection.RIGHT, + 0.1, + ), + ).toEqual("4"); + + checkSimple(await client.lrange(key2, 0, -1)).toEqual([ + "1", + "3", + ]); + checkSimple(await client.lrange(key1, 0, -1)).toEqual([ + "2", + "4", + ]); + + // Non-existing source key with blocking + expect( + await client.blmove( + "{key}-non_existing_key" + uuidv4(), + key1, + ListDirection.LEFT, + ListDirection.LEFT, + 0.1, + ), + ).toEqual(null); + + // Non-list source key with blocking + const key3 = "{key}-3" + uuidv4(); + checkSimple(await client.set(key3, "value")).toEqual("OK"); + await expect( + client.blmove( + key3, + key1, + ListDirection.LEFT, + ListDirection.LEFT, + 0.1, + ), + ).rejects.toThrow(RequestError); + + // Non-list destination key + await expect( + client.blmove( + key1, + key3, + ListDirection.LEFT, + ListDirection.LEFT, + 0.1, + ), + ).rejects.toThrow(RequestError); + + // TODO: add test case with 0 timeout (no timeout) should never time out, + // but we wrap the test with timeout to avoid test failing or stuck forever + }, protocol); + }, + config.timeout, + ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( `lset test_%p`, async (protocol) => { diff --git a/node/tests/TestUtilities.ts b/node/tests/TestUtilities.ts index 479c9da083..60f81133c8 100644 --- a/node/tests/TestUtilities.ts +++ b/node/tests/TestUtilities.ts @@ -542,13 +542,22 @@ export async function transactionTest( field + "3", ]); - baseTransaction.lpopCount(key5, 2); - responseData.push(["lpopCount(key5, 2)", [field + "2"]]); - } else { - baseTransaction.lpopCount(key5, 2); - responseData.push(["lpopCount(key5, 2)", [field + "3", field + "2"]]); + baseTransaction.blmove( + key20, + key5, + ListDirection.LEFT, + ListDirection.LEFT, + 3, + ); + responseData.push([ + "blmove(key20, key5, ListDirection.LEFT, ListDirection.LEFT, 3)", + field + "3", + ]); } + baseTransaction.lpopCount(key5, 2); + responseData.push(["lpopCount(key5, 2)", [field + "3", field + "2"]]); + baseTransaction.linsert( key5, InsertPosition.Before,