From 34e253cdb91e9aac97cde8710590c64df30cea29 Mon Sep 17 00:00:00 2001 From: Yury-Fridlyand Date: Thu, 18 Jul 2024 18:33:08 -0700 Subject: [PATCH] Node: Add `FUNCTION LOAD` command (#1969) * Add `FUNCTION LOAD` command. Signed-off-by: Yury-Fridlyand --- CHANGELOG.md | 1 + node/src/Commands.ts | 11 ++ node/src/GlideClient.ts | 31 +++++- node/src/GlideClusterClient.ts | 33 ++++++ node/src/Transaction.ts | 18 ++++ node/tests/RedisClient.test.ts | 105 ++++++++++++++++++- node/tests/RedisClusterClient.test.ts | 142 ++++++++++++++++++++++++++ node/tests/TestUtilities.ts | 54 ++++++++++ 8 files changed, 393 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 911d93abc5..0c4fb488db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Node: Added SDIFF command ([#1924](https://github.com/valkey-io/valkey-glide/pull/1924)) * Node: Added LOLWUT command ([#1934](https://github.com/valkey-io/valkey-glide/pull/1934)) * Node: Added LPOS command ([#1927](https://github.com/valkey-io/valkey-glide/pull/1927)) +* Node: Added FUNCTION LOAD command ([#1969](https://github.com/valkey-io/valkey-glide/pull/1969)) ## 1.0.0 (2024-07-09) diff --git a/node/src/Commands.ts b/node/src/Commands.ts index 9fbf4ccf57..0c61c11070 100644 --- a/node/src/Commands.ts +++ b/node/src/Commands.ts @@ -1526,6 +1526,17 @@ export function createBLPop( return createCommand(RequestType.BLPop, args); } +/** + * @internal + */ +export function createFunctionLoad( + libraryCode: string, + replace?: boolean, +): command_request.Command { + const args = replace ? ["REPLACE", libraryCode] : [libraryCode]; + return createCommand(RequestType.FunctionLoad, args); +} + export type StreamReadOptions = { /** * If set, the read request will block for the set amount of milliseconds or diff --git a/node/src/GlideClient.ts b/node/src/GlideClient.ts index a6700cd941..652a6a74bb 100644 --- a/node/src/GlideClient.ts +++ b/node/src/GlideClient.ts @@ -22,6 +22,7 @@ import { createCustomCommand, createDBSize, createEcho, + createFunctionLoad, createFlushAll, createInfo, createLolwut, @@ -358,7 +359,7 @@ export class GlideClient extends BaseClient { * * @example * ```typescript - * // Example usage of time method without any argument + * // Example usage of time command * const result = await client.time(); * console.log(result); // Output: ['1710925775', '913580'] * ``` @@ -385,6 +386,34 @@ export class GlideClient extends BaseClient { return this.createWritePromise(createLolwut(options)); } + /** + * Loads a library to Valkey. + * + * See https://valkey.io/commands/function-load/ for details. + * + * since Valkey version 7.0.0. + * + * @param libraryCode - The source code that implements the library. + * @param replace - Whether the given library should overwrite a library with the same name if it + * already exists. + * @returns The library name that was loaded. + * + * @example + * ```typescript + * const code = "#!lua name=mylib \n redis.register_function('myfunc', function(keys, args) return args[1] end)"; + * const result = await client.functionLoad(code, true); + * console.log(result); // Output: 'mylib' + * ``` + */ + public functionLoad( + libraryCode: string, + replace?: boolean, + ): Promise { + return this.createWritePromise( + createFunctionLoad(libraryCode, replace), + ); + } + /** * Deletes all the keys of all the existing databases. This command never fails. * The command will be routed to all primary nodes. diff --git a/node/src/GlideClusterClient.ts b/node/src/GlideClusterClient.ts index 88a2baec1d..9a6d334b6f 100644 --- a/node/src/GlideClusterClient.ts +++ b/node/src/GlideClusterClient.ts @@ -22,6 +22,7 @@ import { createCustomCommand, createDBSize, createEcho, + createFunctionLoad, createFlushAll, createInfo, createLolwut, @@ -655,6 +656,38 @@ export class GlideClusterClient extends BaseClient { ); } + /** + * Loads a library to Valkey. + * + * See https://valkey.io/commands/function-load/ for details. + * + * since Valkey version 7.0.0. + * + * @param libraryCode - The source code that implements the library. + * @param replace - Whether the given library should overwrite a library with the same name if it + * already exists. + * @param route - The command will be routed to a random node, unless `route` is provided, in which + * case the client will route the command to the nodes defined by `route`. + * @returns The library name that was loaded. + * + * @example + * ```typescript + * const code = "#!lua name=mylib \n redis.register_function('myfunc', function(keys, args) return args[1] end)"; + * const result = await client.functionLoad(code, true, 'allNodes'); + * console.log(result); // Output: 'mylib' + * ``` + */ + public functionLoad( + libraryCode: string, + replace?: boolean, + route?: Routes, + ): Promise { + return this.createWritePromise( + createFunctionLoad(libraryCode, replace), + toProtobufRoute(route), + ); + } + /** * Deletes all the keys of all the existing databases. This command never fails. * diff --git a/node/src/Transaction.ts b/node/src/Transaction.ts index 767b96cf55..866163c636 100644 --- a/node/src/Transaction.ts +++ b/node/src/Transaction.ts @@ -127,6 +127,7 @@ import { createZRemRangeByRank, createZRemRangeByScore, createZScore, + createFunctionLoad, } from "./Commands"; import { command_request } from "./ProtobufMessage"; @@ -1737,6 +1738,23 @@ export class BaseTransaction> { return this.addAndReturn(createLolwut(options)); } + /** + * Loads a library to Valkey. + * + * See https://valkey.io/commands/function-load/ for details. + * + * since Valkey version 7.0.0. + * + * @param libraryCode - The source code that implements the library. + * @param replace - Whether the given library should overwrite a library with the same name if it + * already exists. + * + * Command Response - The library name that was loaded. + */ + public functionLoad(libraryCode: string, replace?: boolean): T { + return this.addAndReturn(createFunctionLoad(libraryCode, replace)); + } + /** * Deletes all the keys of all the existing databases. This command never fails. * diff --git a/node/tests/RedisClient.test.ts b/node/tests/RedisClient.test.ts index 16e2572dae..0b0b844813 100644 --- a/node/tests/RedisClient.test.ts +++ b/node/tests/RedisClient.test.ts @@ -21,11 +21,12 @@ import { } from ".."; import { RedisCluster } from "../../utils/TestUtils.js"; import { command_request } from "../src/ProtobufMessage"; -import { runBaseTests } from "./SharedTests"; +import { checkIfServerVersionLessThan, runBaseTests } from "./SharedTests"; import { checkSimple, convertStringArrayToBuffer, flushAndCloseClient, + generateLuaLibCode, getClientConfigurationOption, intoString, parseCommandLineArgs, @@ -361,6 +362,108 @@ describe("GlideClient", () => { TIMEOUT, ); + it.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + "function load test_%p", + async (protocol) => { + if (await checkIfServerVersionLessThan("7.0.0")) return; + + const client = await GlideClient.createClient( + getClientConfigurationOption(cluster.getAddresses(), protocol), + ); + + try { + const libName = "mylib1C" + uuidv4().replaceAll("-", ""); + const funcName = "myfunc1c" + uuidv4().replaceAll("-", ""); + const code = generateLuaLibCode( + libName, + new Map([[funcName, "return args[1]"]]), + true, + ); + // TODO use commands instead of customCommand once implemented + // verify function does not yet exist + expect( + await client.customCommand([ + "FUNCTION", + "LIST", + "LIBRARYNAME", + libName, + ]), + ).toEqual([]); + + checkSimple(await client.functionLoad(code)).toEqual(libName); + + checkSimple( + await client.customCommand([ + "FCALL", + funcName, + "0", + "one", + "two", + ]), + ).toEqual("one"); + checkSimple( + await client.customCommand([ + "FCALL_RO", + funcName, + "0", + "one", + "two", + ]), + ).toEqual("one"); + + // TODO verify with FUNCTION LIST + // re-load library without replace + + await expect(client.functionLoad(code)).rejects.toThrow( + `Library '${libName}' already exists`, + ); + + // re-load library with replace + checkSimple(await client.functionLoad(code, true)).toEqual( + libName, + ); + + // overwrite lib with new code + const func2Name = "myfunc2c" + uuidv4().replaceAll("-", ""); + const newCode = generateLuaLibCode( + libName, + new Map([ + [funcName, "return args[1]"], + [func2Name, "return #args"], + ]), + true, + ); + checkSimple(await client.functionLoad(newCode, true)).toEqual( + libName, + ); + + expect( + await client.customCommand([ + "FCALL", + func2Name, + "0", + "one", + "two", + ]), + ).toEqual(2); + expect( + await client.customCommand([ + "FCALL_RO", + func2Name, + "0", + "one", + "two", + ]), + ).toEqual(2); + } finally { + expect( + await client.customCommand(["FUNCTION", "FLUSH"]), + ).toEqual("OK"); + client.close(); + } + }, + ); + it.each([ProtocolVersion.RESP3])("simple pubsub test", async (protocol) => { const pattern = "*"; const channel = "test-channel"; diff --git a/node/tests/RedisClusterClient.test.ts b/node/tests/RedisClusterClient.test.ts index be9fe1cb5a..c34dc8de2c 100644 --- a/node/tests/RedisClusterClient.test.ts +++ b/node/tests/RedisClusterClient.test.ts @@ -18,11 +18,15 @@ import { GlideClusterClient, InfoOptions, ProtocolVersion, + Routes, } from ".."; import { RedisCluster } from "../../utils/TestUtils.js"; import { checkIfServerVersionLessThan, runBaseTests } from "./SharedTests"; import { + checkClusterResponse, + checkSimple, flushAndCloseClient, + generateLuaLibCode, getClientConfigurationOption, getFirstResult, intoArray, @@ -519,6 +523,144 @@ describe("GlideClusterClient", () => { TIMEOUT, ); + describe.each([ProtocolVersion.RESP2, ProtocolVersion.RESP3])( + "Protocol is RESP2 = %s", + (protocol) => { + describe.each([true, false])( + "Single node route = %s", + (singleNodeRoute) => { + it( + "function load", + async () => { + if (await checkIfServerVersionLessThan("7.0.0")) + return; + + const client = + await GlideClusterClient.createClient( + getClientConfigurationOption( + cluster.getAddresses(), + protocol, + ), + ); + + try { + const libName = + "mylib1C" + uuidv4().replaceAll("-", ""); + const funcName = + "myfunc1c" + uuidv4().replaceAll("-", ""); + const code = generateLuaLibCode( + libName, + new Map([[funcName, "return args[1]"]]), + true, + ); + const route: Routes = singleNodeRoute + ? { type: "primarySlotKey", key: "1" } + : "allPrimaries"; + // TODO use commands instead of customCommand once implemented + // verify function does not yet exist + const functionList = await client.customCommand( + [ + "FUNCTION", + "LIST", + "LIBRARYNAME", + libName, + ], + ); + checkClusterResponse( + functionList as object, + singleNodeRoute, + (value) => expect(value).toEqual([]), + ); + // load the library + checkSimple( + await client.functionLoad(code), + ).toEqual(libName); + // call functions from that library to confirm that it works + let fcall = await client.customCommand( + ["FCALL", funcName, "0", "one", "two"], + route, + ); + checkClusterResponse( + fcall as object, + singleNodeRoute, + (value) => + checkSimple(value).toEqual("one"), + ); + + fcall = await client.customCommand( + ["FCALL_RO", funcName, "0", "one", "two"], + route, + ); + checkClusterResponse( + fcall as object, + singleNodeRoute, + (value) => + checkSimple(value).toEqual("one"), + ); + + // re-load library without replace + await expect( + client.functionLoad(code), + ).rejects.toThrow( + `Library '${libName}' already exists`, + ); + + // re-load library with replace + checkSimple( + await client.functionLoad(code, true), + ).toEqual(libName); + + // overwrite lib with new code + const func2Name = + "myfunc2c" + uuidv4().replaceAll("-", ""); + const newCode = generateLuaLibCode( + libName, + new Map([ + [funcName, "return args[1]"], + [func2Name, "return #args"], + ]), + true, + ); + checkSimple( + await client.functionLoad(newCode, true), + ).toEqual(libName); + + fcall = await client.customCommand( + ["FCALL", func2Name, "0", "one", "two"], + route, + ); + checkClusterResponse( + fcall as object, + singleNodeRoute, + (value) => expect(value).toEqual(2), + ); + + fcall = await client.customCommand( + ["FCALL_RO", func2Name, "0", "one", "two"], + route, + ); + checkClusterResponse( + fcall as object, + singleNodeRoute, + (value) => expect(value).toEqual(2), + ); + } finally { + expect( + await client.customCommand([ + "FUNCTION", + "FLUSH", + ]), + ).toEqual("OK"); + client.close(); + } + }, + TIMEOUT, + ); + }, + ); + }, + ); + it.each([ [true, ProtocolVersion.RESP3], [false, ProtocolVersion.RESP3], diff --git a/node/tests/TestUtilities.ts b/node/tests/TestUtilities.ts index 43d1df5fe3..5f5a678acd 100644 --- a/node/tests/TestUtilities.ts +++ b/node/tests/TestUtilities.ts @@ -202,6 +202,44 @@ export function getFirstResult( return Object.values(res).at(0); } +// TODO use matcher instead of predicate +/** Check a multi-node response from a cluster. */ +export function checkClusterMultiNodeResponse( + res: object, + predicate: (value: ReturnType) => void, +) { + for (const nodeResponse of Object.values(res)) { + predicate(nodeResponse); + } +} + +/** Check a response from a cluster. Response could be either single-node (value) or multi-node (string-value map). */ +export function checkClusterResponse( + res: object, + singleNodeRoute: boolean, + predicate: (value: ReturnType) => void, +) { + if (singleNodeRoute) predicate(res as ReturnType); + else checkClusterMultiNodeResponse(res, predicate); +} + +/** Generate a String of LUA library code. */ +export function generateLuaLibCode( + libName: string, + functions: Map, + readonly: boolean, +): string { + let code = `#!lua name=${libName}\n`; + + for (const [functionName, functionBody] of functions) { + code += `redis.register_function{ function_name = '${functionName}', callback = function(keys, args) ${functionBody} end`; + if (readonly) code += ", flags = { 'no-writes' }"; + code += " }\n"; + } + + return code; +} + /** * Parses the command-line arguments passed to the Node.js process. * @@ -570,5 +608,21 @@ export async function transactionTest( args.push(1); baseTransaction.pfcount([key11]); args.push(3); + + const libName = "mylib1C" + uuidv4().replaceAll("-", ""); + const funcName = "myfunc1c" + uuidv4().replaceAll("-", ""); + const code = generateLuaLibCode( + libName, + new Map([[funcName, "return args[1]"]]), + true, + ); + + if (!(await checkIfServerVersionLessThan("7.0.0"))) { + baseTransaction.functionLoad(code); + args.push(libName); + baseTransaction.functionLoad(code, true); + args.push(libName); + } + return args; }