@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 +195 -32
- package/dist/browser.js.map +11 -11
- package/dist/bun.js +195 -32
- package/dist/bun.js.map +11 -11
- package/dist/job/JobQueueServer.d.ts.map +1 -1
- package/dist/job/JobQueueWorker.d.ts +7 -2
- package/dist/job/JobQueueWorker.d.ts.map +1 -1
- package/dist/limiter/CompositeLimiter.d.ts +17 -1
- package/dist/limiter/CompositeLimiter.d.ts.map +1 -1
- package/dist/limiter/ConcurrencyLimiter.d.ts +11 -1
- package/dist/limiter/ConcurrencyLimiter.d.ts.map +1 -1
- package/dist/limiter/DelayLimiter.d.ts +12 -1
- package/dist/limiter/DelayLimiter.d.ts.map +1 -1
- package/dist/limiter/EvenlySpacedRateLimiter.d.ts +18 -1
- package/dist/limiter/EvenlySpacedRateLimiter.d.ts.map +1 -1
- package/dist/limiter/ILimiter.d.ts +56 -0
- package/dist/limiter/ILimiter.d.ts.map +1 -1
- package/dist/limiter/NullLimiter.d.ts +6 -1
- package/dist/limiter/NullLimiter.d.ts.map +1 -1
- package/dist/limiter/RateLimiter.d.ts +42 -3
- package/dist/limiter/RateLimiter.d.ts.map +1 -1
- package/dist/node.js +195 -32
- package/dist/node.js.map +11 -11
- package/package.json +5 -5
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.
|
|
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
|
|
710
|
-
if (
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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
|
|
1710
|
+
let latestMs = Date.now();
|
|
1550
1711
|
if (oldestExecution) {
|
|
1551
|
-
|
|
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
|
-
|
|
1716
|
+
latestMs = Math.max(latestMs, new Date(nextAvailableStr).getTime());
|
|
1717
|
+
}
|
|
1718
|
+
if (this.localBackoffUntilMs > latestMs) {
|
|
1719
|
+
latestMs = this.localBackoffUntilMs;
|
|
1558
1720
|
}
|
|
1559
|
-
return
|
|
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=
|
|
1762
|
+
//# debugId=79E3C01A65F3267564756E2164756E21
|