@sockethub/data-layer 1.0.0-alpha.12 → 1.0.0-alpha.14

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/src/job-base.ts DELETED
@@ -1,125 +0,0 @@
1
- import EventEmitter from "node:events";
2
- import { type Crypto, crypto } from "@sockethub/crypto";
3
- import type { ActivityStream } from "@sockethub/schemas";
4
- import IORedis, { type Redis } from "ioredis";
5
-
6
- import type { JobDataDecrypted, JobEncrypted, RedisConfig } from "./types.js";
7
-
8
- let sharedRedisConnection: Redis | null = null;
9
-
10
- /**
11
- * Creates or returns a shared Redis connection to enable connection pooling.
12
- * This prevents connection exhaustion under high load by reusing a single
13
- * connection across all JobQueue and JobWorker instances.
14
- *
15
- * @param config - Redis configuration with optional timeout and retry settings
16
- * @returns Shared Redis connection instance
17
- */
18
- export function createIORedisConnection(config: RedisConfig): Redis {
19
- if (!sharedRedisConnection) {
20
- sharedRedisConnection = new IORedis(config.url, {
21
- connectionName: config.connectionName,
22
- enableOfflineQueue: false,
23
- maxRetriesPerRequest: config.maxRetriesPerRequest ?? null,
24
- connectTimeout: config.connectTimeout ?? 10000,
25
- disconnectTimeout: config.disconnectTimeout ?? 5000,
26
- lazyConnect: false,
27
- retryStrategy: (times: number) => {
28
- // Stop retrying after 3 attempts to fail fast
29
- if (times > 3) return null;
30
- // Exponential backoff: 200ms, 400ms, 800ms
31
- return Math.min(2 ** (times - 1) * 200, 2000);
32
- },
33
- });
34
- }
35
- return sharedRedisConnection;
36
- }
37
-
38
- /**
39
- * Resets the shared Redis connection. Used primarily for testing.
40
- * Disconnects the current connection before resetting.
41
- */
42
- export async function resetSharedRedisConnection(): Promise<void> {
43
- if (sharedRedisConnection) {
44
- try {
45
- sharedRedisConnection.disconnect(false);
46
- } catch (_err) {
47
- // Ignore disconnect errors during cleanup
48
- }
49
- sharedRedisConnection = null;
50
- }
51
- }
52
-
53
- /**
54
- * Gets the total number of active Redis client connections.
55
- *
56
- * Note: This queries the Redis server directly using CLIENT LIST and reports
57
- * ALL active connections to the Redis instance. This includes connections from
58
- * Sockethub (BullMQ queues, workers, and the shared connection) as well as any
59
- * other applications or services connected to the same Redis server.
60
- *
61
- * @returns Number of active Redis connections, or 0 if no connection exists
62
- */
63
- export async function getRedisConnectionCount(): Promise<number> {
64
- if (!sharedRedisConnection) {
65
- return 0;
66
- }
67
-
68
- try {
69
- const clientList = (await sharedRedisConnection.client(
70
- "LIST",
71
- )) as string;
72
- // CLIENT LIST returns one line per connection, filter out empty lines
73
- const connections = clientList
74
- .split("\n")
75
- .filter((line: string) => line.trim());
76
- return connections.length;
77
- } catch (_err) {
78
- // Return 0 if Redis query fails (connection issues, etc.)
79
- return 0;
80
- }
81
- }
82
-
83
- export class JobBase extends EventEmitter {
84
- protected crypto: Crypto;
85
- private readonly secret: string;
86
-
87
- constructor(secret: string) {
88
- super();
89
- if (secret.length !== 32) {
90
- throw new Error(
91
- `secret must be a 32 char string, length: ${secret.length}`,
92
- );
93
- }
94
- this.secret = secret;
95
- this.initCrypto();
96
- }
97
-
98
- initCrypto() {
99
- this.crypto = crypto;
100
- }
101
-
102
- disconnectBase() {
103
- this.removeAllListeners();
104
- }
105
-
106
- /**
107
- * @param job
108
- * @private
109
- */
110
- protected decryptJobData(job: JobEncrypted): JobDataDecrypted {
111
- return {
112
- title: job.data.title,
113
- msg: this.decryptActivityStream(job.data.msg) as ActivityStream,
114
- sessionId: job.data.sessionId,
115
- };
116
- }
117
-
118
- protected decryptActivityStream(msg: string): ActivityStream {
119
- return this.crypto.decrypt(msg, this.secret);
120
- }
121
-
122
- protected encryptActivityStream(msg: ActivityStream): string {
123
- return this.crypto.encrypt(msg, this.secret);
124
- }
125
- }
@@ -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
- platform: "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
- platform: "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
- platform: "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
- platform: "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
- platform: "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
- createLogger,
3
- getLoggerNamespace,
4
- type Logger,
5
- } from "@sockethub/logger";
6
- import { type ActivityStream, resolvePlatformId } from "@sockethub/schemas";
7
- import { type Job, Queue, QueueEvents, Worker } from "bullmq";
8
-
9
- import { JobBase } 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: 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: 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
- delete job.data.msg.sessionSecret;
231
- } catch (_e) {
232
- // this property should never be exposed externally
233
- }
234
- }
235
- return job;
236
- }
237
-
238
- private createJob(socketId: string, msg: ActivityStream): JobDataEncrypted {
239
- const platformId = resolvePlatformId(msg) || "unknown";
240
- const title = `${platformId}-${msg.id ? msg.id : this.counter++}`;
241
- return {
242
- title: title,
243
- sessionId: socketId,
244
- msg: this.encryptActivityStream(msg),
245
- };
246
- }
247
- }