@sockethub/data-layer 1.0.0-alpha.10

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.
@@ -0,0 +1,280 @@
1
+ import { expect } from "chai";
2
+ import * as sinon from "sinon";
3
+
4
+ import { JobQueue } from "./index";
5
+
6
+ describe("JobQueue", () => {
7
+ let MockBull, jobQueue, cryptoMocks, sandbox;
8
+
9
+ beforeEach(() => {
10
+ sandbox = sinon.createSandbox();
11
+ cryptoMocks = {
12
+ objectHash: sandbox.stub(),
13
+ decrypt: sandbox.stub(),
14
+ encrypt: sandbox.stub(),
15
+ hash: sandbox.stub(),
16
+ };
17
+ MockBull = sandbox.stub().returns({
18
+ add: sandbox.stub(),
19
+ getJob: sandbox.stub(),
20
+ removeAllListeners: sandbox.stub(),
21
+ pause: sandbox.stub(),
22
+ resume: sandbox.stub(),
23
+ isPaused: sandbox.stub(),
24
+ obliterate: sandbox.stub(),
25
+ isRunning: sandbox.stub(),
26
+ close: sandbox.stub(),
27
+ emit: sandbox.stub(),
28
+ disconnect: sandbox.stub(),
29
+ on: sandbox.stub().callsArgWith(1, "a job id", "a result string"),
30
+ });
31
+
32
+ class TestJobQueue extends JobQueue {
33
+ init() {
34
+ this.redisConnection = MockBull();
35
+ this.queue = MockBull(this.uid, {
36
+ connection: this.redisConnection,
37
+ });
38
+ this.events = MockBull(this.uid, {
39
+ connection: this.redisConnection,
40
+ });
41
+ }
42
+ initCrypto() {
43
+ this.crypto = cryptoMocks;
44
+ }
45
+ }
46
+ jobQueue = new TestJobQueue(
47
+ "a parent id",
48
+ "a session id",
49
+ "secret is 32 char long like this",
50
+ {
51
+ url: "redis config",
52
+ },
53
+ );
54
+ jobQueue.emit = sandbox.stub();
55
+ });
56
+
57
+ afterEach(() => {
58
+ sinon.restore();
59
+ sandbox.reset();
60
+ });
61
+
62
+ it("returns a valid JobQueue object", () => {
63
+ sinon.assert.calledThrice(MockBull);
64
+ sinon.assert.calledWith(
65
+ MockBull,
66
+ "sockethub:data-layer:queue:a parent id:a session id",
67
+ {
68
+ connection: MockBull(),
69
+ },
70
+ );
71
+ expect(typeof jobQueue).to.equal("object");
72
+ expect(jobQueue.uid).to.equal(
73
+ `sockethub:data-layer:queue:a parent id:a session id`,
74
+ );
75
+ expect(typeof jobQueue.add).to.equal("function");
76
+ expect(typeof jobQueue.getJob).to.equal("function");
77
+ expect(typeof jobQueue.shutdown).to.equal("function");
78
+ });
79
+
80
+ // describe("initResultEvents", () => {
81
+ // it("registers handlers when called", () => {
82
+ // bullMocks.on.reset();
83
+ // // jobQueue.initResultEvents();
84
+ // expect(bullMocks.on.callCount).to.eql(4);
85
+ // sinon.assert.calledWith(bullMocks.on, "global:completed");
86
+ // sinon.assert.calledWith(bullMocks.on, "global:error");
87
+ // sinon.assert.calledWith(bullMocks.on, "global:failed");
88
+ // sinon.assert.calledWith(bullMocks.on, "failed");
89
+ // });
90
+ // });
91
+
92
+ describe("createJob", () => {
93
+ it("returns expected job format", () => {
94
+ cryptoMocks.encrypt.returns("an encrypted message");
95
+ const job = jobQueue.createJob("a socket id", {
96
+ context: "some context",
97
+ id: "an identifier",
98
+ });
99
+ expect(job).to.eql({
100
+ title: "some context-an identifier",
101
+ msg: "an encrypted message",
102
+ sessionId: "a socket id",
103
+ });
104
+ });
105
+
106
+ it("uses counter when no id provided", () => {
107
+ cryptoMocks.encrypt.returns("an encrypted message");
108
+ let job = jobQueue.createJob("a socket id", {
109
+ context: "some context",
110
+ });
111
+ expect(job).to.eql({
112
+ title: "some context-0",
113
+ msg: "an encrypted message",
114
+ sessionId: "a socket id",
115
+ });
116
+ job = jobQueue.createJob("a socket id", {
117
+ context: "some context",
118
+ });
119
+ expect(job).to.eql({
120
+ title: "some context-1",
121
+ msg: "an encrypted message",
122
+ sessionId: "a socket id",
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("getJob", () => {
128
+ const encryptedJob = {
129
+ data: {
130
+ title: "a title",
131
+ msg: "an encrypted msg",
132
+ sessionId: "a socket id",
133
+ },
134
+ };
135
+
136
+ it("handles fetching a valid job", async () => {
137
+ jobQueue.queue.getJob.returns(encryptedJob);
138
+ cryptoMocks.decrypt.returns("an unencrypted message");
139
+ const job = await jobQueue.getJob("a valid job");
140
+ sinon.assert.calledOnceWithExactly(
141
+ jobQueue.queue.getJob,
142
+ "a valid job",
143
+ );
144
+ encryptedJob.data.msg = "an unencrypted message";
145
+ expect(job).to.eql(encryptedJob);
146
+ });
147
+
148
+ it("handles fetching an invalid job", async () => {
149
+ jobQueue.queue.getJob.returns(undefined);
150
+ const job = await jobQueue.getJob("an invalid job");
151
+ expect(job).to.eql(undefined);
152
+ sinon.assert.calledOnceWithExactly(
153
+ jobQueue.queue.getJob,
154
+ "an invalid job",
155
+ );
156
+ sinon.assert.notCalled(cryptoMocks.decrypt);
157
+ });
158
+
159
+ it("removes sessionSecret", async () => {
160
+ jobQueue.queue.getJob.returns(encryptedJob);
161
+ cryptoMocks.decrypt.returns({
162
+ foo: "bar",
163
+ sessionSecret: "yarg",
164
+ });
165
+ const job = await jobQueue.getJob("a valid job");
166
+ sinon.assert.calledOnceWithExactly(
167
+ jobQueue.queue.getJob,
168
+ "a valid job",
169
+ );
170
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
171
+ // @ts-ignore
172
+ encryptedJob.data.msg = {
173
+ foo: "bar",
174
+ };
175
+ expect(job).to.eql(encryptedJob);
176
+ });
177
+ });
178
+
179
+ describe("add", () => {
180
+ it("stores encrypted job", async () => {
181
+ cryptoMocks.encrypt.returns("encrypted foo");
182
+ jobQueue.queue.isPaused.returns(false);
183
+ const resultJob = {
184
+ title: "a platform-an identifier",
185
+ sessionId: "a socket id",
186
+ msg: "encrypted foo",
187
+ };
188
+ const res = await jobQueue.add("a socket id", {
189
+ context: "a platform",
190
+ id: "an identifier",
191
+ });
192
+ sinon.assert.calledOnce(jobQueue.queue.isPaused);
193
+ sinon.assert.notCalled(jobQueue.queue.emit);
194
+ sinon.assert.calledOnceWithExactly(
195
+ jobQueue.queue.add,
196
+ "a platform-an identifier",
197
+ resultJob,
198
+ {
199
+ removeOnComplete: { age: 300 },
200
+ removeOnFail: { age: 300 },
201
+ },
202
+ );
203
+ expect(res).to.eql(resultJob);
204
+ });
205
+ it("fails job if queue paused", async () => {
206
+ cryptoMocks.encrypt.returns("encrypted foo");
207
+ jobQueue.queue.isPaused.returns(true);
208
+ try {
209
+ await jobQueue.add("a socket id", {
210
+ context: "a platform",
211
+ id: "an identifier",
212
+ });
213
+ } catch (err) {
214
+ expect(err.toString()).to.eql("Error: queue closed");
215
+ }
216
+ sinon.assert.calledOnce(jobQueue.queue.isPaused);
217
+ sinon.assert.notCalled(jobQueue.queue.add);
218
+ });
219
+ });
220
+
221
+ it("pause", async () => {
222
+ await jobQueue.pause();
223
+ sinon.assert.calledOnce(jobQueue.queue.pause);
224
+ });
225
+
226
+ it("resume", async () => {
227
+ await jobQueue.resume();
228
+ sinon.assert.calledOnce(jobQueue.queue.resume);
229
+ });
230
+
231
+ describe("shutdown", () => {
232
+ // it("is sure to pause when not already paused", async () => {
233
+ // sinon.assert.notCalled(jobQueue.queue.pause);
234
+ // jobQueue.initWorker();
235
+ // sinon.assert.notCalled(jobQueue.queue.pause);
236
+ // jobQueue.queue.isPaused.returns(false);
237
+ // sinon.assert.notCalled(jobQueue.queue.pause);
238
+ // await jobQueue.shutdown();
239
+ // sinon.assert.calledOnce(jobQueue.queue.pause);
240
+ // sinon.assert.calledOnce(jobQueue.queue.removeAllListeners);
241
+ // sinon.assert.calledOnce(jobQueue.queue.obliterate);
242
+ // });
243
+ it("skips pausing when already paused", async () => {
244
+ jobQueue.queue.isPaused.returns(true);
245
+ sinon.assert.notCalled(jobQueue.queue.pause);
246
+ await jobQueue.shutdown();
247
+ sinon.assert.notCalled(jobQueue.queue.pause);
248
+ sinon.assert.calledOnce(jobQueue.queue.removeAllListeners);
249
+ sinon.assert.calledOnce(jobQueue.queue.obliterate);
250
+ });
251
+ });
252
+
253
+ describe("decryptJobData", () => {
254
+ it("decrypts and returns expected object", () => {
255
+ cryptoMocks.decrypt.returnsArg(0);
256
+ const jobData = {
257
+ data: {
258
+ title: "foo",
259
+ msg: "encryptedjobdata",
260
+ sessionId: "foobar",
261
+ },
262
+ };
263
+ const secret = "secretstring";
264
+ expect(jobQueue.decryptJobData(jobData, secret)).to.be.eql(
265
+ jobData.data,
266
+ );
267
+ });
268
+ });
269
+
270
+ describe("decryptActivityStream", () => {
271
+ it("decrypts and returns expected object", () => {
272
+ cryptoMocks.decrypt.returnsArg(0);
273
+ const jobData = "encryptedjobdata";
274
+ const secret = "secretstring";
275
+ expect(jobQueue.decryptActivityStream(jobData, secret)).to.be.eql(
276
+ jobData,
277
+ );
278
+ });
279
+ });
280
+ });
@@ -0,0 +1,227 @@
1
+ import { type Job, Queue, QueueEvents, Worker } from "bullmq";
2
+ import debug, { type Debugger } from "debug";
3
+
4
+ import type { ActivityStream } from "@sockethub/schemas";
5
+
6
+ import { JobBase, createIORedisConnection } from "./job-base.js";
7
+ import type { JobDataEncrypted, JobDecrypted, RedisConfig } from "./types.js";
8
+
9
+ export async function verifyJobQueue(config: RedisConfig): Promise<void> {
10
+ const log = debug("sockethub:data-layer:queue");
11
+ return new Promise((resolve, reject) => {
12
+ const worker = new Worker(
13
+ "connectiontest",
14
+ async (job) => {
15
+ if (job.name !== "foo" || job.data?.foo !== "bar") {
16
+ reject(
17
+ "Worker received invalid job data during JobQueue connection test",
18
+ );
19
+ }
20
+ job.data.test = "touched by worker";
21
+ },
22
+ {
23
+ connection: createIORedisConnection(config),
24
+ },
25
+ );
26
+ worker.on("completed", async (job: Job) => {
27
+ if (job.name !== "foo" || job.data?.test !== "touched by worker") {
28
+ reject(
29
+ "Worker job completed unsuccessfully during JobQueue connection test",
30
+ );
31
+ }
32
+ log("connection verified");
33
+ await queue.close();
34
+ await worker.close();
35
+ resolve();
36
+ });
37
+ worker.on("error", (err) => {
38
+ log(
39
+ `connection verification worker error received ${err.toString()}`,
40
+ );
41
+ reject(err);
42
+ });
43
+ const queue = new Queue("connectiontest", {
44
+ connection: createIORedisConnection(config),
45
+ });
46
+ queue.on("error", (err) => {
47
+ log(
48
+ `connection verification queue error received ${err.toString()}`,
49
+ );
50
+ reject(err);
51
+ });
52
+ queue.add(
53
+ "foo",
54
+ { foo: "bar" },
55
+ { removeOnComplete: true, removeOnFail: true },
56
+ );
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Redis-backed job queue for managing ActivityStreams message processing.
62
+ *
63
+ * Creates isolated queues per platform instance and session, providing reliable
64
+ * message delivery and processing coordination between Sockethub server and
65
+ * platform workers.
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * const queue = new JobQueue('irc-platform', 'session123', secret, redisConfig);
70
+ * await queue.add('socket-id', activityStreamMessage);
71
+ * ```
72
+ */
73
+ export class JobQueue extends JobBase {
74
+ readonly uid: string;
75
+ protected queue: Queue;
76
+ protected events: QueueEvents;
77
+ private readonly debug: Debugger;
78
+ private counter = 0;
79
+ private initialized = false;
80
+
81
+ /**
82
+ * Creates a new JobQueue instance.
83
+ *
84
+ * @param instanceId - Unique identifier for the platform instance
85
+ * @param sessionId - Client session identifier for queue isolation
86
+ * @param secret - 32-character encryption secret for message security
87
+ * @param redisConfig - Redis connection configuration
88
+ */
89
+ constructor(
90
+ instanceId: string,
91
+ sessionId: string,
92
+ secret: string,
93
+ redisConfig: RedisConfig,
94
+ ) {
95
+ super(secret);
96
+ this.uid = `sockethub:data-layer:queue:${instanceId}:${sessionId}`;
97
+ this.debug = debug(this.uid);
98
+ this.init(redisConfig);
99
+ }
100
+
101
+ protected init(redisConfig: RedisConfig) {
102
+ if (this.initialized) {
103
+ throw new Error(`JobQueue already initialized for ${this.uid}`);
104
+ }
105
+ this.initialized = true;
106
+
107
+ // BullMQ v5+ prohibits colons in queue names, so replace with dashes
108
+ // while keeping uid with colons for debug namespace convention
109
+ const queueName = this.uid.replace(/:/g, "-");
110
+ // Let BullMQ create its own connections for better lifecycle management
111
+ this.queue = new Queue(queueName, {
112
+ connection: redisConfig,
113
+ });
114
+ this.events = new QueueEvents(queueName, {
115
+ connection: redisConfig,
116
+ });
117
+
118
+ this.events.on("completed", async ({ jobId, returnvalue }) => {
119
+ const job = await this.getJob(jobId);
120
+ if (!job) {
121
+ this.debug(`completed job ${jobId} (already removed)`);
122
+ return;
123
+ }
124
+ this.debug(`completed ${job.data.title} ${job.data.msg.type}`);
125
+ this.emit("completed", job.data, returnvalue);
126
+ });
127
+
128
+ this.events.on("failed", async ({ jobId, failedReason }) => {
129
+ const job = await this.getJob(jobId);
130
+ if (!job) {
131
+ this.debug(
132
+ `failed job ${jobId} (already removed): ${failedReason}`,
133
+ );
134
+ return;
135
+ }
136
+ this.debug(
137
+ `failed ${job.data.title} ${job.data.msg.type}: ${failedReason}`,
138
+ );
139
+ this.emit("failed", job.data, failedReason);
140
+ });
141
+ this.debug("initialized");
142
+ }
143
+
144
+ /**
145
+ * Adds an ActivityStreams message to the job queue for processing.
146
+ *
147
+ * @param socketId - Socket.IO connection identifier for response routing
148
+ * @param msg - ActivityStreams message to be processed by platform worker
149
+ * @returns Promise resolving to encrypted job data
150
+ * @throws Error if queue is closed or Redis connection fails
151
+ */
152
+ async add(
153
+ socketId: string,
154
+ msg: ActivityStream,
155
+ ): Promise<JobDataEncrypted> {
156
+ const job = this.createJob(socketId, msg);
157
+ if (await this.queue.isPaused()) {
158
+ // this.queue.emit("error", new Error("queue closed"));
159
+ this.debug(
160
+ `failed to add ${job.title} ${msg.type} to queue: queue closed`,
161
+ );
162
+ throw new Error("queue closed");
163
+ }
164
+ await this.queue.add(job.title, job, {
165
+ // Auto-remove jobs after 5 minutes to prevent Redis memory buildup.
166
+ // Jobs only need to exist long enough for event handlers to look them up
167
+ // by jobId and send results to clients (typically < 1 second).
168
+ removeOnComplete: { age: 300 }, // 5 minutes in seconds
169
+ removeOnFail: { age: 300 },
170
+ });
171
+ this.debug(`added ${job.title} ${msg.type} to queue`);
172
+ return job;
173
+ }
174
+
175
+ /**
176
+ * Pauses job processing. New jobs can still be added but won't be processed.
177
+ */
178
+ async pause() {
179
+ await this.queue.pause();
180
+ this.debug("paused");
181
+ }
182
+
183
+ /**
184
+ * Resumes job processing after being paused.
185
+ */
186
+ async resume() {
187
+ await this.queue.resume();
188
+ this.debug("resumed");
189
+ }
190
+
191
+ /**
192
+ * Gracefully shuts down the queue, cleaning up all resources and connections.
193
+ */
194
+ async shutdown() {
195
+ this.removeAllListeners();
196
+ this.queue.removeAllListeners();
197
+ if (!(await this.queue.isPaused())) {
198
+ await this.queue.pause();
199
+ }
200
+ await this.queue.obliterate({ force: true });
201
+ await this.queue.close();
202
+ await this.events.close();
203
+ }
204
+
205
+ private async getJob(jobId: string): Promise<JobDecrypted> {
206
+ const job = await this.queue.getJob(jobId);
207
+ if (job) {
208
+ job.data = this.decryptJobData(job);
209
+ try {
210
+ // biome-ignore lint/performance/noDelete: <explanation>
211
+ delete job.data.msg.sessionSecret;
212
+ } catch (e) {
213
+ // this property should never be exposed externally
214
+ }
215
+ }
216
+ return job;
217
+ }
218
+
219
+ private createJob(socketId: string, msg: ActivityStream): JobDataEncrypted {
220
+ const title = `${msg.context}-${msg.id ? msg.id : this.counter++}`;
221
+ return {
222
+ title: title,
223
+ sessionId: socketId,
224
+ msg: this.encryptActivityStream(msg),
225
+ };
226
+ }
227
+ }
@@ -0,0 +1,128 @@
1
+ import { expect } from "chai";
2
+ import * as sinon from "sinon";
3
+
4
+ import { JobWorker } from "./index";
5
+
6
+ describe("JobWorker", () => {
7
+ let MockBull, jobWorker, cryptoMocks, sandbox;
8
+
9
+ beforeEach(() => {
10
+ sandbox = sinon.createSandbox();
11
+ cryptoMocks = {
12
+ objectHash: sandbox.stub(),
13
+ decrypt: sandbox.stub(),
14
+ encrypt: sandbox.stub(),
15
+ hash: sandbox.stub(),
16
+ };
17
+ MockBull = sandbox.stub().returns({
18
+ add: sandbox.stub(),
19
+ getJob: sandbox.stub(),
20
+ removeAllListeners: sandbox.stub(),
21
+ pause: sandbox.stub(),
22
+ resume: sandbox.stub(),
23
+ isPaused: sandbox.stub(),
24
+ obliterate: sandbox.stub(),
25
+ isRunning: sandbox.stub(),
26
+ close: sandbox.stub(),
27
+ emit: sandbox.stub(),
28
+ disconnect: sandbox.stub(),
29
+ on: sandbox.stub().callsArgWith(1, "a job id", "a result string"),
30
+ });
31
+
32
+ class TestJobWorker extends JobWorker {
33
+ init() {
34
+ this.redisConnection = MockBull();
35
+ this.worker = MockBull(
36
+ this.uid,
37
+ this.jobHandler.bind(this),
38
+ this.redisConnection,
39
+ );
40
+ }
41
+ initCrypto() {
42
+ this.crypto = cryptoMocks;
43
+ }
44
+ }
45
+ jobWorker = new TestJobWorker(
46
+ "a parent id",
47
+ "a session id",
48
+ "secret is 32 char long like this",
49
+ {
50
+ url: "redis config",
51
+ },
52
+ );
53
+ jobWorker.emit = sandbox.stub();
54
+ });
55
+
56
+ afterEach(() => {
57
+ sinon.restore();
58
+ sandbox.reset();
59
+ });
60
+
61
+ it("returns a valid JobWorker object", () => {
62
+ expect(typeof jobWorker).to.equal("object");
63
+ expect(jobWorker.uid).to.equal(
64
+ `sockethub:data-layer:worker:a parent id:a session id`,
65
+ );
66
+ expect(typeof jobWorker.onJob).to.equal("function");
67
+ expect(typeof jobWorker.shutdown).to.equal("function");
68
+ });
69
+
70
+ describe("onJob", () => {
71
+ it("queues the handler", () => {
72
+ jobWorker.onJob(() => {
73
+ throw new Error("This handler should never be called");
74
+ });
75
+ sinon.assert.notCalled(jobWorker.worker.on);
76
+ });
77
+ });
78
+
79
+ describe("jobHandler", () => {
80
+ it("calls handler as expected", async () => {
81
+ cryptoMocks.decrypt.returns("an unencrypted message");
82
+ const encryptedJob = {
83
+ data: {
84
+ title: "a title",
85
+ msg: "an encrypted message",
86
+ sessionId: "a socket id",
87
+ },
88
+ };
89
+ jobWorker.onJob((job) => {
90
+ const decryptedData = encryptedJob.data;
91
+ decryptedData.msg = "an unencrypted message";
92
+ expect(job).to.eql(decryptedData);
93
+ decryptedData.msg += " handled";
94
+ return decryptedData;
95
+ });
96
+ const result = await jobWorker.jobHandler(encryptedJob);
97
+ expect(result.msg).to.eql("an unencrypted message handled");
98
+ });
99
+ });
100
+
101
+ describe("decryptJobData", () => {
102
+ it("decrypts and returns expected object", () => {
103
+ cryptoMocks.decrypt.returnsArg(0);
104
+ const jobData = {
105
+ data: {
106
+ title: "foo",
107
+ msg: "encryptedjobdata",
108
+ sessionId: "foobar",
109
+ },
110
+ };
111
+ const secret = "secretstring";
112
+ expect(jobWorker.decryptJobData(jobData, secret)).to.be.eql(
113
+ jobData.data,
114
+ );
115
+ });
116
+ });
117
+
118
+ describe("decryptActivityStream", () => {
119
+ it("decrypts and returns expected object", () => {
120
+ cryptoMocks.decrypt.returnsArg(0);
121
+ const jobData = "encryptedjobdata";
122
+ const secret = "secretstring";
123
+ expect(jobWorker.decryptActivityStream(jobData, secret)).to.be.eql(
124
+ jobData,
125
+ );
126
+ });
127
+ });
128
+ });