@workglow/job-queue 0.2.25 → 0.2.27

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/bun.js CHANGED
@@ -563,6 +563,12 @@ import { createServiceToken } from "@workglow/util";
563
563
  var NULL_JOB_LIMITER = createServiceToken("jobqueue.limiter.null");
564
564
 
565
565
  class NullLimiter {
566
+ scope = "process";
567
+ static SENTINEL = Symbol("NullLimiter.acquired");
568
+ async tryAcquire() {
569
+ return NullLimiter.SENTINEL;
570
+ }
571
+ async release(_token) {}
566
572
  async canProceed() {
567
573
  return true;
568
574
  }
@@ -585,6 +591,8 @@ import {
585
591
  SpanStatusCode,
586
592
  uuid4
587
593
  } from "@workglow/util";
594
+ var MAX_LIMITER_WAKE_MS = 30000;
595
+
588
596
  class JobQueueWorker {
589
597
  queueName;
590
598
  workerId;
@@ -668,15 +676,16 @@ class JobQueueWorker {
668
676
  return this;
669
677
  }
670
678
  async processNext() {
671
- const canProceed = await this.limiter.canProceed();
672
- if (!canProceed) {
673
- return false;
674
- }
675
679
  const job = await this.next();
676
680
  if (!job) {
677
681
  return false;
678
682
  }
679
- await this.processSingleJob(job);
683
+ const limiterToken = await this.limiter.tryAcquire();
684
+ if (limiterToken === null || limiterToken === undefined) {
685
+ await this.releaseClaimedJob(job);
686
+ return false;
687
+ }
688
+ await this.processSingleJob(job, limiterToken);
680
689
  return true;
681
690
  }
682
691
  isRunning() {
@@ -707,29 +716,46 @@ class JobQueueWorker {
707
716
  while (this.running) {
708
717
  try {
709
718
  await this.checkForAbortingJobs();
710
- const canProceed = await this.limiter.canProceed();
711
- if (canProceed) {
712
- const job = await this.next();
713
- if (job) {
714
- if (!this.running) {
715
- await this.releaseClaimedJob(job);
716
- return;
717
- }
718
- this.processSingleJob(job);
719
- continue;
720
- }
721
- }
722
- if (canProceed) {
719
+ const job = await this.next();
720
+ if (!job) {
723
721
  const delay = await this.getIdleDelay();
724
722
  await this.waitForWakeOrTimeout(delay);
725
- } else {
726
- await this.waitForWakeOrTimeout(this.pollIntervalMs);
723
+ continue;
724
+ }
725
+ if (!this.running) {
726
+ await this.releaseClaimedJob(job);
727
+ return;
728
+ }
729
+ const limiterToken = await this.limiter.tryAcquire();
730
+ if (limiterToken === null || limiterToken === undefined) {
731
+ await this.releaseClaimedJob(job);
732
+ await this.waitForWakeOrTimeout(await this.getLimiterWakeDelay());
733
+ continue;
727
734
  }
735
+ if (!this.running) {
736
+ try {
737
+ await this.limiter.release(limiterToken);
738
+ } catch {}
739
+ await this.releaseClaimedJob(job);
740
+ return;
741
+ }
742
+ this.processSingleJob(job, limiterToken);
728
743
  } catch {
729
744
  await sleep(this.pollIntervalMs);
730
745
  }
731
746
  }
732
747
  }
748
+ async getLimiterWakeDelay() {
749
+ try {
750
+ const next = await this.limiter.getNextAvailableTime();
751
+ const delay = next.getTime() - Date.now();
752
+ if (delay <= 0)
753
+ return this.pollIntervalMs;
754
+ return Math.min(Math.max(delay, this.pollIntervalMs), MAX_LIMITER_WAKE_MS);
755
+ } catch {
756
+ return this.pollIntervalMs;
757
+ }
758
+ }
733
759
  async getIdleDelay() {
734
760
  try {
735
761
  const pending = await this.storage.peek(JobStatus3.PENDING, 1);
@@ -772,7 +798,7 @@ class JobQueueWorker {
772
798
  }
773
799
  }
774
800
  }
775
- async processSingleJob(job) {
801
+ async processSingleJob(job, limiterToken) {
776
802
  if (!job || !job.id) {
777
803
  throw new JobNotFoundError("Invalid job provided for processing");
778
804
  }
@@ -789,9 +815,17 @@ class JobQueueWorker {
789
815
  "workglow.job.max_retries": job.maxRetries
790
816
  }
791
817
  }) : undefined;
818
+ let slotReleased = false;
792
819
  try {
793
- await this.validateJobState(job);
794
- await this.limiter.recordJobStart();
820
+ try {
821
+ await this.validateJobState(job);
822
+ } catch (validationErr) {
823
+ try {
824
+ await this.limiter.release(limiterToken);
825
+ slotReleased = true;
826
+ } catch {}
827
+ throw validationErr;
828
+ }
795
829
  const abortController = this.createAbortController(job.id);
796
830
  this.events.emit("job_start", job.id);
797
831
  const output = await this.executeJob(job, abortController.signal);
@@ -829,7 +863,9 @@ class JobQueueWorker {
829
863
  } finally {
830
864
  span?.end();
831
865
  try {
832
- await this.limiter.recordJobCompletion();
866
+ if (!slotReleased) {
867
+ await this.limiter.recordJobCompletion();
868
+ }
833
869
  } finally {
834
870
  this.inFlight.delete(job.id);
835
871
  resolveInFlight();
@@ -1039,10 +1075,17 @@ class JobQueueServer {
1039
1075
  }
1040
1076
  this.running = true;
1041
1077
  this.events.emit("server_start", this.queueName);
1078
+ if (this.limiter.scope === "process" && this.storage.scope === "cluster" && !(this.limiter instanceof NullLimiter)) {
1079
+ getLogger2().warn("Process-scoped limiter on cluster-scoped queue storage \u2014 limit is enforced per-process, not cluster-wide. Use a cluster-scoped rate limiter storage (Postgres/Supabase) for global enforcement.", {
1080
+ queueName: this.queueName,
1081
+ limiterScope: this.limiter.scope,
1082
+ storage: this.storage.constructor.name
1083
+ });
1084
+ }
1042
1085
  await this.fixupJobs();
1043
1086
  try {
1044
1087
  this.storageUnsubscribe = this.storage.subscribeToChanges((change) => {
1045
- if (change.type === "INSERT" || change.type === "UPDATE" && change.new?.status === JobStatus4.PENDING) {
1088
+ if (change.type === "INSERT" || change.type === "RESYNC" || change.type === "UPDATE" && change.new?.status === JobStatus4.PENDING) {
1046
1089
  this.notifyWorkers();
1047
1090
  }
1048
1091
  });
@@ -1297,6 +1340,9 @@ class CompositeLimiter {
1297
1340
  constructor(limiters = []) {
1298
1341
  this.limiters = limiters;
1299
1342
  }
1343
+ get scope() {
1344
+ return this.limiters.every((l) => l.scope === "cluster") && this.limiters.length > 0 ? "cluster" : "process";
1345
+ }
1300
1346
  addLimiter(limiter) {
1301
1347
  this.limiters.push(limiter);
1302
1348
  }
@@ -1308,6 +1354,27 @@ class CompositeLimiter {
1308
1354
  }
1309
1355
  return true;
1310
1356
  }
1357
+ async tryAcquire() {
1358
+ const tokens = [];
1359
+ for (const limiter of this.limiters) {
1360
+ const t = await limiter.tryAcquire();
1361
+ if (t === null || t === undefined) {
1362
+ for (let i = tokens.length - 1;i >= 0; i--) {
1363
+ try {
1364
+ await this.limiters[i].release(tokens[i]);
1365
+ } catch {}
1366
+ }
1367
+ return null;
1368
+ }
1369
+ tokens.push(t);
1370
+ }
1371
+ return tokens;
1372
+ }
1373
+ async release(token) {
1374
+ if (!Array.isArray(token))
1375
+ return;
1376
+ await Promise.all(this.limiters.map((l, i) => l.release(token[i]).catch(() => {})));
1377
+ }
1311
1378
  async recordJobStart() {
1312
1379
  await Promise.all(this.limiters.map((limiter) => limiter.recordJobStart()));
1313
1380
  }
@@ -1339,6 +1406,7 @@ import { createServiceToken as createServiceToken2 } from "@workglow/util";
1339
1406
  var CONCURRENT_JOB_LIMITER = createServiceToken2("jobqueue.limiter.concurrent");
1340
1407
 
1341
1408
  class ConcurrencyLimiter {
1409
+ scope = "process";
1342
1410
  currentRunningJobs = 0;
1343
1411
  maxConcurrentJobs;
1344
1412
  nextAllowedStartTime = new Date;
@@ -1348,6 +1416,19 @@ class ConcurrencyLimiter {
1348
1416
  async canProceed() {
1349
1417
  return this.currentRunningJobs < this.maxConcurrentJobs && Date.now() >= this.nextAllowedStartTime.getTime();
1350
1418
  }
1419
+ static SENTINEL = Symbol("ConcurrencyLimiter.acquired");
1420
+ async tryAcquire() {
1421
+ if (this.currentRunningJobs >= this.maxConcurrentJobs || Date.now() < this.nextAllowedStartTime.getTime()) {
1422
+ return null;
1423
+ }
1424
+ this.currentRunningJobs++;
1425
+ return ConcurrencyLimiter.SENTINEL;
1426
+ }
1427
+ async release(token) {
1428
+ if (token !== ConcurrencyLimiter.SENTINEL)
1429
+ return;
1430
+ this.currentRunningJobs = Math.max(0, this.currentRunningJobs - 1);
1431
+ }
1351
1432
  async recordJobStart() {
1352
1433
  this.currentRunningJobs++;
1353
1434
  }
@@ -1371,13 +1452,32 @@ class ConcurrencyLimiter {
1371
1452
  // src/limiter/DelayLimiter.ts
1372
1453
  class DelayLimiter {
1373
1454
  delayInMilliseconds;
1455
+ scope = "process";
1374
1456
  nextAvailableTime = new Date;
1457
+ lastAcquireBaseline = 0;
1375
1458
  constructor(delayInMilliseconds = 50) {
1376
1459
  this.delayInMilliseconds = delayInMilliseconds;
1377
1460
  }
1378
1461
  async canProceed() {
1379
1462
  return Date.now() >= this.nextAvailableTime.getTime();
1380
1463
  }
1464
+ async tryAcquire() {
1465
+ const now = Date.now();
1466
+ if (now < this.nextAvailableTime.getTime()) {
1467
+ return null;
1468
+ }
1469
+ const previous = this.nextAvailableTime.getTime();
1470
+ this.lastAcquireBaseline = previous;
1471
+ this.nextAvailableTime = new Date(now + this.delayInMilliseconds);
1472
+ return previous;
1473
+ }
1474
+ async release(token) {
1475
+ if (typeof token !== "number")
1476
+ return;
1477
+ if (this.nextAvailableTime.getTime() === this.lastAcquireBaseline + this.delayInMilliseconds) {
1478
+ this.nextAvailableTime = new Date(token);
1479
+ }
1480
+ }
1381
1481
  async recordJobStart() {
1382
1482
  this.nextAvailableTime = new Date(Date.now() + this.delayInMilliseconds);
1383
1483
  }
@@ -1400,12 +1500,14 @@ import { createServiceToken as createServiceToken3 } from "@workglow/util";
1400
1500
  var EVENLY_SPACED_JOB_RATE_LIMITER = createServiceToken3("jobqueue.limiter.rate.evenlyspaced");
1401
1501
 
1402
1502
  class EvenlySpacedRateLimiter {
1503
+ scope = "process";
1403
1504
  maxExecutions;
1404
1505
  windowSizeMs;
1405
1506
  idealInterval;
1406
1507
  nextAvailableTime = Date.now();
1407
1508
  lastStartTime = 0;
1408
1509
  durations = [];
1510
+ acquireChain = Promise.resolve();
1409
1511
  constructor({ maxExecutions, windowSizeInSeconds }) {
1410
1512
  if (maxExecutions <= 0) {
1411
1513
  throw new Error("maxExecutions must be > 0");
@@ -1421,6 +1523,43 @@ class EvenlySpacedRateLimiter {
1421
1523
  const now = Date.now();
1422
1524
  return now >= this.nextAvailableTime;
1423
1525
  }
1526
+ async tryAcquire() {
1527
+ const previous = this.acquireChain;
1528
+ let release;
1529
+ const next = new Promise((r) => {
1530
+ release = r;
1531
+ });
1532
+ this.acquireChain = next;
1533
+ try {
1534
+ await previous;
1535
+ const now = Date.now();
1536
+ if (now < this.nextAvailableTime) {
1537
+ return null;
1538
+ }
1539
+ const priorNextAvailable = this.nextAvailableTime;
1540
+ this.lastStartTime = now;
1541
+ if (this.durations.length === 0) {
1542
+ this.nextAvailableTime = now + this.idealInterval;
1543
+ } else {
1544
+ const sum = this.durations.reduce((a, b) => a + b, 0);
1545
+ const avgDuration = sum / this.durations.length;
1546
+ const waitMs = Math.max(0, this.idealInterval - avgDuration);
1547
+ this.nextAvailableTime = now + waitMs;
1548
+ }
1549
+ return { priorNextAvailable, advancedTo: this.nextAvailableTime };
1550
+ } finally {
1551
+ release(undefined);
1552
+ }
1553
+ }
1554
+ async release(token) {
1555
+ if (!token || typeof token !== "object" || typeof token.advancedTo !== "number") {
1556
+ return;
1557
+ }
1558
+ const t = token;
1559
+ if (this.nextAvailableTime === t.advancedTo) {
1560
+ this.nextAvailableTime = t.priorNextAvailable;
1561
+ }
1562
+ }
1424
1563
  async recordJobStart() {
1425
1564
  const now = Date.now();
1426
1565
  this.lastStartTime = now;
@@ -1471,6 +1610,7 @@ class RateLimiter {
1471
1610
  initialBackoffDelay;
1472
1611
  backoffMultiplier;
1473
1612
  maxBackoffDelay;
1613
+ localBackoffUntilMs = 0;
1474
1614
  constructor(storage, queueName, {
1475
1615
  maxExecutions,
1476
1616
  windowSizeInSeconds,
@@ -1502,6 +1642,27 @@ class RateLimiter {
1502
1642
  this.maxBackoffDelay = maxBackoffDelay;
1503
1643
  this.currentBackoffDelay = initialBackoffDelay;
1504
1644
  }
1645
+ get scope() {
1646
+ return this.storage.scope;
1647
+ }
1648
+ async tryAcquire() {
1649
+ const token = await this.storage.tryReserveExecution(this.queueName, this.maxExecutions, this.windowSizeInMilliseconds);
1650
+ if (token !== null && token !== undefined) {
1651
+ this.currentBackoffDelay = this.initialBackoffDelay;
1652
+ this.localBackoffUntilMs = 0;
1653
+ return token;
1654
+ }
1655
+ this.localBackoffUntilMs = Date.now() + this.addJitter(this.currentBackoffDelay);
1656
+ this.increaseBackoff();
1657
+ return null;
1658
+ }
1659
+ async release(token) {
1660
+ if (token === null || token === undefined)
1661
+ return;
1662
+ await this.storage.releaseExecution(this.queueName, token);
1663
+ this.currentBackoffDelay = this.initialBackoffDelay;
1664
+ this.localBackoffUntilMs = 0;
1665
+ }
1505
1666
  addJitter(base) {
1506
1667
  return base + Math.random() * base;
1507
1668
  }
@@ -1547,17 +1708,18 @@ class RateLimiter {
1547
1708
  async recordJobCompletion() {}
1548
1709
  async getNextAvailableTime() {
1549
1710
  const oldestExecution = await this.storage.getOldestExecutionAtOffset(this.queueName, this.maxExecutions - 1);
1550
- let rateLimitedTime = new Date;
1711
+ let latestMs = Date.now();
1551
1712
  if (oldestExecution) {
1552
- rateLimitedTime = new Date(oldestExecution);
1553
- rateLimitedTime.setSeconds(rateLimitedTime.getSeconds() + this.windowSizeInMilliseconds / 1000);
1713
+ latestMs = Math.max(latestMs, new Date(oldestExecution).getTime() + this.windowSizeInMilliseconds);
1554
1714
  }
1555
1715
  const nextAvailableStr = await this.storage.getNextAvailableTime(this.queueName);
1556
- let nextAvailableTime = new Date;
1557
1716
  if (nextAvailableStr) {
1558
- nextAvailableTime = new Date(nextAvailableStr);
1717
+ latestMs = Math.max(latestMs, new Date(nextAvailableStr).getTime());
1718
+ }
1719
+ if (this.localBackoffUntilMs > latestMs) {
1720
+ latestMs = this.localBackoffUntilMs;
1559
1721
  }
1560
- return nextAvailableTime > rateLimitedTime ? nextAvailableTime : rateLimitedTime;
1722
+ return new Date(latestMs);
1561
1723
  }
1562
1724
  async setNextAvailableTime(date) {
1563
1725
  await this.storage.setNextAvailableTime(this.queueName, date.toISOString());
@@ -1565,6 +1727,7 @@ class RateLimiter {
1565
1727
  async clear() {
1566
1728
  await this.storage.clear(this.queueName);
1567
1729
  this.currentBackoffDelay = this.initialBackoffDelay;
1730
+ this.localBackoffUntilMs = 0;
1568
1731
  }
1569
1732
  }
1570
1733
  export {
@@ -1597,4 +1760,4 @@ export {
1597
1760
  AbortSignalJobError
1598
1761
  };
1599
1762
 
1600
- //# debugId=306268EEB05F1A3964756E2164756E21
1763
+ //# debugId=81121BC83C83DF5F64756E2164756E21