@workglow/job-queue 0.0.57 → 0.0.59

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/browser.js CHANGED
@@ -1,11 +1,6 @@
1
- // src/job/IJobQueue.ts
1
+ // src/job/Job.ts
2
2
  import { JobStatus } from "@workglow/storage";
3
- var QueueMode;
4
- ((QueueMode2) => {
5
- QueueMode2["CLIENT"] = "CLIENT";
6
- QueueMode2["SERVER"] = "SERVER";
7
- QueueMode2["BOTH"] = "BOTH";
8
- })(QueueMode ||= {});
3
+
9
4
  // src/job/JobError.ts
10
5
  import { BaseError } from "@workglow/util";
11
6
 
@@ -75,7 +70,6 @@ class Job {
75
70
  progress = 0;
76
71
  progressMessage = "";
77
72
  progressDetails = null;
78
- queue;
79
73
  constructor({
80
74
  queueName,
81
75
  input,
@@ -128,7 +122,6 @@ class Job {
128
122
  for (const listener of this.progressListeners) {
129
123
  listener(progress, message, details);
130
124
  }
131
- await this.queue?.updateProgress(this.id, progress, message, details);
132
125
  }
133
126
  onJobProgress(listener) {
134
127
  this.progressListeners.add(listener);
@@ -137,78 +130,81 @@ class Job {
137
130
  };
138
131
  }
139
132
  }
140
- // src/job/JobQueue.ts
141
- import {
142
- InMemoryQueueStorage,
143
- QUEUE_STORAGE
144
- } from "@workglow/storage";
145
- import { EventEmitter, globalServiceRegistry, sleep } from "@workglow/util";
146
-
147
- // src/limiter/ILimiter.ts
148
- import { createServiceToken } from "@workglow/util";
149
- var JOB_LIMITER = createServiceToken("jobqueue.limiter");
150
-
151
- // src/limiter/NullLimiter.ts
152
- import { createServiceToken as createServiceToken2 } from "@workglow/util";
153
- var NULL_JOB_LIMITER = createServiceToken2("jobqueue.limiter.null");
154
-
155
- class NullLimiter {
156
- async canProceed() {
157
- return true;
133
+ // src/job/JobQueueClient.ts
134
+ import { JobStatus as JobStatus2 } from "@workglow/storage";
135
+ import { EventEmitter } from "@workglow/util";
136
+ class JobQueueClient {
137
+ queueName;
138
+ storage;
139
+ events = new EventEmitter;
140
+ server = null;
141
+ storageUnsubscribe = null;
142
+ activeJobPromises = new Map;
143
+ jobProgressListeners = new Map;
144
+ lastKnownProgress = new Map;
145
+ constructor(options) {
146
+ this.queueName = options.queueName;
147
+ this.storage = options.storage;
158
148
  }
159
- async recordJobStart() {}
160
- async recordJobCompletion() {}
161
- async getNextAvailableTime() {
162
- return new Date;
149
+ attach(server) {
150
+ if (this.server) {
151
+ this.detach();
152
+ }
153
+ this.server = server;
154
+ server.addClient(this);
155
+ if (this.storageUnsubscribe) {
156
+ this.storageUnsubscribe();
157
+ this.storageUnsubscribe = null;
158
+ }
163
159
  }
164
- async setNextAvailableTime(date) {}
165
- async clear() {}
166
- }
167
-
168
- // src/job/JobQueue.ts
169
- class JobQueue {
170
- queueName;
171
- jobClass;
172
- options;
173
- constructor(queueName, jobClass, options) {
174
- this.queueName = queueName;
175
- this.jobClass = jobClass;
176
- const { limiter, storage, ...rest } = options;
177
- this.options = {
178
- waitDurationInMilliseconds: 100,
179
- ...rest
180
- };
181
- if (limiter) {
182
- this.limiter = limiter;
183
- } else {
184
- try {
185
- this.limiter = globalServiceRegistry.get(JOB_LIMITER);
186
- } catch (err) {
187
- console.warn("Warning: did not find job limiter in global DI", err);
188
- this.limiter = new NullLimiter;
189
- }
160
+ detach() {
161
+ if (this.server) {
162
+ this.server.removeClient(this);
163
+ this.server = null;
190
164
  }
191
- if (storage) {
192
- this.storage = storage;
193
- } else {
194
- try {
195
- this.storage = globalServiceRegistry.get(QUEUE_STORAGE);
196
- } catch (err) {
197
- console.warn("Warning: did not find queue storage in global DI", err);
198
- this.storage = new InMemoryQueueStorage(queueName);
199
- }
165
+ }
166
+ connect() {
167
+ if (this.server) {
168
+ return;
200
169
  }
201
- this.stats = {
202
- totalJobs: 0,
203
- completedJobs: 0,
204
- failedJobs: 0,
205
- abortedJobs: 0,
206
- retriedJobs: 0,
207
- disabledJobs: 0,
208
- lastUpdateTime: new Date
170
+ if (this.storageUnsubscribe) {
171
+ return;
172
+ }
173
+ this.storageUnsubscribe = this.storage.subscribeToChanges((change) => {
174
+ this.handleStorageChange(change);
175
+ });
176
+ }
177
+ disconnect() {
178
+ if (this.storageUnsubscribe) {
179
+ this.storageUnsubscribe();
180
+ this.storageUnsubscribe = null;
181
+ }
182
+ this.detach();
183
+ }
184
+ async submit(input, options) {
185
+ const job = {
186
+ queue: this.queueName,
187
+ input,
188
+ job_run_id: options?.jobRunId,
189
+ fingerprint: options?.fingerprint,
190
+ max_retries: options?.maxRetries ?? 10,
191
+ run_after: options?.runAfter?.toISOString() ?? new Date().toISOString(),
192
+ deadline_at: options?.deadlineAt?.toISOString() ?? null,
193
+ completed_at: null,
194
+ status: JobStatus2.PENDING
209
195
  };
196
+ const id = await this.storage.add(job);
197
+ return this.createJobHandle(id);
198
+ }
199
+ async submitBatch(inputs, options) {
200
+ const handles = [];
201
+ for (const input of inputs) {
202
+ const handle = await this.submit(input, options);
203
+ handles.push(handle);
204
+ }
205
+ return handles;
210
206
  }
211
- async get(id) {
207
+ async getJob(id) {
212
208
  if (!id)
213
209
  throw new JobNotFoundError("Cannot get undefined job");
214
210
  const job = await this.storage.get(id);
@@ -216,15 +212,11 @@ class JobQueue {
216
212
  return;
217
213
  return this.storageToClass(job);
218
214
  }
219
- async add(job) {
220
- const jobId = await this.storage.add(this.classToStorage(job));
221
- return jobId;
222
- }
223
- async next() {
224
- const job = await this.storage.next();
225
- if (!job)
226
- return;
227
- return this.storageToClass(job);
215
+ async getJobsByRunId(runId) {
216
+ if (!runId)
217
+ throw new JobNotFoundError("Cannot get jobs by undefined runId");
218
+ const jobs = await this.storage.getByRunId(runId);
219
+ return jobs.map((job) => this.storageToClass(job));
228
220
  }
229
221
  async peek(status, num) {
230
222
  const jobs = await this.storage.peek(status, num);
@@ -233,86 +225,48 @@ class JobQueue {
233
225
  async size(status) {
234
226
  return this.storage.size(status);
235
227
  }
236
- async delete(id) {
237
- if (!id)
238
- throw new JobNotFoundError("Cannot delete undefined job");
239
- await this.storage.delete(id);
240
- }
241
- async disable(id) {
242
- if (!id)
243
- throw new JobNotFoundError("Cannot disable undefined job");
244
- const job = await this.get(id);
245
- if (!job)
246
- throw new JobNotFoundError(`Job ${id} not found`);
247
- await this.disableJob(job);
248
- }
249
- getStats() {
250
- return { ...this.stats };
251
- }
252
228
  async outputForInput(input) {
253
229
  if (!input)
254
230
  throw new JobNotFoundError("Cannot get output for undefined input");
255
231
  return this.storage.outputForInput(input);
256
232
  }
257
- async executeJob(job, signal) {
258
- if (!job)
259
- throw new JobNotFoundError("Cannot execute null or undefined job");
260
- return await job.execute(job.input, {
261
- signal,
262
- updateProgress: this.updateProgress.bind(this, job.id)
263
- });
264
- }
265
233
  async waitFor(jobId) {
266
234
  if (!jobId)
267
235
  throw new JobNotFoundError("Cannot wait for undefined job");
268
- const { promise, resolve, reject } = Promise.withResolvers();
269
- promise.catch(() => {});
270
- const promises = this.activeJobPromises.get(jobId) || [];
271
- promises.push({ resolve, reject });
272
- this.activeJobPromises.set(jobId, promises);
273
- const job = await this.get(jobId);
236
+ const job = await this.getJob(jobId);
274
237
  if (!job)
275
238
  throw new JobNotFoundError(`Job ${jobId} not found`);
276
- if (job.status === JobStatus.COMPLETED) {
239
+ if (job.status === JobStatus2.COMPLETED) {
277
240
  return job.output;
278
241
  }
279
- if (job.status === JobStatus.DISABLED) {
280
- return;
242
+ if (job.status === JobStatus2.DISABLED) {
243
+ throw new JobDisabledError(`Job ${jobId} was disabled`);
281
244
  }
282
- if (job.status === JobStatus.FAILED) {
245
+ if (job.status === JobStatus2.FAILED) {
283
246
  throw this.buildErrorFromJob(job);
284
247
  }
248
+ const { promise, resolve, reject } = Promise.withResolvers();
249
+ promise.catch(() => {});
250
+ const promises = this.activeJobPromises.get(jobId) || [];
251
+ promises.push({ resolve, reject });
252
+ this.activeJobPromises.set(jobId, promises);
285
253
  return promise;
286
254
  }
287
- buildErrorFromJob(job) {
288
- const errorMessage = job.error || "Job failed";
289
- if (job.errorCode === "PermanentJobError") {
290
- return new PermanentJobError(errorMessage);
291
- }
292
- if (job.errorCode === "RetryableJobError") {
293
- return new RetryableJobError(errorMessage);
294
- }
295
- if (job.errorCode === "AbortSignalJobError") {
296
- return new AbortSignalJobError(errorMessage);
297
- }
298
- if (job.errorCode === "JobDisabledError") {
299
- return new JobDisabledError(errorMessage);
300
- }
301
- return new JobError(errorMessage);
255
+ async abort(jobId) {
256
+ if (!jobId)
257
+ throw new JobNotFoundError("Cannot abort undefined job");
258
+ await this.storage.abort(jobId);
259
+ this.events.emit("job_aborting", this.queueName, jobId);
302
260
  }
303
- async updateProgress(jobId, progress, message = "", details = null) {
304
- const job = await this.get(jobId);
305
- if (!job)
306
- throw new JobNotFoundError(`Job ${jobId} not found`);
307
- if ([JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.ABORTING, JobStatus.DISABLED].includes(job.status)) {
308
- return;
309
- }
310
- progress = Math.max(0, Math.min(100, progress));
311
- job.progress = progress;
312
- job.progressMessage = message;
313
- job.progressDetails = details;
314
- await this.saveProgress(jobId, progress, message, details ?? null);
315
- this.announceProgress(jobId, progress, message, details ?? null);
261
+ async abortJobRun(jobRunId) {
262
+ if (!jobRunId)
263
+ throw new JobNotFoundError("Cannot abort job run with undefined jobRunId");
264
+ const jobs = await this.getJobsByRunId(jobRunId);
265
+ await Promise.allSettled(jobs.map((job) => {
266
+ if ([JobStatus2.PROCESSING, JobStatus2.PENDING].includes(job.status)) {
267
+ return this.abort(job.id);
268
+ }
269
+ }));
316
270
  }
317
271
  onJobProgress(jobId, listener) {
318
272
  if (!this.jobProgressListeners.has(jobId)) {
@@ -330,77 +284,246 @@ class JobQueue {
330
284
  }
331
285
  };
332
286
  }
333
- async start(mode = "BOTH" /* BOTH */) {
287
+ on(event, listener) {
288
+ this.events.on(event, listener);
289
+ }
290
+ off(event, listener) {
291
+ this.events.off(event, listener);
292
+ }
293
+ once(event, listener) {
294
+ this.events.once(event, listener);
295
+ }
296
+ waitOn(event) {
297
+ return this.events.waitOn(event);
298
+ }
299
+ handleJobStart(jobId) {
300
+ this.lastKnownProgress.set(jobId, {
301
+ progress: 0,
302
+ message: "",
303
+ details: null
304
+ });
305
+ this.events.emit("job_start", this.queueName, jobId);
306
+ }
307
+ handleJobComplete(jobId, output) {
308
+ this.events.emit("job_complete", this.queueName, jobId, output);
309
+ const promises = this.activeJobPromises.get(jobId);
310
+ if (promises) {
311
+ promises.forEach(({ resolve }) => resolve(output));
312
+ }
313
+ this.cleanupJob(jobId);
314
+ }
315
+ handleJobError(jobId, error, errorCode) {
316
+ this.events.emit("job_error", this.queueName, jobId, error);
317
+ const promises = this.activeJobPromises.get(jobId);
318
+ if (promises) {
319
+ const jobError = this.buildErrorFromCode(error, errorCode);
320
+ promises.forEach(({ reject }) => reject(jobError));
321
+ }
322
+ this.cleanupJob(jobId);
323
+ }
324
+ handleJobDisabled(jobId) {
325
+ this.events.emit("job_disabled", this.queueName, jobId);
326
+ const promises = this.activeJobPromises.get(jobId);
327
+ if (promises) {
328
+ promises.forEach(({ reject }) => reject(new JobDisabledError("Job was disabled")));
329
+ }
330
+ this.cleanupJob(jobId);
331
+ }
332
+ handleJobRetry(jobId, runAfter) {
333
+ this.events.emit("job_retry", this.queueName, jobId, runAfter);
334
+ }
335
+ handleJobProgress(jobId, progress, message, details) {
336
+ this.lastKnownProgress.set(jobId, { progress, message, details });
337
+ this.events.emit("job_progress", this.queueName, jobId, progress, message, details);
338
+ const listeners = this.jobProgressListeners.get(jobId);
339
+ if (listeners) {
340
+ for (const listener of listeners) {
341
+ listener(progress, message, details);
342
+ }
343
+ }
344
+ }
345
+ createJobHandle(id) {
346
+ return {
347
+ id,
348
+ waitFor: () => this.waitFor(id),
349
+ abort: () => this.abort(id),
350
+ onProgress: (callback) => this.onJobProgress(id, callback)
351
+ };
352
+ }
353
+ cleanupJob(jobId) {
354
+ this.activeJobPromises.delete(jobId);
355
+ this.lastKnownProgress.delete(jobId);
356
+ this.jobProgressListeners.delete(jobId);
357
+ }
358
+ handleStorageChange(change) {
359
+ if (!change.new && !change.old)
360
+ return;
361
+ const jobId = change.new?.id ?? change.old?.id;
362
+ if (!jobId)
363
+ return;
364
+ const queueName = change.new?.queue ?? change.old?.queue;
365
+ if (queueName !== this.queueName)
366
+ return;
367
+ if (change.type === "UPDATE" && change.new) {
368
+ const newStatus = change.new.status;
369
+ const oldStatus = change.old?.status;
370
+ if (newStatus === JobStatus2.PROCESSING && oldStatus === JobStatus2.PENDING) {
371
+ this.handleJobStart(jobId);
372
+ } else if (newStatus === JobStatus2.COMPLETED) {
373
+ this.handleJobComplete(jobId, change.new.output);
374
+ } else if (newStatus === JobStatus2.FAILED) {
375
+ this.handleJobError(jobId, change.new.error ?? "Job failed", change.new.error_code ?? undefined);
376
+ } else if (newStatus === JobStatus2.DISABLED) {
377
+ this.handleJobDisabled(jobId);
378
+ } else if (newStatus === JobStatus2.PENDING && oldStatus === JobStatus2.PROCESSING) {
379
+ const runAfter = change.new.run_after ? new Date(change.new.run_after) : new Date;
380
+ this.handleJobRetry(jobId, runAfter);
381
+ }
382
+ if (change.new.progress !== change.old?.progress || change.new.progress_message !== change.old?.progress_message) {
383
+ this.handleJobProgress(jobId, change.new.progress ?? 0, change.new.progress_message ?? "", change.new.progress_details ?? null);
384
+ }
385
+ }
386
+ }
387
+ storageToClass(details) {
388
+ const toDate = (date) => {
389
+ if (!date)
390
+ return null;
391
+ const d = new Date(date);
392
+ return isNaN(d.getTime()) ? null : d;
393
+ };
394
+ return new Job({
395
+ id: details.id,
396
+ jobRunId: details.job_run_id,
397
+ queueName: details.queue,
398
+ fingerprint: details.fingerprint,
399
+ input: details.input,
400
+ output: details.output,
401
+ runAfter: toDate(details.run_after),
402
+ createdAt: toDate(details.created_at),
403
+ deadlineAt: toDate(details.deadline_at),
404
+ lastRanAt: toDate(details.last_ran_at),
405
+ completedAt: toDate(details.completed_at),
406
+ progress: details.progress || 0,
407
+ progressMessage: details.progress_message || "",
408
+ progressDetails: details.progress_details ?? null,
409
+ status: details.status,
410
+ error: details.error ?? null,
411
+ errorCode: details.error_code ?? null,
412
+ runAttempts: details.run_attempts ?? 0,
413
+ maxRetries: details.max_retries ?? 10
414
+ });
415
+ }
416
+ buildErrorFromJob(job) {
417
+ return this.buildErrorFromCode(job.error || "Job failed", job.errorCode ?? undefined);
418
+ }
419
+ buildErrorFromCode(message, errorCode) {
420
+ if (errorCode === "PermanentJobError") {
421
+ return new PermanentJobError(message);
422
+ }
423
+ if (errorCode === "RetryableJobError") {
424
+ return new RetryableJobError(message);
425
+ }
426
+ if (errorCode === "AbortSignalJobError") {
427
+ return new AbortSignalJobError(message);
428
+ }
429
+ if (errorCode === "JobDisabledError") {
430
+ return new JobDisabledError(message);
431
+ }
432
+ return new JobError(message);
433
+ }
434
+ }
435
+ // src/job/JobQueueServer.ts
436
+ import { JobStatus as JobStatus4 } from "@workglow/storage";
437
+ import { EventEmitter as EventEmitter3 } from "@workglow/util";
438
+
439
+ // src/limiter/NullLimiter.ts
440
+ import { createServiceToken } from "@workglow/util";
441
+ var NULL_JOB_LIMITER = createServiceToken("jobqueue.limiter.null");
442
+
443
+ class NullLimiter {
444
+ async canProceed() {
445
+ return true;
446
+ }
447
+ async recordJobStart() {}
448
+ async recordJobCompletion() {}
449
+ async getNextAvailableTime() {
450
+ return new Date;
451
+ }
452
+ async setNextAvailableTime(date) {}
453
+ async clear() {}
454
+ }
455
+
456
+ // src/job/JobQueueWorker.ts
457
+ import { JobStatus as JobStatus3 } from "@workglow/storage";
458
+ import { EventEmitter as EventEmitter2, sleep, uuid4 } from "@workglow/util";
459
+ class JobQueueWorker {
460
+ queueName;
461
+ workerId;
462
+ storage;
463
+ jobClass;
464
+ limiter;
465
+ pollIntervalMs;
466
+ events = new EventEmitter2;
467
+ running = false;
468
+ activeJobAbortControllers = new Map;
469
+ processingTimes = new Map;
470
+ constructor(jobClass, options) {
471
+ this.queueName = options.queueName;
472
+ this.workerId = uuid4();
473
+ this.storage = options.storage;
474
+ this.jobClass = jobClass;
475
+ this.limiter = options.limiter ?? new NullLimiter;
476
+ this.pollIntervalMs = options.pollIntervalMs ?? 100;
477
+ }
478
+ async start() {
334
479
  if (this.running) {
335
480
  return this;
336
481
  }
337
- this.mode = mode;
338
482
  this.running = true;
339
- this.events.emit("queue_start", this.queueName);
340
- if (this.mode === "SERVER" /* SERVER */ || this.mode === "BOTH" /* BOTH */) {
341
- await this.fixupJobs();
342
- await this.processJobs();
343
- }
344
- if (this.mode === "CLIENT" /* CLIENT */ || this.mode === "BOTH" /* BOTH */) {
345
- await this.monitorJobs();
346
- }
483
+ this.events.emit("worker_start");
484
+ this.processJobs();
347
485
  return this;
348
486
  }
349
487
  async stop() {
350
- if (!this.running)
488
+ if (!this.running) {
351
489
  return this;
490
+ }
352
491
  this.running = false;
353
- const size = await this.size(JobStatus.PROCESSING);
492
+ const size = await this.storage.size(JobStatus3.PROCESSING);
354
493
  const sleepTime = Math.max(100, size * 2);
355
494
  await sleep(sleepTime);
356
- for (const [jobId] of this.activeJobAbortControllers.entries()) {
357
- this.abort(jobId);
495
+ for (const controller of this.activeJobAbortControllers.values()) {
496
+ if (!controller.signal.aborted) {
497
+ controller.abort();
498
+ }
358
499
  }
359
- this.activeJobPromises.forEach((promises) => promises.forEach(({ reject }) => reject(new PermanentJobError("Queue Stopped"))));
360
500
  await sleep(sleepTime);
361
- this.events.emit("queue_stop", this.queueName);
501
+ this.events.emit("worker_stop");
362
502
  return this;
363
503
  }
364
- async clear() {
365
- await this.storage.deleteAll();
366
- this.activeJobAbortControllers.clear();
367
- this.activeJobPromises.clear();
368
- this.processingTimes.clear();
369
- this.lastKnownProgress.clear();
370
- this.jobProgressListeners.clear();
371
- this.stats = {
372
- totalJobs: 0,
373
- completedJobs: 0,
374
- failedJobs: 0,
375
- abortedJobs: 0,
376
- retriedJobs: 0,
377
- disabledJobs: 0,
378
- lastUpdateTime: new Date
379
- };
380
- this.emitStatsUpdate();
381
- return this;
504
+ async processNext() {
505
+ const canProceed = await this.limiter.canProceed();
506
+ if (!canProceed) {
507
+ return false;
508
+ }
509
+ const job = await this.next();
510
+ if (!job) {
511
+ return false;
512
+ }
513
+ await this.processSingleJob(job);
514
+ return true;
382
515
  }
383
- async restart() {
384
- await this.stop();
385
- await this.clear();
386
- await this.start();
387
- return this;
516
+ isRunning() {
517
+ return this.running;
388
518
  }
389
- async abortJobRun(jobRunId) {
390
- if (!jobRunId)
391
- throw new JobNotFoundError("Cannot abort job run with undefined jobRunId");
392
- const jobs = await this.getJobsByRunId(jobRunId);
393
- await Promise.allSettled(jobs.map((job) => {
394
- if ([JobStatus.PROCESSING, JobStatus.PENDING].includes(job.status)) {
395
- this.abort(job.id);
396
- }
397
- }));
519
+ getActiveJobCount() {
520
+ return this.activeJobAbortControllers.size;
398
521
  }
399
- async getJobsByRunId(runId) {
400
- if (!runId)
401
- throw new JobNotFoundError("Cannot get jobs by undefined runId");
402
- const jobs = await this.storage.getByRunId(runId);
403
- return jobs.map((job) => this.storageToClass(job));
522
+ getAverageProcessingTime() {
523
+ const times = Array.from(this.processingTimes.values());
524
+ if (times.length === 0)
525
+ return;
526
+ return times.reduce((a, b) => a + b, 0) / times.length;
404
527
  }
405
528
  on(event, listener) {
406
529
  this.events.on(event, listener);
@@ -408,35 +531,144 @@ class JobQueue {
408
531
  off(event, listener) {
409
532
  this.events.off(event, listener);
410
533
  }
411
- once(event, listener) {
412
- this.events.once(event, listener);
534
+ async next() {
535
+ const job = await this.storage.next(this.workerId);
536
+ if (!job)
537
+ return;
538
+ return this.storageToClass(job);
413
539
  }
414
- waitOn(event) {
415
- return this.events.waitOn(event);
540
+ async processJobs() {
541
+ if (!this.running) {
542
+ return;
543
+ }
544
+ try {
545
+ await this.checkForAbortingJobs();
546
+ const canProceed = await this.limiter.canProceed();
547
+ if (canProceed) {
548
+ const job = await this.next();
549
+ if (job) {
550
+ this.processSingleJob(job);
551
+ }
552
+ }
553
+ } finally {
554
+ if (this.running) {
555
+ setTimeout(() => this.processJobs(), this.pollIntervalMs);
556
+ }
557
+ }
416
558
  }
417
- limiter;
418
- storage;
419
- running = false;
420
- stats = {
421
- totalJobs: 0,
422
- completedJobs: 0,
423
- failedJobs: 0,
424
- abortedJobs: 0,
425
- retriedJobs: 0,
426
- disabledJobs: 0,
427
- lastUpdateTime: new Date
428
- };
429
- events = new EventEmitter;
430
- activeJobAbortControllers = new Map;
431
- activeJobPromises = new Map;
432
- processingTimes = new Map;
433
- mode = "BOTH" /* BOTH */;
434
- jobProgressListeners = new Map;
435
- lastKnownProgress = new Map;
436
- async saveProgress(id, progress, message, details) {
437
- if (!id)
438
- throw new JobNotFoundError("Cannot save progress for undefined job");
439
- await this.storage.saveProgress(id, progress, message, details);
559
+ async checkForAbortingJobs() {
560
+ const abortingJobs = await this.storage.peek(JobStatus3.ABORTING);
561
+ for (const jobData of abortingJobs) {
562
+ const controller = this.activeJobAbortControllers.get(jobData.id);
563
+ if (controller && !controller.signal.aborted) {
564
+ controller.abort();
565
+ }
566
+ }
567
+ }
568
+ async processSingleJob(job) {
569
+ if (!job || !job.id) {
570
+ throw new JobNotFoundError("Invalid job provided for processing");
571
+ }
572
+ const startTime = Date.now();
573
+ try {
574
+ await this.validateJobState(job);
575
+ await this.limiter.recordJobStart();
576
+ const abortController = this.createAbortController(job.id);
577
+ this.events.emit("job_start", job.id);
578
+ const output = await this.executeJob(job, abortController.signal);
579
+ await this.completeJob(job, output);
580
+ this.processingTimes.set(job.id, Date.now() - startTime);
581
+ } catch (err) {
582
+ const error = this.normalizeError(err);
583
+ if (error instanceof RetryableJobError) {
584
+ if (job.runAttempts > job.maxRetries) {
585
+ await this.failJob(job, error);
586
+ } else {
587
+ await this.rescheduleJob(job, error.retryDate);
588
+ }
589
+ } else {
590
+ await this.failJob(job, error);
591
+ }
592
+ } finally {
593
+ await this.limiter.recordJobCompletion();
594
+ }
595
+ }
596
+ async executeJob(job, signal) {
597
+ if (!job)
598
+ throw new JobNotFoundError("Cannot execute null or undefined job");
599
+ return await job.execute(job.input, {
600
+ signal,
601
+ updateProgress: this.updateProgress.bind(this, job.id)
602
+ });
603
+ }
604
+ async updateProgress(jobId, progress, message = "", details = null) {
605
+ progress = Math.max(0, Math.min(100, progress));
606
+ await this.storage.saveProgress(jobId, progress, message, details);
607
+ this.events.emit("job_progress", jobId, progress, message, details);
608
+ }
609
+ async completeJob(job, output) {
610
+ try {
611
+ job.status = JobStatus3.COMPLETED;
612
+ job.progress = 100;
613
+ job.progressMessage = "";
614
+ job.progressDetails = null;
615
+ job.completedAt = new Date;
616
+ job.output = output ?? null;
617
+ job.error = null;
618
+ job.errorCode = null;
619
+ await this.storage.complete(this.classToStorage(job));
620
+ this.events.emit("job_complete", job.id, output);
621
+ } catch (err) {
622
+ console.error("completeJob errored:", err);
623
+ } finally {
624
+ this.cleanupJob(job.id);
625
+ }
626
+ }
627
+ async failJob(job, error) {
628
+ try {
629
+ job.status = JobStatus3.FAILED;
630
+ job.progress = 100;
631
+ job.completedAt = new Date;
632
+ job.progressMessage = "";
633
+ job.progressDetails = null;
634
+ job.error = error.message;
635
+ job.errorCode = error?.constructor?.name ?? null;
636
+ await this.storage.complete(this.classToStorage(job));
637
+ this.events.emit("job_error", job.id, error.message, error.constructor.name);
638
+ } catch (err) {
639
+ console.error("failJob errored:", err);
640
+ } finally {
641
+ this.cleanupJob(job.id);
642
+ }
643
+ }
644
+ async disableJob(job) {
645
+ try {
646
+ job.status = JobStatus3.DISABLED;
647
+ job.progress = 100;
648
+ job.completedAt = new Date;
649
+ job.progressMessage = "";
650
+ job.progressDetails = null;
651
+ await this.storage.complete(this.classToStorage(job));
652
+ this.events.emit("job_disabled", job.id);
653
+ } catch (err) {
654
+ console.error("disableJob errored:", err);
655
+ } finally {
656
+ this.cleanupJob(job.id);
657
+ }
658
+ }
659
+ async rescheduleJob(job, retryDate) {
660
+ try {
661
+ job.status = JobStatus3.PENDING;
662
+ const nextAvailableTime = await this.limiter.getNextAvailableTime();
663
+ job.runAfter = retryDate instanceof Date ? retryDate : nextAvailableTime;
664
+ job.progress = 0;
665
+ job.progressMessage = "";
666
+ job.progressDetails = null;
667
+ await this.storage.complete(this.classToStorage(job));
668
+ this.events.emit("job_retry", job.id, job.runAfter);
669
+ } catch (err) {
670
+ console.error("rescheduleJob errored:", err);
671
+ }
440
672
  }
441
673
  createAbortController(jobId) {
442
674
  if (!jobId)
@@ -449,20 +681,35 @@ class JobQueue {
449
681
  this.activeJobAbortControllers.set(jobId, abortController);
450
682
  return abortController;
451
683
  }
684
+ async handleAbort(jobId) {
685
+ const job = await this.getJob(jobId);
686
+ if (!job) {
687
+ console.error("handleAbort: job not found", jobId);
688
+ return;
689
+ }
690
+ const error = new AbortSignalJobError("Job Aborted");
691
+ await this.failJob(job, error);
692
+ }
693
+ async getJob(id) {
694
+ const job = await this.storage.get(id);
695
+ if (!job)
696
+ return;
697
+ return this.storageToClass(job);
698
+ }
452
699
  async validateJobState(job) {
453
- if (job.status === JobStatus.COMPLETED) {
700
+ if (job.status === JobStatus3.COMPLETED) {
454
701
  throw new PermanentJobError(`Job ${job.id} is already completed`);
455
702
  }
456
- if (job.status === JobStatus.FAILED) {
703
+ if (job.status === JobStatus3.FAILED) {
457
704
  throw new PermanentJobError(`Job ${job.id} has failed`);
458
705
  }
459
- if (job.status === JobStatus.ABORTING || this.activeJobAbortControllers.get(job.id)?.signal.aborted) {
706
+ if (job.status === JobStatus3.ABORTING || this.activeJobAbortControllers.get(job.id)?.signal.aborted) {
460
707
  throw new AbortSignalJobError(`Job ${job.id} is being aborted`);
461
708
  }
462
709
  if (job.deadlineAt && job.deadlineAt < new Date) {
463
710
  throw new PermanentJobError(`Job ${job.id} has exceeded its deadline`);
464
711
  }
465
- if (job.status === JobStatus.DISABLED) {
712
+ if (job.status === JobStatus3.DISABLED) {
466
713
  throw new JobDisabledError(`Job ${job.id} has been disabled`);
467
714
  }
468
715
  }
@@ -470,34 +717,13 @@ class JobQueue {
470
717
  if (err instanceof JobError) {
471
718
  return err;
472
719
  }
473
- if (err instanceof Error) {
474
- return err;
475
- }
476
- return new PermanentJobError(String(err));
477
- }
478
- updateAverageProcessingTime() {
479
- const times = Array.from(this.processingTimes.values());
480
- if (times.length > 0) {
481
- this.stats.averageProcessingTime = times.reduce((a, b) => a + b, 0) / times.length;
482
- }
483
- }
484
- emitStatsUpdate() {
485
- this.stats.lastUpdateTime = new Date;
486
- this.events.emit("queue_stats_update", this.queueName, { ...this.stats });
487
- }
488
- announceProgress(jobId, progress, message, details) {
489
- this.lastKnownProgress.set(jobId, {
490
- progress,
491
- message,
492
- details
493
- });
494
- this.events.emit("job_progress", this.queueName, jobId, progress, message, details);
495
- const listeners = this.jobProgressListeners.get(jobId);
496
- if (listeners) {
497
- for (const listener of listeners) {
498
- listener(progress, message, details);
499
- }
500
- }
720
+ if (err instanceof Error) {
721
+ return new PermanentJobError(err.message);
722
+ }
723
+ return new PermanentJobError(String(err));
724
+ }
725
+ cleanupJob(jobId) {
726
+ this.activeJobAbortControllers.delete(jobId);
501
727
  }
502
728
  storageToClass(details) {
503
729
  const toDate = (date) => {
@@ -506,7 +732,7 @@ class JobQueue {
506
732
  const d = new Date(date);
507
733
  return isNaN(d.getTime()) ? null : d;
508
734
  };
509
- const job = new this.jobClass({
735
+ return new this.jobClass({
510
736
  id: details.id,
511
737
  jobRunId: details.job_run_id,
512
738
  queueName: details.queue,
@@ -527,8 +753,6 @@ class JobQueue {
527
753
  runAttempts: details.run_attempts ?? 0,
528
754
  maxRetries: details.max_retries ?? 10
529
755
  });
530
- job.queue = this;
531
- return job;
532
756
  }
533
757
  classToStorage(job) {
534
758
  const dateToISOString = (date) => {
@@ -559,233 +783,293 @@ class JobQueue {
559
783
  progress_details: job.progressDetails ?? null
560
784
  };
561
785
  }
562
- async rescheduleJob(job, retryDate) {
563
- try {
564
- job.status = JobStatus.PENDING;
565
- const nextAvailableTime = await this.limiter.getNextAvailableTime();
566
- job.runAfter = retryDate instanceof Date ? retryDate : nextAvailableTime;
567
- job.progress = 0;
568
- job.progressMessage = "";
569
- job.progressDetails = null;
570
- await this.storage.complete(this.classToStorage(job));
571
- this.stats.retriedJobs++;
572
- this.events.emit("job_retry", this.queueName, job.id, job.runAfter);
573
- } catch (err) {
574
- console.error("rescheduleJob", err);
786
+ }
787
+
788
+ // src/job/JobQueueServer.ts
789
+ class JobQueueServer {
790
+ queueName;
791
+ storage;
792
+ jobClass;
793
+ limiter;
794
+ workerCount;
795
+ pollIntervalMs;
796
+ deleteAfterCompletionMs;
797
+ deleteAfterFailureMs;
798
+ deleteAfterDisabledMs;
799
+ cleanupIntervalMs;
800
+ events = new EventEmitter3;
801
+ workers = [];
802
+ clients = new Set;
803
+ running = false;
804
+ cleanupTimer = null;
805
+ stats = {
806
+ totalJobs: 0,
807
+ completedJobs: 0,
808
+ failedJobs: 0,
809
+ abortedJobs: 0,
810
+ retriedJobs: 0,
811
+ disabledJobs: 0,
812
+ lastUpdateTime: new Date
813
+ };
814
+ constructor(jobClass, options) {
815
+ this.queueName = options.queueName;
816
+ this.storage = options.storage;
817
+ this.jobClass = jobClass;
818
+ this.limiter = options.limiter ?? new NullLimiter;
819
+ this.workerCount = options.workerCount ?? 1;
820
+ this.pollIntervalMs = options.pollIntervalMs ?? 100;
821
+ this.deleteAfterCompletionMs = options.deleteAfterCompletionMs;
822
+ this.deleteAfterFailureMs = options.deleteAfterFailureMs;
823
+ this.deleteAfterDisabledMs = options.deleteAfterDisabledMs;
824
+ this.cleanupIntervalMs = options.cleanupIntervalMs ?? 1e4;
825
+ this.initializeWorkers();
826
+ }
827
+ async start() {
828
+ if (this.running) {
829
+ return this;
575
830
  }
831
+ this.running = true;
832
+ this.events.emit("server_start", this.queueName);
833
+ await this.fixupJobs();
834
+ await Promise.all(this.workers.map((worker) => worker.start()));
835
+ this.startCleanupLoop();
836
+ return this;
576
837
  }
577
- async disableJob(job) {
578
- try {
579
- job.status = JobStatus.DISABLED;
580
- job.progress = 100;
581
- job.completedAt = new Date;
582
- job.progressMessage = "";
583
- job.progressDetails = null;
584
- await this.storage.complete(this.classToStorage(job));
585
- if (this.options.deleteAfterDisabledMs === 0) {
586
- await this.delete(job.id);
587
- }
588
- this.stats.disabledJobs++;
589
- this.events.emit("job_disabled", this.queueName, job.id);
590
- const promises = this.activeJobPromises.get(job.id) || [];
591
- promises.forEach(({ resolve }) => resolve(undefined));
592
- this.activeJobPromises.delete(job.id);
593
- } catch (err) {
594
- console.error("disableJob", err);
838
+ async stop() {
839
+ if (!this.running) {
840
+ return this;
595
841
  }
596
- }
597
- async failJob(job, error) {
598
- try {
599
- job.status = JobStatus.FAILED;
600
- job.progress = 100;
601
- job.completedAt = new Date;
602
- job.progressMessage = "";
603
- job.progressDetails = null;
604
- job.error = error.message;
605
- job.errorCode = error?.constructor?.name ?? null;
606
- await this.storage.complete(this.classToStorage(job));
607
- if (this.options.deleteAfterFailureMs === 0) {
608
- await this.delete(job.id);
609
- }
610
- this.stats.failedJobs++;
611
- this.events.emit("job_error", this.queueName, job.id, `${error.name}: ${error.message}`);
612
- const promises = this.activeJobPromises.get(job.id) || [];
613
- promises.forEach(({ reject }) => reject(error));
614
- this.activeJobPromises.delete(job.id);
615
- } catch (err) {
616
- console.error("failJob errored out?", err);
842
+ this.running = false;
843
+ if (this.cleanupTimer) {
844
+ clearTimeout(this.cleanupTimer);
845
+ this.cleanupTimer = null;
617
846
  }
618
- this.activeJobAbortControllers.delete(job.id);
619
- this.lastKnownProgress.delete(job.id);
620
- this.jobProgressListeners.delete(job.id);
621
- this.activeJobPromises.delete(job.id);
847
+ await Promise.all(this.workers.map((worker) => worker.stop()));
848
+ this.events.emit("server_stop", this.queueName);
849
+ return this;
622
850
  }
623
- async completeJob(job, output) {
624
- try {
625
- job.status = JobStatus.COMPLETED;
626
- job.progress = 100;
627
- job.progressMessage = "";
628
- job.progressDetails = null;
629
- job.completedAt = new Date;
630
- job.output = output ?? null;
631
- job.error = null;
632
- job.errorCode = null;
633
- await this.storage.complete(this.classToStorage(job));
634
- if (job && this.options.deleteAfterCompletionMs === 0) {
635
- await this.delete(job.id);
636
- }
637
- this.stats.completedJobs++;
638
- this.events.emit("job_complete", this.queueName, job.id, output);
639
- const promises = this.activeJobPromises.get(job.id);
640
- if (promises) {
641
- promises.forEach(({ resolve }) => resolve(output));
642
- }
643
- this.activeJobPromises.delete(job.id);
644
- } catch (err) {
645
- console.error("completeJob errored out?", err);
646
- }
647
- this.activeJobAbortControllers.delete(job.id);
648
- this.lastKnownProgress.delete(job.id);
649
- this.jobProgressListeners.delete(job.id);
650
- this.activeJobPromises.delete(job.id);
851
+ getStats() {
852
+ return { ...this.stats };
651
853
  }
652
- async abort(jobId) {
653
- if (!jobId)
654
- throw new JobNotFoundError("Cannot abort undefined job");
655
- await this.storage.abort(jobId);
656
- let controller = this.activeJobAbortControllers.get(jobId);
657
- if (!controller) {
658
- controller = this.createAbortController(jobId);
854
+ getStorage() {
855
+ return this.storage;
856
+ }
857
+ async scaleWorkers(count) {
858
+ if (count < 1) {
859
+ throw new Error("Worker count must be at least 1");
659
860
  }
660
- if (!controller.signal.aborted) {
661
- controller.abort();
861
+ const currentCount = this.workers.length;
862
+ if (count > currentCount) {
863
+ for (let i = currentCount;i < count; i++) {
864
+ const worker = this.createWorker();
865
+ this.workers.push(worker);
866
+ if (this.running) {
867
+ await worker.start();
868
+ }
869
+ }
870
+ } else if (count < currentCount) {
871
+ const toRemove = this.workers.splice(count);
872
+ await Promise.all(toRemove.map((worker) => worker.stop()));
662
873
  }
663
- this.events.emit("job_aborting", this.queueName, jobId);
664
874
  }
665
- async handleAbort(jobId) {
666
- const promises = this.activeJobPromises.get(jobId);
667
- if (promises) {
668
- const job = await this.get(jobId);
669
- if (!job) {
670
- console.error("handleAbort: job not found", jobId);
671
- return;
672
- }
673
- const error = new AbortSignalJobError("Job Aborted");
674
- this.failJob(job, error);
875
+ isRunning() {
876
+ return this.running;
877
+ }
878
+ getWorkerCount() {
879
+ return this.workers.length;
880
+ }
881
+ addClient(client) {
882
+ this.clients.add(client);
883
+ }
884
+ removeClient(client) {
885
+ this.clients.delete(client);
886
+ }
887
+ on(event, listener) {
888
+ this.events.on(event, listener);
889
+ }
890
+ off(event, listener) {
891
+ this.events.off(event, listener);
892
+ }
893
+ initializeWorkers() {
894
+ for (let i = 0;i < this.workerCount; i++) {
895
+ const worker = this.createWorker();
896
+ this.workers.push(worker);
675
897
  }
676
- this.stats.abortedJobs++;
677
898
  }
678
- async processSingleJob(job) {
679
- if (!job || !job.id)
680
- throw new JobNotFoundError("Invalid job provided for processing");
681
- const startTime = Date.now();
682
- try {
683
- await this.validateJobState(job);
684
- await this.limiter.recordJobStart();
685
- this.emitStatsUpdate();
686
- const abortController = this.createAbortController(job.id);
687
- this.lastKnownProgress.set(job.id, {
688
- progress: 0,
689
- message: "",
690
- details: null
691
- });
692
- this.events.emit("job_start", this.queueName, job.id);
693
- const output = await this.executeJob(job, abortController.signal);
694
- await this.completeJob(job, output);
695
- this.processingTimes.set(job.id, Date.now() - startTime);
899
+ createWorker() {
900
+ const worker = new JobQueueWorker(this.jobClass, {
901
+ storage: this.storage,
902
+ queueName: this.queueName,
903
+ limiter: this.limiter,
904
+ pollIntervalMs: this.pollIntervalMs
905
+ });
906
+ worker.on("job_start", (jobId) => {
907
+ this.stats = { ...this.stats, totalJobs: this.stats.totalJobs + 1 };
908
+ this.events.emit("job_start", this.queueName, jobId);
909
+ this.forwardToClients("handleJobStart", jobId);
910
+ });
911
+ worker.on("job_complete", async (jobId, output) => {
912
+ this.stats = { ...this.stats, completedJobs: this.stats.completedJobs + 1 };
696
913
  this.updateAverageProcessingTime();
697
- } catch (err) {
698
- const error = this.normalizeError(err);
699
- if (error instanceof RetryableJobError) {
700
- if (job.runAttempts > job.maxRetries) {
701
- await this.failJob(job, error);
702
- } else {
703
- await this.rescheduleJob(job, error.retryDate);
704
- }
705
- } else {
706
- await this.failJob(job, error);
914
+ this.events.emit("job_complete", this.queueName, jobId, output);
915
+ this.forwardToClients("handleJobComplete", jobId, output);
916
+ if (this.deleteAfterCompletionMs === 0) {
917
+ await this.storage.delete(jobId);
707
918
  }
708
- } finally {
709
- await this.limiter.recordJobCompletion();
710
- this.emitStatsUpdate();
711
- }
919
+ });
920
+ worker.on("job_error", async (jobId, error, errorCode) => {
921
+ this.stats = { ...this.stats, failedJobs: this.stats.failedJobs + 1 };
922
+ this.events.emit("job_error", this.queueName, jobId, error);
923
+ this.forwardToClients("handleJobError", jobId, error, errorCode);
924
+ if (this.deleteAfterFailureMs === 0) {
925
+ await this.storage.delete(jobId);
926
+ }
927
+ });
928
+ worker.on("job_disabled", async (jobId) => {
929
+ this.stats = { ...this.stats, disabledJobs: this.stats.disabledJobs + 1 };
930
+ this.events.emit("job_disabled", this.queueName, jobId);
931
+ this.forwardToClients("handleJobDisabled", jobId);
932
+ if (this.deleteAfterDisabledMs === 0) {
933
+ await this.storage.delete(jobId);
934
+ }
935
+ });
936
+ worker.on("job_retry", (jobId, runAfter) => {
937
+ this.stats = { ...this.stats, retriedJobs: this.stats.retriedJobs + 1 };
938
+ this.events.emit("job_retry", this.queueName, jobId, runAfter);
939
+ this.forwardToClients("handleJobRetry", jobId, runAfter);
940
+ });
941
+ worker.on("job_progress", (jobId, progress, message, details) => {
942
+ this.events.emit("job_progress", this.queueName, jobId, progress, message, details);
943
+ this.forwardToClients("handleJobProgress", jobId, progress, message, details);
944
+ });
945
+ return worker;
712
946
  }
713
- async processJobs() {
714
- if (!this.running) {
715
- return;
716
- }
717
- try {
718
- await this.cleanUpJobs();
719
- const canProceed = await this.limiter.canProceed();
720
- if (canProceed) {
721
- const job = await this.next();
722
- if (job) {
723
- this.processSingleJob(job);
724
- }
947
+ forwardToClients(method, ...args) {
948
+ for (const client of this.clients) {
949
+ const fn = client[method];
950
+ if (typeof fn === "function") {
951
+ fn.apply(client, args);
725
952
  }
726
- } finally {
727
- setTimeout(() => this.processJobs(), this.options.waitDurationInMilliseconds);
728
953
  }
729
954
  }
730
- async cleanUpJobs() {
731
- const abortingJobs = await this.peek(JobStatus.ABORTING);
732
- for (const job of abortingJobs) {
733
- await this.handleAbort(job.id);
734
- }
735
- if (this.options.deleteAfterCompletionMs) {
736
- await this.storage.deleteJobsByStatusAndAge(JobStatus.COMPLETED, this.options.deleteAfterCompletionMs);
955
+ updateAverageProcessingTime() {
956
+ const times = [];
957
+ for (const worker of this.workers) {
958
+ const avgTime = worker.getAverageProcessingTime();
959
+ if (avgTime !== undefined) {
960
+ times.push(avgTime);
961
+ }
737
962
  }
738
- if (this.options.deleteAfterFailureMs) {
739
- await this.storage.deleteJobsByStatusAndAge(JobStatus.FAILED, this.options.deleteAfterFailureMs);
963
+ if (times.length > 0) {
964
+ const avg = times.reduce((a, b) => a + b, 0) / times.length;
965
+ this.stats = {
966
+ ...this.stats,
967
+ averageProcessingTime: avg,
968
+ lastUpdateTime: new Date
969
+ };
740
970
  }
741
971
  }
742
- async monitorJobs() {
743
- if (!this.running) {
972
+ startCleanupLoop() {
973
+ if (!this.running)
744
974
  return;
745
- }
975
+ this.cleanupJobs().finally(() => {
976
+ if (this.running) {
977
+ this.cleanupTimer = setTimeout(() => this.startCleanupLoop(), this.cleanupIntervalMs);
978
+ }
979
+ });
980
+ }
981
+ async cleanupJobs() {
746
982
  try {
747
- const jobIds = Array.from(this.jobProgressListeners.keys());
748
- for (const jobId of jobIds) {
749
- const job = await this.get(jobId);
750
- if (job) {
751
- const currentProgress = {
752
- progress: job.progress,
753
- message: job.progressMessage,
754
- details: job.progressDetails || null
755
- };
756
- const lastProgress = this.lastKnownProgress.get(jobId);
757
- const hasChanged = !lastProgress || lastProgress.progress !== currentProgress.progress || lastProgress.message !== currentProgress.message;
758
- if (hasChanged && currentProgress.progress !== 0 && currentProgress.message !== "") {
759
- this.announceProgress(jobId, currentProgress.progress, currentProgress.message, currentProgress.details);
760
- }
761
- }
983
+ if (this.deleteAfterCompletionMs !== undefined && this.deleteAfterCompletionMs > 0) {
984
+ await this.storage.deleteJobsByStatusAndAge(JobStatus4.COMPLETED, this.deleteAfterCompletionMs);
762
985
  }
763
- for (const jobId of this.lastKnownProgress.keys()) {
764
- const job = await this.get(jobId);
765
- if (!job || job.status === JobStatus.COMPLETED || job.status === JobStatus.FAILED) {
766
- this.lastKnownProgress.delete(jobId);
767
- }
986
+ if (this.deleteAfterFailureMs !== undefined && this.deleteAfterFailureMs > 0) {
987
+ await this.storage.deleteJobsByStatusAndAge(JobStatus4.FAILED, this.deleteAfterFailureMs);
988
+ }
989
+ if (this.deleteAfterDisabledMs !== undefined && this.deleteAfterDisabledMs > 0) {
990
+ await this.storage.deleteJobsByStatusAndAge(JobStatus4.DISABLED, this.deleteAfterDisabledMs);
768
991
  }
769
992
  } catch (error) {
770
- console.error(`Error in monitorJobs: ${error}`);
993
+ console.error("Error in cleanup:", error);
771
994
  }
772
- setTimeout(() => this.monitorJobs(), this.options.waitDurationInMilliseconds);
773
995
  }
774
996
  async fixupJobs() {
775
- const stuckProcessingJobs = await this.peek(JobStatus.PROCESSING);
776
- const stuckAbortingJobs = await this.peek(JobStatus.ABORTING);
777
- const stuckJobs = [...stuckProcessingJobs, ...stuckAbortingJobs];
778
- for (const job of stuckJobs) {
779
- job.status = JobStatus.PENDING;
780
- job.runAfter = job.lastRanAt || new Date;
781
- job.progress = 0;
782
- job.progressMessage = "";
783
- job.progressDetails = null;
784
- job.error = "Restarting server";
785
- await this.storage.complete(this.classToStorage(job));
786
- await this.rescheduleJob(job, job.lastRanAt);
997
+ try {
998
+ const stuckProcessingJobs = await this.storage.peek(JobStatus4.PROCESSING);
999
+ const stuckAbortingJobs = await this.storage.peek(JobStatus4.ABORTING);
1000
+ const stuckJobs = [...stuckProcessingJobs, ...stuckAbortingJobs];
1001
+ for (const jobData of stuckJobs) {
1002
+ const job = this.storageToClass(jobData);
1003
+ job.status = JobStatus4.PENDING;
1004
+ job.runAfter = job.lastRanAt || new Date;
1005
+ job.progress = 0;
1006
+ job.progressMessage = "";
1007
+ job.progressDetails = null;
1008
+ job.error = "Server restarted";
1009
+ await this.storage.complete(this.classToStorage(job));
1010
+ }
1011
+ } catch (error) {
1012
+ console.error("Error in fixupJobs:", error);
787
1013
  }
788
1014
  }
1015
+ storageToClass(details) {
1016
+ const toDate = (date) => {
1017
+ if (!date)
1018
+ return null;
1019
+ const d = new Date(date);
1020
+ return isNaN(d.getTime()) ? null : d;
1021
+ };
1022
+ return new this.jobClass({
1023
+ id: details.id,
1024
+ jobRunId: details.job_run_id,
1025
+ queueName: details.queue,
1026
+ fingerprint: details.fingerprint,
1027
+ input: details.input,
1028
+ output: details.output,
1029
+ runAfter: toDate(details.run_after),
1030
+ createdAt: toDate(details.created_at),
1031
+ deadlineAt: toDate(details.deadline_at),
1032
+ lastRanAt: toDate(details.last_ran_at),
1033
+ completedAt: toDate(details.completed_at),
1034
+ progress: details.progress || 0,
1035
+ progressMessage: details.progress_message || "",
1036
+ progressDetails: details.progress_details ?? null,
1037
+ status: details.status,
1038
+ error: details.error ?? null,
1039
+ errorCode: details.error_code ?? null,
1040
+ runAttempts: details.run_attempts ?? 0,
1041
+ maxRetries: details.max_retries ?? 10
1042
+ });
1043
+ }
1044
+ classToStorage(job) {
1045
+ const dateToISOString = (date) => {
1046
+ if (!date)
1047
+ return null;
1048
+ return isNaN(date.getTime()) ? null : date.toISOString();
1049
+ };
1050
+ const now = new Date().toISOString();
1051
+ return {
1052
+ id: job.id,
1053
+ job_run_id: job.jobRunId,
1054
+ queue: job.queueName || this.queueName,
1055
+ fingerprint: job.fingerprint,
1056
+ input: job.input,
1057
+ status: job.status,
1058
+ output: job.output ?? null,
1059
+ error: job.error === null ? null : String(job.error),
1060
+ error_code: job.errorCode || null,
1061
+ run_attempts: job.runAttempts ?? 0,
1062
+ max_retries: job.maxRetries ?? 10,
1063
+ run_after: dateToISOString(job.runAfter) ?? now,
1064
+ created_at: dateToISOString(job.createdAt) ?? now,
1065
+ deadline_at: dateToISOString(job.deadlineAt),
1066
+ last_ran_at: dateToISOString(job.lastRanAt),
1067
+ completed_at: dateToISOString(job.completedAt),
1068
+ progress: job.progress ?? 0,
1069
+ progress_message: job.progressMessage ?? "",
1070
+ progress_details: job.progressDetails ?? null
1071
+ };
1072
+ }
789
1073
  }
790
1074
  // src/limiter/CompositeLimiter.ts
791
1075
  class CompositeLimiter {
@@ -830,8 +1114,8 @@ class CompositeLimiter {
830
1114
  }
831
1115
  }
832
1116
  // src/limiter/ConcurrencyLimiter.ts
833
- import { createServiceToken as createServiceToken3 } from "@workglow/util";
834
- var CONCURRENT_JOB_LIMITER = createServiceToken3("jobqueue.limiter.concurrent");
1117
+ import { createServiceToken as createServiceToken2 } from "@workglow/util";
1118
+ var CONCURRENT_JOB_LIMITER = createServiceToken2("jobqueue.limiter.concurrent");
835
1119
 
836
1120
  class ConcurrencyLimiter {
837
1121
  currentRunningJobs = 0;
@@ -894,8 +1178,8 @@ class DelayLimiter {
894
1178
  }
895
1179
  }
896
1180
  // src/limiter/EvenlySpacedRateLimiter.ts
897
- import { createServiceToken as createServiceToken4 } from "@workglow/util";
898
- var EVENLY_SPACED_JOB_RATE_LIMITER = createServiceToken4("jobqueue.limiter.rate.evenlyspaced");
1181
+ import { createServiceToken as createServiceToken3 } from "@workglow/util";
1182
+ var EVENLY_SPACED_JOB_RATE_LIMITER = createServiceToken3("jobqueue.limiter.rate.evenlyspaced");
899
1183
 
900
1184
  class EvenlySpacedRateLimiter {
901
1185
  maxExecutions;
@@ -954,26 +1238,28 @@ class EvenlySpacedRateLimiter {
954
1238
  this.lastStartTime = 0;
955
1239
  }
956
1240
  }
957
- // src/limiter/InMemoryRateLimiter.ts
958
- import { createServiceToken as createServiceToken5 } from "@workglow/util";
959
- var MEMORY_JOB_RATE_LIMITER = createServiceToken5("jobqueue.limiter.rate.memory");
960
-
961
- class InMemoryRateLimiter {
962
- requests = [];
963
- nextAvailableTime = new Date;
1241
+ // src/limiter/ILimiter.ts
1242
+ import { createServiceToken as createServiceToken4 } from "@workglow/util";
1243
+ var JOB_LIMITER = createServiceToken4("jobqueue.limiter");
1244
+ // src/limiter/RateLimiter.ts
1245
+ class RateLimiter {
1246
+ storage;
1247
+ queueName;
1248
+ windowSizeInMilliseconds;
964
1249
  currentBackoffDelay;
965
1250
  maxExecutions;
966
- windowSizeInMilliseconds;
967
1251
  initialBackoffDelay;
968
1252
  backoffMultiplier;
969
1253
  maxBackoffDelay;
970
- constructor({
1254
+ constructor(storage, queueName, {
971
1255
  maxExecutions,
972
1256
  windowSizeInSeconds,
973
1257
  initialBackoffDelay = 1000,
974
1258
  backoffMultiplier = 2,
975
1259
  maxBackoffDelay = 600000
976
1260
  }) {
1261
+ this.storage = storage;
1262
+ this.queueName = queueName;
977
1263
  if (maxExecutions <= 0) {
978
1264
  throw new Error("maxExecutions must be greater than 0");
979
1265
  }
@@ -989,80 +1275,93 @@ class InMemoryRateLimiter {
989
1275
  if (maxBackoffDelay <= initialBackoffDelay) {
990
1276
  throw new Error("maxBackoffDelay must be greater than initialBackoffDelay");
991
1277
  }
992
- this.maxExecutions = maxExecutions;
993
1278
  this.windowSizeInMilliseconds = windowSizeInSeconds * 1000;
1279
+ this.maxExecutions = maxExecutions;
994
1280
  this.initialBackoffDelay = initialBackoffDelay;
995
1281
  this.backoffMultiplier = backoffMultiplier;
996
1282
  this.maxBackoffDelay = maxBackoffDelay;
997
1283
  this.currentBackoffDelay = initialBackoffDelay;
998
1284
  }
999
- removeOldRequests() {
1000
- const now = Date.now();
1001
- const cutoff = now - this.windowSizeInMilliseconds;
1002
- this.requests = this.requests.filter((d) => d.getTime() > cutoff);
1003
- if (this.nextAvailableTime.getTime() < now) {
1004
- this.nextAvailableTime = new Date(now);
1005
- }
1285
+ addJitter(base) {
1286
+ return base + Math.random() * base;
1006
1287
  }
1007
1288
  increaseBackoff() {
1008
1289
  this.currentBackoffDelay = Math.min(this.currentBackoffDelay * this.backoffMultiplier, this.maxBackoffDelay);
1009
1290
  }
1010
- addJitter(base) {
1011
- return base + Math.random() * base;
1012
- }
1013
1291
  async canProceed() {
1014
- this.removeOldRequests();
1015
- const now = Date.now();
1016
- const okRequestCount = this.requests.length < this.maxExecutions;
1017
- const okTime = now >= this.nextAvailableTime.getTime();
1018
- const canProceedNow = okRequestCount && okTime;
1019
- if (!canProceedNow) {
1020
- this.increaseBackoff();
1021
- } else {
1292
+ const windowStartTime = new Date(Date.now() - this.windowSizeInMilliseconds).toISOString();
1293
+ const attemptCount = await this.storage.getExecutionCount(this.queueName, windowStartTime);
1294
+ const canProceedNow = attemptCount < this.maxExecutions;
1295
+ if (canProceedNow) {
1296
+ const nextAvailableTime2 = await this.storage.getNextAvailableTime(this.queueName);
1297
+ if (nextAvailableTime2 && new Date(nextAvailableTime2).getTime() > Date.now()) {
1298
+ const pastTime = new Date(Date.now() - 1000);
1299
+ await this.storage.setNextAvailableTime(this.queueName, pastTime.toISOString());
1300
+ }
1022
1301
  this.currentBackoffDelay = this.initialBackoffDelay;
1302
+ return true;
1303
+ }
1304
+ const nextAvailableTime = await this.storage.getNextAvailableTime(this.queueName);
1305
+ if (nextAvailableTime && new Date(nextAvailableTime).getTime() > Date.now()) {
1306
+ this.increaseBackoff();
1307
+ return false;
1023
1308
  }
1024
- return canProceedNow;
1309
+ this.increaseBackoff();
1310
+ return false;
1025
1311
  }
1026
1312
  async recordJobStart() {
1027
- this.requests.push(new Date);
1028
- if (this.requests.length >= this.maxExecutions) {
1029
- const earliest = this.requests[0].getTime();
1030
- const windowExpires = earliest + this.windowSizeInMilliseconds;
1031
- const backoffExpires = Date.now() + this.addJitter(this.currentBackoffDelay);
1032
- this.nextAvailableTime = new Date(Math.max(windowExpires, backoffExpires));
1313
+ await this.storage.recordExecution(this.queueName);
1314
+ const windowStartTime = new Date(Date.now() - this.windowSizeInMilliseconds).toISOString();
1315
+ const attemptCount = await this.storage.getExecutionCount(this.queueName, windowStartTime);
1316
+ if (attemptCount >= this.maxExecutions) {
1317
+ const backoffExpires = new Date(Date.now() + this.addJitter(this.currentBackoffDelay));
1318
+ await this.setNextAvailableTime(backoffExpires);
1319
+ } else {
1320
+ const nextAvailableTime = await this.storage.getNextAvailableTime(this.queueName);
1321
+ if (nextAvailableTime && new Date(nextAvailableTime).getTime() > Date.now()) {
1322
+ const pastTime = new Date(Date.now() - 1000);
1323
+ await this.storage.setNextAvailableTime(this.queueName, pastTime.toISOString());
1324
+ }
1033
1325
  }
1034
1326
  }
1035
1327
  async recordJobCompletion() {}
1036
1328
  async getNextAvailableTime() {
1037
- this.removeOldRequests();
1038
- return new Date(Math.max(Date.now(), this.nextAvailableTime.getTime()));
1329
+ const oldestExecution = await this.storage.getOldestExecutionAtOffset(this.queueName, this.maxExecutions - 1);
1330
+ let rateLimitedTime = new Date;
1331
+ if (oldestExecution) {
1332
+ rateLimitedTime = new Date(oldestExecution);
1333
+ rateLimitedTime.setSeconds(rateLimitedTime.getSeconds() + this.windowSizeInMilliseconds / 1000);
1334
+ }
1335
+ const nextAvailableStr = await this.storage.getNextAvailableTime(this.queueName);
1336
+ let nextAvailableTime = new Date;
1337
+ if (nextAvailableStr) {
1338
+ nextAvailableTime = new Date(nextAvailableStr);
1339
+ }
1340
+ return nextAvailableTime > rateLimitedTime ? nextAvailableTime : rateLimitedTime;
1039
1341
  }
1040
1342
  async setNextAvailableTime(date) {
1041
- if (date.getTime() > this.nextAvailableTime.getTime()) {
1042
- this.nextAvailableTime = date;
1043
- }
1343
+ await this.storage.setNextAvailableTime(this.queueName, date.toISOString());
1044
1344
  }
1045
1345
  async clear() {
1046
- this.requests = [];
1047
- this.nextAvailableTime = new Date;
1346
+ await this.storage.clear(this.queueName);
1048
1347
  this.currentBackoffDelay = this.initialBackoffDelay;
1049
1348
  }
1050
1349
  }
1051
1350
  export {
1052
1351
  RetryableJobError,
1053
- QueueMode,
1352
+ RateLimiter,
1054
1353
  PermanentJobError,
1055
1354
  NullLimiter,
1056
1355
  NULL_JOB_LIMITER,
1057
- MEMORY_JOB_RATE_LIMITER,
1058
1356
  JobStatus,
1059
- JobQueue,
1357
+ JobQueueWorker,
1358
+ JobQueueServer,
1359
+ JobQueueClient,
1060
1360
  JobNotFoundError,
1061
1361
  JobError,
1062
1362
  JobDisabledError,
1063
1363
  Job,
1064
1364
  JOB_LIMITER,
1065
- InMemoryRateLimiter,
1066
1365
  EvenlySpacedRateLimiter,
1067
1366
  EVENLY_SPACED_JOB_RATE_LIMITER,
1068
1367
  DelayLimiter,
@@ -1072,4 +1371,4 @@ export {
1072
1371
  AbortSignalJobError
1073
1372
  };
1074
1373
 
1075
- //# debugId=4FA097AF2EE0650C64756E2164756E21
1374
+ //# debugId=98D28D3ECAF6920F64756E2164756E21