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