@queuedash/sdk 2.0.0 → 2.0.1
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 +14 -12
- package/dist/client.d.ts +17 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +159 -80
- package/dist/client.spec.js +373 -4
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @queuedash/sdk
|
|
2
2
|
|
|
3
|
-
Official SDK for Queuedash -
|
|
3
|
+
Official SDK for Queuedash - queue monitoring for BullMQ, Bull, and Bee-Queue.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ yarn add @queuedash/sdk
|
|
|
14
14
|
|
|
15
15
|
## Quick Start
|
|
16
16
|
|
|
17
|
-
The SDK auto-detects your queue library (BullMQ
|
|
17
|
+
The SDK auto-detects your queue library (BullMQ 5+, Bull 4+, or Bee-Queue 1+) - just use `attach()`. First add the queue in Queuedash under the same name: jobs sent for a queue name Queuedash doesn't know are dropped.
|
|
18
18
|
|
|
19
19
|
### BullMQ
|
|
20
20
|
|
|
@@ -82,9 +82,9 @@ qd.attach(new Queue("notifications", { connection }));
|
|
|
82
82
|
|
|
83
83
|
Get your API key from [Queuedash](https://queuedash.com):
|
|
84
84
|
|
|
85
|
-
1.
|
|
86
|
-
2.
|
|
87
|
-
3.
|
|
85
|
+
1. Open your project
|
|
86
|
+
2. In the environment's API Keys section, click Add
|
|
87
|
+
3. Copy the key (it's shown once)
|
|
88
88
|
|
|
89
89
|
```bash
|
|
90
90
|
# Set in your environment
|
|
@@ -98,7 +98,8 @@ const qd = new Queuedash({
|
|
|
98
98
|
// Required: Your API key from Queuedash
|
|
99
99
|
apiKey: string,
|
|
100
100
|
|
|
101
|
-
// Optional:
|
|
101
|
+
// Optional: Where batches are sent (default: https://sync.queuedash.com
|
|
102
|
+
// when NODE_ENV is "production", otherwise http://localhost:4002)
|
|
102
103
|
baseUrl?: string,
|
|
103
104
|
|
|
104
105
|
// Optional: Batch size for syncing jobs (default: 50)
|
|
@@ -128,8 +129,9 @@ The SDK is production-ready with multiple layers of protection:
|
|
|
128
129
|
**Retry Logic**
|
|
129
130
|
|
|
130
131
|
- Automatic retries up to 5 times (configurable)
|
|
131
|
-
- Exponential backoff: 1s → 2s → 4s → 8s → 16s
|
|
132
|
-
- Jobs
|
|
132
|
+
- Exponential backoff: 1s → 2s → 4s → 8s → 16s (capped at 30s)
|
|
133
|
+
- Jobs that arrive during a backoff are held and sent with the retry
|
|
134
|
+
- A batch that still fails after the last retry (~31s after the first failure, with defaults) is dropped and reported via `onError`
|
|
133
135
|
|
|
134
136
|
**Circuit Breaker**
|
|
135
137
|
|
|
@@ -147,18 +149,18 @@ The SDK is production-ready with multiple layers of protection:
|
|
|
147
149
|
|
|
148
150
|
- SIGTERM/SIGINT handlers flush pending jobs before exit
|
|
149
151
|
- Call `await qd.stop()` to manually stop
|
|
150
|
-
-
|
|
152
|
+
- Shutdown doesn't wait out a retry backoff: pending jobs get one final attempt, and if that fails they're reported via `onError` instead of retried
|
|
151
153
|
|
|
152
154
|
**Request Safety**
|
|
153
155
|
|
|
154
156
|
- 30-second timeout on all HTTP requests
|
|
155
|
-
-
|
|
157
|
+
- Only the latest pending update for each job is sent
|
|
156
158
|
- Network errors handled gracefully
|
|
157
159
|
|
|
158
160
|
## Features
|
|
159
161
|
|
|
160
|
-
- **Auto-detection**: Works with BullMQ, Bull, Bee-Queue
|
|
161
|
-
- **
|
|
162
|
+
- **Auto-detection**: Works with BullMQ, Bull, and Bee-Queue automatically
|
|
163
|
+
- **Event-driven**: A job is sent when the SDK hears an event for it (GroupMQ isn't supported)
|
|
162
164
|
- **Smart batching**: Events are automatically batched for performance
|
|
163
165
|
- **Auto-retry**: Failed syncs are automatically retried
|
|
164
166
|
- **Zero config**: Works out of the box with sensible defaults
|
package/dist/client.d.ts
CHANGED
|
@@ -15,17 +15,19 @@ export declare class Queuedash {
|
|
|
15
15
|
private jobQueue;
|
|
16
16
|
private flushTimer?;
|
|
17
17
|
private isFlushing;
|
|
18
|
+
private inFlightFlush?;
|
|
19
|
+
private retryTimer?;
|
|
20
|
+
private stopped;
|
|
18
21
|
private retryCount;
|
|
19
22
|
private circuitBreakerFailures;
|
|
20
23
|
private circuitBreakerOpen;
|
|
21
24
|
private circuitBreakerResetTimer?;
|
|
22
|
-
private syncingJobIds;
|
|
23
25
|
private managedQueueEvents;
|
|
24
26
|
private attachedQueues;
|
|
25
27
|
constructor(options: QueuedashOptions);
|
|
26
28
|
/**
|
|
27
29
|
* Attach a queue or worker for monitoring.
|
|
28
|
-
* Auto-detects the queue library (BullMQ, Bull, Bee-Queue
|
|
30
|
+
* Auto-detects the queue library (BullMQ, Bull, Bee-Queue).
|
|
29
31
|
*
|
|
30
32
|
* @example
|
|
31
33
|
* ```ts
|
|
@@ -60,16 +62,27 @@ export declare class Queuedash {
|
|
|
60
62
|
* Queue a job for syncing (internal use)
|
|
61
63
|
*/
|
|
62
64
|
syncJob(job: JobData): void;
|
|
65
|
+
private jobKey;
|
|
63
66
|
/**
|
|
64
|
-
* Manually flush all pending jobs
|
|
67
|
+
* Manually flush all pending jobs. Unlike the periodic and batch-size
|
|
68
|
+
* flushes, this doesn't wait for a pending retry backoff.
|
|
65
69
|
*/
|
|
66
70
|
flush(): Promise<void>;
|
|
71
|
+
private syncPendingJobs;
|
|
72
|
+
/**
|
|
73
|
+
* Put unsent jobs back at the front of the queue, unless a newer snapshot
|
|
74
|
+
* of the same job was queued while they were in flight
|
|
75
|
+
*/
|
|
76
|
+
private requeue;
|
|
67
77
|
private splitIntoChunks;
|
|
68
78
|
private sendBatch;
|
|
69
79
|
private openCircuitBreaker;
|
|
70
80
|
private log;
|
|
81
|
+
private clearRetryTimer;
|
|
71
82
|
/**
|
|
72
|
-
* Stop the client, close managed resources, and flush remaining jobs
|
|
83
|
+
* Stop the client, close managed resources, and flush remaining jobs.
|
|
84
|
+
* A pending retry backoff is skipped: remaining jobs get one final attempt,
|
|
85
|
+
* and if that fails they're reported via onError instead of retried.
|
|
73
86
|
*/
|
|
74
87
|
stop(): Promise<void>;
|
|
75
88
|
private startFlushTimer;
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAgB,MAAM,SAAS,CAAC;AAiBlE,KAAK,iBAAiB,GAAG;IACvB,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,GAAG,CAAC;IAC9D,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B,CAAC;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAgB,MAAM,SAAS,CAAC;AAiBlE,KAAK,iBAAiB,GAAG;IACvB,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,GAAG,CAAC;IAC9D,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B,CAAC;AAeF,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAC,CAAyB;IAGzC,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,UAAU,CAAC,CAAiB;IACpC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAC,CAAgB;IAGtC,OAAO,CAAC,UAAU,CAAC,CAAiB;IAEpC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,sBAAsB,CAAK;IACnC,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,wBAAwB,CAAC,CAAiB;IAGlD,OAAO,CAAC,kBAAkB,CAA2B;IACrD,OAAO,CAAC,cAAc,CAAqB;gBAE/B,OAAO,EAAE,gBAAgB;IAsBrC;;;;;;;;;;;;;;;;;OAiBG;IACH,MAAM,CAAC,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,iBAAiB,CAAA;KAAE,GAAG,IAAI;IAqCzE,OAAO,CAAC,UAAU;YAqCJ,YAAY;IAwE1B,OAAO,CAAC,kBAAkB;IAK1B,OAAO,CAAC,UAAU;IAsClB,OAAO,CAAC,SAAS;IA+BjB,OAAO,CAAC,oBAAoB;IAoB5B,OAAO,CAAC,kBAAkB;IAmB1B,OAAO,CAAC,iBAAiB;IAiBzB;;OAEG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAqChE;;OAEG;IACH,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI;IAgC3B,OAAO,CAAC,MAAM;IAId;;;OAGG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAsBd,eAAe;IAoE7B;;;OAGG;IACH,OAAO,CAAC,OAAO;IAcf,OAAO,CAAC,eAAe;YAuBT,SAAS;IAmCvB,OAAO,CAAC,kBAAkB;IAmB1B,OAAO,CAAC,GAAG;IAQX,OAAO,CAAC,eAAe;IAOvB;;;;OAIG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA6B3B,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,qBAAqB;CAiB9B"}
|
package/dist/client.js
CHANGED
|
@@ -37,12 +37,14 @@ exports.Queuedash = void 0;
|
|
|
37
37
|
const MAX_PAYLOAD_SIZE = 4.5 * 1024 * 1024; // 4.5MB
|
|
38
38
|
class Queuedash {
|
|
39
39
|
constructor(options) {
|
|
40
|
-
|
|
40
|
+
// Latest unsent snapshot per `${queueName}:${jobId}`, oldest first
|
|
41
|
+
this.jobQueue = new Map();
|
|
41
42
|
this.isFlushing = false;
|
|
43
|
+
// Set by stop(): failed syncs are reported instead of retried
|
|
44
|
+
this.stopped = false;
|
|
42
45
|
this.retryCount = 0;
|
|
43
46
|
this.circuitBreakerFailures = 0;
|
|
44
47
|
this.circuitBreakerOpen = false;
|
|
45
|
-
this.syncingJobIds = new Set();
|
|
46
48
|
// Track attached resources for cleanup
|
|
47
49
|
this.managedQueueEvents = [];
|
|
48
50
|
this.attachedQueues = new Set();
|
|
@@ -66,7 +68,7 @@ class Queuedash {
|
|
|
66
68
|
}
|
|
67
69
|
/**
|
|
68
70
|
* Attach a queue or worker for monitoring.
|
|
69
|
-
* Auto-detects the queue library (BullMQ, Bull, Bee-Queue
|
|
71
|
+
* Auto-detects the queue library (BullMQ, Bull, Bee-Queue).
|
|
70
72
|
*
|
|
71
73
|
* @example
|
|
72
74
|
* ```ts
|
|
@@ -85,9 +87,22 @@ class Queuedash {
|
|
|
85
87
|
attach(resource, options) {
|
|
86
88
|
const type = this.detectType(resource);
|
|
87
89
|
switch (type) {
|
|
88
|
-
case "bullmq-queue":
|
|
89
|
-
|
|
90
|
+
case "bullmq-queue": {
|
|
91
|
+
const queue = resource;
|
|
92
|
+
// This can fail after attach() has returned, where the caller can't
|
|
93
|
+
// catch it, and an unhandled rejection would crash the process
|
|
94
|
+
this.attachBullMQ(queue, options?.events).catch((error) => {
|
|
95
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
96
|
+
const errorMsg = `Failed to attach queue "${queue.name}"`;
|
|
97
|
+
if (this.onError) {
|
|
98
|
+
this.onError(new Error(`${errorMsg}: ${err.message}`));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.error(`Queuedash: ${errorMsg}`, err);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
90
104
|
break;
|
|
105
|
+
}
|
|
91
106
|
case "bullmq-worker":
|
|
92
107
|
this.attachBullMQWorker(resource);
|
|
93
108
|
break;
|
|
@@ -98,7 +113,7 @@ class Queuedash {
|
|
|
98
113
|
this.attachBee(resource);
|
|
99
114
|
break;
|
|
100
115
|
default:
|
|
101
|
-
throw new Error("Queuedash: Unknown queue type. Supported: BullMQ Queue
|
|
116
|
+
throw new Error("Queuedash: Unknown queue type. Supported: BullMQ Queue, Bull, Bee-Queue");
|
|
102
117
|
}
|
|
103
118
|
return this;
|
|
104
119
|
}
|
|
@@ -139,27 +154,31 @@ class Queuedash {
|
|
|
139
154
|
events = existingEvents;
|
|
140
155
|
}
|
|
141
156
|
else {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
157
|
+
// Dynamic import to avoid requiring bullmq as a direct dependency
|
|
158
|
+
const { QueueEvents } = await Promise.resolve().then(() => __importStar(require("bullmq"))).catch((error) => {
|
|
159
|
+
throw new Error(`Could not load bullmq (is it installed?): ${error.message}`);
|
|
160
|
+
});
|
|
161
|
+
// Throws if BullMQ rejects the connection, e.g. a shared ioredis instance
|
|
162
|
+
// without maxRetriesPerRequest: null. attach() reports the error as is
|
|
163
|
+
const queueEvents = new QueueEvents(queueName, {
|
|
164
|
+
connection: queue.opts?.connection,
|
|
165
|
+
// BullMQ keeps the key prefix in opts.prefix; without it QueueEvents
|
|
166
|
+
// listens on the default "bull" keys
|
|
167
|
+
prefix: queue.opts?.prefix ?? queue.redisPrefix,
|
|
168
|
+
});
|
|
169
|
+
this.managedQueueEvents.push(queueEvents);
|
|
170
|
+
events = queueEvents;
|
|
155
171
|
}
|
|
156
172
|
this.attachedQueues.add(`bullmq:${queueName}`);
|
|
157
173
|
const syncJobById = (jobId, status) => {
|
|
158
|
-
queue
|
|
174
|
+
queue
|
|
175
|
+
.getJob(jobId)
|
|
176
|
+
.then((job) => {
|
|
159
177
|
if (job) {
|
|
160
178
|
this.syncJob(this.extractBullMQJobData(job, queueName, status));
|
|
161
179
|
}
|
|
162
|
-
})
|
|
180
|
+
})
|
|
181
|
+
.catch((error) => this.log(`Failed to load job ${jobId}`, error));
|
|
163
182
|
};
|
|
164
183
|
events.on("waiting", ({ jobId }) => syncJobById(jobId, "waiting"));
|
|
165
184
|
events.on("progress", ({ jobId }) => syncJobById(jobId, "active"));
|
|
@@ -185,14 +204,19 @@ class Queuedash {
|
|
|
185
204
|
return;
|
|
186
205
|
}
|
|
187
206
|
this.attachedQueues.add(`bull:${queueName}`);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
// Global events pass (jobId, result) rather than a Job, so load the job
|
|
208
|
+
const syncJobById = (jobId) => {
|
|
209
|
+
queue
|
|
210
|
+
.getJob(jobId)
|
|
211
|
+
.then((job) => {
|
|
212
|
+
if (job) {
|
|
213
|
+
this.syncJob(this.extractBullJobData(job, queueName));
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
.catch((error) => this.log(`Failed to load job ${jobId}`, error));
|
|
217
|
+
};
|
|
218
|
+
queue.on("global:completed", (jobId) => syncJobById(jobId));
|
|
219
|
+
queue.on("global:failed", (jobId) => syncJobById(jobId));
|
|
196
220
|
queue.on("active", (job) => {
|
|
197
221
|
this.syncJob(this.extractBullJobData(job, queueName));
|
|
198
222
|
});
|
|
@@ -285,9 +309,9 @@ class Queuedash {
|
|
|
285
309
|
* Delete a job (soft delete in Queuedash)
|
|
286
310
|
*/
|
|
287
311
|
async deleteJob(jobId, queueName) {
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
288
314
|
try {
|
|
289
|
-
const controller = new AbortController();
|
|
290
|
-
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
291
315
|
const response = await fetch(`${this.apiUrl}/api/v1/jobs/${encodeURIComponent(jobId)}?queueName=${encodeURIComponent(queueName)}`, {
|
|
292
316
|
method: "DELETE",
|
|
293
317
|
headers: {
|
|
@@ -295,7 +319,6 @@ class Queuedash {
|
|
|
295
319
|
},
|
|
296
320
|
signal: controller.signal,
|
|
297
321
|
});
|
|
298
|
-
clearTimeout(timeoutId);
|
|
299
322
|
if (!response.ok) {
|
|
300
323
|
const errorText = await response.text();
|
|
301
324
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
@@ -310,6 +333,9 @@ class Queuedash {
|
|
|
310
333
|
console.error(`Queuedash: Failed to delete job ${jobId}`, err);
|
|
311
334
|
}
|
|
312
335
|
}
|
|
336
|
+
finally {
|
|
337
|
+
clearTimeout(timeoutId);
|
|
338
|
+
}
|
|
313
339
|
}
|
|
314
340
|
/**
|
|
315
341
|
* Queue a job for syncing (internal use)
|
|
@@ -318,32 +344,55 @@ class Queuedash {
|
|
|
318
344
|
if (this.circuitBreakerOpen) {
|
|
319
345
|
return;
|
|
320
346
|
}
|
|
321
|
-
const jobKey =
|
|
322
|
-
|
|
347
|
+
const jobKey = this.jobKey(job);
|
|
348
|
+
// A newer snapshot replaces the pending one and keeps its place in line.
|
|
349
|
+
// If the previous snapshot is already in flight, this one waits here for
|
|
350
|
+
// the next flush (the API upserts on queue + jobId).
|
|
351
|
+
if (this.jobQueue.has(jobKey)) {
|
|
352
|
+
this.jobQueue.set(jobKey, job);
|
|
323
353
|
return;
|
|
324
354
|
}
|
|
325
|
-
this.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
355
|
+
if (this.jobQueue.size >= this.maxQueueSize) {
|
|
356
|
+
const oldest = this.jobQueue.entries().next().value;
|
|
357
|
+
if (oldest) {
|
|
358
|
+
this.jobQueue.delete(oldest[0]);
|
|
359
|
+
}
|
|
360
|
+
this.log(`Queue size limit reached (${this.maxQueueSize}), dropping oldest job: ${oldest?.[1].jobId}`);
|
|
329
361
|
}
|
|
330
|
-
this.jobQueue.
|
|
331
|
-
if (this.jobQueue.
|
|
362
|
+
this.jobQueue.set(jobKey, job);
|
|
363
|
+
if (this.jobQueue.size >= this.batchSize && !this.retryTimer) {
|
|
332
364
|
this.flush();
|
|
333
365
|
}
|
|
334
366
|
}
|
|
367
|
+
jobKey(job) {
|
|
368
|
+
return `${job.queueName}:${job.jobId}`;
|
|
369
|
+
}
|
|
335
370
|
/**
|
|
336
|
-
* Manually flush all pending jobs
|
|
371
|
+
* Manually flush all pending jobs. Unlike the periodic and batch-size
|
|
372
|
+
* flushes, this doesn't wait for a pending retry backoff.
|
|
337
373
|
*/
|
|
338
374
|
async flush() {
|
|
339
375
|
if (this.isFlushing ||
|
|
340
|
-
this.jobQueue.
|
|
376
|
+
this.jobQueue.size === 0 ||
|
|
341
377
|
this.circuitBreakerOpen) {
|
|
342
378
|
return;
|
|
343
379
|
}
|
|
380
|
+
// This attempt replaces any scheduled retry; if it fails, the next retry
|
|
381
|
+
// is scheduled with a longer backoff
|
|
382
|
+
this.clearRetryTimer();
|
|
344
383
|
this.isFlushing = true;
|
|
345
|
-
|
|
346
|
-
|
|
384
|
+
this.inFlightFlush = this.syncPendingJobs();
|
|
385
|
+
try {
|
|
386
|
+
await this.inFlightFlush;
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
this.isFlushing = false;
|
|
390
|
+
this.inFlightFlush = undefined;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
async syncPendingJobs() {
|
|
394
|
+
const jobsToSync = [...this.jobQueue.values()];
|
|
395
|
+
this.jobQueue = new Map();
|
|
347
396
|
try {
|
|
348
397
|
// Check if payload is too large and split into chunks if needed
|
|
349
398
|
const payload = JSON.stringify({ jobs: jobsToSync });
|
|
@@ -353,9 +402,6 @@ class Queuedash {
|
|
|
353
402
|
await this.sendBatch(chunk);
|
|
354
403
|
// Remove successfully sent jobs so they aren't re-queued on later chunk failure
|
|
355
404
|
jobsToSync.splice(0, chunk.length);
|
|
356
|
-
chunk.forEach((job) => {
|
|
357
|
-
this.syncingJobIds.delete(`${job.queueName}:${job.jobId}`);
|
|
358
|
-
});
|
|
359
405
|
}
|
|
360
406
|
this.retryCount = 0;
|
|
361
407
|
this.circuitBreakerFailures = 0;
|
|
@@ -364,9 +410,6 @@ class Queuedash {
|
|
|
364
410
|
await this.sendBatch(jobsToSync);
|
|
365
411
|
this.retryCount = 0;
|
|
366
412
|
this.circuitBreakerFailures = 0;
|
|
367
|
-
jobsToSync.forEach((job) => {
|
|
368
|
-
this.syncingJobIds.delete(`${job.queueName}:${job.jobId}`);
|
|
369
|
-
});
|
|
370
413
|
}
|
|
371
414
|
catch (error) {
|
|
372
415
|
this.retryCount++;
|
|
@@ -375,22 +418,22 @@ class Queuedash {
|
|
|
375
418
|
if (this.circuitBreakerFailures >= 10) {
|
|
376
419
|
this.openCircuitBreaker();
|
|
377
420
|
}
|
|
378
|
-
if (this.retryCount <= this.maxRetries) {
|
|
379
|
-
this.
|
|
421
|
+
if (this.retryCount <= this.maxRetries && !this.stopped) {
|
|
422
|
+
this.requeue(jobsToSync);
|
|
380
423
|
const backoffMs = Math.min(1000 * Math.pow(2, this.retryCount - 1), 30000);
|
|
381
424
|
this.log(`Retry ${this.retryCount}/${this.maxRetries} after ${backoffMs}ms`, err);
|
|
382
|
-
setTimeout(() => {
|
|
383
|
-
|
|
425
|
+
this.retryTimer = setTimeout(() => {
|
|
426
|
+
this.retryTimer = undefined;
|
|
427
|
+
if (this.jobQueue.size > 0) {
|
|
384
428
|
this.flush();
|
|
385
429
|
}
|
|
386
430
|
}, backoffMs);
|
|
387
431
|
}
|
|
388
432
|
else {
|
|
389
433
|
this.retryCount = 0;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const errorMsg = `Failed to sync ${jobsToSync.length} jobs after ${this.maxRetries} retries`;
|
|
434
|
+
const errorMsg = this.stopped
|
|
435
|
+
? `Failed to sync ${jobsToSync.length} jobs while stopping`
|
|
436
|
+
: `Failed to sync ${jobsToSync.length} jobs after ${this.maxRetries} retries`;
|
|
394
437
|
if (this.onError) {
|
|
395
438
|
this.onError(new Error(`${errorMsg}: ${err.message}`));
|
|
396
439
|
}
|
|
@@ -399,9 +442,23 @@ class Queuedash {
|
|
|
399
442
|
}
|
|
400
443
|
}
|
|
401
444
|
}
|
|
402
|
-
|
|
403
|
-
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Put unsent jobs back at the front of the queue, unless a newer snapshot
|
|
448
|
+
* of the same job was queued while they were in flight
|
|
449
|
+
*/
|
|
450
|
+
requeue(jobs) {
|
|
451
|
+
const requeued = new Map();
|
|
452
|
+
for (const job of jobs) {
|
|
453
|
+
const jobKey = this.jobKey(job);
|
|
454
|
+
if (!this.jobQueue.has(jobKey)) {
|
|
455
|
+
requeued.set(jobKey, job);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const [jobKey, job] of this.jobQueue) {
|
|
459
|
+
requeued.set(jobKey, job);
|
|
404
460
|
}
|
|
461
|
+
this.jobQueue = requeued;
|
|
405
462
|
}
|
|
406
463
|
splitIntoChunks(jobs, maxSize) {
|
|
407
464
|
const chunks = [];
|
|
@@ -424,24 +481,30 @@ class Queuedash {
|
|
|
424
481
|
}
|
|
425
482
|
async sendBatch(jobs) {
|
|
426
483
|
const controller = new AbortController();
|
|
484
|
+
// Covers reading the body too: if a response stalled mid-body, this flush
|
|
485
|
+
// would never finish and no later flush could start
|
|
427
486
|
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
487
|
+
try {
|
|
488
|
+
const response = await fetch(`${this.apiUrl}/api/v1/jobs/sync`, {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers: {
|
|
491
|
+
"Content-Type": "application/json",
|
|
492
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
493
|
+
},
|
|
494
|
+
body: JSON.stringify({ jobs }),
|
|
495
|
+
signal: controller.signal,
|
|
496
|
+
});
|
|
497
|
+
if (!response.ok) {
|
|
498
|
+
const errorText = await response.text();
|
|
499
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
500
|
+
}
|
|
501
|
+
const result = (await response.json());
|
|
502
|
+
if (!result.success && result.errors) {
|
|
503
|
+
console.warn("Queuedash: Some jobs failed to sync:", result.errors);
|
|
504
|
+
}
|
|
441
505
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
console.warn("Queuedash: Some jobs failed to sync:", result.errors);
|
|
506
|
+
finally {
|
|
507
|
+
clearTimeout(timeoutId);
|
|
445
508
|
}
|
|
446
509
|
}
|
|
447
510
|
openCircuitBreaker() {
|
|
@@ -466,8 +529,16 @@ class Queuedash {
|
|
|
466
529
|
console.warn(`Queuedash: ${message}`);
|
|
467
530
|
}
|
|
468
531
|
}
|
|
532
|
+
clearRetryTimer() {
|
|
533
|
+
if (this.retryTimer) {
|
|
534
|
+
clearTimeout(this.retryTimer);
|
|
535
|
+
this.retryTimer = undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
469
538
|
/**
|
|
470
|
-
* Stop the client, close managed resources, and flush remaining jobs
|
|
539
|
+
* Stop the client, close managed resources, and flush remaining jobs.
|
|
540
|
+
* A pending retry backoff is skipped: remaining jobs get one final attempt,
|
|
541
|
+
* and if that fails they're reported via onError instead of retried.
|
|
471
542
|
*/
|
|
472
543
|
async stop() {
|
|
473
544
|
if (this.flushTimer) {
|
|
@@ -485,11 +556,19 @@ class Queuedash {
|
|
|
485
556
|
// Ignore close errors
|
|
486
557
|
}
|
|
487
558
|
}
|
|
559
|
+
// flush() is a no-op while another flush is in flight, so let that one
|
|
560
|
+
// finish first; otherwise snapshots queued behind it would never be sent
|
|
561
|
+
while (this.inFlightFlush) {
|
|
562
|
+
await this.inFlightFlush.catch(() => { });
|
|
563
|
+
}
|
|
564
|
+
// Don't wait out a pending backoff (up to 30s) during shutdown
|
|
565
|
+
this.stopped = true;
|
|
566
|
+
this.clearRetryTimer();
|
|
488
567
|
await this.flush();
|
|
489
568
|
}
|
|
490
569
|
startFlushTimer() {
|
|
491
570
|
this.flushTimer = setInterval(() => {
|
|
492
|
-
if (this.jobQueue.
|
|
571
|
+
if (this.jobQueue.size > 0 && !this.retryTimer) {
|
|
493
572
|
this.flush();
|
|
494
573
|
}
|
|
495
574
|
}, this.flushInterval);
|
|
@@ -506,8 +585,8 @@ class Queuedash {
|
|
|
506
585
|
process.once("SIGTERM", shutdown);
|
|
507
586
|
process.once("SIGINT", shutdown);
|
|
508
587
|
process.once("beforeExit", () => {
|
|
509
|
-
if (this.jobQueue.
|
|
510
|
-
console.warn(`Queuedash: Process exiting with ${this.jobQueue.
|
|
588
|
+
if (this.jobQueue.size > 0) {
|
|
589
|
+
console.warn(`Queuedash: Process exiting with ${this.jobQueue.size} unsync'd jobs`);
|
|
511
590
|
}
|
|
512
591
|
});
|
|
513
592
|
}
|
package/dist/client.spec.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const events_1 = require("events");
|
|
4
|
+
const bullmq_1 = require("bullmq");
|
|
3
5
|
const client_1 = require("./client");
|
|
6
|
+
// Stub QueueEvents so attaching a BullMQ queue doesn't connect to Redis
|
|
7
|
+
jest.mock("bullmq", () => ({
|
|
8
|
+
QueueEvents: jest.fn().mockImplementation(() => ({
|
|
9
|
+
on: jest.fn(),
|
|
10
|
+
close: jest.fn().mockResolvedValue(undefined),
|
|
11
|
+
})),
|
|
12
|
+
}));
|
|
13
|
+
const MockQueueEvents = bullmq_1.QueueEvents;
|
|
4
14
|
// Increase max listeners to avoid warnings from multiple SDK instances
|
|
5
15
|
process.setMaxListeners(100);
|
|
6
16
|
// Mock timers for testing
|
|
@@ -172,12 +182,9 @@ describe("Queuedash SDK", () => {
|
|
|
172
182
|
redisPrefix: "bull",
|
|
173
183
|
};
|
|
174
184
|
// BullMQ detection works - attach returns this for chaining
|
|
175
|
-
//
|
|
176
|
-
// but detection itself should not throw "Unknown queue type"
|
|
185
|
+
// and doesn't throw "Unknown queue type"
|
|
177
186
|
const result = sdk.attach(mockBullMQQueue);
|
|
178
187
|
expect(result).toBe(sdk);
|
|
179
|
-
// The error will be thrown asynchronously but detection worked
|
|
180
|
-
// because it didn't throw "Unknown queue type" synchronously
|
|
181
188
|
});
|
|
182
189
|
it("should detect BullMQ Worker (has opts.connection, no getJob)", () => {
|
|
183
190
|
const mockBullMQWorker = {
|
|
@@ -219,6 +226,89 @@ describe("Queuedash SDK", () => {
|
|
|
219
226
|
expect(() => sdk.attach(123)).toThrow("Queuedash: Unknown queue type");
|
|
220
227
|
});
|
|
221
228
|
});
|
|
229
|
+
describe("BullMQ QueueEvents", () => {
|
|
230
|
+
beforeEach(() => {
|
|
231
|
+
MockQueueEvents.mockClear();
|
|
232
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
|
|
233
|
+
});
|
|
234
|
+
// attach() creates QueueEvents after an async import of bullmq
|
|
235
|
+
const attachAndWait = async (queue) => {
|
|
236
|
+
sdk.attach(queue);
|
|
237
|
+
jest.useRealTimers();
|
|
238
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
239
|
+
jest.useFakeTimers();
|
|
240
|
+
};
|
|
241
|
+
it("should listen with the queue's custom prefix", async () => {
|
|
242
|
+
// Shaped like a BullMQ 5 Queue: the prefix lives in opts, no redisPrefix
|
|
243
|
+
await attachAndWait({
|
|
244
|
+
name: "prefixed-queue",
|
|
245
|
+
getJob: jest.fn(),
|
|
246
|
+
opts: { connection: {}, prefix: "myapp" },
|
|
247
|
+
});
|
|
248
|
+
expect(MockQueueEvents).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(MockQueueEvents).toHaveBeenCalledWith("prefixed-queue", expect.objectContaining({ prefix: "myapp" }));
|
|
250
|
+
});
|
|
251
|
+
it("should fall back to redisPrefix when opts.prefix is not set", async () => {
|
|
252
|
+
await attachAndWait({
|
|
253
|
+
name: "legacy-queue",
|
|
254
|
+
getJob: jest.fn(),
|
|
255
|
+
opts: { connection: {} },
|
|
256
|
+
redisPrefix: "legacy",
|
|
257
|
+
});
|
|
258
|
+
expect(MockQueueEvents).toHaveBeenCalledWith("legacy-queue", expect.objectContaining({ prefix: "legacy" }));
|
|
259
|
+
});
|
|
260
|
+
// e.g. the queue shares an ioredis instance with default options: fine
|
|
261
|
+
// for a Queue, but BullMQ rejects it for QueueEvents' blocking connection
|
|
262
|
+
const connectionError = () => new Error("BullMQ: Your redis options maxRetriesPerRequest must be null.");
|
|
263
|
+
it("should report a QueueEvents failure instead of crashing the process", async () => {
|
|
264
|
+
MockQueueEvents.mockImplementationOnce(() => {
|
|
265
|
+
throw connectionError();
|
|
266
|
+
});
|
|
267
|
+
// attach() has already returned when this fails; Jest fails this test
|
|
268
|
+
// if the rejection goes unhandled
|
|
269
|
+
await attachAndWait({
|
|
270
|
+
name: "shared-connection-queue",
|
|
271
|
+
getJob: jest.fn(),
|
|
272
|
+
opts: { connection: {} },
|
|
273
|
+
});
|
|
274
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('Queuedash: Failed to attach queue "shared-connection-queue"', connectionError());
|
|
275
|
+
});
|
|
276
|
+
it("should pass a QueueEvents failure to onError", async () => {
|
|
277
|
+
const onError = jest.fn();
|
|
278
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
|
|
279
|
+
MockQueueEvents.mockImplementationOnce(() => {
|
|
280
|
+
throw connectionError();
|
|
281
|
+
});
|
|
282
|
+
await attachAndWait({
|
|
283
|
+
name: "shared-connection-queue",
|
|
284
|
+
getJob: jest.fn(),
|
|
285
|
+
opts: { connection: {} },
|
|
286
|
+
});
|
|
287
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
288
|
+
expect(onError.mock.calls[0][0].message).toBe('Failed to attach queue "shared-connection-queue": ' +
|
|
289
|
+
connectionError().message);
|
|
290
|
+
});
|
|
291
|
+
it("should warn instead of leaving an unhandled rejection when getJob fails", async () => {
|
|
292
|
+
// e.g. the app closed its Queue before calling qd.stop()
|
|
293
|
+
const queue = {
|
|
294
|
+
name: "closed-queue",
|
|
295
|
+
opts: { connection: {} },
|
|
296
|
+
getJob: jest.fn().mockRejectedValue(new Error("Connection is closed.")),
|
|
297
|
+
};
|
|
298
|
+
// Shaped like QueueEvents: an EventEmitter with close()
|
|
299
|
+
const events = Object.assign(new events_1.EventEmitter(), {
|
|
300
|
+
close: async () => { },
|
|
301
|
+
});
|
|
302
|
+
sdk.attach(queue, { events });
|
|
303
|
+
events.emit("completed", { jobId: "1" });
|
|
304
|
+
// Let the rejection settle; Jest fails this test if it goes unhandled
|
|
305
|
+
jest.useRealTimers();
|
|
306
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
307
|
+
jest.useFakeTimers();
|
|
308
|
+
expect(queue.getJob).toHaveBeenCalledWith("1");
|
|
309
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to load job 1"));
|
|
310
|
+
});
|
|
311
|
+
});
|
|
222
312
|
describe("attach() Method", () => {
|
|
223
313
|
beforeEach(() => {
|
|
224
314
|
sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
|
|
@@ -340,6 +430,98 @@ describe("Queuedash SDK", () => {
|
|
|
340
430
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
341
431
|
});
|
|
342
432
|
});
|
|
433
|
+
describe("Snapshot Updates", () => {
|
|
434
|
+
const okResponse = {
|
|
435
|
+
ok: true,
|
|
436
|
+
json: () => Promise.resolve({ success: true, synced: 1 }),
|
|
437
|
+
};
|
|
438
|
+
// Keeps the next sync request in flight until the test settles it
|
|
439
|
+
const holdNextFetch = () => {
|
|
440
|
+
const request = {
|
|
441
|
+
resolve: (_response) => { },
|
|
442
|
+
reject: (_error) => { },
|
|
443
|
+
};
|
|
444
|
+
mockFetch.mockImplementationOnce(() => new Promise((resolve, reject) => {
|
|
445
|
+
request.resolve = resolve;
|
|
446
|
+
request.reject = reject;
|
|
447
|
+
}));
|
|
448
|
+
return request;
|
|
449
|
+
};
|
|
450
|
+
const sentJobs = (call) => JSON.parse(mockFetch.mock.calls[call][1].body).jobs;
|
|
451
|
+
const snapshot = (jobId, status) => ({
|
|
452
|
+
...createMockJobData(jobId),
|
|
453
|
+
status,
|
|
454
|
+
});
|
|
455
|
+
beforeEach(() => {
|
|
456
|
+
mockFetch.mockResolvedValue(okResponse);
|
|
457
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key" });
|
|
458
|
+
});
|
|
459
|
+
it("should replace a pending snapshot with a newer one", () => {
|
|
460
|
+
sdk.syncJob(snapshot("job-1", "waiting"));
|
|
461
|
+
sdk.syncJob(snapshot("job-1", "completed"));
|
|
462
|
+
jest.advanceTimersByTime(100);
|
|
463
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
464
|
+
expect(sentJobs(0)).toEqual([
|
|
465
|
+
expect.objectContaining({ jobId: "job-1", status: "completed" }),
|
|
466
|
+
]);
|
|
467
|
+
});
|
|
468
|
+
it("should send a snapshot that arrives while the job's batch is in flight", async () => {
|
|
469
|
+
const request = holdNextFetch();
|
|
470
|
+
sdk.syncJob(snapshot("job-1", "waiting"));
|
|
471
|
+
const inFlight = sdk.flush();
|
|
472
|
+
sdk.syncJob(snapshot("job-1", "completed"));
|
|
473
|
+
request.resolve(okResponse);
|
|
474
|
+
await inFlight;
|
|
475
|
+
jest.advanceTimersByTime(100);
|
|
476
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
477
|
+
expect(sentJobs(1)).toEqual([
|
|
478
|
+
expect.objectContaining({ jobId: "job-1", status: "completed" }),
|
|
479
|
+
]);
|
|
480
|
+
});
|
|
481
|
+
it("should not requeue a failed snapshot over a newer one", async () => {
|
|
482
|
+
const request = holdNextFetch();
|
|
483
|
+
sdk.syncJob(snapshot("job-1", "waiting"));
|
|
484
|
+
const inFlight = sdk.flush();
|
|
485
|
+
sdk.syncJob(snapshot("job-1", "completed"));
|
|
486
|
+
request.reject(new Error("Network error"));
|
|
487
|
+
await inFlight;
|
|
488
|
+
// Retried once the 1s backoff elapses
|
|
489
|
+
jest.advanceTimersByTime(1000);
|
|
490
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
491
|
+
expect(sentJobs(1)).toEqual([
|
|
492
|
+
expect.objectContaining({ jobId: "job-1", status: "completed" }),
|
|
493
|
+
]);
|
|
494
|
+
});
|
|
495
|
+
it("should send snapshots queued behind an in-flight flush on stop()", async () => {
|
|
496
|
+
const request = holdNextFetch();
|
|
497
|
+
sdk.syncJob(snapshot("job-1", "waiting"));
|
|
498
|
+
const inFlight = sdk.flush();
|
|
499
|
+
sdk.syncJob(snapshot("job-1", "completed"));
|
|
500
|
+
const stopped = sdk.stop();
|
|
501
|
+
request.resolve(okResponse);
|
|
502
|
+
await Promise.all([inFlight, stopped]);
|
|
503
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
504
|
+
expect(sentJobs(1)).toEqual([
|
|
505
|
+
expect.objectContaining({ jobId: "job-1", status: "completed" }),
|
|
506
|
+
]);
|
|
507
|
+
});
|
|
508
|
+
it("should accept new snapshots for a job dropped by maxQueueSize", () => {
|
|
509
|
+
sdk = new client_1.Queuedash({
|
|
510
|
+
apiKey: "test-api-key",
|
|
511
|
+
batchSize: 100,
|
|
512
|
+
maxQueueSize: 2,
|
|
513
|
+
});
|
|
514
|
+
sdk.syncJob(snapshot("job-1", "waiting"));
|
|
515
|
+
sdk.syncJob(snapshot("job-2", "waiting"));
|
|
516
|
+
sdk.syncJob(snapshot("job-3", "waiting")); // drops job-1
|
|
517
|
+
sdk.syncJob(snapshot("job-1", "completed")); // drops job-2
|
|
518
|
+
jest.advanceTimersByTime(100);
|
|
519
|
+
expect(sentJobs(0)).toEqual([
|
|
520
|
+
expect.objectContaining({ jobId: "job-3", status: "waiting" }),
|
|
521
|
+
expect.objectContaining({ jobId: "job-1", status: "completed" }),
|
|
522
|
+
]);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
343
525
|
describe("flush() Method", () => {
|
|
344
526
|
beforeEach(() => {
|
|
345
527
|
mockFetch.mockResolvedValue({
|
|
@@ -478,6 +660,132 @@ describe("Queuedash SDK", () => {
|
|
|
478
660
|
expect(onError).toHaveBeenCalled();
|
|
479
661
|
expect(onError.mock.calls[0][0].message).toContain("401");
|
|
480
662
|
});
|
|
663
|
+
it("should clear the request timeout when fetch rejects", async () => {
|
|
664
|
+
mockFetch.mockRejectedValue(new Error("Network error"));
|
|
665
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key", maxRetries: 0 });
|
|
666
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
667
|
+
const timerCount = jest.getTimerCount();
|
|
668
|
+
await sdk.flush();
|
|
669
|
+
// A leftover abort timer would keep the process alive for 30s
|
|
670
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
671
|
+
expect(jest.getTimerCount()).toBe(timerCount);
|
|
672
|
+
});
|
|
673
|
+
it("should time out a response body that stalls", async () => {
|
|
674
|
+
mockFetch.mockImplementation((_url, { signal }) => Promise.resolve(stalledResponse(signal, { ok: true })));
|
|
675
|
+
const onError = jest.fn();
|
|
676
|
+
sdk = new client_1.Queuedash({
|
|
677
|
+
apiKey: "test-api-key",
|
|
678
|
+
maxRetries: 0,
|
|
679
|
+
requestTimeout: 5000,
|
|
680
|
+
onError,
|
|
681
|
+
});
|
|
682
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
683
|
+
sdk.flush();
|
|
684
|
+
await jest.advanceTimersByTimeAsync(4999);
|
|
685
|
+
expect(onError).not.toHaveBeenCalled();
|
|
686
|
+
// Fails at requestTimeout instead of blocking every later flush
|
|
687
|
+
await jest.advanceTimersByTimeAsync(1);
|
|
688
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
describe("Retry Backoff", () => {
|
|
692
|
+
const okResponse = {
|
|
693
|
+
ok: true,
|
|
694
|
+
json: () => Promise.resolve({ success: true, synced: 1 }),
|
|
695
|
+
};
|
|
696
|
+
let onError;
|
|
697
|
+
beforeEach(() => {
|
|
698
|
+
onError = jest.fn();
|
|
699
|
+
mockFetch.mockRejectedValue(new Error("Network error"));
|
|
700
|
+
});
|
|
701
|
+
it("should not retry before each backoff elapses", async () => {
|
|
702
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
|
|
703
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
704
|
+
await jest.advanceTimersByTimeAsync(100); // periodic flush: attempt 1
|
|
705
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
706
|
+
// The periodic flush keeps ticking every 100ms throughout
|
|
707
|
+
for (const backoffMs of [1000, 2000, 4000, 8000, 16000]) {
|
|
708
|
+
const attempts = mockFetch.mock.calls.length;
|
|
709
|
+
await jest.advanceTimersByTimeAsync(backoffMs - 1);
|
|
710
|
+
expect(mockFetch).toHaveBeenCalledTimes(attempts);
|
|
711
|
+
await jest.advanceTimersByTimeAsync(1);
|
|
712
|
+
expect(mockFetch).toHaveBeenCalledTimes(attempts + 1);
|
|
713
|
+
}
|
|
714
|
+
// The sixth failed attempt uses up maxRetries (5)
|
|
715
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
716
|
+
expect(onError.mock.calls[0][0].message).toContain("Failed to sync 1 jobs after 5 retries");
|
|
717
|
+
});
|
|
718
|
+
it("should not flush a full batch before the backoff elapses", async () => {
|
|
719
|
+
// No periodic flush within the test, only batch-size flushes
|
|
720
|
+
sdk = new client_1.Queuedash({
|
|
721
|
+
apiKey: "test-api-key",
|
|
722
|
+
batchSize: 2,
|
|
723
|
+
flushInterval: 60000,
|
|
724
|
+
onError,
|
|
725
|
+
});
|
|
726
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
727
|
+
sdk.syncJob(createMockJobData("job-2"));
|
|
728
|
+
await jest.advanceTimersByTimeAsync(0); // attempt 1 fails
|
|
729
|
+
sdk.syncJob(createMockJobData("job-3"));
|
|
730
|
+
sdk.syncJob(createMockJobData("job-4"));
|
|
731
|
+
await jest.advanceTimersByTimeAsync(999);
|
|
732
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
733
|
+
// The retry also sends the jobs held back during the backoff
|
|
734
|
+
mockFetch.mockResolvedValue(okResponse);
|
|
735
|
+
await jest.advanceTimersByTimeAsync(1);
|
|
736
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
737
|
+
expect(JSON.parse(mockFetch.mock.calls[1][1].body).jobs).toHaveLength(4);
|
|
738
|
+
});
|
|
739
|
+
it("should not wait for the backoff on an explicit flush()", async () => {
|
|
740
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
|
|
741
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
742
|
+
await jest.advanceTimersByTimeAsync(100); // attempt 1 fails
|
|
743
|
+
mockFetch.mockResolvedValue(okResponse);
|
|
744
|
+
await sdk.flush();
|
|
745
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
746
|
+
// The success cancels the backoff, so periodic flushes resume
|
|
747
|
+
sdk.syncJob(createMockJobData("job-2"));
|
|
748
|
+
await jest.advanceTimersByTimeAsync(100);
|
|
749
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
750
|
+
});
|
|
751
|
+
it("should resume flushing held jobs after the circuit breaker resets", async () => {
|
|
752
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key", maxRetries: 20, onError });
|
|
753
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
754
|
+
// 10 consecutive failures, 1s, 2s, 4s, 8s, 16s, then 30s (capped) apart
|
|
755
|
+
await jest.advanceTimersByTimeAsync(100 + 1000 + 2000 + 4000 + 8000 + 16000 + 30000 * 4);
|
|
756
|
+
expect(mockFetch).toHaveBeenCalledTimes(10);
|
|
757
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Circuit breaker opened"));
|
|
758
|
+
// Nothing is sent while it's open, including the retry due 30s later
|
|
759
|
+
await jest.advanceTimersByTimeAsync(59999);
|
|
760
|
+
expect(mockFetch).toHaveBeenCalledTimes(10);
|
|
761
|
+
// It resets after 60s and the next periodic flush sends the held job
|
|
762
|
+
mockFetch.mockResolvedValue(okResponse);
|
|
763
|
+
await jest.advanceTimersByTimeAsync(101);
|
|
764
|
+
expect(mockFetch).toHaveBeenCalledTimes(11);
|
|
765
|
+
});
|
|
766
|
+
it("should make one final attempt on stop() instead of waiting out the backoff", async () => {
|
|
767
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
|
|
768
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
769
|
+
await jest.advanceTimersByTimeAsync(100); // attempt 1 fails
|
|
770
|
+
mockFetch.mockResolvedValue(okResponse);
|
|
771
|
+
await sdk.stop();
|
|
772
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
773
|
+
expect(JSON.parse(mockFetch.mock.calls[1][1].body).jobs).toEqual([
|
|
774
|
+
expect.objectContaining({ jobId: "job-1" }),
|
|
775
|
+
]);
|
|
776
|
+
expect(onError).not.toHaveBeenCalled();
|
|
777
|
+
});
|
|
778
|
+
it("should report instead of retrying when the final attempt on stop() fails", async () => {
|
|
779
|
+
sdk = new client_1.Queuedash({ apiKey: "test-api-key", onError });
|
|
780
|
+
sdk.syncJob(createMockJobData("job-1"));
|
|
781
|
+
await jest.advanceTimersByTimeAsync(100); // attempt 1 fails
|
|
782
|
+
await sdk.stop();
|
|
783
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
784
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
785
|
+
expect(onError.mock.calls[0][0].message).toContain("Failed to sync 1 jobs while stopping");
|
|
786
|
+
await jest.advanceTimersByTimeAsync(60000);
|
|
787
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
788
|
+
});
|
|
481
789
|
});
|
|
482
790
|
describe("deleteJob() Method", () => {
|
|
483
791
|
beforeEach(() => {
|
|
@@ -536,6 +844,27 @@ describe("Queuedash SDK", () => {
|
|
|
536
844
|
jest.useFakeTimers();
|
|
537
845
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to delete job"), expect.any(Error));
|
|
538
846
|
});
|
|
847
|
+
it("should clear the request timeout when fetch rejects", async () => {
|
|
848
|
+
mockFetch.mockRejectedValue(new Error("Network error"));
|
|
849
|
+
const timerCount = jest.getTimerCount();
|
|
850
|
+
await sdk.deleteJob("job-123", "test-queue");
|
|
851
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
852
|
+
expect(jest.getTimerCount()).toBe(timerCount);
|
|
853
|
+
});
|
|
854
|
+
it("should time out an error response body that stalls", async () => {
|
|
855
|
+
mockFetch.mockImplementation((_url, { signal }) => Promise.resolve(stalledResponse(signal, { ok: false, status: 500 })));
|
|
856
|
+
const onError = jest.fn();
|
|
857
|
+
sdk = new client_1.Queuedash({
|
|
858
|
+
apiKey: "test-api-key",
|
|
859
|
+
requestTimeout: 5000,
|
|
860
|
+
onError,
|
|
861
|
+
});
|
|
862
|
+
sdk.deleteJob("job-123", "test-queue");
|
|
863
|
+
await jest.advanceTimersByTimeAsync(4999);
|
|
864
|
+
expect(onError).not.toHaveBeenCalled();
|
|
865
|
+
await jest.advanceTimersByTimeAsync(1);
|
|
866
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
867
|
+
});
|
|
539
868
|
});
|
|
540
869
|
describe("Circuit Breaker", () => {
|
|
541
870
|
beforeEach(() => {
|
|
@@ -669,6 +998,38 @@ describe("Queuedash SDK", () => {
|
|
|
669
998
|
expect(body.jobs[0].data).toEqual({ foo: "bar" });
|
|
670
999
|
expect(body.jobs[0].progress).toBe(50);
|
|
671
1000
|
});
|
|
1001
|
+
it("should load the job for Bull global events, which pass a job id", async () => {
|
|
1002
|
+
const mockBullQueue = {
|
|
1003
|
+
name: "test-queue",
|
|
1004
|
+
clients: [{}],
|
|
1005
|
+
on: jest.fn(),
|
|
1006
|
+
getJob: jest.fn((jobId) => Promise.resolve({
|
|
1007
|
+
id: jobId,
|
|
1008
|
+
name: "test-job",
|
|
1009
|
+
data: { foo: "bar" },
|
|
1010
|
+
opts: {},
|
|
1011
|
+
timestamp: Date.now(),
|
|
1012
|
+
finishedOn: Date.now(),
|
|
1013
|
+
progress: () => 100,
|
|
1014
|
+
})),
|
|
1015
|
+
};
|
|
1016
|
+
sdk.attach(mockBullQueue);
|
|
1017
|
+
const handler = (event) => mockBullQueue.on.mock.calls.find((call) => call[0] === event)[1];
|
|
1018
|
+
// Bull emits global events as (jobId, result, previousState)
|
|
1019
|
+
handler("global:completed")("123", '"done"', "active");
|
|
1020
|
+
handler("global:failed")("456", "boom", "active");
|
|
1021
|
+
await Promise.all(mockBullQueue.getJob.mock.results.map((r) => r.value));
|
|
1022
|
+
jest.useRealTimers();
|
|
1023
|
+
await sdk.flush();
|
|
1024
|
+
jest.useFakeTimers();
|
|
1025
|
+
expect(mockBullQueue.getJob).toHaveBeenCalledWith("123");
|
|
1026
|
+
expect(mockBullQueue.getJob).toHaveBeenCalledWith("456");
|
|
1027
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
1028
|
+
expect(body.jobs).toEqual([
|
|
1029
|
+
expect.objectContaining({ jobId: "123", data: { foo: "bar" } }),
|
|
1030
|
+
expect.objectContaining({ jobId: "456", data: { foo: "bar" } }),
|
|
1031
|
+
]);
|
|
1032
|
+
});
|
|
672
1033
|
it("should handle Bee-Queue succeeded event and set finishedAt", () => {
|
|
673
1034
|
const mockBeeQueue = {
|
|
674
1035
|
name: "test-queue",
|
|
@@ -807,3 +1168,11 @@ function createMockJobData(jobId, queueName = "test-queue") {
|
|
|
807
1168
|
finishedAt: null,
|
|
808
1169
|
};
|
|
809
1170
|
}
|
|
1171
|
+
// Mock response whose headers arrived but whose body never finishes, unless
|
|
1172
|
+
// the request is aborted
|
|
1173
|
+
function stalledResponse(signal, fields) {
|
|
1174
|
+
const readBody = () => new Promise((_resolve, reject) => {
|
|
1175
|
+
signal.addEventListener("abort", () => reject(new Error("This operation was aborted")));
|
|
1176
|
+
});
|
|
1177
|
+
return { ...fields, json: readBody, text: readBody };
|
|
1178
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@queuedash/sdk",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "Queuedash SDK for
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "Queuedash SDK for BullMQ, Bull, and Bee-Queue monitoring",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"bullmq",
|
|
14
14
|
"bull",
|
|
15
15
|
"bee-queue",
|
|
16
|
-
"groupmq",
|
|
17
16
|
"monitoring",
|
|
18
17
|
"dashboard"
|
|
19
18
|
],
|