qwerk 0.1.0
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/LICENSE +21 -0
- package/README.md +380 -0
- package/dist/index.d.ts +744 -0
- package/dist/index.js +1739 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1739 @@
|
|
|
1
|
+
// src/backoff.ts
|
|
2
|
+
var DEFAULT_MAX_DELAY = 30000;
|
|
3
|
+
function calculateBackoff(options, attempt) {
|
|
4
|
+
const maxDelay = options.maxDelay ?? DEFAULT_MAX_DELAY;
|
|
5
|
+
let delay;
|
|
6
|
+
switch (options.type) {
|
|
7
|
+
case "exponential":
|
|
8
|
+
delay = Math.pow(2, attempt) * options.delay;
|
|
9
|
+
break;
|
|
10
|
+
case "linear":
|
|
11
|
+
delay = (attempt + 1) * options.delay;
|
|
12
|
+
break;
|
|
13
|
+
case "fixed":
|
|
14
|
+
delay = options.delay;
|
|
15
|
+
break;
|
|
16
|
+
default:
|
|
17
|
+
delay = options.delay;
|
|
18
|
+
}
|
|
19
|
+
const jitter = delay * 0.1 * (Math.random() * 2 - 1);
|
|
20
|
+
delay = Math.floor(delay + jitter);
|
|
21
|
+
return Math.min(delay, maxDelay);
|
|
22
|
+
}
|
|
23
|
+
var defaultBackoff = {
|
|
24
|
+
type: "exponential",
|
|
25
|
+
delay: 1000,
|
|
26
|
+
maxDelay: DEFAULT_MAX_DELAY
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/cron.ts
|
|
30
|
+
var MONTH_NAMES = {
|
|
31
|
+
jan: 1,
|
|
32
|
+
feb: 2,
|
|
33
|
+
mar: 3,
|
|
34
|
+
apr: 4,
|
|
35
|
+
may: 5,
|
|
36
|
+
jun: 6,
|
|
37
|
+
jul: 7,
|
|
38
|
+
aug: 8,
|
|
39
|
+
sep: 9,
|
|
40
|
+
oct: 10,
|
|
41
|
+
nov: 11,
|
|
42
|
+
dec: 12
|
|
43
|
+
};
|
|
44
|
+
var DAY_NAMES = {
|
|
45
|
+
sun: 0,
|
|
46
|
+
mon: 1,
|
|
47
|
+
tue: 2,
|
|
48
|
+
wed: 3,
|
|
49
|
+
thu: 4,
|
|
50
|
+
fri: 5,
|
|
51
|
+
sat: 6
|
|
52
|
+
};
|
|
53
|
+
var FIELD_CONFIG = {
|
|
54
|
+
minute: { min: 0, max: 59, names: null },
|
|
55
|
+
hour: { min: 0, max: 23, names: null },
|
|
56
|
+
dayOfMonth: { min: 1, max: 31, names: null },
|
|
57
|
+
month: { min: 1, max: 12, names: MONTH_NAMES },
|
|
58
|
+
dayOfWeek: { min: 0, max: 6, names: DAY_NAMES }
|
|
59
|
+
};
|
|
60
|
+
function parseValue(value, names) {
|
|
61
|
+
if (names) {
|
|
62
|
+
const lower = value.toLowerCase();
|
|
63
|
+
if (lower in names) {
|
|
64
|
+
return names[lower];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const num = parseInt(value, 10);
|
|
68
|
+
if (Number.isNaN(num)) {
|
|
69
|
+
throw new Error(`Invalid value: "${value}"`);
|
|
70
|
+
}
|
|
71
|
+
return num;
|
|
72
|
+
}
|
|
73
|
+
function validateRange(value, min, max, field) {
|
|
74
|
+
if (value < min || value > max) {
|
|
75
|
+
throw new Error(`${field} value ${value} out of range [${min}-${max}]`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function parseField(field, fieldName) {
|
|
79
|
+
const config = FIELD_CONFIG[fieldName];
|
|
80
|
+
const { min, max, names } = config;
|
|
81
|
+
const validationMax = fieldName === "dayOfWeek" ? 7 : max;
|
|
82
|
+
const values = new Set;
|
|
83
|
+
let isWildcard = false;
|
|
84
|
+
const addValue = (v) => {
|
|
85
|
+
values.add(fieldName === "dayOfWeek" && v === 7 ? 0 : v);
|
|
86
|
+
};
|
|
87
|
+
const parts = field.split(",");
|
|
88
|
+
for (const part of parts) {
|
|
89
|
+
const trimmed = part.trim();
|
|
90
|
+
if (trimmed === "*") {
|
|
91
|
+
isWildcard = true;
|
|
92
|
+
for (let i = min;i <= max; i++) {
|
|
93
|
+
values.add(i);
|
|
94
|
+
}
|
|
95
|
+
} else if (trimmed.includes("/")) {
|
|
96
|
+
const slashIndex = trimmed.indexOf("/");
|
|
97
|
+
const range = trimmed.slice(0, slashIndex);
|
|
98
|
+
const stepStr = trimmed.slice(slashIndex + 1);
|
|
99
|
+
const step = parseInt(stepStr, 10);
|
|
100
|
+
if (Number.isNaN(step) || step <= 0) {
|
|
101
|
+
throw new Error(`Invalid step value: "${stepStr}"`);
|
|
102
|
+
}
|
|
103
|
+
let start = min;
|
|
104
|
+
let end = max;
|
|
105
|
+
if (range === "*") {
|
|
106
|
+
isWildcard = true;
|
|
107
|
+
} else if (range.includes("-")) {
|
|
108
|
+
const dashIndex = range.indexOf("-");
|
|
109
|
+
start = parseValue(range.slice(0, dashIndex), names);
|
|
110
|
+
end = parseValue(range.slice(dashIndex + 1), names);
|
|
111
|
+
validateRange(start, min, validationMax, fieldName);
|
|
112
|
+
validateRange(end, min, validationMax, fieldName);
|
|
113
|
+
if (start > end) {
|
|
114
|
+
throw new Error(`Invalid range: ${start}-${end}`);
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
start = parseValue(range, names);
|
|
118
|
+
validateRange(start, min, validationMax, fieldName);
|
|
119
|
+
}
|
|
120
|
+
for (let i = start;i <= end; i += step) {
|
|
121
|
+
addValue(i);
|
|
122
|
+
}
|
|
123
|
+
} else if (trimmed.includes("-")) {
|
|
124
|
+
const dashIndex = trimmed.indexOf("-");
|
|
125
|
+
const start = parseValue(trimmed.slice(0, dashIndex), names);
|
|
126
|
+
const end = parseValue(trimmed.slice(dashIndex + 1), names);
|
|
127
|
+
validateRange(start, min, validationMax, fieldName);
|
|
128
|
+
validateRange(end, min, validationMax, fieldName);
|
|
129
|
+
if (start > end) {
|
|
130
|
+
throw new Error(`Invalid range: ${start}-${end}`);
|
|
131
|
+
}
|
|
132
|
+
for (let i = start;i <= end; i++) {
|
|
133
|
+
addValue(i);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
const value = parseValue(trimmed, names);
|
|
137
|
+
validateRange(value, min, validationMax, fieldName);
|
|
138
|
+
addValue(value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (values.size === 0) {
|
|
142
|
+
throw new Error(`No valid values for field: ${fieldName}`);
|
|
143
|
+
}
|
|
144
|
+
return { values, isWildcard };
|
|
145
|
+
}
|
|
146
|
+
function parseCron(expression) {
|
|
147
|
+
const trimmed = expression.trim();
|
|
148
|
+
if (!trimmed) {
|
|
149
|
+
throw new Error("Empty cron expression");
|
|
150
|
+
}
|
|
151
|
+
const fields = trimmed.split(/\s+/);
|
|
152
|
+
if (fields.length !== 5) {
|
|
153
|
+
throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length}`);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
minute: parseField(fields[0], "minute"),
|
|
157
|
+
hour: parseField(fields[1], "hour"),
|
|
158
|
+
dayOfMonth: parseField(fields[2], "dayOfMonth"),
|
|
159
|
+
month: parseField(fields[3], "month"),
|
|
160
|
+
dayOfWeek: parseField(fields[4], "dayOfWeek")
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function getNextCronTime(expression, after = new Date) {
|
|
164
|
+
const cron = parseCron(expression);
|
|
165
|
+
const next = new Date(after);
|
|
166
|
+
next.setSeconds(0, 0);
|
|
167
|
+
next.setMinutes(next.getMinutes() + 1);
|
|
168
|
+
const maxIterations = 4 * 366 * 24 * 60;
|
|
169
|
+
let iterations = 0;
|
|
170
|
+
while (iterations < maxIterations) {
|
|
171
|
+
iterations++;
|
|
172
|
+
const minute = next.getMinutes();
|
|
173
|
+
const hour = next.getHours();
|
|
174
|
+
const dayOfMonth = next.getDate();
|
|
175
|
+
const month = next.getMonth() + 1;
|
|
176
|
+
const dayOfWeek = next.getDay();
|
|
177
|
+
if (!cron.month.values.has(month)) {
|
|
178
|
+
next.setMonth(next.getMonth() + 1, 1);
|
|
179
|
+
next.setHours(0, 0, 0, 0);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const dayOfMonthMatch = cron.dayOfMonth.values.has(dayOfMonth);
|
|
183
|
+
const dayOfWeekMatch = cron.dayOfWeek.values.has(dayOfWeek);
|
|
184
|
+
let dayMatches;
|
|
185
|
+
if (cron.dayOfMonth.isWildcard && cron.dayOfWeek.isWildcard) {
|
|
186
|
+
dayMatches = true;
|
|
187
|
+
} else if (cron.dayOfMonth.isWildcard) {
|
|
188
|
+
dayMatches = dayOfWeekMatch;
|
|
189
|
+
} else if (cron.dayOfWeek.isWildcard) {
|
|
190
|
+
dayMatches = dayOfMonthMatch;
|
|
191
|
+
} else {
|
|
192
|
+
dayMatches = dayOfMonthMatch || dayOfWeekMatch;
|
|
193
|
+
}
|
|
194
|
+
if (!dayMatches) {
|
|
195
|
+
next.setDate(next.getDate() + 1);
|
|
196
|
+
next.setHours(0, 0, 0, 0);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!cron.hour.values.has(hour)) {
|
|
200
|
+
next.setHours(next.getHours() + 1, 0, 0, 0);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!cron.minute.values.has(minute)) {
|
|
204
|
+
next.setMinutes(next.getMinutes() + 1, 0, 0);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
return next;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/job-handle.ts
|
|
213
|
+
class JobHandle {
|
|
214
|
+
queue;
|
|
215
|
+
name;
|
|
216
|
+
defaults;
|
|
217
|
+
events = new Map;
|
|
218
|
+
onceEvents = new Map;
|
|
219
|
+
handler = null;
|
|
220
|
+
constructor(queue, name, defaults = {}) {
|
|
221
|
+
this.queue = queue;
|
|
222
|
+
this.name = name;
|
|
223
|
+
this.defaults = defaults;
|
|
224
|
+
}
|
|
225
|
+
process(handler) {
|
|
226
|
+
this.handler = handler;
|
|
227
|
+
this.queue.processJob(this.name, async (job, ctx) => {
|
|
228
|
+
return handler(job.data, ctx);
|
|
229
|
+
});
|
|
230
|
+
return this;
|
|
231
|
+
}
|
|
232
|
+
async add(data, options = {}) {
|
|
233
|
+
const mergedOptions = {
|
|
234
|
+
maxAttempts: options.maxAttempts ?? this.defaults.maxAttempts,
|
|
235
|
+
timeout: options.timeout ?? this.defaults.timeout,
|
|
236
|
+
backoff: options.backoff ?? this.defaults.backoff,
|
|
237
|
+
priority: options.priority ?? this.defaults.priority,
|
|
238
|
+
delay: options.delay,
|
|
239
|
+
jobId: options.jobId,
|
|
240
|
+
repeat: options.repeat
|
|
241
|
+
};
|
|
242
|
+
const job = await this.queue.addJob(this.name, data, mergedOptions);
|
|
243
|
+
return job;
|
|
244
|
+
}
|
|
245
|
+
async addBulk(items) {
|
|
246
|
+
const jobs = items.map(({ data, options = {} }) => ({
|
|
247
|
+
name: this.name,
|
|
248
|
+
data,
|
|
249
|
+
options: {
|
|
250
|
+
maxAttempts: options.maxAttempts ?? this.defaults.maxAttempts,
|
|
251
|
+
timeout: options.timeout ?? this.defaults.timeout,
|
|
252
|
+
backoff: options.backoff ?? this.defaults.backoff,
|
|
253
|
+
priority: options.priority ?? this.defaults.priority,
|
|
254
|
+
delay: options.delay,
|
|
255
|
+
jobId: options.jobId,
|
|
256
|
+
repeat: options.repeat
|
|
257
|
+
}
|
|
258
|
+
}));
|
|
259
|
+
const result = await this.queue.addJobBulk(jobs);
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
on(event, callback) {
|
|
263
|
+
if (!this.events.has(event)) {
|
|
264
|
+
this.events.set(event, new Set);
|
|
265
|
+
}
|
|
266
|
+
this.events.get(event).add(callback);
|
|
267
|
+
this.queue.subscribeToEvent(event, this.name, callback);
|
|
268
|
+
return this;
|
|
269
|
+
}
|
|
270
|
+
once(event, callback) {
|
|
271
|
+
if (!this.onceEvents.has(event)) {
|
|
272
|
+
this.onceEvents.set(event, new Set);
|
|
273
|
+
}
|
|
274
|
+
const wrappedCallback = (...args) => {
|
|
275
|
+
this.onceEvents.get(event)?.delete(wrappedCallback);
|
|
276
|
+
this.queue.unsubscribeFromEvent(event, this.name, wrappedCallback);
|
|
277
|
+
callback(...args);
|
|
278
|
+
};
|
|
279
|
+
this.onceEvents.get(event).add(wrappedCallback);
|
|
280
|
+
this.queue.subscribeToEvent(event, this.name, wrappedCallback);
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
off(event, callback) {
|
|
284
|
+
this.events.get(event)?.delete(callback);
|
|
285
|
+
this.queue.unsubscribeFromEvent(event, this.name, callback);
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/types.ts
|
|
291
|
+
var consoleLogger = {
|
|
292
|
+
debug: (msg, ...args) => console.debug(`[jobqueue] ${msg}`, ...args),
|
|
293
|
+
info: (msg, ...args) => console.info(`[jobqueue] ${msg}`, ...args),
|
|
294
|
+
warn: (msg, ...args) => console.warn(`[jobqueue] ${msg}`, ...args),
|
|
295
|
+
error: (msg, ...args) => console.error(`[jobqueue] ${msg}`, ...args)
|
|
296
|
+
};
|
|
297
|
+
var silentLogger = {
|
|
298
|
+
debug: () => {},
|
|
299
|
+
info: () => {},
|
|
300
|
+
warn: () => {},
|
|
301
|
+
error: () => {}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// src/queue.ts
|
|
305
|
+
var DEFAULT_VISIBILITY_TIMEOUT = 30000;
|
|
306
|
+
var DEFAULT_STALLED_INTERVAL = 5000;
|
|
307
|
+
var DEFAULT_JOB_TIMEOUT = 30000;
|
|
308
|
+
|
|
309
|
+
class Queue {
|
|
310
|
+
backend;
|
|
311
|
+
handlers = new Map;
|
|
312
|
+
events = new Map;
|
|
313
|
+
onceEvents = new Map;
|
|
314
|
+
jobEvents = new Map;
|
|
315
|
+
running = false;
|
|
316
|
+
processing = 0;
|
|
317
|
+
pollInterval;
|
|
318
|
+
concurrency;
|
|
319
|
+
visibilityTimeout;
|
|
320
|
+
stalledInterval;
|
|
321
|
+
pollTimer = null;
|
|
322
|
+
stalledTimer = null;
|
|
323
|
+
activeJobs = new Map;
|
|
324
|
+
shuttingDown = false;
|
|
325
|
+
shutdownResolve = null;
|
|
326
|
+
rateLimit = null;
|
|
327
|
+
rateLimitTokens = 0;
|
|
328
|
+
rateLimitLastRefill = 0;
|
|
329
|
+
logger;
|
|
330
|
+
maxPayloadSize = null;
|
|
331
|
+
maxQueueSize = null;
|
|
332
|
+
metricsData = {
|
|
333
|
+
completed: 0,
|
|
334
|
+
totalFailed: 0,
|
|
335
|
+
totalProcessingTime: 0,
|
|
336
|
+
jobCount: 0,
|
|
337
|
+
recentCompletions: []
|
|
338
|
+
};
|
|
339
|
+
constructor(backend, options = {}) {
|
|
340
|
+
this.backend = backend;
|
|
341
|
+
this.pollInterval = options.pollInterval ?? 1000;
|
|
342
|
+
this.concurrency = options.concurrency ?? 1;
|
|
343
|
+
this.visibilityTimeout = options.visibilityTimeout ?? DEFAULT_VISIBILITY_TIMEOUT;
|
|
344
|
+
this.stalledInterval = options.stalledInterval ?? DEFAULT_STALLED_INTERVAL;
|
|
345
|
+
this.logger = options.logger ?? consoleLogger;
|
|
346
|
+
this.maxPayloadSize = options.maxPayloadSize ?? null;
|
|
347
|
+
this.maxQueueSize = options.maxQueueSize ?? null;
|
|
348
|
+
if (options.rateLimit) {
|
|
349
|
+
this.rateLimit = {
|
|
350
|
+
max: options.rateLimit.max,
|
|
351
|
+
duration: options.rateLimit.duration ?? 1000
|
|
352
|
+
};
|
|
353
|
+
this.rateLimitTokens = this.rateLimit.max;
|
|
354
|
+
this.rateLimitLastRefill = Date.now();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async add(name, data, options = {}) {
|
|
358
|
+
if (this.maxPayloadSize !== null) {
|
|
359
|
+
const payloadSize = JSON.stringify(data).length;
|
|
360
|
+
if (payloadSize > this.maxPayloadSize) {
|
|
361
|
+
throw new Error(`Job payload size (${payloadSize} bytes) exceeds limit (${this.maxPayloadSize} bytes)`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (this.maxQueueSize !== null) {
|
|
365
|
+
const currentSize = await this.backend.size();
|
|
366
|
+
if (currentSize >= this.maxQueueSize) {
|
|
367
|
+
throw new Error(`Queue size (${currentSize}) has reached limit (${this.maxQueueSize})`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const repeat = options.repeat ? {
|
|
371
|
+
...options.repeat,
|
|
372
|
+
key: options.repeat.key ?? `${name}:${options.repeat.cron ?? options.repeat.every}`,
|
|
373
|
+
count: 0
|
|
374
|
+
} : undefined;
|
|
375
|
+
const job = {
|
|
376
|
+
id: options.jobId ?? crypto.randomUUID(),
|
|
377
|
+
name,
|
|
378
|
+
data,
|
|
379
|
+
attempts: 0,
|
|
380
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
381
|
+
createdAt: Date.now(),
|
|
382
|
+
scheduledAt: Date.now() + (options.delay ?? 0),
|
|
383
|
+
backoff: options.backoff ?? defaultBackoff,
|
|
384
|
+
priority: options.priority ?? 0,
|
|
385
|
+
timeout: options.timeout ?? DEFAULT_JOB_TIMEOUT,
|
|
386
|
+
repeat
|
|
387
|
+
};
|
|
388
|
+
const added = await this.backend.push(job);
|
|
389
|
+
if (added) {
|
|
390
|
+
this.logger.debug(`Job added: ${job.id} (${name})`);
|
|
391
|
+
this.emit("added", job);
|
|
392
|
+
}
|
|
393
|
+
return added ? job : null;
|
|
394
|
+
}
|
|
395
|
+
async addBulk(jobs) {
|
|
396
|
+
let availableSlots = Infinity;
|
|
397
|
+
if (this.maxQueueSize !== null) {
|
|
398
|
+
const currentSize = await this.backend.size();
|
|
399
|
+
availableSlots = Math.max(0, this.maxQueueSize - currentSize);
|
|
400
|
+
if (availableSlots === 0) {
|
|
401
|
+
this.logger.warn("Queue is full, rejecting bulk add");
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const jobsToAdd = [];
|
|
406
|
+
const skippedPayload = [];
|
|
407
|
+
for (let i = 0;i < jobs.length && jobsToAdd.length < availableSlots; i++) {
|
|
408
|
+
const { name, data, options = {} } = jobs[i];
|
|
409
|
+
if (this.maxPayloadSize !== null) {
|
|
410
|
+
const payloadSize = JSON.stringify(data).length;
|
|
411
|
+
if (payloadSize > this.maxPayloadSize) {
|
|
412
|
+
skippedPayload.push(i);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const repeat = options.repeat ? {
|
|
417
|
+
...options.repeat,
|
|
418
|
+
key: options.repeat.key ?? `${name}:${options.repeat.cron ?? options.repeat.every}`,
|
|
419
|
+
count: 0
|
|
420
|
+
} : undefined;
|
|
421
|
+
jobsToAdd.push({
|
|
422
|
+
id: options.jobId ?? crypto.randomUUID(),
|
|
423
|
+
name,
|
|
424
|
+
data,
|
|
425
|
+
attempts: 0,
|
|
426
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
427
|
+
createdAt: Date.now(),
|
|
428
|
+
scheduledAt: Date.now() + (options.delay ?? 0),
|
|
429
|
+
backoff: options.backoff ?? defaultBackoff,
|
|
430
|
+
priority: options.priority ?? 0,
|
|
431
|
+
timeout: options.timeout ?? DEFAULT_JOB_TIMEOUT,
|
|
432
|
+
repeat
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (skippedPayload.length > 0) {
|
|
436
|
+
this.logger.warn(`Skipped ${skippedPayload.length} jobs due to payload size limit`);
|
|
437
|
+
}
|
|
438
|
+
if (jobsToAdd.length === 0) {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
const addedJobs = [];
|
|
442
|
+
for (const job of jobsToAdd) {
|
|
443
|
+
const added = await this.backend.push(job);
|
|
444
|
+
if (added) {
|
|
445
|
+
addedJobs.push(job);
|
|
446
|
+
this.emit("added", job);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
this.logger.debug(`Bulk added ${addedJobs.length}/${jobs.length} jobs`);
|
|
450
|
+
return addedJobs;
|
|
451
|
+
}
|
|
452
|
+
define(name, defaults = {}) {
|
|
453
|
+
return new JobHandle(this, name, defaults);
|
|
454
|
+
}
|
|
455
|
+
addJob(name, data, options) {
|
|
456
|
+
return this.add(name, data, options);
|
|
457
|
+
}
|
|
458
|
+
addJobBulk(jobs) {
|
|
459
|
+
return this.addBulk(jobs);
|
|
460
|
+
}
|
|
461
|
+
processJob(name, handler) {
|
|
462
|
+
this.handlers.set(name, handler);
|
|
463
|
+
}
|
|
464
|
+
subscribeToEvent(event, name, callback) {
|
|
465
|
+
if (!this.jobEvents.has(event)) {
|
|
466
|
+
this.jobEvents.set(event, new Map);
|
|
467
|
+
}
|
|
468
|
+
const eventMap = this.jobEvents.get(event);
|
|
469
|
+
if (!eventMap.has(name)) {
|
|
470
|
+
eventMap.set(name, new Set);
|
|
471
|
+
}
|
|
472
|
+
eventMap.get(name).add(callback);
|
|
473
|
+
}
|
|
474
|
+
unsubscribeFromEvent(event, name, callback) {
|
|
475
|
+
this.jobEvents.get(event)?.get(name)?.delete(callback);
|
|
476
|
+
}
|
|
477
|
+
start() {
|
|
478
|
+
if (this.running)
|
|
479
|
+
return;
|
|
480
|
+
this.running = true;
|
|
481
|
+
this.shuttingDown = false;
|
|
482
|
+
this.backend.subscribe(() => void this.tick());
|
|
483
|
+
this.tick();
|
|
484
|
+
this.startStalledChecker();
|
|
485
|
+
this.logger.info("Queue started");
|
|
486
|
+
}
|
|
487
|
+
async stop(timeout = 30000) {
|
|
488
|
+
if (!this.running)
|
|
489
|
+
return;
|
|
490
|
+
this.logger.info(`Stopping queue (waiting up to ${timeout}ms for ${this.processing} in-flight jobs)`);
|
|
491
|
+
this.shuttingDown = true;
|
|
492
|
+
this.running = false;
|
|
493
|
+
this.backend.unsubscribe();
|
|
494
|
+
if (this.pollTimer) {
|
|
495
|
+
clearTimeout(this.pollTimer);
|
|
496
|
+
this.pollTimer = null;
|
|
497
|
+
}
|
|
498
|
+
if (this.stalledTimer) {
|
|
499
|
+
clearInterval(this.stalledTimer);
|
|
500
|
+
this.stalledTimer = null;
|
|
501
|
+
}
|
|
502
|
+
if (this.processing > 0) {
|
|
503
|
+
await Promise.race([
|
|
504
|
+
new Promise((resolve) => {
|
|
505
|
+
this.shutdownResolve = resolve;
|
|
506
|
+
}),
|
|
507
|
+
new Promise((resolve) => setTimeout(resolve, timeout))
|
|
508
|
+
]);
|
|
509
|
+
}
|
|
510
|
+
const abortedCount = this.activeJobs.size;
|
|
511
|
+
for (const [, { controller }] of this.activeJobs) {
|
|
512
|
+
controller.abort();
|
|
513
|
+
}
|
|
514
|
+
if (abortedCount > 0) {
|
|
515
|
+
this.logger.warn(`Force aborted ${abortedCount} jobs after timeout`);
|
|
516
|
+
}
|
|
517
|
+
this.activeJobs.clear();
|
|
518
|
+
this.shuttingDown = false;
|
|
519
|
+
this.shutdownResolve = null;
|
|
520
|
+
this.logger.info("Queue stopped");
|
|
521
|
+
}
|
|
522
|
+
pause() {
|
|
523
|
+
this.running = false;
|
|
524
|
+
if (this.pollTimer) {
|
|
525
|
+
clearTimeout(this.pollTimer);
|
|
526
|
+
this.pollTimer = null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
resume() {
|
|
530
|
+
if (this.running || this.shuttingDown)
|
|
531
|
+
return;
|
|
532
|
+
this.running = true;
|
|
533
|
+
this.tick();
|
|
534
|
+
}
|
|
535
|
+
async drain() {
|
|
536
|
+
while (this.processing > 0) {
|
|
537
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
on(event, callback) {
|
|
541
|
+
if (!this.events.has(event)) {
|
|
542
|
+
this.events.set(event, new Set);
|
|
543
|
+
}
|
|
544
|
+
this.events.get(event).add(callback);
|
|
545
|
+
return this;
|
|
546
|
+
}
|
|
547
|
+
once(event, callback) {
|
|
548
|
+
if (!this.onceEvents.has(event)) {
|
|
549
|
+
this.onceEvents.set(event, new Set);
|
|
550
|
+
}
|
|
551
|
+
this.onceEvents.get(event).add(callback);
|
|
552
|
+
return this;
|
|
553
|
+
}
|
|
554
|
+
off(event, callback) {
|
|
555
|
+
this.events.get(event)?.delete(callback);
|
|
556
|
+
this.onceEvents.get(event)?.delete(callback);
|
|
557
|
+
return this;
|
|
558
|
+
}
|
|
559
|
+
async size() {
|
|
560
|
+
return this.backend.size();
|
|
561
|
+
}
|
|
562
|
+
async activeCount() {
|
|
563
|
+
return this.backend.activeCount();
|
|
564
|
+
}
|
|
565
|
+
async failedCount() {
|
|
566
|
+
return this.backend.failedCount();
|
|
567
|
+
}
|
|
568
|
+
async getFailed(limit) {
|
|
569
|
+
return this.backend.getFailed(limit);
|
|
570
|
+
}
|
|
571
|
+
async getCompleted(jobId) {
|
|
572
|
+
return this.backend.getCompleted(jobId);
|
|
573
|
+
}
|
|
574
|
+
async completedCount() {
|
|
575
|
+
return this.backend.completedCount();
|
|
576
|
+
}
|
|
577
|
+
async getCompletedJobs(limit) {
|
|
578
|
+
return this.backend.getCompletedJobs(limit);
|
|
579
|
+
}
|
|
580
|
+
async retryFailed(jobId) {
|
|
581
|
+
return this.backend.retryFailed(jobId);
|
|
582
|
+
}
|
|
583
|
+
async remove(jobId) {
|
|
584
|
+
return this.backend.remove(jobId);
|
|
585
|
+
}
|
|
586
|
+
async clear() {
|
|
587
|
+
return this.backend.clear();
|
|
588
|
+
}
|
|
589
|
+
async close() {
|
|
590
|
+
await this.stop();
|
|
591
|
+
if (this.backend.close) {
|
|
592
|
+
await this.backend.close();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async getMetrics() {
|
|
596
|
+
const now = Date.now();
|
|
597
|
+
this.metricsData.recentCompletions = this.metricsData.recentCompletions.filter((t) => now - t < 60000);
|
|
598
|
+
const throughput = this.metricsData.recentCompletions.length > 0 ? this.metricsData.recentCompletions.length / 60 : 0;
|
|
599
|
+
return {
|
|
600
|
+
waiting: await this.backend.size(),
|
|
601
|
+
active: await this.backend.activeCount(),
|
|
602
|
+
failed: await this.backend.failedCount(),
|
|
603
|
+
completed: this.metricsData.completed,
|
|
604
|
+
totalFailed: this.metricsData.totalFailed,
|
|
605
|
+
totalProcessingTime: this.metricsData.totalProcessingTime,
|
|
606
|
+
avgProcessingTime: this.metricsData.jobCount > 0 ? this.metricsData.totalProcessingTime / this.metricsData.jobCount : 0,
|
|
607
|
+
throughput
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
startStalledChecker() {
|
|
611
|
+
this.stalledTimer = setInterval(() => {
|
|
612
|
+
this.checkStalled();
|
|
613
|
+
}, this.stalledInterval);
|
|
614
|
+
}
|
|
615
|
+
async checkStalled() {
|
|
616
|
+
const stalledJobs = await this.backend.getStalled();
|
|
617
|
+
for (const job of stalledJobs) {
|
|
618
|
+
this.logger.warn(`Job stalled: ${job.id} (${job.name})`);
|
|
619
|
+
this.emit("stalled", job);
|
|
620
|
+
if (job.attempts + 1 >= job.maxAttempts) {
|
|
621
|
+
const err = new Error("Job stalled and exceeded max attempts");
|
|
622
|
+
await this.backend.fail(job, err);
|
|
623
|
+
this.logger.error(`Stalled job failed permanently: ${job.id} (${job.name})`);
|
|
624
|
+
this.emit("failed", job, err);
|
|
625
|
+
this.metricsData.totalFailed++;
|
|
626
|
+
} else {
|
|
627
|
+
const delay = calculateBackoff(job.backoff, job.attempts);
|
|
628
|
+
await this.backend.nack(job, Date.now() + delay);
|
|
629
|
+
this.logger.debug(`Stalled job will retry: ${job.id} (${job.name})`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
checkRateLimit() {
|
|
634
|
+
if (!this.rateLimit)
|
|
635
|
+
return true;
|
|
636
|
+
const now = Date.now();
|
|
637
|
+
const elapsed = now - this.rateLimitLastRefill;
|
|
638
|
+
if (elapsed >= this.rateLimit.duration) {
|
|
639
|
+
const periods = Math.floor(elapsed / this.rateLimit.duration);
|
|
640
|
+
this.rateLimitTokens = Math.min(this.rateLimit.max, this.rateLimitTokens + periods * this.rateLimit.max);
|
|
641
|
+
this.rateLimitLastRefill = now - elapsed % this.rateLimit.duration;
|
|
642
|
+
}
|
|
643
|
+
if (this.rateLimitTokens > 0) {
|
|
644
|
+
this.rateLimitTokens--;
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
async tick() {
|
|
650
|
+
if (!this.running)
|
|
651
|
+
return;
|
|
652
|
+
while (this.processing < this.concurrency && this.running) {
|
|
653
|
+
if (!this.checkRateLimit()) {
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
const job = await this.backend.pop(this.visibilityTimeout);
|
|
657
|
+
if (!job)
|
|
658
|
+
break;
|
|
659
|
+
this.processing++;
|
|
660
|
+
this.emit("active", job);
|
|
661
|
+
this.executeJob(job).finally(() => {
|
|
662
|
+
this.processing--;
|
|
663
|
+
if (this.shuttingDown && this.processing === 0 && this.shutdownResolve) {
|
|
664
|
+
this.shutdownResolve();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
if (this.running && !this.pollTimer) {
|
|
669
|
+
this.pollTimer = setTimeout(() => {
|
|
670
|
+
this.pollTimer = null;
|
|
671
|
+
this.tick();
|
|
672
|
+
}, this.pollInterval);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async executeJob(job) {
|
|
676
|
+
const handler = this.handlers.get(job.name);
|
|
677
|
+
const startTime = Date.now();
|
|
678
|
+
if (!handler) {
|
|
679
|
+
this.logger.warn(`No handler registered for job type: ${job.name}`);
|
|
680
|
+
await this.backend.ack(job.id);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const controller = new AbortController;
|
|
684
|
+
this.activeJobs.set(job.id, { controller, job });
|
|
685
|
+
const timeoutId = setTimeout(() => {
|
|
686
|
+
controller.abort();
|
|
687
|
+
}, job.timeout);
|
|
688
|
+
const ctx = {
|
|
689
|
+
signal: controller.signal,
|
|
690
|
+
updateProgress: async (progress) => {
|
|
691
|
+
const clampedProgress = Math.max(0, Math.min(100, progress));
|
|
692
|
+
await this.backend.updateProgress(job.id, clampedProgress);
|
|
693
|
+
this.emit("progress", job, clampedProgress);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
try {
|
|
697
|
+
const result = await Promise.race([
|
|
698
|
+
handler(job, ctx),
|
|
699
|
+
new Promise((_, reject) => {
|
|
700
|
+
controller.signal.addEventListener("abort", () => {
|
|
701
|
+
reject(new Error(`Job timed out after ${job.timeout}ms`));
|
|
702
|
+
});
|
|
703
|
+
})
|
|
704
|
+
]);
|
|
705
|
+
clearTimeout(timeoutId);
|
|
706
|
+
await this.backend.ack(job.id, result);
|
|
707
|
+
const processingTime = Date.now() - startTime;
|
|
708
|
+
this.metricsData.completed++;
|
|
709
|
+
this.metricsData.jobCount++;
|
|
710
|
+
this.metricsData.totalProcessingTime += processingTime;
|
|
711
|
+
this.metricsData.recentCompletions.push(Date.now());
|
|
712
|
+
this.logger.debug(`Job completed: ${job.id} (${job.name}) in ${processingTime}ms`);
|
|
713
|
+
this.emit("completed", job, result);
|
|
714
|
+
if (job.repeat) {
|
|
715
|
+
await this.scheduleNextRepeat(job);
|
|
716
|
+
}
|
|
717
|
+
} catch (error) {
|
|
718
|
+
clearTimeout(timeoutId);
|
|
719
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
720
|
+
const isTimeout = err.message.includes("timed out");
|
|
721
|
+
if (isTimeout) {
|
|
722
|
+
this.logger.warn(`Job timed out: ${job.id} (${job.name})`);
|
|
723
|
+
this.emit("timeout", job);
|
|
724
|
+
}
|
|
725
|
+
if (job.attempts + 1 >= job.maxAttempts) {
|
|
726
|
+
await this.backend.fail(job, err);
|
|
727
|
+
this.logger.error(`Job failed permanently: ${job.id} (${job.name})`, err.message);
|
|
728
|
+
this.emit("failed", job, err);
|
|
729
|
+
this.metricsData.totalFailed++;
|
|
730
|
+
} else {
|
|
731
|
+
const delay = calculateBackoff(job.backoff, job.attempts);
|
|
732
|
+
const nextAttemptAt = Date.now() + delay;
|
|
733
|
+
await this.backend.nack(job, nextAttemptAt);
|
|
734
|
+
this.logger.debug(`Job will retry: ${job.id} (${job.name}) attempt ${job.attempts + 1}/${job.maxAttempts}`);
|
|
735
|
+
this.emit("retry", job, err);
|
|
736
|
+
}
|
|
737
|
+
} finally {
|
|
738
|
+
this.activeJobs.delete(job.id);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async scheduleNextRepeat(job) {
|
|
742
|
+
if (!job.repeat)
|
|
743
|
+
return;
|
|
744
|
+
const repeat = job.repeat;
|
|
745
|
+
const newCount = (repeat.count ?? 0) + 1;
|
|
746
|
+
if (repeat.limit !== undefined && newCount >= repeat.limit) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
let nextScheduledAt;
|
|
750
|
+
if (repeat.cron) {
|
|
751
|
+
const nextTime = getNextCronTime(repeat.cron);
|
|
752
|
+
if (!nextTime)
|
|
753
|
+
return;
|
|
754
|
+
nextScheduledAt = nextTime.getTime();
|
|
755
|
+
} else if (repeat.every) {
|
|
756
|
+
nextScheduledAt = Date.now() + repeat.every;
|
|
757
|
+
} else {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const nextJob = {
|
|
761
|
+
...job,
|
|
762
|
+
id: `${repeat.key}:${newCount}`,
|
|
763
|
+
attempts: 0,
|
|
764
|
+
createdAt: Date.now(),
|
|
765
|
+
scheduledAt: nextScheduledAt,
|
|
766
|
+
startedAt: undefined,
|
|
767
|
+
repeat: {
|
|
768
|
+
...repeat,
|
|
769
|
+
count: newCount
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
await this.backend.push(nextJob);
|
|
773
|
+
}
|
|
774
|
+
emit(event, ...args) {
|
|
775
|
+
const listeners = this.events.get(event);
|
|
776
|
+
if (listeners) {
|
|
777
|
+
for (const listener of listeners) {
|
|
778
|
+
try {
|
|
779
|
+
listener(...args);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
this.logger.error(`Error in ${event} listener:`, error);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const onceListeners = this.onceEvents.get(event);
|
|
786
|
+
if (onceListeners && onceListeners.size > 0) {
|
|
787
|
+
const listenersToCall = [...onceListeners];
|
|
788
|
+
onceListeners.clear();
|
|
789
|
+
for (const listener of listenersToCall) {
|
|
790
|
+
try {
|
|
791
|
+
listener(...args);
|
|
792
|
+
} catch (error) {
|
|
793
|
+
this.logger.error(`Error in ${event} once listener:`, error);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const job = args[0];
|
|
798
|
+
if (job?.name) {
|
|
799
|
+
const jobListeners = this.jobEvents.get(event)?.get(job.name);
|
|
800
|
+
if (jobListeners) {
|
|
801
|
+
for (const listener of jobListeners) {
|
|
802
|
+
try {
|
|
803
|
+
listener(...args);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
this.logger.error(`Error in ${event} job listener for ${job.name}:`, error);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// src/backends/memory.ts
|
|
813
|
+
class MinHeap {
|
|
814
|
+
compare;
|
|
815
|
+
items = [];
|
|
816
|
+
constructor(compare) {
|
|
817
|
+
this.compare = compare;
|
|
818
|
+
}
|
|
819
|
+
get size() {
|
|
820
|
+
return this.items.length;
|
|
821
|
+
}
|
|
822
|
+
push(item) {
|
|
823
|
+
this.items.push(item);
|
|
824
|
+
this.bubbleUp(this.items.length - 1);
|
|
825
|
+
}
|
|
826
|
+
pop() {
|
|
827
|
+
if (this.items.length === 0)
|
|
828
|
+
return;
|
|
829
|
+
if (this.items.length === 1)
|
|
830
|
+
return this.items.pop();
|
|
831
|
+
const result = this.items[0];
|
|
832
|
+
this.items[0] = this.items.pop();
|
|
833
|
+
this.bubbleDown(0);
|
|
834
|
+
return result;
|
|
835
|
+
}
|
|
836
|
+
peek() {
|
|
837
|
+
return this.items[0];
|
|
838
|
+
}
|
|
839
|
+
remove(predicate) {
|
|
840
|
+
const index = this.items.findIndex(predicate);
|
|
841
|
+
if (index === -1)
|
|
842
|
+
return false;
|
|
843
|
+
if (index === this.items.length - 1) {
|
|
844
|
+
this.items.pop();
|
|
845
|
+
} else {
|
|
846
|
+
this.items[index] = this.items.pop();
|
|
847
|
+
this.bubbleDown(index);
|
|
848
|
+
this.bubbleUp(index);
|
|
849
|
+
}
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
*values() {
|
|
853
|
+
for (const item of this.items) {
|
|
854
|
+
yield item;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
clear() {
|
|
858
|
+
this.items = [];
|
|
859
|
+
}
|
|
860
|
+
bubbleUp(index) {
|
|
861
|
+
while (index > 0) {
|
|
862
|
+
const parentIndex = Math.floor((index - 1) / 2);
|
|
863
|
+
if (this.compare(this.items[index], this.items[parentIndex]) >= 0)
|
|
864
|
+
break;
|
|
865
|
+
[this.items[index], this.items[parentIndex]] = [this.items[parentIndex], this.items[index]];
|
|
866
|
+
index = parentIndex;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
bubbleDown(index) {
|
|
870
|
+
while (true) {
|
|
871
|
+
const leftChild = 2 * index + 1;
|
|
872
|
+
const rightChild = 2 * index + 2;
|
|
873
|
+
let smallest = index;
|
|
874
|
+
if (leftChild < this.items.length && this.compare(this.items[leftChild], this.items[smallest]) < 0) {
|
|
875
|
+
smallest = leftChild;
|
|
876
|
+
}
|
|
877
|
+
if (rightChild < this.items.length && this.compare(this.items[rightChild], this.items[smallest]) < 0) {
|
|
878
|
+
smallest = rightChild;
|
|
879
|
+
}
|
|
880
|
+
if (smallest === index)
|
|
881
|
+
break;
|
|
882
|
+
[this.items[index], this.items[smallest]] = [this.items[smallest], this.items[index]];
|
|
883
|
+
index = smallest;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
class MemoryBackend {
|
|
889
|
+
waitingHeap = new MinHeap((a, b) => {
|
|
890
|
+
if (a.priority !== b.priority)
|
|
891
|
+
return a.priority - b.priority;
|
|
892
|
+
return a.scheduledAt - b.scheduledAt;
|
|
893
|
+
});
|
|
894
|
+
waiting = new Map;
|
|
895
|
+
active = new Map;
|
|
896
|
+
failed = new Map;
|
|
897
|
+
completed = new Map;
|
|
898
|
+
allIds = new Set;
|
|
899
|
+
progress = new Map;
|
|
900
|
+
listener = null;
|
|
901
|
+
async push(job) {
|
|
902
|
+
if (this.allIds.has(job.id)) {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
this.allIds.add(job.id);
|
|
906
|
+
this.waiting.set(job.id, job);
|
|
907
|
+
this.waitingHeap.push(job);
|
|
908
|
+
this.notify();
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
async pushBulk(jobs) {
|
|
912
|
+
let added = 0;
|
|
913
|
+
for (const job of jobs) {
|
|
914
|
+
if (!this.allIds.has(job.id)) {
|
|
915
|
+
this.allIds.add(job.id);
|
|
916
|
+
this.waiting.set(job.id, job);
|
|
917
|
+
this.waitingHeap.push(job);
|
|
918
|
+
added++;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (added > 0) {
|
|
922
|
+
this.notify();
|
|
923
|
+
}
|
|
924
|
+
return added;
|
|
925
|
+
}
|
|
926
|
+
async pop(visibilityTimeout) {
|
|
927
|
+
const now = Date.now();
|
|
928
|
+
while (this.waitingHeap.size > 0) {
|
|
929
|
+
const top = this.waitingHeap.peek();
|
|
930
|
+
if (!top)
|
|
931
|
+
break;
|
|
932
|
+
if (!this.waiting.has(top.id)) {
|
|
933
|
+
this.waitingHeap.pop();
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (top.scheduledAt > now) {
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
this.waitingHeap.pop();
|
|
940
|
+
this.waiting.delete(top.id);
|
|
941
|
+
const activeJob = {
|
|
942
|
+
...top,
|
|
943
|
+
startedAt: now
|
|
944
|
+
};
|
|
945
|
+
this.active.set(top.id, {
|
|
946
|
+
job: activeJob,
|
|
947
|
+
expiresAt: now + visibilityTimeout
|
|
948
|
+
});
|
|
949
|
+
return activeJob;
|
|
950
|
+
}
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
async ack(jobId, result) {
|
|
954
|
+
const activeJob = this.active.get(jobId);
|
|
955
|
+
this.active.delete(jobId);
|
|
956
|
+
this.progress.delete(jobId);
|
|
957
|
+
if (activeJob) {
|
|
958
|
+
this.completed.set(jobId, {
|
|
959
|
+
job: activeJob.job,
|
|
960
|
+
result,
|
|
961
|
+
completedAt: Date.now()
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async nack(job, nextAttemptAt) {
|
|
966
|
+
this.active.delete(job.id);
|
|
967
|
+
this.progress.delete(job.id);
|
|
968
|
+
const updated = {
|
|
969
|
+
...job,
|
|
970
|
+
scheduledAt: nextAttemptAt,
|
|
971
|
+
attempts: job.attempts + 1,
|
|
972
|
+
startedAt: undefined
|
|
973
|
+
};
|
|
974
|
+
this.waiting.set(job.id, updated);
|
|
975
|
+
this.waitingHeap.push(updated);
|
|
976
|
+
this.notify();
|
|
977
|
+
}
|
|
978
|
+
async fail(job, error) {
|
|
979
|
+
this.active.delete(job.id);
|
|
980
|
+
this.progress.delete(job.id);
|
|
981
|
+
this.failed.set(job.id, {
|
|
982
|
+
job,
|
|
983
|
+
error: error.message,
|
|
984
|
+
failedAt: Date.now()
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
async getStalled() {
|
|
988
|
+
const now = Date.now();
|
|
989
|
+
const stalled = [];
|
|
990
|
+
for (const [id, active] of this.active) {
|
|
991
|
+
if (active.expiresAt <= now) {
|
|
992
|
+
stalled.push(active.job);
|
|
993
|
+
this.active.delete(id);
|
|
994
|
+
this.progress.delete(id);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return stalled;
|
|
998
|
+
}
|
|
999
|
+
async updateProgress(jobId, progress) {
|
|
1000
|
+
if (this.active.has(jobId)) {
|
|
1001
|
+
this.progress.set(jobId, progress);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async getCompleted(jobId) {
|
|
1005
|
+
return this.completed.get(jobId) ?? null;
|
|
1006
|
+
}
|
|
1007
|
+
async completedCount() {
|
|
1008
|
+
return this.completed.size;
|
|
1009
|
+
}
|
|
1010
|
+
async getCompletedJobs(limit = 100) {
|
|
1011
|
+
const result = [];
|
|
1012
|
+
let count = 0;
|
|
1013
|
+
const entries = [...this.completed.entries()].sort((a, b) => b[1].completedAt - a[1].completedAt);
|
|
1014
|
+
for (const [, completed] of entries) {
|
|
1015
|
+
if (count >= limit)
|
|
1016
|
+
break;
|
|
1017
|
+
result.push(completed);
|
|
1018
|
+
count++;
|
|
1019
|
+
}
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|
|
1022
|
+
subscribe(callback) {
|
|
1023
|
+
this.listener = callback;
|
|
1024
|
+
}
|
|
1025
|
+
unsubscribe() {
|
|
1026
|
+
this.listener = null;
|
|
1027
|
+
}
|
|
1028
|
+
async size() {
|
|
1029
|
+
return this.waiting.size;
|
|
1030
|
+
}
|
|
1031
|
+
async activeCount() {
|
|
1032
|
+
return this.active.size;
|
|
1033
|
+
}
|
|
1034
|
+
async failedCount() {
|
|
1035
|
+
return this.failed.size;
|
|
1036
|
+
}
|
|
1037
|
+
async getFailed(limit = 100) {
|
|
1038
|
+
const result = [];
|
|
1039
|
+
let count = 0;
|
|
1040
|
+
for (const failed of this.failed.values()) {
|
|
1041
|
+
if (count >= limit)
|
|
1042
|
+
break;
|
|
1043
|
+
result.push(failed);
|
|
1044
|
+
count++;
|
|
1045
|
+
}
|
|
1046
|
+
return result;
|
|
1047
|
+
}
|
|
1048
|
+
async retryFailed(jobId) {
|
|
1049
|
+
const failed = this.failed.get(jobId);
|
|
1050
|
+
if (!failed)
|
|
1051
|
+
return false;
|
|
1052
|
+
this.failed.delete(jobId);
|
|
1053
|
+
const job = {
|
|
1054
|
+
...failed.job,
|
|
1055
|
+
attempts: 0,
|
|
1056
|
+
scheduledAt: Date.now(),
|
|
1057
|
+
startedAt: undefined
|
|
1058
|
+
};
|
|
1059
|
+
this.waiting.set(jobId, job);
|
|
1060
|
+
this.waitingHeap.push(job);
|
|
1061
|
+
this.notify();
|
|
1062
|
+
return true;
|
|
1063
|
+
}
|
|
1064
|
+
async remove(jobId) {
|
|
1065
|
+
let existed = false;
|
|
1066
|
+
if (this.waiting.delete(jobId)) {
|
|
1067
|
+
this.waitingHeap.remove((j) => j.id === jobId);
|
|
1068
|
+
existed = true;
|
|
1069
|
+
}
|
|
1070
|
+
existed = this.active.delete(jobId) || existed;
|
|
1071
|
+
existed = this.failed.delete(jobId) || existed;
|
|
1072
|
+
existed = this.completed.delete(jobId) || existed;
|
|
1073
|
+
if (existed) {
|
|
1074
|
+
this.allIds.delete(jobId);
|
|
1075
|
+
this.progress.delete(jobId);
|
|
1076
|
+
}
|
|
1077
|
+
return existed;
|
|
1078
|
+
}
|
|
1079
|
+
async clear() {
|
|
1080
|
+
this.waiting.clear();
|
|
1081
|
+
this.waitingHeap.clear();
|
|
1082
|
+
this.active.clear();
|
|
1083
|
+
this.failed.clear();
|
|
1084
|
+
this.completed.clear();
|
|
1085
|
+
this.allIds.clear();
|
|
1086
|
+
this.progress.clear();
|
|
1087
|
+
}
|
|
1088
|
+
async close() {
|
|
1089
|
+
this.unsubscribe();
|
|
1090
|
+
await this.clear();
|
|
1091
|
+
}
|
|
1092
|
+
notify() {
|
|
1093
|
+
if (this.listener) {
|
|
1094
|
+
queueMicrotask(this.listener);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// src/backends/broadcast.ts
|
|
1099
|
+
class BroadcastBackend {
|
|
1100
|
+
waiting = new Map;
|
|
1101
|
+
active = new Map;
|
|
1102
|
+
failed = new Map;
|
|
1103
|
+
completed = new Map;
|
|
1104
|
+
allIds = new Set;
|
|
1105
|
+
progress = new Map;
|
|
1106
|
+
channel;
|
|
1107
|
+
listener = null;
|
|
1108
|
+
isLeader = false;
|
|
1109
|
+
leaderTimeout = null;
|
|
1110
|
+
instanceId = crypto.randomUUID();
|
|
1111
|
+
pendingClaims = new Map;
|
|
1112
|
+
constructor(channelName = "jobqueue") {
|
|
1113
|
+
this.channel = new BroadcastChannel(channelName);
|
|
1114
|
+
this.channel.onmessage = (event) => this.handleMessage(event);
|
|
1115
|
+
this.electLeader();
|
|
1116
|
+
}
|
|
1117
|
+
electLeader() {
|
|
1118
|
+
this.channel.postMessage({ type: "sync-request" });
|
|
1119
|
+
this.leaderTimeout = setTimeout(() => {
|
|
1120
|
+
this.isLeader = true;
|
|
1121
|
+
}, 100);
|
|
1122
|
+
}
|
|
1123
|
+
handleMessage(event) {
|
|
1124
|
+
const msg = event.data;
|
|
1125
|
+
switch (msg.type) {
|
|
1126
|
+
case "push":
|
|
1127
|
+
if (msg.job && !this.allIds.has(msg.job.id)) {
|
|
1128
|
+
this.allIds.add(msg.job.id);
|
|
1129
|
+
this.waiting.set(msg.job.id, msg.job);
|
|
1130
|
+
this.notify();
|
|
1131
|
+
}
|
|
1132
|
+
break;
|
|
1133
|
+
case "ack":
|
|
1134
|
+
if (msg.jobId) {
|
|
1135
|
+
this.active.delete(msg.jobId);
|
|
1136
|
+
this.allIds.delete(msg.jobId);
|
|
1137
|
+
}
|
|
1138
|
+
break;
|
|
1139
|
+
case "nack":
|
|
1140
|
+
if (msg.job && msg.nextAttemptAt !== undefined) {
|
|
1141
|
+
this.active.delete(msg.job.id);
|
|
1142
|
+
const updated = {
|
|
1143
|
+
...msg.job,
|
|
1144
|
+
scheduledAt: msg.nextAttemptAt,
|
|
1145
|
+
attempts: msg.job.attempts + 1,
|
|
1146
|
+
startedAt: undefined
|
|
1147
|
+
};
|
|
1148
|
+
this.waiting.set(msg.job.id, updated);
|
|
1149
|
+
this.notify();
|
|
1150
|
+
}
|
|
1151
|
+
break;
|
|
1152
|
+
case "fail":
|
|
1153
|
+
if (msg.job && msg.error !== undefined && msg.failedAt !== undefined) {
|
|
1154
|
+
this.active.delete(msg.job.id);
|
|
1155
|
+
this.failed.set(msg.job.id, {
|
|
1156
|
+
job: msg.job,
|
|
1157
|
+
error: msg.error,
|
|
1158
|
+
failedAt: msg.failedAt
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
break;
|
|
1162
|
+
case "claim":
|
|
1163
|
+
if (msg.jobId && msg.claimId) {
|
|
1164
|
+
const hasJob = this.waiting.has(msg.jobId);
|
|
1165
|
+
this.channel.postMessage({
|
|
1166
|
+
type: "claim-response",
|
|
1167
|
+
claimId: msg.claimId,
|
|
1168
|
+
claimed: hasJob,
|
|
1169
|
+
jobId: msg.jobId
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
break;
|
|
1173
|
+
case "claim-response":
|
|
1174
|
+
if (msg.claimId && msg.claimed) {
|
|
1175
|
+
const pending = this.pendingClaims.get(msg.claimId);
|
|
1176
|
+
if (pending) {
|
|
1177
|
+
clearTimeout(pending.timeout);
|
|
1178
|
+
this.pendingClaims.delete(msg.claimId);
|
|
1179
|
+
pending.resolve(false);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
break;
|
|
1183
|
+
case "sync-request":
|
|
1184
|
+
if (this.isLeader) {
|
|
1185
|
+
this.channel.postMessage({
|
|
1186
|
+
type: "sync-response",
|
|
1187
|
+
jobs: Array.from(this.waiting.values())
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
break;
|
|
1191
|
+
case "sync-response":
|
|
1192
|
+
if (this.leaderTimeout) {
|
|
1193
|
+
clearTimeout(this.leaderTimeout);
|
|
1194
|
+
this.leaderTimeout = null;
|
|
1195
|
+
}
|
|
1196
|
+
if (msg.jobs) {
|
|
1197
|
+
for (const job of msg.jobs) {
|
|
1198
|
+
if (!this.allIds.has(job.id)) {
|
|
1199
|
+
this.allIds.add(job.id);
|
|
1200
|
+
this.waiting.set(job.id, job);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
this.notify();
|
|
1204
|
+
}
|
|
1205
|
+
break;
|
|
1206
|
+
case "progress":
|
|
1207
|
+
if (msg.jobId && msg.progress !== undefined) {
|
|
1208
|
+
if (this.active.has(msg.jobId)) {
|
|
1209
|
+
this.progress.set(msg.jobId, msg.progress);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
async push(job) {
|
|
1216
|
+
if (this.allIds.has(job.id)) {
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
this.allIds.add(job.id);
|
|
1220
|
+
this.waiting.set(job.id, job);
|
|
1221
|
+
this.channel.postMessage({ type: "push", job });
|
|
1222
|
+
this.notify();
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
async pushBulk(jobs) {
|
|
1226
|
+
let added = 0;
|
|
1227
|
+
for (const job of jobs) {
|
|
1228
|
+
if (!this.allIds.has(job.id)) {
|
|
1229
|
+
this.allIds.add(job.id);
|
|
1230
|
+
this.waiting.set(job.id, job);
|
|
1231
|
+
this.channel.postMessage({ type: "push", job });
|
|
1232
|
+
added++;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (added > 0) {
|
|
1236
|
+
this.notify();
|
|
1237
|
+
}
|
|
1238
|
+
return added;
|
|
1239
|
+
}
|
|
1240
|
+
async pop(visibilityTimeout) {
|
|
1241
|
+
const now = Date.now();
|
|
1242
|
+
let best = null;
|
|
1243
|
+
for (const job of this.waiting.values()) {
|
|
1244
|
+
if (job.scheduledAt > now)
|
|
1245
|
+
continue;
|
|
1246
|
+
if (!best || job.priority < best.priority || job.priority === best.priority && job.scheduledAt < best.scheduledAt) {
|
|
1247
|
+
best = job;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (!best) {
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
const claimId = `${this.instanceId}-${best.id}-${now}`;
|
|
1254
|
+
const claimed = await this.tryClaim(best.id, claimId);
|
|
1255
|
+
if (!claimed) {
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
this.waiting.delete(best.id);
|
|
1259
|
+
const activeJob = {
|
|
1260
|
+
...best,
|
|
1261
|
+
startedAt: now
|
|
1262
|
+
};
|
|
1263
|
+
this.active.set(best.id, {
|
|
1264
|
+
job: activeJob,
|
|
1265
|
+
expiresAt: now + visibilityTimeout
|
|
1266
|
+
});
|
|
1267
|
+
return activeJob;
|
|
1268
|
+
}
|
|
1269
|
+
tryClaim(jobId, claimId) {
|
|
1270
|
+
return new Promise((resolve) => {
|
|
1271
|
+
this.channel.postMessage({
|
|
1272
|
+
type: "claim",
|
|
1273
|
+
jobId,
|
|
1274
|
+
claimId
|
|
1275
|
+
});
|
|
1276
|
+
const timeout = setTimeout(() => {
|
|
1277
|
+
this.pendingClaims.delete(claimId);
|
|
1278
|
+
resolve(true);
|
|
1279
|
+
}, 50);
|
|
1280
|
+
this.pendingClaims.set(claimId, { resolve, timeout });
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
async ack(jobId, result) {
|
|
1284
|
+
const activeJob = this.active.get(jobId);
|
|
1285
|
+
this.active.delete(jobId);
|
|
1286
|
+
this.progress.delete(jobId);
|
|
1287
|
+
if (activeJob) {
|
|
1288
|
+
const completedAt = Date.now();
|
|
1289
|
+
this.completed.set(jobId, {
|
|
1290
|
+
job: activeJob.job,
|
|
1291
|
+
result,
|
|
1292
|
+
completedAt
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
this.channel.postMessage({ type: "ack", jobId });
|
|
1296
|
+
}
|
|
1297
|
+
async nack(job, nextAttemptAt) {
|
|
1298
|
+
this.active.delete(job.id);
|
|
1299
|
+
this.progress.delete(job.id);
|
|
1300
|
+
const updated = {
|
|
1301
|
+
...job,
|
|
1302
|
+
scheduledAt: nextAttemptAt,
|
|
1303
|
+
attempts: job.attempts + 1,
|
|
1304
|
+
startedAt: undefined
|
|
1305
|
+
};
|
|
1306
|
+
this.waiting.set(job.id, updated);
|
|
1307
|
+
this.channel.postMessage({
|
|
1308
|
+
type: "nack",
|
|
1309
|
+
job,
|
|
1310
|
+
nextAttemptAt
|
|
1311
|
+
});
|
|
1312
|
+
this.notify();
|
|
1313
|
+
}
|
|
1314
|
+
async fail(job, error) {
|
|
1315
|
+
this.active.delete(job.id);
|
|
1316
|
+
this.progress.delete(job.id);
|
|
1317
|
+
const failedAt = Date.now();
|
|
1318
|
+
this.failed.set(job.id, {
|
|
1319
|
+
job,
|
|
1320
|
+
error: error.message,
|
|
1321
|
+
failedAt
|
|
1322
|
+
});
|
|
1323
|
+
this.channel.postMessage({
|
|
1324
|
+
type: "fail",
|
|
1325
|
+
job,
|
|
1326
|
+
error: error.message,
|
|
1327
|
+
failedAt
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
async getStalled() {
|
|
1331
|
+
const now = Date.now();
|
|
1332
|
+
const stalled = [];
|
|
1333
|
+
for (const [id, active] of this.active) {
|
|
1334
|
+
if (active.expiresAt <= now) {
|
|
1335
|
+
stalled.push(active.job);
|
|
1336
|
+
this.active.delete(id);
|
|
1337
|
+
this.progress.delete(id);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
return stalled;
|
|
1341
|
+
}
|
|
1342
|
+
async updateProgress(jobId, progress) {
|
|
1343
|
+
if (this.active.has(jobId)) {
|
|
1344
|
+
this.progress.set(jobId, progress);
|
|
1345
|
+
this.channel.postMessage({
|
|
1346
|
+
type: "progress",
|
|
1347
|
+
jobId,
|
|
1348
|
+
progress
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
async getCompleted(jobId) {
|
|
1353
|
+
return this.completed.get(jobId) ?? null;
|
|
1354
|
+
}
|
|
1355
|
+
async completedCount() {
|
|
1356
|
+
return this.completed.size;
|
|
1357
|
+
}
|
|
1358
|
+
async getCompletedJobs(limit = 100) {
|
|
1359
|
+
const result = [];
|
|
1360
|
+
let count = 0;
|
|
1361
|
+
const entries = [...this.completed.entries()].sort((a, b) => b[1].completedAt - a[1].completedAt);
|
|
1362
|
+
for (const [, completed] of entries) {
|
|
1363
|
+
if (count >= limit)
|
|
1364
|
+
break;
|
|
1365
|
+
result.push(completed);
|
|
1366
|
+
count++;
|
|
1367
|
+
}
|
|
1368
|
+
return result;
|
|
1369
|
+
}
|
|
1370
|
+
subscribe(callback) {
|
|
1371
|
+
this.listener = callback;
|
|
1372
|
+
}
|
|
1373
|
+
unsubscribe() {
|
|
1374
|
+
this.listener = null;
|
|
1375
|
+
}
|
|
1376
|
+
async size() {
|
|
1377
|
+
return this.waiting.size;
|
|
1378
|
+
}
|
|
1379
|
+
async activeCount() {
|
|
1380
|
+
return this.active.size;
|
|
1381
|
+
}
|
|
1382
|
+
async failedCount() {
|
|
1383
|
+
return this.failed.size;
|
|
1384
|
+
}
|
|
1385
|
+
async getFailed(limit = 100) {
|
|
1386
|
+
const result = [];
|
|
1387
|
+
let count = 0;
|
|
1388
|
+
for (const failed of this.failed.values()) {
|
|
1389
|
+
if (count >= limit)
|
|
1390
|
+
break;
|
|
1391
|
+
result.push(failed);
|
|
1392
|
+
count++;
|
|
1393
|
+
}
|
|
1394
|
+
return result;
|
|
1395
|
+
}
|
|
1396
|
+
async retryFailed(jobId) {
|
|
1397
|
+
const failed = this.failed.get(jobId);
|
|
1398
|
+
if (!failed)
|
|
1399
|
+
return false;
|
|
1400
|
+
this.failed.delete(jobId);
|
|
1401
|
+
const job = {
|
|
1402
|
+
...failed.job,
|
|
1403
|
+
attempts: 0,
|
|
1404
|
+
scheduledAt: Date.now(),
|
|
1405
|
+
startedAt: undefined
|
|
1406
|
+
};
|
|
1407
|
+
this.waiting.set(jobId, job);
|
|
1408
|
+
this.channel.postMessage({ type: "push", job });
|
|
1409
|
+
this.notify();
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
async remove(jobId) {
|
|
1413
|
+
let existed = false;
|
|
1414
|
+
existed = this.waiting.delete(jobId) || existed;
|
|
1415
|
+
existed = this.active.delete(jobId) || existed;
|
|
1416
|
+
existed = this.failed.delete(jobId) || existed;
|
|
1417
|
+
existed = this.completed.delete(jobId) || existed;
|
|
1418
|
+
if (existed) {
|
|
1419
|
+
this.allIds.delete(jobId);
|
|
1420
|
+
this.progress.delete(jobId);
|
|
1421
|
+
this.channel.postMessage({ type: "ack", jobId });
|
|
1422
|
+
}
|
|
1423
|
+
return existed;
|
|
1424
|
+
}
|
|
1425
|
+
async clear() {
|
|
1426
|
+
this.waiting.clear();
|
|
1427
|
+
this.active.clear();
|
|
1428
|
+
this.failed.clear();
|
|
1429
|
+
this.completed.clear();
|
|
1430
|
+
this.allIds.clear();
|
|
1431
|
+
this.progress.clear();
|
|
1432
|
+
}
|
|
1433
|
+
async close() {
|
|
1434
|
+
this.channel.close();
|
|
1435
|
+
if (this.leaderTimeout) {
|
|
1436
|
+
clearTimeout(this.leaderTimeout);
|
|
1437
|
+
}
|
|
1438
|
+
for (const [, pending] of this.pendingClaims) {
|
|
1439
|
+
clearTimeout(pending.timeout);
|
|
1440
|
+
}
|
|
1441
|
+
this.pendingClaims.clear();
|
|
1442
|
+
}
|
|
1443
|
+
notify() {
|
|
1444
|
+
if (this.listener) {
|
|
1445
|
+
queueMicrotask(this.listener);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
// src/backends/redis.ts
|
|
1450
|
+
var POP_SCRIPT = `
|
|
1451
|
+
-- Get the first job by score (lowest priority, then earliest scheduledAt)
|
|
1452
|
+
local jobs = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
|
1453
|
+
if #jobs == 0 then
|
|
1454
|
+
return nil
|
|
1455
|
+
end
|
|
1456
|
+
|
|
1457
|
+
local jobId = jobs[1]
|
|
1458
|
+
local score = tonumber(jobs[2])
|
|
1459
|
+
|
|
1460
|
+
-- Extract scheduledAt from score: scheduledAt = score % 1e15
|
|
1461
|
+
local scheduledAt = score % 1000000000000000
|
|
1462
|
+
|
|
1463
|
+
-- Check if job is ready (scheduledAt <= now)
|
|
1464
|
+
if scheduledAt > tonumber(ARGV[1]) then
|
|
1465
|
+
return nil
|
|
1466
|
+
end
|
|
1467
|
+
|
|
1468
|
+
-- Atomically remove from waiting
|
|
1469
|
+
local removed = redis.call('ZREM', KEYS[1], jobId)
|
|
1470
|
+
if removed == 0 then
|
|
1471
|
+
return nil
|
|
1472
|
+
end
|
|
1473
|
+
|
|
1474
|
+
-- Get job data
|
|
1475
|
+
local jobData = redis.call('HGET', KEYS[3], jobId)
|
|
1476
|
+
if not jobData then
|
|
1477
|
+
return nil
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
-- Add to active with expiry timestamp
|
|
1481
|
+
local expiresAt = tonumber(ARGV[1]) + tonumber(ARGV[2])
|
|
1482
|
+
redis.call('HSET', KEYS[2], jobId, tostring(expiresAt))
|
|
1483
|
+
|
|
1484
|
+
return jobData
|
|
1485
|
+
`;
|
|
1486
|
+
var STALLED_SCRIPT = `
|
|
1487
|
+
local stalled = {}
|
|
1488
|
+
local active = redis.call('HGETALL', KEYS[1])
|
|
1489
|
+
|
|
1490
|
+
for i = 1, #active, 2 do
|
|
1491
|
+
local jobId = active[i]
|
|
1492
|
+
local expiresAt = tonumber(active[i + 1])
|
|
1493
|
+
|
|
1494
|
+
if expiresAt <= tonumber(ARGV[1]) then
|
|
1495
|
+
local jobData = redis.call('HGET', KEYS[2], jobId)
|
|
1496
|
+
if jobData then
|
|
1497
|
+
table.insert(stalled, jobData)
|
|
1498
|
+
end
|
|
1499
|
+
redis.call('HDEL', KEYS[1], jobId)
|
|
1500
|
+
end
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
return stalled
|
|
1504
|
+
`;
|
|
1505
|
+
|
|
1506
|
+
class RedisBackend {
|
|
1507
|
+
client;
|
|
1508
|
+
subscriber = null;
|
|
1509
|
+
prefix;
|
|
1510
|
+
listener = null;
|
|
1511
|
+
ownsSubscriber = false;
|
|
1512
|
+
url;
|
|
1513
|
+
constructor(url, options = {}) {
|
|
1514
|
+
this.url = url;
|
|
1515
|
+
this.client = new Bun.RedisClient(url);
|
|
1516
|
+
this.prefix = options.prefix ?? "qwerk";
|
|
1517
|
+
if (options.subscriber) {
|
|
1518
|
+
this.subscriber = options.subscriber;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
key(name) {
|
|
1522
|
+
return `${this.prefix}:${name}`;
|
|
1523
|
+
}
|
|
1524
|
+
evalScript(script, keys, args) {
|
|
1525
|
+
return this.client.send("EVAL", [script, String(keys.length), ...keys, ...args.map(String)]);
|
|
1526
|
+
}
|
|
1527
|
+
async push(job) {
|
|
1528
|
+
const jobKey = this.key("jobs");
|
|
1529
|
+
const waitingKey = this.key("waiting");
|
|
1530
|
+
const existing = await this.client.hget(jobKey, job.id);
|
|
1531
|
+
if (existing) {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
await this.client.hset(jobKey, job.id, JSON.stringify(job));
|
|
1535
|
+
const score = job.priority * 1000000000000000 + job.scheduledAt;
|
|
1536
|
+
await this.client.zadd(waitingKey, score, job.id);
|
|
1537
|
+
await this.client.publish(this.key("notify"), "push");
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
async pushBulk(jobs) {
|
|
1541
|
+
const jobKey = this.key("jobs");
|
|
1542
|
+
const waitingKey = this.key("waiting");
|
|
1543
|
+
let added = 0;
|
|
1544
|
+
for (const job of jobs) {
|
|
1545
|
+
const existing = await this.client.hget(jobKey, job.id);
|
|
1546
|
+
if (existing)
|
|
1547
|
+
continue;
|
|
1548
|
+
await this.client.hset(jobKey, job.id, JSON.stringify(job));
|
|
1549
|
+
const score = job.priority * 1000000000000000 + job.scheduledAt;
|
|
1550
|
+
await this.client.zadd(waitingKey, score, job.id);
|
|
1551
|
+
added++;
|
|
1552
|
+
}
|
|
1553
|
+
if (added > 0) {
|
|
1554
|
+
await this.client.publish(this.key("notify"), "push");
|
|
1555
|
+
}
|
|
1556
|
+
return added;
|
|
1557
|
+
}
|
|
1558
|
+
async pop(visibilityTimeout) {
|
|
1559
|
+
const now = Date.now();
|
|
1560
|
+
const result = await this.evalScript(POP_SCRIPT, [this.key("waiting"), this.key("active"), this.key("jobs")], [now, visibilityTimeout]);
|
|
1561
|
+
if (!result) {
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
const job = JSON.parse(result);
|
|
1565
|
+
return { ...job, startedAt: now };
|
|
1566
|
+
}
|
|
1567
|
+
async ack(jobId, result) {
|
|
1568
|
+
const jobData = await this.client.hget(this.key("jobs"), jobId);
|
|
1569
|
+
await this.client.hdel(this.key("active"), jobId);
|
|
1570
|
+
await this.client.hdel(this.key("progress"), jobId);
|
|
1571
|
+
if (jobData) {
|
|
1572
|
+
const job = JSON.parse(jobData);
|
|
1573
|
+
const completedData = JSON.stringify({
|
|
1574
|
+
job,
|
|
1575
|
+
result,
|
|
1576
|
+
completedAt: Date.now()
|
|
1577
|
+
});
|
|
1578
|
+
await this.client.hset(this.key("completed"), jobId, completedData);
|
|
1579
|
+
}
|
|
1580
|
+
await this.client.hdel(this.key("jobs"), jobId);
|
|
1581
|
+
}
|
|
1582
|
+
async nack(job, nextAttemptAt) {
|
|
1583
|
+
const updated = {
|
|
1584
|
+
...job,
|
|
1585
|
+
scheduledAt: nextAttemptAt,
|
|
1586
|
+
attempts: job.attempts + 1,
|
|
1587
|
+
startedAt: undefined
|
|
1588
|
+
};
|
|
1589
|
+
await this.client.hdel(this.key("active"), job.id);
|
|
1590
|
+
await this.client.hdel(this.key("progress"), job.id);
|
|
1591
|
+
await this.client.hset(this.key("jobs"), job.id, JSON.stringify(updated));
|
|
1592
|
+
const score = updated.priority * 1000000000000000 + nextAttemptAt;
|
|
1593
|
+
await this.client.zadd(this.key("waiting"), score, job.id);
|
|
1594
|
+
await this.client.publish(this.key("notify"), "nack");
|
|
1595
|
+
}
|
|
1596
|
+
async fail(job, error) {
|
|
1597
|
+
await this.client.hdel(this.key("active"), job.id);
|
|
1598
|
+
await this.client.hdel(this.key("progress"), job.id);
|
|
1599
|
+
const failedData = JSON.stringify({
|
|
1600
|
+
job,
|
|
1601
|
+
error: error.message,
|
|
1602
|
+
failedAt: Date.now()
|
|
1603
|
+
});
|
|
1604
|
+
await this.client.hset(this.key("failed"), job.id, failedData);
|
|
1605
|
+
}
|
|
1606
|
+
async getStalled() {
|
|
1607
|
+
const now = Date.now();
|
|
1608
|
+
const results = await this.evalScript(STALLED_SCRIPT, [this.key("active"), this.key("jobs")], [now]);
|
|
1609
|
+
if (!results || results.length === 0) {
|
|
1610
|
+
return [];
|
|
1611
|
+
}
|
|
1612
|
+
return results.map((data) => JSON.parse(data));
|
|
1613
|
+
}
|
|
1614
|
+
async updateProgress(jobId, progress) {
|
|
1615
|
+
const isActive = await this.client.hget(this.key("active"), jobId);
|
|
1616
|
+
if (isActive) {
|
|
1617
|
+
await this.client.hset(this.key("progress"), jobId, String(progress));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
async getCompleted(jobId) {
|
|
1621
|
+
const data = await this.client.hget(this.key("completed"), jobId);
|
|
1622
|
+
if (!data)
|
|
1623
|
+
return null;
|
|
1624
|
+
return JSON.parse(data);
|
|
1625
|
+
}
|
|
1626
|
+
async completedCount() {
|
|
1627
|
+
return await this.client.hlen(this.key("completed"));
|
|
1628
|
+
}
|
|
1629
|
+
async getCompletedJobs(limit = 100) {
|
|
1630
|
+
const all = await this.client.hgetall(this.key("completed"));
|
|
1631
|
+
const result = [];
|
|
1632
|
+
const entries = Object.values(all).map((v) => JSON.parse(v)).sort((a, b) => b.completedAt - a.completedAt);
|
|
1633
|
+
for (const entry of entries) {
|
|
1634
|
+
if (result.length >= limit)
|
|
1635
|
+
break;
|
|
1636
|
+
result.push(entry);
|
|
1637
|
+
}
|
|
1638
|
+
return result;
|
|
1639
|
+
}
|
|
1640
|
+
subscribe(callback) {
|
|
1641
|
+
this.listener = callback;
|
|
1642
|
+
if (!this.subscriber) {
|
|
1643
|
+
this.subscriber = new Bun.RedisClient(this.url);
|
|
1644
|
+
this.ownsSubscriber = true;
|
|
1645
|
+
}
|
|
1646
|
+
this.subscriber.subscribe(this.key("notify"), () => {
|
|
1647
|
+
if (this.listener) {
|
|
1648
|
+
this.listener();
|
|
1649
|
+
}
|
|
1650
|
+
}).catch(() => {});
|
|
1651
|
+
}
|
|
1652
|
+
unsubscribe() {
|
|
1653
|
+
this.listener = null;
|
|
1654
|
+
if (this.subscriber && this.ownsSubscriber) {
|
|
1655
|
+
this.subscriber.close();
|
|
1656
|
+
this.subscriber = null;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
async size() {
|
|
1660
|
+
return await this.client.zcard(this.key("waiting"));
|
|
1661
|
+
}
|
|
1662
|
+
async activeCount() {
|
|
1663
|
+
return await this.client.hlen(this.key("active"));
|
|
1664
|
+
}
|
|
1665
|
+
async failedCount() {
|
|
1666
|
+
return await this.client.hlen(this.key("failed"));
|
|
1667
|
+
}
|
|
1668
|
+
async getFailed(limit = 100) {
|
|
1669
|
+
const all = await this.client.hgetall(this.key("failed"));
|
|
1670
|
+
const result = [];
|
|
1671
|
+
let count = 0;
|
|
1672
|
+
for (const value of Object.values(all)) {
|
|
1673
|
+
if (count >= limit)
|
|
1674
|
+
break;
|
|
1675
|
+
result.push(JSON.parse(value));
|
|
1676
|
+
count++;
|
|
1677
|
+
}
|
|
1678
|
+
return result;
|
|
1679
|
+
}
|
|
1680
|
+
async retryFailed(jobId) {
|
|
1681
|
+
const failedData = await this.client.hget(this.key("failed"), jobId);
|
|
1682
|
+
if (!failedData)
|
|
1683
|
+
return false;
|
|
1684
|
+
const { job } = JSON.parse(failedData);
|
|
1685
|
+
await this.client.hdel(this.key("failed"), jobId);
|
|
1686
|
+
const resetJob = {
|
|
1687
|
+
...job,
|
|
1688
|
+
attempts: 0,
|
|
1689
|
+
scheduledAt: Date.now(),
|
|
1690
|
+
startedAt: undefined
|
|
1691
|
+
};
|
|
1692
|
+
await this.client.hset(this.key("jobs"), jobId, JSON.stringify(resetJob));
|
|
1693
|
+
const score = resetJob.priority * 1000000000000000 + resetJob.scheduledAt;
|
|
1694
|
+
await this.client.zadd(this.key("waiting"), score, jobId);
|
|
1695
|
+
await this.client.publish(this.key("notify"), "push");
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1698
|
+
async remove(jobId) {
|
|
1699
|
+
const removed = await this.client.zrem(this.key("waiting"), jobId) > 0 || await this.client.hdel(this.key("active"), jobId) > 0 || await this.client.hdel(this.key("failed"), jobId) > 0 || await this.client.hdel(this.key("completed"), jobId) > 0;
|
|
1700
|
+
if (removed) {
|
|
1701
|
+
await this.client.hdel(this.key("jobs"), jobId);
|
|
1702
|
+
await this.client.hdel(this.key("progress"), jobId);
|
|
1703
|
+
}
|
|
1704
|
+
return removed;
|
|
1705
|
+
}
|
|
1706
|
+
async clear() {
|
|
1707
|
+
const waiting = await this.client.zrange(this.key("waiting"), 0, -1);
|
|
1708
|
+
const active = await this.client.hgetall(this.key("active"));
|
|
1709
|
+
const failed = await this.client.hgetall(this.key("failed"));
|
|
1710
|
+
const completed = await this.client.hgetall(this.key("completed"));
|
|
1711
|
+
const allIds = [
|
|
1712
|
+
...waiting,
|
|
1713
|
+
...Object.keys(active),
|
|
1714
|
+
...Object.keys(failed),
|
|
1715
|
+
...Object.keys(completed)
|
|
1716
|
+
];
|
|
1717
|
+
if (allIds.length > 0) {
|
|
1718
|
+
await this.client.hdel(this.key("jobs"), ...allIds);
|
|
1719
|
+
}
|
|
1720
|
+
await this.client.del(this.key("waiting"));
|
|
1721
|
+
await this.client.del(this.key("active"));
|
|
1722
|
+
await this.client.del(this.key("failed"));
|
|
1723
|
+
await this.client.del(this.key("completed"));
|
|
1724
|
+
await this.client.del(this.key("progress"));
|
|
1725
|
+
}
|
|
1726
|
+
async close() {
|
|
1727
|
+
this.unsubscribe();
|
|
1728
|
+
this.client.close();
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
export {
|
|
1732
|
+
silentLogger,
|
|
1733
|
+
consoleLogger,
|
|
1734
|
+
RedisBackend,
|
|
1735
|
+
Queue,
|
|
1736
|
+
MemoryBackend,
|
|
1737
|
+
JobHandle,
|
|
1738
|
+
BroadcastBackend
|
|
1739
|
+
};
|