From 07486a11a57bea521125971adceb16df9728f81e Mon Sep 17 00:00:00 2001 From: Tyson Andre Date: Wed, 28 Jul 2021 12:34:42 -0400 Subject: [PATCH] fix(cluster): autopipeline when keyPrefix is used Previously the building of pipelines ignored the key prefix. It was possible that two keys, foo and bar, might be set into the same pipeline. However, after being prefixed by a configured "keyPrefix" value, they may no longer belong to the same pipeline. This led to the error: "All keys in the pipeline should belong to the same slots allocation group" Amended version of https://github.com/luin/ioredis/pull/1335/files - see comments on that PR This may fix #1264 and #1248. --- lib/autoPipelining.ts | 34 ++++++++++++- test/functional/cluster/autopipelining.ts | 58 +++++++++++++++++++---- test/unit/autoPipelining.ts | 19 ++++++++ 3 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 test/unit/autoPipelining.ts diff --git a/lib/autoPipelining.ts b/lib/autoPipelining.ts index 238fe1149..e94d8fdd2 100644 --- a/lib/autoPipelining.ts +++ b/lib/autoPipelining.ts @@ -1,4 +1,5 @@ import * as PromiseContainer from "./promiseContainer"; +import { flatten } from "./utils/lodash"; import * as calculateSlot from "cluster-key-slot"; import asCallback from "standard-as-callback"; @@ -74,11 +75,33 @@ export function shouldUseAutoPipelining( ); } +/** @private */ +export function getFirstValueInFlattenedArray( + args: (string | string[])[] +): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (typeof arg === "string") { + return arg; + } else if (Array.isArray(arg)) { + if (arg.length === 0) { + continue; + } + return arg[0]; + } + const flattened = flatten([arg]); + if (flattened.length > 0) { + return flattened[0]; + } + } + return undefined; +} + export function executeWithAutoPipelining( client, functionName: string, commandName: string, - args: string[], + args: (string | string[])[], callback ) { const CustomPromise = PromiseContainer.get(); @@ -104,7 +127,14 @@ export function executeWithAutoPipelining( } // If we have slot information, we can improve routing by grouping slots served by the same subset of nodes - const slotKey = client.isCluster ? client.slots[calculateSlot(args[0])].join(",") : 'main'; + // Note that the first value in args may be a (possibly empty) array. + // ioredis will only flatten one level of the array, in the Command constructor. + const prefix = client.options.keyPrefix || ""; + const slotKey = client.isCluster + ? client.slots[ + calculateSlot(`${client.options.keyPrefix}${flatten(args)[0]}`) + ].join(",") + : "main"; if (!client._autoPipelines.has(slotKey)) { const pipeline = client.pipeline(); diff --git a/test/functional/cluster/autopipelining.ts b/test/functional/cluster/autopipelining.ts index 02f933d64..82ac77add 100644 --- a/test/functional/cluster/autopipelining.ts +++ b/test/functional/cluster/autopipelining.ts @@ -35,6 +35,10 @@ describe("autoPipelining for cluster", function () { if (argv[0] === "get" && argv[1] === "foo6") { return "bar6"; } + + if (argv[0] === "get" && argv[1] === "baz:foo10") { + return "bar10"; + } }); new MockServer(30002, function (argv) { @@ -68,6 +72,10 @@ describe("autoPipelining for cluster", function () { return "bar5"; } + if (argv[0] === "get" && argv[1] === "baz:foo1") { + return "bar1"; + } + if (argv[0] === "evalsha") { return argv.slice(argv.length - 4); } @@ -177,6 +185,40 @@ describe("autoPipelining for cluster", function () { cluster.disconnect(); }); + it("should support building pipelines when a prefix is used", async () => { + const cluster = new Cluster(hosts, { + enableAutoPipelining: true, + keyPrefix: "baz:", + }); + await new Promise((resolve) => cluster.once("connect", resolve)); + + await cluster.set("foo1", "bar1"); + await cluster.set("foo10", "bar10"); + + expect( + await Promise.all([cluster.get("foo1"), cluster.get("foo10")]) + ).to.eql(["bar1", "bar10"]); + + cluster.disconnect(); + }); + + it("should support building pipelines when a prefix is used with arrays to flatten", async () => { + const cluster = new Cluster(hosts, { + enableAutoPipelining: true, + keyPrefix: "baz:", + }); + await new Promise((resolve) => cluster.once("connect", resolve)); + + await cluster.set(["foo1"], "bar1"); + await cluster.set(["foo10"], "bar10"); + + expect( + await Promise.all([cluster.get(["foo1"]), cluster.get(["foo10"])]) + ).to.eql(["bar1", "bar10"]); + + cluster.disconnect(); + }); + it("should support commands queued after a pipeline is already queued for execution", (done) => { const cluster = new Cluster(hosts, { enableAutoPipelining: true }); @@ -407,9 +449,9 @@ describe("autoPipelining for cluster", function () { const promise4 = cluster.set("foo6", "bar"); // Override slots to induce a failure - const key1Slot = calculateKeySlot('foo1'); - const key2Slot = calculateKeySlot('foo2'); - const key5Slot = calculateKeySlot('foo5'); + const key1Slot = calculateKeySlot("foo1"); + const key2Slot = calculateKeySlot("foo2"); + const key5Slot = calculateKeySlot("foo5"); changeSlot(cluster, key1Slot, key2Slot); changeSlot(cluster, key2Slot, key5Slot); @@ -498,9 +540,9 @@ describe("autoPipelining for cluster", function () { expect(cluster.autoPipelineQueueSize).to.eql(4); // Override slots to induce a failure - const key1Slot = calculateKeySlot('foo1'); - const key2Slot = calculateKeySlot('foo2'); - const key5Slot = calculateKeySlot('foo5'); + const key1Slot = calculateKeySlot("foo1"); + const key2Slot = calculateKeySlot("foo2"); + const key5Slot = calculateKeySlot("foo5"); changeSlot(cluster, key1Slot, key2Slot); changeSlot(cluster, key2Slot, key5Slot); }); @@ -547,8 +589,8 @@ describe("autoPipelining for cluster", function () { expect(cluster.autoPipelineQueueSize).to.eql(3); - const key1Slot = calculateKeySlot('foo1'); - const key2Slot = calculateKeySlot('foo2'); + const key1Slot = calculateKeySlot("foo1"); + const key2Slot = calculateKeySlot("foo2"); changeSlot(cluster, key1Slot, key2Slot); }); }); diff --git a/test/unit/autoPipelining.ts b/test/unit/autoPipelining.ts new file mode 100644 index 000000000..1471d912c --- /dev/null +++ b/test/unit/autoPipelining.ts @@ -0,0 +1,19 @@ +import * as sinon from "sinon"; +import { expect } from "chai"; +import { getFirstValueInFlattenedArray } from "../../lib/autoPipelining"; + +describe("autoPipelining", function () { + it("should be able to efficiently get array args", function () { + expect(getFirstValueInFlattenedArray([])).to.eql(undefined); + expect(getFirstValueInFlattenedArray([null, "key"])).to.eql(null); + expect(getFirstValueInFlattenedArray(["key", "value"])).to.eql("key"); + expect(getFirstValueInFlattenedArray([[], "key"])).to.eql("key"); + expect(getFirstValueInFlattenedArray([["key"]])).to.eql("key"); + // @ts-ignore + expect(getFirstValueInFlattenedArray([[["key"]]])).to.eql(["key"]); + // @ts-ignore + expect(getFirstValueInFlattenedArray([0, 1, 2, 3, 4])).to.eql(0); + // @ts-ignore + expect(getFirstValueInFlattenedArray([[true]])).to.eql(true); + }); +});