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/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
+ };