@sockethub/data-layer 1.0.0-alpha.11 → 1.0.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/credentials-store.d.ts +88 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +55596 -29060
- package/dist/index.js.map +157 -11
- package/dist/job-base.d.ts +44 -0
- package/dist/job-queue.d.ts +60 -0
- package/dist/job-worker.d.ts +58 -0
- package/dist/queue-id.d.ts +2 -0
- package/{src/types.ts → dist/types.d.ts} +2 -13
- package/package.json +8 -8
- package/src/credentials-store.test.ts +0 -171
- package/src/credentials-store.ts +0 -192
- package/src/index.ts +0 -34
- package/src/job-base.ts +0 -124
- package/src/job-queue.test.ts +0 -295
- package/src/job-queue.ts +0 -247
- package/src/job-worker.test.ts +0 -142
- package/src/job-worker.ts +0 -116
- package/src/queue-id.test.ts +0 -17
- package/src/queue-id.ts +0 -14
package/src/job-base.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import EventEmitter from "node:events";
|
|
2
|
-
import IORedis, { type Redis } from "ioredis";
|
|
3
|
-
|
|
4
|
-
import { crypto, type Crypto } from "@sockethub/crypto";
|
|
5
|
-
import type { ActivityStream } from "@sockethub/schemas";
|
|
6
|
-
|
|
7
|
-
import type { JobDataDecrypted, JobEncrypted, RedisConfig } from "./types.js";
|
|
8
|
-
|
|
9
|
-
let sharedRedisConnection: Redis | null = null;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Creates or returns a shared Redis connection to enable connection pooling.
|
|
13
|
-
* This prevents connection exhaustion under high load by reusing a single
|
|
14
|
-
* connection across all JobQueue and JobWorker instances.
|
|
15
|
-
*
|
|
16
|
-
* @param config - Redis configuration with optional timeout and retry settings
|
|
17
|
-
* @returns Shared Redis connection instance
|
|
18
|
-
*/
|
|
19
|
-
export function createIORedisConnection(config: RedisConfig): Redis {
|
|
20
|
-
if (!sharedRedisConnection) {
|
|
21
|
-
sharedRedisConnection = new IORedis(config.url, {
|
|
22
|
-
connectionName: config.connectionName,
|
|
23
|
-
enableOfflineQueue: false,
|
|
24
|
-
maxRetriesPerRequest: config.maxRetriesPerRequest ?? null,
|
|
25
|
-
connectTimeout: config.connectTimeout ?? 10000,
|
|
26
|
-
disconnectTimeout: config.disconnectTimeout ?? 5000,
|
|
27
|
-
lazyConnect: false,
|
|
28
|
-
retryStrategy: (times: number) => {
|
|
29
|
-
// Stop retrying after 3 attempts to fail fast
|
|
30
|
-
if (times > 3) return null;
|
|
31
|
-
// Exponential backoff: 200ms, 400ms, 800ms
|
|
32
|
-
return Math.min(2 ** (times - 1) * 200, 2000);
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
return sharedRedisConnection;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Resets the shared Redis connection. Used primarily for testing.
|
|
41
|
-
* Disconnects the current connection before resetting.
|
|
42
|
-
*/
|
|
43
|
-
export async function resetSharedRedisConnection(): Promise<void> {
|
|
44
|
-
if (sharedRedisConnection) {
|
|
45
|
-
try {
|
|
46
|
-
sharedRedisConnection.disconnect(false);
|
|
47
|
-
} catch (err) {
|
|
48
|
-
// Ignore disconnect errors during cleanup
|
|
49
|
-
}
|
|
50
|
-
sharedRedisConnection = null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Gets the total number of active Redis client connections.
|
|
56
|
-
*
|
|
57
|
-
* Note: This queries the Redis server directly using CLIENT LIST and reports
|
|
58
|
-
* ALL active connections to the Redis instance. This includes connections from
|
|
59
|
-
* Sockethub (BullMQ queues, workers, and the shared connection) as well as any
|
|
60
|
-
* other applications or services connected to the same Redis server.
|
|
61
|
-
*
|
|
62
|
-
* @returns Number of active Redis connections, or 0 if no connection exists
|
|
63
|
-
*/
|
|
64
|
-
export async function getRedisConnectionCount(): Promise<number> {
|
|
65
|
-
if (!sharedRedisConnection) {
|
|
66
|
-
return 0;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const clientList = await sharedRedisConnection.client("LIST");
|
|
71
|
-
// CLIENT LIST returns one line per connection, filter out empty lines
|
|
72
|
-
const connections = clientList
|
|
73
|
-
.split("\n")
|
|
74
|
-
.filter((line) => line.trim());
|
|
75
|
-
return connections.length;
|
|
76
|
-
} catch (err) {
|
|
77
|
-
// Return 0 if Redis query fails (connection issues, etc.)
|
|
78
|
-
return 0;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export class JobBase extends EventEmitter {
|
|
83
|
-
protected crypto: Crypto;
|
|
84
|
-
private readonly secret: string;
|
|
85
|
-
|
|
86
|
-
constructor(secret: string) {
|
|
87
|
-
super();
|
|
88
|
-
if (secret.length !== 32) {
|
|
89
|
-
throw new Error(
|
|
90
|
-
`secret must be a 32 char string, length: ${secret.length}`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
this.secret = secret;
|
|
94
|
-
this.initCrypto();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
initCrypto() {
|
|
98
|
-
this.crypto = crypto;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
disconnectBase() {
|
|
102
|
-
this.removeAllListeners();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* @param job
|
|
107
|
-
* @private
|
|
108
|
-
*/
|
|
109
|
-
protected decryptJobData(job: JobEncrypted): JobDataDecrypted {
|
|
110
|
-
return {
|
|
111
|
-
title: job.data.title,
|
|
112
|
-
msg: this.decryptActivityStream(job.data.msg) as ActivityStream,
|
|
113
|
-
sessionId: job.data.sessionId,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
protected decryptActivityStream(msg: string): ActivityStream {
|
|
118
|
-
return this.crypto.decrypt(msg, this.secret);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
protected encryptActivityStream(msg: ActivityStream): string {
|
|
122
|
-
return this.crypto.encrypt(msg, this.secret);
|
|
123
|
-
}
|
|
124
|
-
}
|
package/src/job-queue.test.ts
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import { expect } from "chai";
|
|
2
|
-
import * as sinon from "sinon";
|
|
3
|
-
|
|
4
|
-
import type { Logger } from "@sockethub/schemas";
|
|
5
|
-
|
|
6
|
-
import { JobQueue } from "./index";
|
|
7
|
-
|
|
8
|
-
const mockLogger: Logger = {
|
|
9
|
-
error: () => {},
|
|
10
|
-
warn: () => {},
|
|
11
|
-
info: () => {},
|
|
12
|
-
debug: () => {},
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
describe("JobQueue", () => {
|
|
16
|
-
let MockBull, jobQueue, cryptoMocks, sandbox;
|
|
17
|
-
|
|
18
|
-
beforeEach(() => {
|
|
19
|
-
sandbox = sinon.createSandbox();
|
|
20
|
-
cryptoMocks = {
|
|
21
|
-
objectHash: sandbox.stub(),
|
|
22
|
-
decrypt: sandbox.stub(),
|
|
23
|
-
encrypt: sandbox.stub(),
|
|
24
|
-
hash: sandbox.stub(),
|
|
25
|
-
};
|
|
26
|
-
MockBull = sandbox.stub().returns({
|
|
27
|
-
add: sandbox.stub(),
|
|
28
|
-
getJob: sandbox.stub(),
|
|
29
|
-
removeAllListeners: sandbox.stub(),
|
|
30
|
-
pause: sandbox.stub(),
|
|
31
|
-
resume: sandbox.stub(),
|
|
32
|
-
isPaused: sandbox.stub(),
|
|
33
|
-
obliterate: sandbox.stub(),
|
|
34
|
-
isRunning: sandbox.stub(),
|
|
35
|
-
close: sandbox.stub(),
|
|
36
|
-
emit: sandbox.stub(),
|
|
37
|
-
disconnect: sandbox.stub(),
|
|
38
|
-
on: sandbox.stub().callsArgWith(1, "a job id", "a result string"),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
class TestJobQueue extends JobQueue {
|
|
42
|
-
init() {
|
|
43
|
-
// BullMQ v5+ prohibits colons in queue names, so replace with dashes
|
|
44
|
-
const queueName = this.queueId.replace(/:/g, "-");
|
|
45
|
-
this.redisConnection = MockBull();
|
|
46
|
-
this.queue = MockBull(queueName, {
|
|
47
|
-
connection: this.redisConnection,
|
|
48
|
-
});
|
|
49
|
-
this.events = MockBull(queueName, {
|
|
50
|
-
connection: this.redisConnection,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
initCrypto() {
|
|
54
|
-
this.crypto = cryptoMocks;
|
|
55
|
-
}
|
|
56
|
-
getQueueId() {
|
|
57
|
-
return this.queueId;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
jobQueue = new TestJobQueue(
|
|
61
|
-
"a parent id",
|
|
62
|
-
"a session id",
|
|
63
|
-
"secret is 32 char long like this",
|
|
64
|
-
{
|
|
65
|
-
url: "redis config",
|
|
66
|
-
},
|
|
67
|
-
mockLogger,
|
|
68
|
-
);
|
|
69
|
-
jobQueue.emit = sandbox.stub();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
afterEach(() => {
|
|
73
|
-
sinon.restore();
|
|
74
|
-
sandbox.reset();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("returns a valid JobQueue object", () => {
|
|
78
|
-
sinon.assert.calledThrice(MockBull);
|
|
79
|
-
sinon.assert.calledWith(
|
|
80
|
-
MockBull,
|
|
81
|
-
"sockethub-a parent id-data-layer-queue-a session id",
|
|
82
|
-
{
|
|
83
|
-
connection: MockBull(),
|
|
84
|
-
},
|
|
85
|
-
);
|
|
86
|
-
expect(typeof jobQueue).to.equal("object");
|
|
87
|
-
expect(jobQueue.getQueueId()).to.equal(
|
|
88
|
-
`sockethub:a parent id:data-layer:queue:a session id`,
|
|
89
|
-
);
|
|
90
|
-
expect(typeof jobQueue.add).to.equal("function");
|
|
91
|
-
expect(typeof jobQueue.getJob).to.equal("function");
|
|
92
|
-
expect(typeof jobQueue.shutdown).to.equal("function");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
// describe("initResultEvents", () => {
|
|
96
|
-
// it("registers handlers when called", () => {
|
|
97
|
-
// bullMocks.on.reset();
|
|
98
|
-
// // jobQueue.initResultEvents();
|
|
99
|
-
// expect(bullMocks.on.callCount).to.eql(4);
|
|
100
|
-
// sinon.assert.calledWith(bullMocks.on, "global:completed");
|
|
101
|
-
// sinon.assert.calledWith(bullMocks.on, "global:error");
|
|
102
|
-
// sinon.assert.calledWith(bullMocks.on, "global:failed");
|
|
103
|
-
// sinon.assert.calledWith(bullMocks.on, "failed");
|
|
104
|
-
// });
|
|
105
|
-
// });
|
|
106
|
-
|
|
107
|
-
describe("createJob", () => {
|
|
108
|
-
it("returns expected job format", () => {
|
|
109
|
-
cryptoMocks.encrypt.returns("an encrypted message");
|
|
110
|
-
const job = jobQueue.createJob("a socket id", {
|
|
111
|
-
context: "some context",
|
|
112
|
-
id: "an identifier",
|
|
113
|
-
});
|
|
114
|
-
expect(job).to.eql({
|
|
115
|
-
title: "some context-an identifier",
|
|
116
|
-
msg: "an encrypted message",
|
|
117
|
-
sessionId: "a socket id",
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("uses counter when no id provided", () => {
|
|
122
|
-
cryptoMocks.encrypt.returns("an encrypted message");
|
|
123
|
-
let job = jobQueue.createJob("a socket id", {
|
|
124
|
-
context: "some context",
|
|
125
|
-
});
|
|
126
|
-
expect(job).to.eql({
|
|
127
|
-
title: "some context-0",
|
|
128
|
-
msg: "an encrypted message",
|
|
129
|
-
sessionId: "a socket id",
|
|
130
|
-
});
|
|
131
|
-
job = jobQueue.createJob("a socket id", {
|
|
132
|
-
context: "some context",
|
|
133
|
-
});
|
|
134
|
-
expect(job).to.eql({
|
|
135
|
-
title: "some context-1",
|
|
136
|
-
msg: "an encrypted message",
|
|
137
|
-
sessionId: "a socket id",
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("getJob", () => {
|
|
143
|
-
const encryptedJob = {
|
|
144
|
-
data: {
|
|
145
|
-
title: "a title",
|
|
146
|
-
msg: "an encrypted msg",
|
|
147
|
-
sessionId: "a socket id",
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
it("handles fetching a valid job", async () => {
|
|
152
|
-
jobQueue.queue.getJob.returns(encryptedJob);
|
|
153
|
-
cryptoMocks.decrypt.returns("an unencrypted message");
|
|
154
|
-
const job = await jobQueue.getJob("a valid job");
|
|
155
|
-
sinon.assert.calledOnceWithExactly(
|
|
156
|
-
jobQueue.queue.getJob,
|
|
157
|
-
"a valid job",
|
|
158
|
-
);
|
|
159
|
-
encryptedJob.data.msg = "an unencrypted message";
|
|
160
|
-
expect(job).to.eql(encryptedJob);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("handles fetching an invalid job", async () => {
|
|
164
|
-
jobQueue.queue.getJob.returns(undefined);
|
|
165
|
-
const job = await jobQueue.getJob("an invalid job");
|
|
166
|
-
expect(job).to.eql(undefined);
|
|
167
|
-
sinon.assert.calledOnceWithExactly(
|
|
168
|
-
jobQueue.queue.getJob,
|
|
169
|
-
"an invalid job",
|
|
170
|
-
);
|
|
171
|
-
sinon.assert.notCalled(cryptoMocks.decrypt);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("removes sessionSecret", async () => {
|
|
175
|
-
jobQueue.queue.getJob.returns(encryptedJob);
|
|
176
|
-
cryptoMocks.decrypt.returns({
|
|
177
|
-
foo: "bar",
|
|
178
|
-
sessionSecret: "yarg",
|
|
179
|
-
});
|
|
180
|
-
const job = await jobQueue.getJob("a valid job");
|
|
181
|
-
sinon.assert.calledOnceWithExactly(
|
|
182
|
-
jobQueue.queue.getJob,
|
|
183
|
-
"a valid job",
|
|
184
|
-
);
|
|
185
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
186
|
-
// @ts-ignore
|
|
187
|
-
encryptedJob.data.msg = {
|
|
188
|
-
foo: "bar",
|
|
189
|
-
};
|
|
190
|
-
expect(job).to.eql(encryptedJob);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe("add", () => {
|
|
195
|
-
it("stores encrypted job", async () => {
|
|
196
|
-
cryptoMocks.encrypt.returns("encrypted foo");
|
|
197
|
-
jobQueue.queue.isPaused.returns(false);
|
|
198
|
-
const resultJob = {
|
|
199
|
-
title: "a platform-an identifier",
|
|
200
|
-
sessionId: "a socket id",
|
|
201
|
-
msg: "encrypted foo",
|
|
202
|
-
};
|
|
203
|
-
const res = await jobQueue.add("a socket id", {
|
|
204
|
-
context: "a platform",
|
|
205
|
-
id: "an identifier",
|
|
206
|
-
});
|
|
207
|
-
sinon.assert.calledOnce(jobQueue.queue.isPaused);
|
|
208
|
-
sinon.assert.notCalled(jobQueue.queue.emit);
|
|
209
|
-
sinon.assert.calledOnceWithExactly(
|
|
210
|
-
jobQueue.queue.add,
|
|
211
|
-
"a platform-an identifier",
|
|
212
|
-
resultJob,
|
|
213
|
-
{
|
|
214
|
-
removeOnComplete: { age: 300 },
|
|
215
|
-
removeOnFail: { age: 300 },
|
|
216
|
-
},
|
|
217
|
-
);
|
|
218
|
-
expect(res).to.eql(resultJob);
|
|
219
|
-
});
|
|
220
|
-
it("fails job if queue paused", async () => {
|
|
221
|
-
cryptoMocks.encrypt.returns("encrypted foo");
|
|
222
|
-
jobQueue.queue.isPaused.returns(true);
|
|
223
|
-
try {
|
|
224
|
-
await jobQueue.add("a socket id", {
|
|
225
|
-
context: "a platform",
|
|
226
|
-
id: "an identifier",
|
|
227
|
-
});
|
|
228
|
-
} catch (err) {
|
|
229
|
-
expect(err.toString()).to.eql("Error: queue closed");
|
|
230
|
-
}
|
|
231
|
-
sinon.assert.calledOnce(jobQueue.queue.isPaused);
|
|
232
|
-
sinon.assert.notCalled(jobQueue.queue.add);
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("pause", async () => {
|
|
237
|
-
await jobQueue.pause();
|
|
238
|
-
sinon.assert.calledOnce(jobQueue.queue.pause);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("resume", async () => {
|
|
242
|
-
await jobQueue.resume();
|
|
243
|
-
sinon.assert.calledOnce(jobQueue.queue.resume);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
describe("shutdown", () => {
|
|
247
|
-
// it("is sure to pause when not already paused", async () => {
|
|
248
|
-
// sinon.assert.notCalled(jobQueue.queue.pause);
|
|
249
|
-
// jobQueue.initWorker();
|
|
250
|
-
// sinon.assert.notCalled(jobQueue.queue.pause);
|
|
251
|
-
// jobQueue.queue.isPaused.returns(false);
|
|
252
|
-
// sinon.assert.notCalled(jobQueue.queue.pause);
|
|
253
|
-
// await jobQueue.shutdown();
|
|
254
|
-
// sinon.assert.calledOnce(jobQueue.queue.pause);
|
|
255
|
-
// sinon.assert.calledOnce(jobQueue.queue.removeAllListeners);
|
|
256
|
-
// sinon.assert.calledOnce(jobQueue.queue.obliterate);
|
|
257
|
-
// });
|
|
258
|
-
it("skips pausing when already paused", async () => {
|
|
259
|
-
jobQueue.queue.isPaused.returns(true);
|
|
260
|
-
sinon.assert.notCalled(jobQueue.queue.pause);
|
|
261
|
-
await jobQueue.shutdown();
|
|
262
|
-
sinon.assert.notCalled(jobQueue.queue.pause);
|
|
263
|
-
sinon.assert.calledOnce(jobQueue.queue.removeAllListeners);
|
|
264
|
-
sinon.assert.calledOnce(jobQueue.queue.obliterate);
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
describe("decryptJobData", () => {
|
|
269
|
-
it("decrypts and returns expected object", () => {
|
|
270
|
-
cryptoMocks.decrypt.returnsArg(0);
|
|
271
|
-
const jobData = {
|
|
272
|
-
data: {
|
|
273
|
-
title: "foo",
|
|
274
|
-
msg: "encryptedjobdata",
|
|
275
|
-
sessionId: "foobar",
|
|
276
|
-
},
|
|
277
|
-
};
|
|
278
|
-
const secret = "secretstring";
|
|
279
|
-
expect(jobQueue.decryptJobData(jobData, secret)).to.be.eql(
|
|
280
|
-
jobData.data,
|
|
281
|
-
);
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
describe("decryptActivityStream", () => {
|
|
286
|
-
it("decrypts and returns expected object", () => {
|
|
287
|
-
cryptoMocks.decrypt.returnsArg(0);
|
|
288
|
-
const jobData = "encryptedjobdata";
|
|
289
|
-
const secret = "secretstring";
|
|
290
|
-
expect(jobQueue.decryptActivityStream(jobData, secret)).to.be.eql(
|
|
291
|
-
jobData,
|
|
292
|
-
);
|
|
293
|
-
});
|
|
294
|
-
});
|
|
295
|
-
});
|
package/src/job-queue.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type Logger,
|
|
3
|
-
createLogger,
|
|
4
|
-
getLoggerNamespace,
|
|
5
|
-
} from "@sockethub/logger";
|
|
6
|
-
import type { ActivityStream } from "@sockethub/schemas";
|
|
7
|
-
import { type Job, Queue, QueueEvents, Worker } from "bullmq";
|
|
8
|
-
|
|
9
|
-
import { JobBase, createIORedisConnection } from "./job-base.js";
|
|
10
|
-
import { buildQueueId } from "./queue-id.js";
|
|
11
|
-
import type { JobDataEncrypted, JobDecrypted, RedisConfig } from "./types.js";
|
|
12
|
-
|
|
13
|
-
export async function verifyJobQueue(config: RedisConfig): Promise<void> {
|
|
14
|
-
const log = createLogger("data-layer:verify-job-queue");
|
|
15
|
-
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
const worker = new Worker(
|
|
18
|
-
"connectiontest",
|
|
19
|
-
async (job) => {
|
|
20
|
-
if (job.name !== "foo" || job.data?.foo !== "bar") {
|
|
21
|
-
reject(
|
|
22
|
-
"Worker received invalid job data during JobQueue connection test",
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
job.data.test = "touched by worker";
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
connection: createIORedisConnection(config),
|
|
29
|
-
},
|
|
30
|
-
);
|
|
31
|
-
worker.on("completed", async (job: Job) => {
|
|
32
|
-
if (job.name !== "foo" || job.data?.test !== "touched by worker") {
|
|
33
|
-
reject(
|
|
34
|
-
"Worker job completed unsuccessfully during JobQueue connection test",
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
log.info("job queue connection verified");
|
|
38
|
-
await queue.close();
|
|
39
|
-
await worker.close();
|
|
40
|
-
resolve();
|
|
41
|
-
});
|
|
42
|
-
worker.on("error", (err) => {
|
|
43
|
-
log.warn(
|
|
44
|
-
`connection verification worker error received ${err.toString()}`,
|
|
45
|
-
);
|
|
46
|
-
reject(err);
|
|
47
|
-
});
|
|
48
|
-
const queue = new Queue("connectiontest", {
|
|
49
|
-
connection: createIORedisConnection(config),
|
|
50
|
-
});
|
|
51
|
-
queue.on("error", (err) => {
|
|
52
|
-
log.warn(
|
|
53
|
-
`connection verification queue error received ${err.toString()}`,
|
|
54
|
-
);
|
|
55
|
-
reject(err);
|
|
56
|
-
});
|
|
57
|
-
queue.add(
|
|
58
|
-
"foo",
|
|
59
|
-
{ foo: "bar" },
|
|
60
|
-
{ removeOnComplete: true, removeOnFail: true },
|
|
61
|
-
);
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Redis-backed job queue for managing ActivityStreams message processing.
|
|
67
|
-
*
|
|
68
|
-
* Creates isolated queues per platform instance and session, providing reliable
|
|
69
|
-
* message delivery and processing coordination between Sockethub server and
|
|
70
|
-
* platform workers.
|
|
71
|
-
*
|
|
72
|
-
* @example
|
|
73
|
-
* ```typescript
|
|
74
|
-
* const queue = new JobQueue('irc-platform', 'session123', secret, redisConfig);
|
|
75
|
-
* await queue.add('socket-id', activityStreamMessage);
|
|
76
|
-
* ```
|
|
77
|
-
*/
|
|
78
|
-
export class JobQueue extends JobBase {
|
|
79
|
-
private readonly connectionName: string;
|
|
80
|
-
protected readonly queueId: string;
|
|
81
|
-
protected queue: Queue;
|
|
82
|
-
protected events: QueueEvents;
|
|
83
|
-
private readonly log: Logger;
|
|
84
|
-
private counter = 0;
|
|
85
|
-
private initialized = false;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Creates a new JobQueue instance.
|
|
89
|
-
*
|
|
90
|
-
* @param parentId - Sockethub instance identifier for queue isolation
|
|
91
|
-
* @param instanceId - Unique identifier for the platform instance
|
|
92
|
-
* @param secret - 32-character encryption secret for message security
|
|
93
|
-
* @param redisConfig - Redis connection configuration
|
|
94
|
-
*/
|
|
95
|
-
constructor(
|
|
96
|
-
parentId: string,
|
|
97
|
-
instanceId: string,
|
|
98
|
-
secret: string,
|
|
99
|
-
redisConfig: RedisConfig,
|
|
100
|
-
) {
|
|
101
|
-
super(secret);
|
|
102
|
-
// Create logger with full namespace (context will be prepended automatically)
|
|
103
|
-
this.log = createLogger(`data-layer:queue:${parentId}:${instanceId}`);
|
|
104
|
-
|
|
105
|
-
this.queueId = buildQueueId(parentId, instanceId);
|
|
106
|
-
// Use logger's full namespace (includes context) for Redis connection name
|
|
107
|
-
this.connectionName = getLoggerNamespace(this.log);
|
|
108
|
-
redisConfig.connectionName = this.connectionName;
|
|
109
|
-
this.init(redisConfig);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
protected init(redisConfig: RedisConfig) {
|
|
113
|
-
if (this.initialized) {
|
|
114
|
-
throw new Error(`JobQueue already initialized for ${this.queueId}`);
|
|
115
|
-
}
|
|
116
|
-
this.initialized = true;
|
|
117
|
-
|
|
118
|
-
// BullMQ v5+ prohibits colons in queue names; derive the queue name
|
|
119
|
-
// from the canonical queue id by replacing ':' with '-'.
|
|
120
|
-
const queueName = this.queueId.replace(/:/g, "-");
|
|
121
|
-
// Let BullMQ create its own connections (it duplicates them internally anyway)
|
|
122
|
-
this.queue = new Queue(queueName, {
|
|
123
|
-
connection: redisConfig,
|
|
124
|
-
});
|
|
125
|
-
this.events = new QueueEvents(queueName, {
|
|
126
|
-
connection: redisConfig,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Handle Redis contention errors (e.g., BUSY from Lua scripts)
|
|
130
|
-
this.queue.on("error", (err) => {
|
|
131
|
-
this.log.warn(`queue error: ${err.message}`);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
this.events.on("error", (err) => {
|
|
135
|
-
this.log.warn(`events error: ${err.message}`);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
this.events.on("completed", async ({ jobId, returnvalue }) => {
|
|
139
|
-
const job = await this.getJob(jobId);
|
|
140
|
-
if (!job) {
|
|
141
|
-
this.log.debug(`completed job ${jobId} (already removed)`);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
this.log.debug(`completed ${job.data.title} ${job.data.msg.type}`);
|
|
145
|
-
this.emit("completed", job.data, returnvalue);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
this.events.on("failed", async ({ jobId, failedReason }) => {
|
|
149
|
-
const job = await this.getJob(jobId);
|
|
150
|
-
if (!job) {
|
|
151
|
-
this.log.debug(
|
|
152
|
-
`failed job ${jobId} (already removed): ${failedReason}`,
|
|
153
|
-
);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
this.log.warn(
|
|
157
|
-
`failed ${job.data.title} ${job.data.msg.type}: ${failedReason}`,
|
|
158
|
-
);
|
|
159
|
-
this.emit("failed", job.data, failedReason);
|
|
160
|
-
});
|
|
161
|
-
this.log.info("initialized");
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Adds an ActivityStreams message to the job queue for processing.
|
|
166
|
-
*
|
|
167
|
-
* @param socketId - Socket.IO connection identifier for response routing
|
|
168
|
-
* @param msg - ActivityStreams message to be processed by platform worker
|
|
169
|
-
* @returns Promise resolving to encrypted job data
|
|
170
|
-
* @throws Error if queue is closed or Redis connection fails
|
|
171
|
-
*/
|
|
172
|
-
async add(
|
|
173
|
-
socketId: string,
|
|
174
|
-
msg: ActivityStream,
|
|
175
|
-
): Promise<JobDataEncrypted> {
|
|
176
|
-
const job = this.createJob(socketId, msg);
|
|
177
|
-
if (await this.queue.isPaused()) {
|
|
178
|
-
// this.queue.emit("error", new Error("queue closed"));
|
|
179
|
-
this.log.debug(
|
|
180
|
-
`failed to add ${job.title} ${msg.type} to queue: queue closed`,
|
|
181
|
-
);
|
|
182
|
-
throw new Error("queue closed");
|
|
183
|
-
}
|
|
184
|
-
await this.queue.add(job.title, job, {
|
|
185
|
-
// Auto-remove jobs after 5 minutes to prevent Redis memory buildup.
|
|
186
|
-
// Jobs only need to exist long enough for event handlers to look them up
|
|
187
|
-
// by jobId and send results to clients (typically < 1 second).
|
|
188
|
-
removeOnComplete: { age: 300 }, // 5 minutes in seconds
|
|
189
|
-
removeOnFail: { age: 300 },
|
|
190
|
-
});
|
|
191
|
-
this.log.debug(`added ${job.title} ${msg.type} to queue`);
|
|
192
|
-
return job;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Pauses job processing. New jobs can still be added but won't be processed.
|
|
197
|
-
*/
|
|
198
|
-
async pause() {
|
|
199
|
-
await this.queue.pause();
|
|
200
|
-
this.log.debug("paused");
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Resumes job processing after being paused.
|
|
205
|
-
*/
|
|
206
|
-
async resume() {
|
|
207
|
-
await this.queue.resume();
|
|
208
|
-
this.log.debug("resumed");
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Gracefully shuts down the queue, cleaning up all resources and connections.
|
|
213
|
-
*/
|
|
214
|
-
async shutdown() {
|
|
215
|
-
this.removeAllListeners();
|
|
216
|
-
this.queue.removeAllListeners();
|
|
217
|
-
if (!(await this.queue.isPaused())) {
|
|
218
|
-
await this.queue.pause();
|
|
219
|
-
}
|
|
220
|
-
await this.queue.obliterate({ force: true });
|
|
221
|
-
await this.queue.close();
|
|
222
|
-
await this.events.close();
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
private async getJob(jobId: string): Promise<JobDecrypted> {
|
|
226
|
-
const job = await this.queue.getJob(jobId);
|
|
227
|
-
if (job) {
|
|
228
|
-
job.data = this.decryptJobData(job);
|
|
229
|
-
try {
|
|
230
|
-
// biome-ignore lint/performance/noDelete: <explanation>
|
|
231
|
-
delete job.data.msg.sessionSecret;
|
|
232
|
-
} catch (e) {
|
|
233
|
-
// this property should never be exposed externally
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return job;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
private createJob(socketId: string, msg: ActivityStream): JobDataEncrypted {
|
|
240
|
-
const title = `${msg.context}-${msg.id ? msg.id : this.counter++}`;
|
|
241
|
-
return {
|
|
242
|
-
title: title,
|
|
243
|
-
sessionId: socketId,
|
|
244
|
-
msg: this.encryptActivityStream(msg),
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
}
|