Skip to content

Commit 89a10bb

Browse files
committed
Fix rate limiter delaying tasks in a fresh window when intervalCap > 1
Fixes #251
1 parent 98ccb48 commit 89a10bb

4 files changed

Lines changed: 177 additions & 25 deletions

File tree

source/index.ts

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ export default class PQueue<QueueType extends Queue<RunFunction, EnqueueOptionsT
2727

2828
readonly #interval: number;
2929

30+
/*
31+
Timestamp when the current interval window ends. Kept accurate at every window transition: `#initializeIntervalIfNeeded` sets it when a window starts and `#onInterval` advances it as the recurring timer rolls into each new window. This is the source of truth for whether a task added after the queue goes idle belongs to a fresh window, so it must not go stale.
32+
*/
3033
#intervalEnd = 0;
3134

32-
#lastExecutionTime = 0;
33-
35+
// Recurring timer that drives windows while the queue is actively processing. Absent (`undefined`) once the queue goes idle.
3436
#intervalId?: NodeJS.Timeout;
3537

38+
// One-shot timer used while idle to resume at the next window boundary, since `#intervalId` is cleared when idle.
3639
#timeoutId?: NodeJS.Timeout;
3740

3841
readonly #strict: boolean;
@@ -234,26 +237,14 @@ export default class PQueue<QueueType extends Queue<RunFunction, EnqueueOptionsT
234237
return false;
235238
}
236239

237-
// Fixed window mode (original logic)
240+
// Fixed window mode. While the recurring timer runs it already governs the windows, so pausing is only decided here when the queue is idle (the timer is absent) and we rely on `#intervalEnd`.
238241
if (this.#intervalId === undefined) {
239242
const delay = this.#intervalEnd - now;
240243
if (delay < 0) {
241-
// If the interval has expired while idle, check if we should enforce the interval
242-
// from the last task execution. This ensures proper spacing between tasks even
243-
// when the queue becomes empty and then new tasks are added.
244-
if (this.#lastExecutionTime > 0) {
245-
const timeSinceLastExecution = now - this.#lastExecutionTime;
246-
if (timeSinceLastExecution < this.#interval) {
247-
// Not enough time has passed since the last task execution
248-
this.#createIntervalTimeout(this.#interval - timeSinceLastExecution);
249-
return true;
250-
}
251-
}
252-
253-
// Enough time has passed or no previous execution, allow execution
254-
this.#intervalCount = (this.#carryoverIntervalCount) ? this.#pending : 0;
244+
// The interval expired while idle, so reset the count for the new window.
245+
this.#intervalCount = this.#carryoverIntervalCount ? this.#pending : 0;
255246
} else {
256-
// Act as the interval is pending
247+
// Still inside the previous window; wait out the remaining time before starting the next task.
257248
this.#createIntervalTimeout(delay);
258249
return true;
259250
}
@@ -323,13 +314,13 @@ export default class PQueue<QueueType extends Queue<RunFunction, EnqueueOptionsT
323314
this.#scheduleRateLimitUpdate();
324315
}
325316

326-
this.emit('active');
327-
job();
328-
329317
if (canInitializeInterval) {
330318
this.#initializeIntervalIfNeeded();
331319
}
332320

321+
this.emit('active');
322+
job();
323+
333324
taskStarted = true;
334325
}
335326
}
@@ -360,8 +351,14 @@ export default class PQueue<QueueType extends Queue<RunFunction, EnqueueOptionsT
360351
#onInterval(): void {
361352
// Non-strict mode uses interval timers and intervalCount
362353
if (!this.#strict) {
363-
if (this.#intervalCount === 0 && this.#pending === 0 && this.#intervalId) {
364-
this.#clearIntervalTimer();
354+
// Only touch the recurring interval timer here. When resumed via a timeout instead, `#intervalId` is undefined and `#initializeIntervalIfNeeded` sets `#intervalEnd` right after.
355+
if (this.#intervalId !== undefined) {
356+
if (this.#intervalCount === 0 && this.#pending === 0) {
357+
this.#clearIntervalTimer();
358+
} else {
359+
// The recurring timer fired, starting a new window. Keep the boundary accurate so tasks added after the queue later goes idle are scheduled against the correct window.
360+
this.#intervalEnd = Date.now() + this.#interval;
361+
}
365362
}
366363

367364
this.#intervalCount = this.#carryoverIntervalCount ? this.#pending : 0;
@@ -489,8 +486,6 @@ export default class PQueue<QueueType extends Queue<RunFunction, EnqueueOptionsT
489486
throw error;
490487
}
491488

492-
this.#lastExecutionTime = Date.now();
493-
494489
let operation = function_({signal: options.signal});
495490

496491
if (options.timeout !== undefined) {

test/advanced.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,92 @@ test('interval should be maintained when using await between adds (issue #182)',
999999
}
10001000
});
10011001

1002+
test('task added in a fresh window runs immediately after queue went idle mid-window (issue #251)', async () => {
1003+
const queue = new PQueue({intervalCap: 2, interval: 200});
1004+
1005+
// Window [0, 200): consume slot 1 at t≈0.
1006+
queue.add(() => undefined);
1007+
1008+
// Consume slot 2 later in the window at t≈120.
1009+
await delay(120);
1010+
queue.add(() => undefined);
1011+
await queue.onIdle();
1012+
1013+
// Now past the window boundary at t≈220. Both slots of the fresh window should be available immediately.
1014+
await delay(100);
1015+
const start = Date.now();
1016+
queue.add(() => undefined);
1017+
await queue.add(() => undefined);
1018+
const elapsed = Date.now() - start;
1019+
1020+
assert.ok(elapsed < 50, `Both tasks should start immediately in the new window, but waited ${elapsed}ms`);
1021+
});
1022+
1023+
test('fresh window after idle still enforces the cap for the following task (issue #251)', async () => {
1024+
const queue = new PQueue({intervalCap: 2, interval: 200});
1025+
1026+
// Consume both slots of the first window, then let the queue drain.
1027+
queue.add(() => undefined);
1028+
await delay(120);
1029+
queue.add(() => undefined);
1030+
await queue.onIdle();
1031+
1032+
// Past the boundary: the fresh window grants exactly two slots, so a third task must wait for the next window.
1033+
await delay(100);
1034+
const timestamps: number[] = [];
1035+
const start = Date.now();
1036+
queue.add(() => {
1037+
timestamps.push(Date.now() - start);
1038+
});
1039+
queue.add(() => {
1040+
timestamps.push(Date.now() - start);
1041+
});
1042+
await queue.add(() => {
1043+
timestamps.push(Date.now() - start);
1044+
});
1045+
1046+
assert.ok(timestamps[0] < 50, `First task should be immediate, waited ${timestamps[0]}ms`);
1047+
assert.ok(timestamps[1] < 50, `Second task should be immediate, waited ${timestamps[1]}ms`);
1048+
assert.ok(timestamps[2] >= 170, `Third task should wait for the next window, but ran after ${timestamps[2]}ms`);
1049+
});
1050+
1051+
test('carryoverIntervalCount does not break the fresh window after idle mid-window (issue #251)', async () => {
1052+
const queue = new PQueue({intervalCap: 2, interval: 200, carryoverIntervalCount: true});
1053+
1054+
queue.add(() => undefined);
1055+
await delay(120);
1056+
queue.add(() => undefined);
1057+
await queue.onIdle();
1058+
1059+
// With no pending tasks, carrying the count over resets it to zero, so the fresh window is fully available.
1060+
await delay(100);
1061+
const start = Date.now();
1062+
queue.add(() => undefined);
1063+
await queue.add(() => undefined);
1064+
const elapsed = Date.now() - start;
1065+
1066+
assert.ok(elapsed < 50, `Both tasks should start immediately in the new window, but waited ${elapsed}ms`);
1067+
});
1068+
1069+
test('task added after going idle but before the boundary still waits for the current window (issue #251)', async () => {
1070+
const queue = new PQueue({intervalCap: 2, interval: 200});
1071+
const windowStart = Date.now();
1072+
1073+
// Fill both slots spread across the window, after which the queue goes idle.
1074+
queue.add(() => undefined);
1075+
await delay(120);
1076+
queue.add(() => undefined);
1077+
await queue.onIdle();
1078+
1079+
// Added while still inside the window [0, 200): the cap is reached, so it must wait for the boundary, not run immediately. This guards against the reset firing before the window has actually expired.
1080+
const start = Date.now();
1081+
await queue.add(() => undefined);
1082+
const waited = Date.now() - start;
1083+
1084+
assert.ok(waited >= 40, `Task should wait for the current window to end, but only waited ${waited}ms`);
1085+
assert.ok(Date.now() - windowStart >= 190, 'Task should run at the window boundary');
1086+
});
1087+
10021088
test('interval maintained when queue becomes empty multiple times', async () => {
10031089
const queue = new PQueue({
10041090
intervalCap: 1,

test/rate-limit.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,56 @@ test('rate-limit rapid pause/start cycles', async () => {
4343
assert.equal(results.length, 4);
4444
});
4545

46+
test('rate-limit preserves the interval count for re-entrant task additions', async () => {
47+
const queue = new PQueue({
48+
interval: 100,
49+
intervalCap: 2,
50+
});
51+
52+
const starts: number[] = [];
53+
const addTask = (index: number): void => {
54+
queue.add(() => {
55+
starts.push(index);
56+
57+
if (index < 4) {
58+
addTask(index + 1);
59+
}
60+
});
61+
};
62+
63+
addTask(0);
64+
assert.deepEqual(starts, [0, 1]);
65+
66+
await queue.onIdle();
67+
assert.deepEqual(starts, [0, 1, 2, 3, 4]);
68+
});
69+
70+
test('rate-limit preserves the interval count for re-entrant active listeners', async () => {
71+
const queue = new PQueue({
72+
interval: 100,
73+
intervalCap: 2,
74+
});
75+
76+
const starts: number[] = [];
77+
let nextTask = 0;
78+
queue.on('active', () => {
79+
if (nextTask < 5) {
80+
const taskIndex = nextTask++;
81+
queue.add(() => {
82+
starts.push(taskIndex);
83+
});
84+
}
85+
});
86+
87+
queue.add(() => {
88+
starts.push(-1);
89+
});
90+
assert.equal(starts.length, 2);
91+
92+
await queue.onIdle();
93+
assert.equal(starts.length, 6);
94+
});
95+
4696
test('rate-limit edge case with zero-interval', async () => {
4797
// Zero interval should effectively disable rate limiting
4898
const queue = new PQueue({

test/strict.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,27 @@ test('strict mode after queue becomes idle', async () => {
507507
assert.ok(elapsed < 50, `Second batch should execute immediately after idle, took ${elapsed}ms`);
508508
});
509509

510+
test('strict mode frees a slot as soon as the oldest tick ages out, even with spread ticks', async () => {
511+
const queue = new PQueue({
512+
interval: 100,
513+
intervalCap: 2,
514+
strict: true,
515+
});
516+
517+
// Consume both slots spread across the window: tick at ≈0 and tick at ≈60.
518+
await queue.add(async () => undefined);
519+
await delay(60);
520+
await queue.add(async () => undefined);
521+
522+
// At ≈120ms the first tick (≈0) has aged out but the second (≈60) has not, so exactly one slot is free and the next task should run immediately.
523+
await delay(60);
524+
const start = Date.now();
525+
await queue.add(async () => undefined);
526+
const elapsed = Date.now() - start;
527+
528+
assert.ok(elapsed < 40, `Task should run as soon as the oldest tick ages out, but waited ${elapsed}ms`);
529+
});
530+
510531
test('strict mode prevents boundary bursts', async () => {
511532
// This test verifies that strict mode prevents the boundary burst problem
512533
// that occurs with fixed window mode

0 commit comments

Comments
 (0)