Skip to content

Commit 7be3d31

Browse files
authored
feat: add queue auto-discovery support for BullMQ meta keys (#134)
1 parent d9b6b83 commit 7be3d31

3 files changed

Lines changed: 201 additions & 38 deletions

File tree

lib/queue-factory.ts

Lines changed: 82 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,37 @@ import { Integration } from "./interfaces/integration";
99

1010
const chalk = require("chalk");
1111

12-
const queueNameRegExp = new RegExp("(.*):(.*):id");
1312
const maxCount = 150000;
1413
const maxTime = 40000;
1514

15+
const parseQueueKey = (key: string) => {
16+
const suffixSeparator = key.lastIndexOf(":");
17+
if (suffixSeparator === -1) {
18+
return;
19+
}
20+
21+
const keySuffix = key.slice(suffixSeparator + 1);
22+
if (keySuffix !== "id" && keySuffix !== "meta") {
23+
return;
24+
}
25+
26+
const prefixAndQueueName = key.slice(0, suffixSeparator);
27+
const queueNameSeparator = prefixAndQueueName.lastIndexOf(":");
28+
29+
if (queueNameSeparator === -1) {
30+
return;
31+
}
32+
33+
const prefix = prefixAndQueueName.slice(0, queueNameSeparator);
34+
const name = prefixAndQueueName.slice(queueNameSeparator + 1);
35+
36+
if (!prefix || !name) {
37+
return;
38+
}
39+
40+
return { prefix, name };
41+
};
42+
1643
export type RedisConnection = Redis | Cluster;
1744

1845
// We keep a redis client that we can reuse for all the queues.
@@ -28,9 +55,9 @@ export interface FoundQueue {
2855

2956
const scanForQueues = async (node: Redis | Cluster, startTime: number) => {
3057
let cursor = "0";
31-
const keys = [];
58+
const keys = new Set<string>();
3259
do {
33-
const [nextCursor, scannedKeys] = await node.scan(
60+
const [nextCursor, scannedIdKeys] = await node.scan(
3461
cursor,
3562
"MATCH",
3663
"*:*:id",
@@ -39,61 +66,78 @@ const scanForQueues = async (node: Redis | Cluster, startTime: number) => {
3966
"TYPE",
4067
"string"
4168
);
69+
const [, scannedMetaKeys] = await node.scan(
70+
cursor,
71+
"MATCH",
72+
"*:*:meta",
73+
"COUNT",
74+
maxCount,
75+
"TYPE",
76+
"hash"
77+
);
4278
cursor = nextCursor;
4379

44-
keys.push(...scannedKeys);
80+
scannedIdKeys.forEach((key) => keys.add(key));
81+
scannedMetaKeys.forEach((key) => keys.add(key.replace(/:meta$/, ":id")));
4582
} while (Date.now() - startTime < maxTime && cursor !== "0");
4683

47-
return keys;
84+
return [...keys];
4885
};
4986

5087
const getQueueKeys = async (client: Redis | Cluster, queueNames?: string[]) => {
5188
let nodes = "nodes" in client ? client.nodes("master") : [client];
52-
let keys = [];
89+
let keys: string[] = [];
5390
const startTime = Date.now();
5491
const foundQueues = new Set<string>();
92+
const queueKeys = queueNames?.map((queueName) => {
93+
// Separate queue name from prefix
94+
let [prefix, name] = queueName.split(":");
95+
if (!name) {
96+
name = prefix;
97+
prefix = "bull";
98+
}
99+
100+
// If the queue name includes a prefix use that, otherwise use the default prefix "bull"
101+
return `${prefix}:${name}:id`;
102+
});
55103

56104
for await (const node of nodes) {
57105
// If we have proposed queue names, lets check if they exist (including prefix)
58106
// Basically checking if there is a id key for the queue (prefix:name:id)
59-
if (queueNames) {
60-
const queueKeys = queueNames.map((queueName) => {
61-
// Separate queue name from prefix
62-
let [prefix, name] = queueName.split(":");
63-
if (!name) {
64-
name = prefix;
65-
prefix = "bull";
107+
if (queueKeys) {
108+
for (const key of queueKeys) {
109+
if (foundQueues.has(key)) {
110+
continue;
66111
}
67112

68-
// If the queue name includes a prefix use that, otherwise use the default prefix "bull"
69-
return `${prefix}:${name}:id`;
70-
});
71-
72-
for (const key of queueKeys) {
73-
const exists = await node.exists(key);
113+
const metaKey = key.replace(/:id$/, ":meta");
114+
const exists = await node.exists(key, metaKey);
74115
if (exists) {
75116
foundQueues.add(key);
76117
}
77118
}
78-
keys.push(...foundQueues);
79-
80-
// Warn for missing queues
81-
for (const key of queueKeys) {
82-
if (!foundQueues.has(key)) {
83-
// Extract queue name from key
84-
const match = queueNameRegExp.exec(key);
85-
console.log(
86-
chalk.yellow("Redis:") +
87-
chalk.red(
88-
` Queue "${match[1]}:${match[2]}" not found in Redis. Skipping...`
89-
)
90-
);
91-
}
92-
}
93119
} else {
94120
keys.push(...(await scanForQueues(node, startTime)));
95121
}
96122
}
123+
124+
if (queueKeys) {
125+
keys.push(...foundQueues);
126+
127+
// Warn for missing queues
128+
for (const key of queueKeys) {
129+
if (!foundQueues.has(key)) {
130+
// Extract queue name from key
131+
const queue = parseQueueKey(key);
132+
const queueLabel = queue ? `${queue.prefix}:${queue.name}` : key;
133+
console.log(
134+
chalk.yellow("Redis:") +
135+
chalk.red(` Queue "${queueLabel}" not found in Redis. Skipping...`)
136+
);
137+
}
138+
}
139+
}
140+
97141
return keys;
98142
};
99143

@@ -111,11 +155,11 @@ export async function getConnectionQueues(
111155
const queues = await Promise.all(
112156
keys
113157
.map(function (key) {
114-
var match = queueNameRegExp.exec(key);
115-
if (match) {
158+
const queue = parseQueueKey(key);
159+
if (queue) {
116160
return {
117-
prefix: match[1],
118-
name: match[2],
161+
prefix: queue.prefix,
162+
name: queue.name,
119163
type: "bull", // default to bull
120164
};
121165
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
},
4949
"scripts": {
5050
"build": "tsc",
51+
"pretest": "yarn build",
5152
"test": "jest",
5253
"start": "node app.js",
5354
"prepare": "npm run build",

tests/queue-factory.spec.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
const { getConnectionQueues } = require("../dist/queue-factory");
2+
3+
describe("queue auto discovery", () => {
4+
const createMockRedisClient = (idKeys, metaKeys) => ({
5+
scan: jest
6+
.fn()
7+
.mockResolvedValueOnce(["0", idKeys])
8+
.mockResolvedValueOnce(["0", metaKeys]),
9+
exists: jest.fn().mockResolvedValue(1),
10+
hget: jest.fn().mockImplementation((_key, field) => {
11+
if (field === "version") {
12+
return Promise.resolve("bullmq:5.47.0");
13+
}
14+
return Promise.resolve(null);
15+
}),
16+
});
17+
18+
const createMockQueueLookupClient = (exists) => ({
19+
exists: jest.fn().mockImplementation(exists),
20+
hget: jest.fn().mockImplementation((_key, field) => {
21+
if (field === "version") {
22+
return Promise.resolve("bullmq:5.47.0");
23+
}
24+
return Promise.resolve(null);
25+
}),
26+
});
27+
28+
it("discovers queue when only meta key exists", async () => {
29+
const client = createMockRedisClient([], ["bull:emails:meta"]);
30+
31+
const queues = await getConnectionQueues(undefined, undefined, undefined, client);
32+
33+
expect(queues).toHaveLength(1);
34+
expect(queues[0]).toMatchObject({
35+
prefix: "bull",
36+
name: "emails",
37+
type: "bullmq",
38+
majorVersion: 5,
39+
version: "5.47.0",
40+
});
41+
});
42+
43+
it("does not duplicate queue discovered by id and meta keys", async () => {
44+
const client = createMockRedisClient(
45+
["bull:notifications:id"],
46+
["bull:notifications:meta"]
47+
);
48+
49+
const queues = await getConnectionQueues(undefined, undefined, undefined, client);
50+
51+
expect(queues).toHaveLength(1);
52+
expect(queues[0]).toMatchObject({
53+
prefix: "bull",
54+
name: "notifications",
55+
});
56+
expect(client.exists).toHaveBeenCalledTimes(1);
57+
});
58+
59+
it("accepts provided queue names when only the meta key exists", async () => {
60+
const client = createMockQueueLookupClient((...keys) =>
61+
Promise.resolve(keys.includes("bull:emails:meta") ? 1 : 0)
62+
);
63+
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});
64+
65+
const queues = await getConnectionQueues(
66+
undefined,
67+
undefined,
68+
["emails"],
69+
client
70+
);
71+
72+
expect(queues).toHaveLength(1);
73+
expect(queues[0]).toMatchObject({
74+
prefix: "bull",
75+
name: "emails",
76+
type: "bullmq",
77+
majorVersion: 5,
78+
version: "5.47.0",
79+
});
80+
expect(client.exists).toHaveBeenCalledWith(
81+
"bull:emails:id",
82+
"bull:emails:meta"
83+
);
84+
expect(consoleSpy).not.toHaveBeenCalled();
85+
86+
consoleSpy.mockRestore();
87+
});
88+
89+
it("does not duplicate provided queue names across cluster nodes", async () => {
90+
const node = {
91+
exists: jest.fn().mockResolvedValue(1),
92+
};
93+
const client = {
94+
nodes: jest.fn().mockReturnValue([node, node]),
95+
exists: jest.fn().mockResolvedValue(1),
96+
hget: jest.fn().mockImplementation((_key, field) => {
97+
if (field === "version") {
98+
return Promise.resolve("bullmq:5.47.0");
99+
}
100+
return Promise.resolve(null);
101+
}),
102+
};
103+
104+
const queues = await getConnectionQueues(
105+
undefined,
106+
undefined,
107+
["emails"],
108+
client
109+
);
110+
111+
expect(queues).toHaveLength(1);
112+
expect(queues[0]).toMatchObject({
113+
prefix: "bull",
114+
name: "emails",
115+
});
116+
expect(node.exists).toHaveBeenCalledTimes(1);
117+
});
118+
});

0 commit comments

Comments
 (0)