Skip to content

Commit a5d808b

Browse files
authored
feat: support client setinfo (#2011)
* feat: support client setinfo * fix: use require instead of import for package.json version * fix: replace synchronous package.json require with async getPackageMeta utility * refactor: unsafe usage of ReturnStatement
1 parent 7be3c8d commit a5d808b

File tree

4 files changed

+293
-1
lines changed

4 files changed

+293
-1
lines changed

lib/redis/RedisOptions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ export interface CommonRedisOptions extends CommanderOptions {
4444
*/
4545
connectionName?: string;
4646

47+
/**
48+
* If true, skips setting library info via CLIENT SETINFO.
49+
* @link https://redis.io/docs/latest/commands/client-setinfo/
50+
* @default false
51+
*/
52+
disableClientInfo?: boolean;
53+
54+
/**
55+
* Tag to append to the library name in CLIENT SETINFO (ioredis(tag)).
56+
* @link https://redis.io/docs/latest/commands/client-setinfo/
57+
* @default undefined
58+
*/
59+
clientInfoTag?: string;
60+
4761
/**
4862
* If set, client will send AUTH command with the value of this option as the first argument when connected.
4963
* This is supported since Redis 6.
@@ -208,6 +222,8 @@ export const DEFAULT_REDIS_OPTIONS: RedisOptions = {
208222
keepAlive: 0,
209223
noDelay: true,
210224
connectionName: null,
225+
disableClientInfo: false,
226+
clientInfoTag: undefined,
211227
// Sentinel
212228
sentinels: null,
213229
name: null,

lib/redis/event_handler.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { AbortError } from "redis-errors";
55
import Command from "../Command";
66
import { MaxRetriesPerRequestError } from "../errors";
77
import { CommandItem, Respondable } from "../types";
8-
import { Debug, noop, CONNECTION_CLOSED_ERROR_MSG } from "../utils";
8+
import {
9+
Debug,
10+
noop,
11+
CONNECTION_CLOSED_ERROR_MSG,
12+
getPackageMeta,
13+
} from "../utils";
914
import DataHandler from "../DataHandler";
1015

1116
const debug = Debug("connection");
@@ -264,6 +269,33 @@ export function readyHandler(self) {
264269
self.client("setname", self.options.connectionName).catch(noop);
265270
}
266271

272+
if (!self.options?.disableClientInfo) {
273+
debug("set the client info");
274+
275+
let version = null;
276+
277+
getPackageMeta()
278+
.then((packageMeta) => {
279+
version = packageMeta?.version;
280+
})
281+
.catch(noop)
282+
.finally(() => {
283+
self
284+
.client("SETINFO", "LIB-VER", version)
285+
.catch(noop);
286+
});
287+
288+
self
289+
.client(
290+
"SETINFO",
291+
"LIB-NAME",
292+
self.options?.clientInfoTag
293+
? `ioredis(${self.options.clientInfoTag})`
294+
: "ioredis"
295+
)
296+
.catch(noop);
297+
}
298+
267299
if (self.options.readOnly) {
268300
debug("set the connection to readonly mode");
269301
self.readonly().catch(noop);

lib/utils/index.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { promises as fsPromises } from "fs";
2+
import { resolve } from "path";
13
import { parse as urllibParse } from "url";
24
import { defaults, noop } from "./lodash";
35
import { Callback } from "../types";
@@ -319,4 +321,41 @@ export function zipMap<K, V>(keys: K[], values: V[]): Map<K, V> {
319321
return map;
320322
}
321323

324+
/**
325+
* Memoized package metadata to avoid repeated file system reads.
326+
*
327+
* @internal
328+
*/
329+
let cachedPackageMeta: { version: string } = null;
330+
331+
/**
332+
* Retrieves cached package metadata from package.json.
333+
*
334+
* @internal
335+
* @returns {Promise<{version: string} | null>} Package metadata or null if unavailable
336+
*/
337+
export async function getPackageMeta() {
338+
if (cachedPackageMeta) {
339+
return cachedPackageMeta;
340+
}
341+
342+
try {
343+
const filePath = resolve(__dirname, "..", "..", "package.json");
344+
const data = await fsPromises.readFile(filePath, "utf8");
345+
const parsed = JSON.parse(data);
346+
347+
cachedPackageMeta = {
348+
version: parsed.version,
349+
};
350+
351+
return cachedPackageMeta;
352+
} catch (err) {
353+
cachedPackageMeta = {
354+
version: "error-fetching-version",
355+
};
356+
357+
return cachedPackageMeta;
358+
}
359+
}
360+
322361
export { Debug, defaults, noop };

test/functional/client_info.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { expect } from "chai";
2+
import Redis, { Cluster } from "../../lib";
3+
import MockServer from "../helpers/mock_server";
4+
5+
describe("clientInfo", function () {
6+
describe("Redis", function () {
7+
let redis: Redis;
8+
let mockServer: MockServer;
9+
let clientInfoCommands: Array<{ key: string; value: string }>;
10+
11+
beforeEach(() => {
12+
clientInfoCommands = [];
13+
mockServer = new MockServer(30001, (argv) => {
14+
if (
15+
argv[0].toLowerCase() === "client" &&
16+
argv[1].toLowerCase() === "setinfo"
17+
) {
18+
clientInfoCommands.push({
19+
key: argv[2],
20+
value: argv[3],
21+
});
22+
}
23+
});
24+
});
25+
26+
afterEach(() => {
27+
mockServer.disconnect();
28+
29+
if (redis && redis.status !== "end") {
30+
redis.disconnect();
31+
}
32+
});
33+
34+
it("should send client info by default", async () => {
35+
redis = new Redis({ port: 30001 });
36+
37+
// Wait for the client info to be sent, as it happens after the ready event
38+
await redis.ping();
39+
40+
expect(clientInfoCommands).to.have.length(2);
41+
42+
const libVerCommand = clientInfoCommands.find(
43+
(cmd) => cmd.key === "LIB-VER"
44+
);
45+
const libNameCommand = clientInfoCommands.find(
46+
(cmd) => cmd.key === "LIB-NAME"
47+
);
48+
49+
expect(libVerCommand).to.exist;
50+
expect(libVerCommand?.value).to.be.a("string");
51+
expect(libVerCommand?.value).to.not.equal("unknown");
52+
expect(libNameCommand).to.exist;
53+
expect(libNameCommand?.value).to.equal("ioredis");
54+
});
55+
56+
it("should not send client info when disableClientInfo is true", async () => {
57+
redis = new Redis({ port: 30001, disableClientInfo: true });
58+
59+
// Wait for the client info to be sent, as it happens after the ready event
60+
await redis.ping();
61+
62+
expect(clientInfoCommands).to.have.length(0);
63+
});
64+
65+
it("should append tag to library name when clientInfoTag is set", async () => {
66+
redis = new Redis({ port: 30001, clientInfoTag: "tag-test" });
67+
68+
// Wait for the client info to be sent, as it happens after the ready event
69+
await redis.ping();
70+
71+
expect(clientInfoCommands).to.have.length(2);
72+
73+
const libNameCommand = clientInfoCommands.find(
74+
(cmd) => cmd.key === "LIB-NAME"
75+
);
76+
expect(libNameCommand).to.exist;
77+
expect(libNameCommand?.value).to.equal("ioredis(tag-test)");
78+
});
79+
80+
it("should send client info after reconnection", async () => {
81+
redis = new Redis({ port: 30001 });
82+
83+
// Wait for the client info to be sent, as it happens after the ready event
84+
await redis.ping();
85+
redis.disconnect();
86+
87+
// Make sure the client is disconnected
88+
await new Promise<void>((resolve) => {
89+
redis.once("end", () => {
90+
resolve();
91+
});
92+
});
93+
94+
await redis.connect();
95+
await redis.ping();
96+
97+
expect(clientInfoCommands).to.have.length(4);
98+
});
99+
});
100+
101+
describe("Error handling", () => {
102+
let mockServer: MockServer;
103+
let redis: Redis;
104+
105+
afterEach(() => {
106+
mockServer.disconnect();
107+
redis.disconnect();
108+
});
109+
110+
it("should handle server that doesn't support CLIENT SETINFO", async () => {
111+
mockServer = new MockServer(30002, (argv) => {
112+
if (
113+
argv[0].toLowerCase() === "client" &&
114+
argv[1].toLowerCase() === "setinfo"
115+
) {
116+
// Simulate older Redis version that doesn't support SETINFO
117+
return new Error("ERR unknown subcommand 'SETINFO'");
118+
}
119+
});
120+
121+
redis = new Redis({ port: 30002 });
122+
await redis.ping();
123+
124+
expect(redis.status).to.equal("ready");
125+
});
126+
});
127+
128+
describe("Cluster", () => {
129+
let cluster: Cluster;
130+
let mockServers: MockServer[];
131+
let clientInfoCommands: Array<{ key: string; value: string }>;
132+
const slotTable = [
133+
[0, 5000, ["127.0.0.1", 30001]],
134+
[5001, 9999, ["127.0.0.1", 30002]],
135+
[10000, 16383, ["127.0.0.1", 30003]],
136+
];
137+
138+
beforeEach(() => {
139+
clientInfoCommands = [];
140+
141+
// Create mock server that handles both cluster commands and client info
142+
const handler = (argv) => {
143+
if (argv[0] === "cluster" && argv[1] === "SLOTS") {
144+
return slotTable;
145+
}
146+
if (
147+
argv[0].toLowerCase() === "client" &&
148+
argv[1].toLowerCase() === "setinfo"
149+
) {
150+
clientInfoCommands.push({
151+
key: argv[2],
152+
value: argv[3],
153+
});
154+
}
155+
};
156+
157+
mockServers = [
158+
new MockServer(30001, handler),
159+
new MockServer(30002, handler),
160+
new MockServer(30003, handler),
161+
];
162+
});
163+
164+
afterEach(() => {
165+
mockServers.forEach((server) => server.disconnect());
166+
if (cluster) {
167+
cluster.disconnect();
168+
}
169+
});
170+
171+
it("should send client info by default", async () => {
172+
cluster = new Redis.Cluster([{ host: "127.0.0.1", port: 30001 }]);
173+
174+
// Wait for cluster to be ready and send a command to ensure connection
175+
await cluster.ping();
176+
177+
// Should have sent 2 SETINFO commands (LIB-VER and LIB-NAME)
178+
expect(clientInfoCommands).to.have.length.at.least(2);
179+
180+
const libVerCommand = clientInfoCommands.find(
181+
(cmd) => cmd.key === "LIB-VER"
182+
);
183+
const libNameCommand = clientInfoCommands.find(
184+
(cmd) => cmd.key === "LIB-NAME"
185+
);
186+
187+
expect(libVerCommand).to.exist;
188+
expect(libVerCommand?.value).to.be.a("string");
189+
expect(libVerCommand?.value).to.not.equal("unknown");
190+
expect(libNameCommand).to.exist;
191+
expect(libNameCommand?.value).to.equal("ioredis");
192+
});
193+
194+
it("should propagate disableClientInfo to child nodes", async () => {
195+
cluster = new Redis.Cluster([{ host: "127.0.0.1", port: 30001 }], {
196+
redisOptions: {
197+
disableClientInfo: true,
198+
},
199+
});
200+
await cluster.ping();
201+
202+
expect(clientInfoCommands).to.have.length(0);
203+
});
204+
});
205+
});

0 commit comments

Comments
 (0)