diff --git a/README.md b/README.md index 7d4a4311..6b887760 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,6 @@ redis.zrange("sortedSet", 0, 2, "WITHSCORES").then((elements) => { redis.set("mykey", "hello", "EX", 10); ``` - See the `examples/` folder for more examples. ## Connect to Redis @@ -164,7 +163,7 @@ new Redis({ password: "my-top-secret", db: 0, // Defaults to 0 }); -```` +``` You can also specify connection options as a [`redis://` URL](http://www.iana.org/assignments/uri-schemes/prov/redis) or [`rediss://` URL](https://www.iana.org/assignments/uri-schemes/prov/rediss) when using [TLS encryption](#tls-options): @@ -496,6 +495,8 @@ redis.myechoBuffer("k1", "k2", "a1", "a2", (err, result) => { redis.pipeline().set("foo", "bar").myecho("k1", "k2", "a1", "a2").exec(); ``` +### Dynamic Keys + If the number of keys can't be determined when defining a command, you can omit the `numberOfKeys` property and pass the number of keys as the first argument when you call the command: @@ -512,6 +513,25 @@ redis.echoDynamicKeyNumber(2, "k1", "k2", "a1", "a2", (err, result) => { }); ``` +### As Constructor Options + +Besides `defineCommand()`, you can also define custom commands with the `scripts` constructor option: + +```javascript +const redis = new Redis({ + scripts: { + myecho: { + numberOfKeys: 2, + lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", + }, + }, +}); +``` + +### TypeScript Usages + +You can refer to [the example](examples/typescript/scripts.ts) for how to declare your custom commands. + ## Transparent Key Prefixing This feature allows you to specify a string that will automatically be prepended @@ -769,7 +789,7 @@ const redis = new Redis({ Set maxRetriesPerRequest to `null` to disable this behavior, and every command will wait forever until the connection is alive again (which is the default behavior before ioredis v4). -### Reconnect on error +### Reconnect on Error Besides auto-reconnect when the connection is closed, ioredis supports reconnecting on certain Redis errors using the `reconnectOnError` option. Here's an example that will reconnect when receiving `READONLY` error: @@ -1020,7 +1040,7 @@ cluster.get("foo", (err, res) => { - `slotsRefreshTimeout`: Milliseconds before a timeout occurs while refreshing slots from the cluster (default `1000`). - `slotsRefreshInterval`: Milliseconds between every automatic slots refresh (default `5000`). -### Read-write splitting +### Read-Write Splitting A typical redis cluster contains three or more masters and several slaves for each master. It's possible to scale out redis cluster by sending read queries to slaves and write queries to masters by setting the `scaleReads` option. @@ -1049,7 +1069,7 @@ cluster.get("foo", (err, res) => { **NB** In the code snippet above, the `res` may not be equal to "bar" because of the lag of replication between the master and slaves. -### Running commands to multiple nodes +### Running Commands to Multiple Nodes Every command will be sent to exactly one node. For commands containing keys, (e.g. `GET`, `SET` and `HGETALL`), ioredis sends them to the node that serving the keys, and for other commands not containing keys, (e.g. `INFO`, `KEYS` and `FLUSHDB`), ioredis sends them to a random node. @@ -1099,7 +1119,7 @@ const cluster = new Redis.Cluster( This option is also useful when the cluster is running inside a Docker container. -### Transaction and pipeline in Cluster mode +### Transaction and Pipeline in Cluster Mode Almost all features that are supported by `Redis` are also supported by `Redis.Cluster`, e.g. custom commands, transaction and pipeline. However there are some differences when using transaction and pipeline in Cluster mode: @@ -1178,7 +1198,7 @@ const cluster = new Redis.Cluster( ); ``` -### Special note: AWS ElastiCache Clusters with TLS +### Special Note: Aws Elasticache Clusters with TLS AWS ElastiCache for Redis (Clustered Mode) supports TLS encryption. If you use this, you may encounter errors with invalid certificates. To resolve this @@ -1223,7 +1243,7 @@ A pipeline will thus contain commands using different slots but that ultimately Note that the same slot limitation within a single command still holds, as it is a Redis limitation. -### Example of automatic pipeline enqueuing +### Example of Automatic Pipeline Enqueuing This sample code uses ioredis with automatic pipeline enabled. diff --git a/examples/typescript/package-lock.json b/examples/typescript/package-lock.json new file mode 100644 index 00000000..3209a5ba --- /dev/null +++ b/examples/typescript/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "typescript", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "typescript", + "version": "0.0.0", + "devDependencies": { + "typescript": "^4.6.2" + } + }, + "node_modules/typescript": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", + "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + } + }, + "dependencies": { + "typescript": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", + "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", + "dev": true + } + } +} diff --git a/examples/typescript/package.json b/examples/typescript/package.json new file mode 100644 index 00000000..73a7e1cb --- /dev/null +++ b/examples/typescript/package.json @@ -0,0 +1,13 @@ +{ + "name": "typescript", + "version": "0.0.0", + "description": "", + "private": true, + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "devDependencies": { + "typescript": "^4.6.2" + } +} diff --git a/examples/typescript/scripts.ts b/examples/typescript/scripts.ts new file mode 100644 index 00000000..5482f694 --- /dev/null +++ b/examples/typescript/scripts.ts @@ -0,0 +1,39 @@ +import Redis, { RedisCommander, Result, Callback } from "ioredis"; +const redis = new Redis(); + +/** + * Define our command + */ +redis.defineCommand("myecho", { + numberOfKeys: 1, + lua: "return KEYS[1] .. ARGV[1]", +}); + +// Add declarations +declare module "ioredis" { + interface RedisCommander { + myecho( + key: string, + argv: string, + callback?: Callback + ): Result; + } +} + +// Works with callbacks +redis.myecho("key", "argv", (err, result) => { + console.log("callback", result); +}); + +// Works with Promises +(async () => { + console.log("promise", await redis.myecho("key", "argv")); +})(); + +// Works with pipelining +redis + .pipeline() + .myecho("key", "argv") + .exec((err, result) => { + console.log("pipeline", result); + }); diff --git a/lib/Redis.ts b/lib/Redis.ts index 66f51bde..1df870ff 100644 --- a/lib/Redis.ts +++ b/lib/Redis.ts @@ -134,6 +134,12 @@ class Redis extends Commander { this.connector = new StandaloneConnector(this.options); } + if (this.options.scripts) { + Object.entries(this.options.scripts).forEach(([name, definition]) => { + this.defineCommand(name, definition); + }); + } + // end(or wait) -> connecting -> connect -> ready -> end if (this.options.lazyConnect) { this.setStatus("wait"); diff --git a/lib/cluster/ClusterOptions.ts b/lib/cluster/ClusterOptions.ts index c85516e5..5c6bc901 100644 --- a/lib/cluster/ClusterOptions.ts +++ b/lib/cluster/ClusterOptions.ts @@ -195,6 +195,14 @@ export interface ClusterOptions extends CommanderOptions { * @default 60000 */ maxScriptsCachingTime?: number; + + /** + * Custom LUA commands + */ + scripts?: Record< + string, + { lua: string; numberOfKeys?: number; readOnly?: boolean } + >; } export const DEFAULT_CLUSTER_OPTIONS: ClusterOptions = { diff --git a/lib/cluster/index.ts b/lib/cluster/index.ts index 0325d769..08b64035 100644 --- a/lib/cluster/index.ts +++ b/lib/cluster/index.ts @@ -1,12 +1,13 @@ -import { EventEmitter } from "events"; import { exists, hasFlag } from "@ioredis/commands"; +import { EventEmitter } from "events"; import { AbortError, RedisError } from "redis-errors"; import asCallback from "standard-as-callback"; -import Pipeline from "../Pipeline"; import Command from "../Command"; import ClusterAllFailedError from "../errors/ClusterAllFailedError"; +import Pipeline from "../Pipeline"; import Redis from "../Redis"; import ScanStream from "../ScanStream"; +import { addTransactionSupport, Transaction } from "../transaction"; import { Callback, ScanStreamOptions, WriteableStream } from "../types"; import { CONNECTION_CLOSED_ERROR_MSG, @@ -36,7 +37,6 @@ import { weightSrvRecords, } from "./util"; import Deque = require("denque"); -import { addTransactionSupport, Transaction } from "../transaction"; const debug = Debug("cluster"); @@ -145,6 +145,12 @@ class Cluster extends Commander { this.subscriber = new ClusterSubscriber(this.connectionPool, this); + if (this.options.scripts) { + Object.entries(this.options.scripts).forEach(([name, definition]) => { + this.defineCommand(name, definition); + }); + } + if (this.options.lazyConnect) { this.setStatus("wait"); } else { diff --git a/lib/index.ts b/lib/index.ts index 64dcd09e..003a655a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -8,6 +8,15 @@ export { default as Cluster } from "./cluster"; */ export { default as Command } from "./Command"; +/** + * @ignore + */ +export { + default as RedisCommander, + Result, + ClientContext, +} from "./utils/RedisCommander"; + /** * @ignore */ @@ -31,6 +40,11 @@ export { SentinelIterator, } from "./connectors/SentinelConnector"; +/** + * @ignore + */ +export { Callback } from "./types"; + // Type Exports export { SentinelAddress } from "./connectors/SentinelConnector"; export { RedisOptions } from "./redis/RedisOptions"; diff --git a/lib/redis/RedisOptions.ts b/lib/redis/RedisOptions.ts index 782d5526..5802f9f2 100644 --- a/lib/redis/RedisOptions.ts +++ b/lib/redis/RedisOptions.ts @@ -32,6 +32,10 @@ interface CommonRedisOptions extends CommanderOptions { enableOfflineQueue?: boolean; enableReadyCheck?: boolean; lazyConnect?: boolean; + scripts?: Record< + string, + { lua: string; numberOfKeys?: number; readOnly?: boolean } + >; } export type RedisOptions = CommonRedisOptions & diff --git a/test/functional/cluster/scripting.ts b/test/functional/cluster/scripting.ts new file mode 100644 index 00000000..2f07b4d1 --- /dev/null +++ b/test/functional/cluster/scripting.ts @@ -0,0 +1,42 @@ +import MockServer from "../../helpers/mock_server"; +import { expect } from "chai"; +import { Cluster } from "../../../lib"; + +describe("cluster:scripting", () => { + it("should throw when not all keys in a pipeline command belong to the same slot", async () => { + const lua = "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"; + const handler = (argv) => { + if (argv[0] === "cluster" && argv[1] === "SLOTS") { + return [ + [0, 12181, ["127.0.0.1", 30001]], + [12182, 16383, ["127.0.0.1", 30002]], + ]; + } + console.log(argv); + if (argv[0] === "eval" && argv[1] === lua && argv[2] === "2") { + return argv.slice(3); + } + }; + new MockServer(30001, handler); + new MockServer(30002, handler); + + const cluster = new Cluster([{ host: "127.0.0.1", port: "30001" }], { + scripts: { test: { lua, numberOfKeys: 2 }, testDynamic: { lua } }, + }); + + // @ts-expect-error + expect(await cluster.test("{foo}1", "{foo}2", "argv1", "argv2")).to.eql([ + "{foo}1", + "{foo}2", + "argv1", + "argv2", + ]); + + expect( + // @ts-expect-error + await cluster.testDynamic(2, "{foo}1", "{foo}2", "argv1", "argv2") + ).to.eql(["{foo}1", "{foo}2", "argv1", "argv2"]); + + cluster.disconnect(); + }); +}); diff --git a/test/functional/scripting.ts b/test/functional/scripting.ts index d2896d17..15c383d5 100644 --- a/test/functional/scripting.ts +++ b/test/functional/scripting.ts @@ -4,6 +4,36 @@ import * as sinon from "sinon"; import { getCommandsFromMonitor } from "../helpers/util"; describe("scripting", () => { + it("accepts constructor options", async () => { + const redis = new Redis({ + scripts: { + test: { + numberOfKeys: 2, + lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", + }, + testDynamic: { + lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", + }, + }, + }); + + // @ts-expect-error + expect(await redis.test("k1", "k2", "a1", "a2")).to.eql([ + "k1", + "k2", + "a1", + "a2", + ]); + // @ts-expect-error + expect(await redis.testDynamic(2, "k1", "k2", "a1", "a2")).to.eql([ + "k1", + "k2", + "a1", + "a2", + ]); + redis.disconnect(); + }); + describe("#numberOfKeys", () => { it("should recognize the numberOfKeys property", (done) => { const redis = new Redis(); @@ -13,7 +43,8 @@ describe("scripting", () => { lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", }); - redis.test("k1", "k2", "a1", "a2", function (err, result) { + // @ts-expect-error + redis.test("k1", "k2", "a1", "a2", (err, result) => { expect(result).to.eql(["k1", "k2", "a1", "a2"]); redis.disconnect(); done(); @@ -27,7 +58,8 @@ describe("scripting", () => { lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", }); - redis.test(2, "k1", "k2", "a1", "a2", function (err, result) { + // @ts-expect-error + redis.test(2, "k1", "k2", "a1", "a2", (err, result) => { expect(result).to.eql(["k1", "k2", "a1", "a2"]); redis.disconnect(); done(); @@ -42,7 +74,8 @@ describe("scripting", () => { lua: "return {ARGV[1],ARGV[2]}", }); - redis.test("2", "a2", function (err, result) { + // @ts-expect-error + redis.test("2", "a2", (err, result) => { expect(result).to.eql(["2", "a2"]); redis.disconnect(); done(); @@ -56,7 +89,8 @@ describe("scripting", () => { lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", }); - redis.test("k1", "k2", "a1", "a2", function (err, result) { + // @ts-expect-error + redis.test("k1", "k2", "a1", "a2", function (err) { expect(err).to.be.instanceof(Error); expect(err.toString()).to.match(/value is not an integer/); redis.disconnect(); @@ -73,7 +107,8 @@ describe("scripting", () => { lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", }); - redis.testBuffer("k1", "k2", "a1", "a2", function (err, result) { + // @ts-expect-error + redis.testBuffer("k1", "k2", "a1", "a2", (err, result) => { expect(result).to.eql([ Buffer.from("k1"), Buffer.from("k2"), @@ -96,8 +131,9 @@ describe("scripting", () => { redis .pipeline() .set("test", "pipeline") + // @ts-expect-error .test("test") - .exec(function (err, results) { + .exec((err, results) => { expect(results).to.eql([ [null, "OK"], [null, "pipeline"], @@ -117,6 +153,7 @@ describe("scripting", () => { redis .pipeline() .set("test", "pipeline") + // @ts-expect-error .test("test") .exec(function (err, results) { expect(err).to.eql(null); @@ -131,9 +168,11 @@ describe("scripting", () => { const redis = new Redis(); redis.defineCommand("test", { lua: "return 1" }); + // @ts-expect-error await redis.test(0); const commands = await getCommandsFromMonitor(redis, 1, () => { + // @ts-expect-error return redis.test(0); }); @@ -152,11 +191,15 @@ describe("scripting", () => { lua: 'return redis.call("get", KEYS[1])', }); + // @ts-expect-error await redis.test("preload"); + // @ts-expect-error await redis.script("flush"); const commands = await getCommandsFromMonitor(redis, 5, async () => { + // @ts-expect-error await redis.test("foo"); + // @ts-expect-error await redis.test("bar"); }); @@ -172,6 +215,7 @@ describe("scripting", () => { lua: 'return redis.call("get", KEYS[1])', }); + // @ts-expect-error await redis.testGet("init"); redis.defineCommand("testSet", { @@ -180,6 +224,7 @@ describe("scripting", () => { }); const commands = await getCommandsFromMonitor(redis, 5, () => { + // @ts-expect-error return redis.pipeline().testGet("foo").testSet("foo").get("foo").exec(); }); @@ -196,10 +241,13 @@ describe("scripting", () => { lua: 'return redis.call("get", KEYS[1])', }); + // @ts-expect-error await redis.test("preload"); + // @ts-expect-error await redis.script("flush"); const spy = sinon.spy(redis, "sendCommand"); const commands = await getCommandsFromMonitor(redis, 4, async () => { + // @ts-expect-error const [a, b] = await redis.multi().test("foo").test("bar").exec(); expect(a[0].message).to.equal( @@ -223,7 +271,9 @@ describe("scripting", () => { lua: 'return redis.call("get", KEYS[1])', }); + // @ts-expect-error await redis.test("preload"); + // @ts-expect-error await redis.script("flush"); const spy = sinon.spy(redis, "sendCommand"); const commands = await getCommandsFromMonitor(redis, 4, async () => { @@ -245,7 +295,8 @@ describe("scripting", () => { lua: "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", }); - redis.echo("k1", "k2", "a1", "a2", function (err, result) { + // @ts-expect-error + redis.echo("k1", "k2", "a1", "a2", (err, result) => { expect(result).to.eql(["foo:k1", "foo:k2", "a1", "a2"]); redis.disconnect(); done();