@sockethub/data-layer 1.0.0-alpha.10 → 1.0.0-alpha.12
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/dist/index.js +50335 -29187
- package/dist/index.js.map +320 -598
- package/package.json +11 -9
- package/src/credentials-store.test.ts +18 -2
- package/src/credentials-store.ts +82 -14
- package/src/index.ts +13 -4
- package/src/job-base.ts +74 -7
- package/src/job-queue.test.ts +25 -10
- package/src/job-queue.ts +53 -33
- package/src/job-worker.test.ts +17 -3
- package/src/job-worker.ts +35 -16
- package/src/queue-id.test.ts +17 -0
- package/src/queue-id.ts +14 -0
- package/src/types.ts +4 -0
package/src/job-queue.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createLogger,
|
|
3
|
+
getLoggerNamespace,
|
|
4
|
+
type Logger,
|
|
5
|
+
} from "@sockethub/logger";
|
|
6
|
+
import { type ActivityStream, resolvePlatformId } from "@sockethub/schemas";
|
|
1
7
|
import { type Job, Queue, QueueEvents, Worker } from "bullmq";
|
|
2
|
-
import debug, { type Debugger } from "debug";
|
|
3
8
|
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
import { JobBase, createIORedisConnection } from "./job-base.js";
|
|
9
|
+
import { JobBase } from "./job-base.js";
|
|
10
|
+
import { buildQueueId } from "./queue-id.js";
|
|
7
11
|
import type { JobDataEncrypted, JobDecrypted, RedisConfig } from "./types.js";
|
|
8
12
|
|
|
9
13
|
export async function verifyJobQueue(config: RedisConfig): Promise<void> {
|
|
10
|
-
const log =
|
|
14
|
+
const log = createLogger("data-layer:verify-job-queue");
|
|
15
|
+
|
|
11
16
|
return new Promise((resolve, reject) => {
|
|
12
17
|
const worker = new Worker(
|
|
13
18
|
"connectiontest",
|
|
@@ -20,7 +25,7 @@ export async function verifyJobQueue(config: RedisConfig): Promise<void> {
|
|
|
20
25
|
job.data.test = "touched by worker";
|
|
21
26
|
},
|
|
22
27
|
{
|
|
23
|
-
connection:
|
|
28
|
+
connection: config,
|
|
24
29
|
},
|
|
25
30
|
);
|
|
26
31
|
worker.on("completed", async (job: Job) => {
|
|
@@ -29,22 +34,22 @@ export async function verifyJobQueue(config: RedisConfig): Promise<void> {
|
|
|
29
34
|
"Worker job completed unsuccessfully during JobQueue connection test",
|
|
30
35
|
);
|
|
31
36
|
}
|
|
32
|
-
log("connection verified");
|
|
37
|
+
log.info("job queue connection verified");
|
|
33
38
|
await queue.close();
|
|
34
39
|
await worker.close();
|
|
35
40
|
resolve();
|
|
36
41
|
});
|
|
37
42
|
worker.on("error", (err) => {
|
|
38
|
-
log(
|
|
43
|
+
log.warn(
|
|
39
44
|
`connection verification worker error received ${err.toString()}`,
|
|
40
45
|
);
|
|
41
46
|
reject(err);
|
|
42
47
|
});
|
|
43
48
|
const queue = new Queue("connectiontest", {
|
|
44
|
-
connection:
|
|
49
|
+
connection: config,
|
|
45
50
|
});
|
|
46
51
|
queue.on("error", (err) => {
|
|
47
|
-
log(
|
|
52
|
+
log.warn(
|
|
48
53
|
`connection verification queue error received ${err.toString()}`,
|
|
49
54
|
);
|
|
50
55
|
reject(err);
|
|
@@ -71,43 +76,49 @@ export async function verifyJobQueue(config: RedisConfig): Promise<void> {
|
|
|
71
76
|
* ```
|
|
72
77
|
*/
|
|
73
78
|
export class JobQueue extends JobBase {
|
|
74
|
-
readonly
|
|
79
|
+
private readonly connectionName: string;
|
|
80
|
+
protected readonly queueId: string;
|
|
75
81
|
protected queue: Queue;
|
|
76
82
|
protected events: QueueEvents;
|
|
77
|
-
private readonly
|
|
83
|
+
private readonly log: Logger;
|
|
78
84
|
private counter = 0;
|
|
79
85
|
private initialized = false;
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
88
|
* Creates a new JobQueue instance.
|
|
83
89
|
*
|
|
90
|
+
* @param parentId - Sockethub instance identifier for queue isolation
|
|
84
91
|
* @param instanceId - Unique identifier for the platform instance
|
|
85
|
-
* @param sessionId - Client session identifier for queue isolation
|
|
86
92
|
* @param secret - 32-character encryption secret for message security
|
|
87
93
|
* @param redisConfig - Redis connection configuration
|
|
88
94
|
*/
|
|
89
95
|
constructor(
|
|
96
|
+
parentId: string,
|
|
90
97
|
instanceId: string,
|
|
91
|
-
sessionId: string,
|
|
92
98
|
secret: string,
|
|
93
99
|
redisConfig: RedisConfig,
|
|
94
100
|
) {
|
|
95
101
|
super(secret);
|
|
96
|
-
|
|
97
|
-
this.
|
|
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;
|
|
98
109
|
this.init(redisConfig);
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
protected init(redisConfig: RedisConfig) {
|
|
102
113
|
if (this.initialized) {
|
|
103
|
-
throw new Error(`JobQueue already initialized for ${this.
|
|
114
|
+
throw new Error(`JobQueue already initialized for ${this.queueId}`);
|
|
104
115
|
}
|
|
105
116
|
this.initialized = true;
|
|
106
117
|
|
|
107
|
-
// BullMQ v5+ prohibits colons in queue names
|
|
108
|
-
//
|
|
109
|
-
const queueName = this.
|
|
110
|
-
// Let BullMQ create its own connections
|
|
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)
|
|
111
122
|
this.queue = new Queue(queueName, {
|
|
112
123
|
connection: redisConfig,
|
|
113
124
|
});
|
|
@@ -115,30 +126,39 @@ export class JobQueue extends JobBase {
|
|
|
115
126
|
connection: redisConfig,
|
|
116
127
|
});
|
|
117
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
|
+
|
|
118
138
|
this.events.on("completed", async ({ jobId, returnvalue }) => {
|
|
119
139
|
const job = await this.getJob(jobId);
|
|
120
140
|
if (!job) {
|
|
121
|
-
this.debug(`completed job ${jobId} (already removed)`);
|
|
141
|
+
this.log.debug(`completed job ${jobId} (already removed)`);
|
|
122
142
|
return;
|
|
123
143
|
}
|
|
124
|
-
this.debug(`completed ${job.data.title} ${job.data.msg.type}`);
|
|
144
|
+
this.log.debug(`completed ${job.data.title} ${job.data.msg.type}`);
|
|
125
145
|
this.emit("completed", job.data, returnvalue);
|
|
126
146
|
});
|
|
127
147
|
|
|
128
148
|
this.events.on("failed", async ({ jobId, failedReason }) => {
|
|
129
149
|
const job = await this.getJob(jobId);
|
|
130
150
|
if (!job) {
|
|
131
|
-
this.debug(
|
|
151
|
+
this.log.debug(
|
|
132
152
|
`failed job ${jobId} (already removed): ${failedReason}`,
|
|
133
153
|
);
|
|
134
154
|
return;
|
|
135
155
|
}
|
|
136
|
-
this.
|
|
156
|
+
this.log.warn(
|
|
137
157
|
`failed ${job.data.title} ${job.data.msg.type}: ${failedReason}`,
|
|
138
158
|
);
|
|
139
159
|
this.emit("failed", job.data, failedReason);
|
|
140
160
|
});
|
|
141
|
-
this.
|
|
161
|
+
this.log.info("initialized");
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
/**
|
|
@@ -156,7 +176,7 @@ export class JobQueue extends JobBase {
|
|
|
156
176
|
const job = this.createJob(socketId, msg);
|
|
157
177
|
if (await this.queue.isPaused()) {
|
|
158
178
|
// this.queue.emit("error", new Error("queue closed"));
|
|
159
|
-
this.debug(
|
|
179
|
+
this.log.debug(
|
|
160
180
|
`failed to add ${job.title} ${msg.type} to queue: queue closed`,
|
|
161
181
|
);
|
|
162
182
|
throw new Error("queue closed");
|
|
@@ -168,7 +188,7 @@ export class JobQueue extends JobBase {
|
|
|
168
188
|
removeOnComplete: { age: 300 }, // 5 minutes in seconds
|
|
169
189
|
removeOnFail: { age: 300 },
|
|
170
190
|
});
|
|
171
|
-
this.debug(`added ${job.title} ${msg.type} to queue`);
|
|
191
|
+
this.log.debug(`added ${job.title} ${msg.type} to queue`);
|
|
172
192
|
return job;
|
|
173
193
|
}
|
|
174
194
|
|
|
@@ -177,7 +197,7 @@ export class JobQueue extends JobBase {
|
|
|
177
197
|
*/
|
|
178
198
|
async pause() {
|
|
179
199
|
await this.queue.pause();
|
|
180
|
-
this.debug("paused");
|
|
200
|
+
this.log.debug("paused");
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
/**
|
|
@@ -185,7 +205,7 @@ export class JobQueue extends JobBase {
|
|
|
185
205
|
*/
|
|
186
206
|
async resume() {
|
|
187
207
|
await this.queue.resume();
|
|
188
|
-
this.debug("resumed");
|
|
208
|
+
this.log.debug("resumed");
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
/**
|
|
@@ -207,9 +227,8 @@ export class JobQueue extends JobBase {
|
|
|
207
227
|
if (job) {
|
|
208
228
|
job.data = this.decryptJobData(job);
|
|
209
229
|
try {
|
|
210
|
-
// biome-ignore lint/performance/noDelete: <explanation>
|
|
211
230
|
delete job.data.msg.sessionSecret;
|
|
212
|
-
} catch (
|
|
231
|
+
} catch (_e) {
|
|
213
232
|
// this property should never be exposed externally
|
|
214
233
|
}
|
|
215
234
|
}
|
|
@@ -217,7 +236,8 @@ export class JobQueue extends JobBase {
|
|
|
217
236
|
}
|
|
218
237
|
|
|
219
238
|
private createJob(socketId: string, msg: ActivityStream): JobDataEncrypted {
|
|
220
|
-
const
|
|
239
|
+
const platformId = resolvePlatformId(msg) || "unknown";
|
|
240
|
+
const title = `${platformId}-${msg.id ? msg.id : this.counter++}`;
|
|
221
241
|
return {
|
|
222
242
|
title: title,
|
|
223
243
|
sessionId: socketId,
|
package/src/job-worker.test.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { expect } from "chai";
|
|
2
2
|
import * as sinon from "sinon";
|
|
3
3
|
|
|
4
|
+
import type { Logger } from "@sockethub/schemas";
|
|
5
|
+
|
|
4
6
|
import { JobWorker } from "./index";
|
|
5
7
|
|
|
8
|
+
const mockLogger: Logger = {
|
|
9
|
+
error: () => {},
|
|
10
|
+
warn: () => {},
|
|
11
|
+
info: () => {},
|
|
12
|
+
debug: () => {},
|
|
13
|
+
};
|
|
14
|
+
|
|
6
15
|
describe("JobWorker", () => {
|
|
7
16
|
let MockBull, jobWorker, cryptoMocks, sandbox;
|
|
8
17
|
|
|
@@ -32,8 +41,9 @@ describe("JobWorker", () => {
|
|
|
32
41
|
class TestJobWorker extends JobWorker {
|
|
33
42
|
init() {
|
|
34
43
|
this.redisConnection = MockBull();
|
|
44
|
+
const queueName = this.queueId.replace(/:/g, "-");
|
|
35
45
|
this.worker = MockBull(
|
|
36
|
-
|
|
46
|
+
queueName,
|
|
37
47
|
this.jobHandler.bind(this),
|
|
38
48
|
this.redisConnection,
|
|
39
49
|
);
|
|
@@ -41,6 +51,9 @@ describe("JobWorker", () => {
|
|
|
41
51
|
initCrypto() {
|
|
42
52
|
this.crypto = cryptoMocks;
|
|
43
53
|
}
|
|
54
|
+
getQueueId() {
|
|
55
|
+
return this.queueId;
|
|
56
|
+
}
|
|
44
57
|
}
|
|
45
58
|
jobWorker = new TestJobWorker(
|
|
46
59
|
"a parent id",
|
|
@@ -49,6 +62,7 @@ describe("JobWorker", () => {
|
|
|
49
62
|
{
|
|
50
63
|
url: "redis config",
|
|
51
64
|
},
|
|
65
|
+
mockLogger,
|
|
52
66
|
);
|
|
53
67
|
jobWorker.emit = sandbox.stub();
|
|
54
68
|
});
|
|
@@ -60,8 +74,8 @@ describe("JobWorker", () => {
|
|
|
60
74
|
|
|
61
75
|
it("returns a valid JobWorker object", () => {
|
|
62
76
|
expect(typeof jobWorker).to.equal("object");
|
|
63
|
-
expect(jobWorker.
|
|
64
|
-
`sockethub:
|
|
77
|
+
expect(jobWorker.getQueueId()).to.equal(
|
|
78
|
+
`sockethub:a parent id:data-layer:queue:a session id`,
|
|
65
79
|
);
|
|
66
80
|
expect(typeof jobWorker.onJob).to.equal("function");
|
|
67
81
|
expect(typeof jobWorker.shutdown).to.equal("function");
|
package/src/job-worker.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createLogger,
|
|
3
|
+
getLoggerNamespace,
|
|
4
|
+
type Logger,
|
|
5
|
+
} from "@sockethub/logger";
|
|
1
6
|
import { Worker } from "bullmq";
|
|
2
|
-
import debug, { type Debugger } from "debug";
|
|
3
7
|
|
|
4
|
-
import { JobBase
|
|
8
|
+
import { JobBase } from "./job-base.js";
|
|
9
|
+
import { buildQueueId } from "./queue-id.js";
|
|
5
10
|
import type { JobEncrypted, JobHandler, RedisConfig } from "./types.js";
|
|
6
11
|
|
|
7
12
|
/**
|
|
@@ -21,44 +26,52 @@ import type { JobEncrypted, JobHandler, RedisConfig } from "./types.js";
|
|
|
21
26
|
* ```
|
|
22
27
|
*/
|
|
23
28
|
export class JobWorker extends JobBase {
|
|
24
|
-
readonly
|
|
29
|
+
private readonly connectionName: string;
|
|
25
30
|
protected worker: Worker;
|
|
26
31
|
protected handler: JobHandler;
|
|
27
|
-
private readonly
|
|
32
|
+
private readonly log: Logger;
|
|
28
33
|
private readonly redisConfig: RedisConfig;
|
|
29
|
-
|
|
34
|
+
protected readonly queueId: string;
|
|
30
35
|
private initialized = false;
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* Creates a new JobWorker instance.
|
|
34
39
|
*
|
|
40
|
+
* @param parentId - Must match the parentId of the corresponding JobQueue
|
|
35
41
|
* @param instanceId - Must match the instanceId of the corresponding JobQueue
|
|
36
|
-
* @param sessionId - Must match the sessionId of the corresponding JobQueue
|
|
37
42
|
* @param secret - 32-character encryption secret, must match JobQueue secret
|
|
38
43
|
* @param redisConfig - Redis connection configuration
|
|
39
44
|
*/
|
|
40
45
|
constructor(
|
|
46
|
+
parentId: string,
|
|
41
47
|
instanceId: string,
|
|
42
|
-
sessionId: string,
|
|
43
48
|
secret: string,
|
|
44
49
|
redisConfig: RedisConfig,
|
|
45
50
|
) {
|
|
46
51
|
super(secret);
|
|
47
|
-
|
|
48
|
-
this.
|
|
49
|
-
|
|
52
|
+
// Create logger with full namespace (context will be prepended automatically)
|
|
53
|
+
this.log = createLogger(`data-layer:worker:${parentId}:${instanceId}`);
|
|
54
|
+
|
|
55
|
+
// Use logger's full namespace (includes context) for Redis connection name
|
|
56
|
+
this.connectionName = getLoggerNamespace(this.log);
|
|
57
|
+
redisConfig.connectionName = this.connectionName;
|
|
58
|
+
|
|
59
|
+
// Queue ID must match JobQueue's namespace (context-free) for cross-process connection
|
|
60
|
+
this.queueId = buildQueueId(parentId, instanceId);
|
|
50
61
|
this.redisConfig = redisConfig;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
protected init() {
|
|
54
65
|
if (this.initialized) {
|
|
55
|
-
throw new Error(
|
|
66
|
+
throw new Error(
|
|
67
|
+
`JobWorker already initialized for ${this.queueId}`,
|
|
68
|
+
);
|
|
56
69
|
}
|
|
57
70
|
this.initialized = true;
|
|
58
|
-
// BullMQ v5+ prohibits colons in queue names
|
|
59
|
-
//
|
|
71
|
+
// BullMQ v5+ prohibits colons in queue names; derive the queue name
|
|
72
|
+
// from the canonical queue id by replacing ':' with '-'.
|
|
60
73
|
const queueName = this.queueId.replace(/:/g, "-");
|
|
61
|
-
// Let BullMQ create its own connection
|
|
74
|
+
// Let BullMQ create its own connection (it duplicates them internally anyway)
|
|
62
75
|
this.worker = new Worker(queueName, this.jobHandler.bind(this), {
|
|
63
76
|
connection: this.redisConfig,
|
|
64
77
|
// Prevent infinite retry loops when platform child process crashes mid-job.
|
|
@@ -66,7 +79,13 @@ export class JobWorker extends JobBase {
|
|
|
66
79
|
// up to maxStalledCount times (with default 30s interval) before failing permanently.
|
|
67
80
|
maxStalledCount: 3,
|
|
68
81
|
});
|
|
69
|
-
|
|
82
|
+
|
|
83
|
+
// Handle Redis contention errors (e.g., BUSY from Lua scripts)
|
|
84
|
+
this.worker.on("error", (err) => {
|
|
85
|
+
this.log.warn(`worker error: ${err.message}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.log.info("initialized");
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
/**
|
|
@@ -91,7 +110,7 @@ export class JobWorker extends JobBase {
|
|
|
91
110
|
|
|
92
111
|
protected async jobHandler(encryptedJob: JobEncrypted) {
|
|
93
112
|
const job = this.decryptJobData(encryptedJob);
|
|
94
|
-
this.debug(`handling ${job.title} ${job.msg.type}`);
|
|
113
|
+
this.log.debug(`handling ${job.title} ${job.msg.type}`);
|
|
95
114
|
return await this.handler(job);
|
|
96
115
|
}
|
|
97
116
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildCredentialsStoreId, buildQueueId } from "./queue-id";
|
|
4
|
+
|
|
5
|
+
describe("queue-id helpers", () => {
|
|
6
|
+
it("buildQueueId uses canonical namespace", () => {
|
|
7
|
+
expect(buildQueueId("parent", "platform")).toBe(
|
|
8
|
+
"sockethub:parent:data-layer:queue:platform",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("buildCredentialsStoreId uses canonical namespace", () => {
|
|
13
|
+
expect(buildCredentialsStoreId("parent", "session")).toBe(
|
|
14
|
+
"sockethub:parent:data-layer:credentials-store:session",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
package/src/queue-id.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const REDIS_QUEUE_PREFIX = "sockethub";
|
|
2
|
+
|
|
3
|
+
// Canonical ids use ':' separators; BullMQ queue names are derived by replacing
|
|
4
|
+
// ':' with '-' when constructing the queue name.
|
|
5
|
+
export function buildQueueId(parentId: string, instanceId: string): string {
|
|
6
|
+
return `${REDIS_QUEUE_PREFIX}:${parentId}:data-layer:queue:${instanceId}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildCredentialsStoreId(
|
|
10
|
+
parentId: string,
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): string {
|
|
13
|
+
return `${REDIS_QUEUE_PREFIX}:${parentId}:data-layer:credentials-store:${sessionId}`;
|
|
14
|
+
}
|
package/src/types.ts
CHANGED