dynamic-throttled-queue 3.0.0-rc.1d87d99 → 3.0.0-rc.ea3019c
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/package.json +8 -4
- package/skills/configure-throttled-queue/SKILL.md +340 -0
- package/skills/configure-throttled-queue/references/lifecycle.md +46 -0
- package/skills/configure-throttled-queue/references/rate-strategies.md +68 -0
- package/skills/configure-throttled-queue/references/retries.md +52 -0
- package/skills/configure-throttled-queue/references/state-and-idle.md +38 -0
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dynamic-throttled-queue",
|
|
3
|
-
"version": "3.0.0-rc.
|
|
3
|
+
"version": "3.0.0-rc.ea3019c",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Dynamically throttles arbitrary code to execute between a minimum and maximum number of times per interval. Best for making throttled API requests.",
|
|
6
6
|
"files": [
|
|
7
|
-
"dist"
|
|
7
|
+
"dist",
|
|
8
|
+
"skills/configure-throttled-queue"
|
|
8
9
|
],
|
|
9
10
|
"zshy": {
|
|
10
11
|
"exports": {
|
|
@@ -22,7 +23,8 @@
|
|
|
22
23
|
"queue",
|
|
23
24
|
"throttle",
|
|
24
25
|
"throttled",
|
|
25
|
-
"request"
|
|
26
|
+
"request",
|
|
27
|
+
"tanstack-intent"
|
|
26
28
|
],
|
|
27
29
|
"author": "Adrian Elton-Browning <gingacodemonkey@gmail.com>",
|
|
28
30
|
"license": "MIT",
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
"@commitlint/cli": "21.2.3",
|
|
34
36
|
"@commitlint/config-conventional": "20.0.0",
|
|
35
37
|
"@gingacodemonkey/config": "^0.0.38",
|
|
38
|
+
"@tanstack/intent": "0.5.0",
|
|
36
39
|
"@types/node": "^24.13.3",
|
|
37
40
|
"@varlock/bumpy": "^1.18.1",
|
|
38
41
|
"eslint": "^9.39.5",
|
|
@@ -72,6 +75,7 @@
|
|
|
72
75
|
"lint:e18e": "pnpm dlx @e18e/cli analyze",
|
|
73
76
|
"preinstall": "only-allow pnpm",
|
|
74
77
|
"bump": "bumpy add",
|
|
75
|
-
"bump:status": "bumpy status"
|
|
78
|
+
"bump:status": "bumpy status",
|
|
79
|
+
"skills:check": "intent maintainer check"
|
|
76
80
|
}
|
|
77
81
|
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: configure-throttled-queue
|
|
3
|
+
description: >
|
|
4
|
+
Use when creating, configuring, or debugging a dynamic-throttled-queue
|
|
5
|
+
(createThrottledQueue): pacing calls to a rate-limited API, choosing
|
|
6
|
+
min_rpi/max_rpi/interval/concurrency, adapting the rate to errors or HTTP
|
|
7
|
+
429s, retrying failed work, honoring Retry-After, getting results back with
|
|
8
|
+
submit() and cancelling them, or pausing, stopping, aborting, and draining
|
|
9
|
+
the queue.
|
|
10
|
+
metadata:
|
|
11
|
+
purpose: >
|
|
12
|
+
Teach agents to configure dynamic-throttled-queue correctly: start-rate
|
|
13
|
+
pacing versus async concurrency, adaptive-rate outcome classification
|
|
14
|
+
versus retry eligibility, server-directed cooldowns, task handles, and the
|
|
15
|
+
pause/resume/stop/abort/failed lifecycle semantics.
|
|
16
|
+
type: core
|
|
17
|
+
library: dynamic-throttled-queue
|
|
18
|
+
library_version: 2.2.0
|
|
19
|
+
sources:
|
|
20
|
+
- src/*.ts
|
|
21
|
+
- src/__tests__/*.test.ts
|
|
22
|
+
- tests/skills/configure-throttled-queue/*
|
|
23
|
+
- README.md
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Configure a dynamic-throttled-queue
|
|
27
|
+
|
|
28
|
+
`createThrottledQueue(options)` returns a function that enqueues callbacks, plus
|
|
29
|
+
control methods. Work through the steps in order; each ends with a check.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
35
|
+
|
|
36
|
+
// At most 5 request starts per second, spread evenly (one every 200 ms).
|
|
37
|
+
const queue = createThrottledQueue({ min_rpi: 5, interval: 1000 });
|
|
38
|
+
|
|
39
|
+
queue(async ({ signal }) => {
|
|
40
|
+
const response = await fetch("https://api.example.com/data", { signal });
|
|
41
|
+
if (!response.ok) return false; // fire-and-forget failure signal
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The package is ESM-only and requires Node.js 24 or later.
|
|
46
|
+
|
|
47
|
+
## 1. Pick the start rate
|
|
48
|
+
|
|
49
|
+
The rate counts **callback starts per `interval` milliseconds**. It says nothing
|
|
50
|
+
about how long a callback runs.
|
|
51
|
+
|
|
52
|
+
| Option | Meaning | Rule |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `min_rpi` | Lowest starts per interval | Required positive integer |
|
|
55
|
+
| `max_rpi` | Highest starts per interval | Integer `>= min_rpi`; defaults to `min_rpi` |
|
|
56
|
+
| `interval` | Window length in ms | Required positive number |
|
|
57
|
+
| `evenly_spaced` | `true` (default): one start every `interval / rate` ms. `false`: up to `rate` starts at once per interval | |
|
|
58
|
+
|
|
59
|
+
- A provider limit of "100 requests per minute" is `{ min_rpi: 100, interval: 60_000 }`.
|
|
60
|
+
Use `evenly_spaced: false` only when bursts are allowed.
|
|
61
|
+
- With `max_rpi === min_rpi` the rate is fixed. With a range, the queue starts at
|
|
62
|
+
`Math.ceil((min_rpi + max_rpi) / 2)` and adapts between the bounds (step 3).
|
|
63
|
+
- Set `max_rpi` to the provider's documented ceiling, never above it: the default
|
|
64
|
+
strategy raises the rate after every clean interval that has pending work.
|
|
65
|
+
|
|
66
|
+
Check: `createThrottledQueue` throws synchronously on invalid numbers, and
|
|
67
|
+
`queue.getState().rate` shows the starting rate.
|
|
68
|
+
|
|
69
|
+
## 2. Bound in-flight work separately
|
|
70
|
+
|
|
71
|
+
`concurrency` caps callbacks still awaiting asynchronous settlement. It is
|
|
72
|
+
independent of the start rate; omitting it allows unlimited in-flight work.
|
|
73
|
+
Use both when the provider limits requests per window **and** simultaneous
|
|
74
|
+
connections:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
78
|
+
|
|
79
|
+
const queue = createThrottledQueue({
|
|
80
|
+
min_rpi: 10,
|
|
81
|
+
interval: 1000,
|
|
82
|
+
concurrency: 3, // never more than 3 unsettled callbacks
|
|
83
|
+
maxQueueSize: 1000, // bound accepted, not-yet-terminal work
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
When `maxQueueSize` is reached, `queue(...)` and `submit(...)` **throw
|
|
88
|
+
synchronously** (`Cannot enqueue work: maxQueueSize has been reached`). Capacity covers
|
|
89
|
+
pending, active, and retrying work until each item is terminal.
|
|
90
|
+
|
|
91
|
+
Check: `getState().active` never exceeds `concurrency`.
|
|
92
|
+
|
|
93
|
+
## 3. Decide which failures adapt the rate
|
|
94
|
+
|
|
95
|
+
A failure is a callback that returns `false`, throws, or rejects. By default
|
|
96
|
+
every failure counts toward `errors_per_interval` (default `5`). When the count
|
|
97
|
+
reaches the threshold, the default `linear` strategy lowers the rate by 1; an
|
|
98
|
+
interval with zero errors and pending work raises it by 1. `back_off: true`
|
|
99
|
+
also skips one full interval after the threshold is hit.
|
|
100
|
+
|
|
101
|
+
Count only capacity signals with `rateOutcomeClassifier`. It receives
|
|
102
|
+
`{ kind: "returned-false" }`, `{ kind: "thrown", error }`, or
|
|
103
|
+
`{ kind: "rejected", error }` and returns whether the failure reduces the rate:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { createThrottledQueue, type FailureOutcome } from "dynamic-throttled-queue";
|
|
107
|
+
|
|
108
|
+
class HttpError extends Error {
|
|
109
|
+
constructor(readonly status: number) {
|
|
110
|
+
super(`HTTP ${status}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isCapacitySignal(outcome: FailureOutcome): boolean {
|
|
115
|
+
if (outcome.kind === "returned-false") return false;
|
|
116
|
+
const { error } = outcome;
|
|
117
|
+
return error instanceof HttpError && (error.status === 429 || error.status >= 500);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const queue = createThrottledQueue({
|
|
121
|
+
min_rpi: 1,
|
|
122
|
+
max_rpi: 20,
|
|
123
|
+
interval: 1000,
|
|
124
|
+
errors_per_interval: 3,
|
|
125
|
+
back_off: true,
|
|
126
|
+
rateOutcomeClassifier: isCapacitySignal,
|
|
127
|
+
onRateChange: rate => console.log(`rate is now ${rate}/s`),
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If the classifier throws, the failure counts as rate-reducing. To change how
|
|
132
|
+
the rate moves (for example AIMD), read
|
|
133
|
+
[rate strategies and timing](references/rate-strategies.md).
|
|
134
|
+
|
|
135
|
+
Check: `getState().rateDecreases` grows only for the failures you classify as
|
|
136
|
+
capacity signals.
|
|
137
|
+
|
|
138
|
+
## 4. Configure retries independently
|
|
139
|
+
|
|
140
|
+
`retry` is the hard cap on extra attempts (default `0`). `retryClassifier`
|
|
141
|
+
decides eligibility per failure and must return literal `true` to retry.
|
|
142
|
+
`retryBackoff` delays retries; without it they are immediate.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
146
|
+
|
|
147
|
+
const queue = createThrottledQueue({
|
|
148
|
+
min_rpi: 5,
|
|
149
|
+
interval: 1000,
|
|
150
|
+
retry: 3,
|
|
151
|
+
retryClassifier: (outcome, attempt) => outcome.kind !== "returned-false" && attempt <= 3,
|
|
152
|
+
retryBackoff: { strategy: "exponential", baseDelay: 250, maxDelay: 10_000, jitter: 0.2 },
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Retry and rate decisions are separate: a failure can be retryable,
|
|
157
|
+
rate-reducing, both, or neither. Every retry is a new attempt that obeys normal
|
|
158
|
+
pacing. For backoff formulas and validation, read
|
|
159
|
+
[retry details](references/retries.md).
|
|
160
|
+
|
|
161
|
+
Check: `getState().retried` counts scheduled retries; the callback's
|
|
162
|
+
`attempt` (1-based) counts up across retries.
|
|
163
|
+
|
|
164
|
+
## 5. Honor server cooldowns
|
|
165
|
+
|
|
166
|
+
When a server says when capacity returns (HTTP 429 with `Retry-After`), call
|
|
167
|
+
`cooldownFor(ms)`. The queue starts nothing new until then, keeps pending
|
|
168
|
+
work, and makes no rate decision meanwhile. It does not retry or lower the rate
|
|
169
|
+
on its own; combine it with steps 3 and 4.
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
173
|
+
|
|
174
|
+
const queue = createThrottledQueue({ min_rpi: 1, max_rpi: 10, interval: 1000, retry: 2, maxCooldown: 60_000 });
|
|
175
|
+
|
|
176
|
+
const task = queue.submit(async ({ signal }) => {
|
|
177
|
+
const response = await fetch("https://api.example.com/data", { signal });
|
|
178
|
+
if (response.status === 429) {
|
|
179
|
+
const seconds = Number(response.headers.get("Retry-After"));
|
|
180
|
+
if (Number.isFinite(seconds) && seconds >= 0) queue.cooldownFor(seconds * 1000);
|
|
181
|
+
throw new Error("rate limited");
|
|
182
|
+
}
|
|
183
|
+
return response.json() as Promise<unknown>;
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Convert the header yourself and clamp past dates to `0`: `NaN`, negative, and
|
|
188
|
+
infinite delays throw `RangeError`. Delays above `maxCooldown` are clamped. A
|
|
189
|
+
later deadline replaces an earlier one; overlapping hints do not add up.
|
|
190
|
+
|
|
191
|
+
Check: `getState().cooldownRemaining` is non-zero during the cooldown.
|
|
192
|
+
|
|
193
|
+
## 6. Get results with task handles
|
|
194
|
+
|
|
195
|
+
Use `submit()` when the caller needs the value. Its semantics differ from the
|
|
196
|
+
fire-and-forget form:
|
|
197
|
+
|
|
198
|
+
- Every returned value, **including `false`**, is a successful result. Only a
|
|
199
|
+
throw or rejection fails, retries, and counts for the rate.
|
|
200
|
+
- `result` settles once: the final value, the last attempt's error, or the
|
|
201
|
+
cancel reason.
|
|
202
|
+
- `cancel(reason?)` rejects `result` immediately (an `AbortError` by default).
|
|
203
|
+
A pending item is removed; an active one has only its own signal aborted and
|
|
204
|
+
keeps its concurrency slot until the callback returns. Cancelled work never
|
|
205
|
+
retries and never counts as success, failure, or a rate error.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
209
|
+
|
|
210
|
+
const queue = createThrottledQueue({ min_rpi: 5, interval: 1000, retry: 2 });
|
|
211
|
+
|
|
212
|
+
const task = queue.submit(async ({ signal, attempt }) => {
|
|
213
|
+
const response = await fetch("https://api.example.com/item", { signal });
|
|
214
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} on attempt ${attempt}`);
|
|
215
|
+
return (await response.json()) as { id: string };
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
setTimeout(() => task.cancel(), 5000);
|
|
219
|
+
const item = await task.result.catch(() => undefined);
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Check: every `result` you cancel or abort has a rejection handler.
|
|
223
|
+
|
|
224
|
+
## 7. Control the lifecycle
|
|
225
|
+
|
|
226
|
+
| Call | Effect | Reversible |
|
|
227
|
+
| --- | --- | --- |
|
|
228
|
+
| `pause()` | No new starts; keeps work; active callbacks finish | `resume()` |
|
|
229
|
+
| `stop()` | Clears timers; keeps work | The next enqueue restarts it |
|
|
230
|
+
| `abort()` | Discards pending work, aborts the shared signal, later enqueues throw | No |
|
|
231
|
+
|
|
232
|
+
A throwing or malformed `rateStrategy` moves the queue to `failed`: terminal,
|
|
233
|
+
pending work discarded, enqueues throw the strategy error, and `waitForIdle()`
|
|
234
|
+
promises reject with it.
|
|
235
|
+
Create a new queue to continue. For per-state rules, read
|
|
236
|
+
[lifecycle details](references/lifecycle.md) before writing shutdown,
|
|
237
|
+
pause, or error-recovery code.
|
|
238
|
+
|
|
239
|
+
Drain and inspect:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
243
|
+
|
|
244
|
+
const queue = createThrottledQueue({ min_rpi: 5, interval: 1000 });
|
|
245
|
+
for (const id of ["a", "b", "c"]) {
|
|
246
|
+
queue(async ({ signal }) => {
|
|
247
|
+
await fetch(`https://api.example.com/items/${id}`, { signal });
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await queue.waitForIdle(); // pending, active, and delayed retries all terminal
|
|
252
|
+
const { succeeded, failed, state } = queue.getState();
|
|
253
|
+
console.log({ succeeded, failed, state });
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
For every `getState()` field and `waitForIdle()` edge case, read
|
|
257
|
+
[state and idle](references/state-and-idle.md).
|
|
258
|
+
|
|
259
|
+
Check: `waitForIdle()` resolves and `getState().pending` and `.active` are `0`.
|
|
260
|
+
|
|
261
|
+
## Common Mistakes
|
|
262
|
+
|
|
263
|
+
### CRITICAL: Ignoring the signal and expecting abort or cancel to stop work
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
267
|
+
|
|
268
|
+
const queue = createThrottledQueue({ min_rpi: 5, interval: 1000 });
|
|
269
|
+
// Wrong: abort() cannot stop this request
|
|
270
|
+
queue(async () => { await fetch("https://api.example.com/data"); });
|
|
271
|
+
// Right
|
|
272
|
+
queue(async ({ signal }) => { await fetch("https://api.example.com/data", { signal }); });
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Cancellation is cooperative. A callback that ignores `signal` keeps running
|
|
276
|
+
after `abort()` or `cancel()`; only its outcome is discarded. Source: README
|
|
277
|
+
"Lifecycle"; `src/scheduler.ts`.
|
|
278
|
+
|
|
279
|
+
### CRITICAL: Starting a promise without returning or awaiting it
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
import { createThrottledQueue } from "dynamic-throttled-queue";
|
|
283
|
+
|
|
284
|
+
const queue = createThrottledQueue({ min_rpi: 5, interval: 1000, concurrency: 2, retry: 2 });
|
|
285
|
+
// Wrong: the queue sees a synchronous success; no retry, no concurrency slot
|
|
286
|
+
queue(({ signal }) => { void fetch("https://api.example.com/data", { signal }).catch(() => {}); });
|
|
287
|
+
// Right: return (or await) the work
|
|
288
|
+
queue(({ signal }) => fetch("https://api.example.com/data", { signal }));
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The queue only tracks what the callback returns. An unreturned promise cannot
|
|
292
|
+
fail, retry, hold a `concurrency` slot, or delay `waitForIdle()`. Source: README
|
|
293
|
+
"Queuing work".
|
|
294
|
+
|
|
295
|
+
### HIGH: Using the rate to limit simultaneous requests
|
|
296
|
+
|
|
297
|
+
`min_rpi: 3` allows 3 starts per interval; slow callbacks pile up without
|
|
298
|
+
bound. Add `concurrency` for an in-flight cap. Source: `ThrottleOptions.concurrency`.
|
|
299
|
+
|
|
300
|
+
### HIGH: Returning `false` from `submit()` to signal failure
|
|
301
|
+
|
|
302
|
+
In `submit()`, `false` is a successful result: no retry, no rate reduction.
|
|
303
|
+
Throw instead. Fire-and-forget callbacks still treat `false` as failure; any
|
|
304
|
+
other returned value is success. Source: `TaskCallback` and `ThrottleCallback`
|
|
305
|
+
in `src/scheduler.ts`.
|
|
306
|
+
|
|
307
|
+
### HIGH: Expecting `retryClassifier` or cooldowns to change the rate
|
|
308
|
+
|
|
309
|
+
Retry eligibility, rate counting (`rateOutcomeClassifier`), and `cooldownFor`
|
|
310
|
+
are three independent decisions. Configure each one you need. Source: README
|
|
311
|
+
"Failure classification" and "Server-directed cooldown".
|
|
312
|
+
|
|
313
|
+
### HIGH: Unhandled rejections from cancelled or aborted tasks
|
|
314
|
+
|
|
315
|
+
`cancel()` and `abort()` reject `task.result`. Attach a handler such as
|
|
316
|
+
`task.result.catch(() => {})` if you do not await it.
|
|
317
|
+
|
|
318
|
+
### MEDIUM: Treating `stop()` as shutdown
|
|
319
|
+
|
|
320
|
+
`stop()` keeps pending work and the next enqueue restarts processing. Use
|
|
321
|
+
`abort()` for terminal shutdown.
|
|
322
|
+
|
|
323
|
+
### MEDIUM: Catching the `maxQueueSize` overflow as a return value
|
|
324
|
+
|
|
325
|
+
A full queue throws from `queue(...)` and `submit(...)`. Wrap enqueue
|
|
326
|
+
calls in `try`/`catch` when `maxQueueSize` is set.
|
|
327
|
+
|
|
328
|
+
## Completion
|
|
329
|
+
|
|
330
|
+
The configuration is complete when:
|
|
331
|
+
|
|
332
|
+
1. The start rate matches the provider limit and `max_rpi` does not exceed it.
|
|
333
|
+
2. `concurrency` is set when the provider limits simultaneous requests.
|
|
334
|
+
3. Each failure type is deliberately classified for rate reduction and retry.
|
|
335
|
+
4. Every async callback passes `signal` to its I/O.
|
|
336
|
+
5. Every `submit()` result is awaited or has a rejection handler.
|
|
337
|
+
6. Shutdown uses `abort()` or `waitForIdle()`, not `stop()`.
|
|
338
|
+
|
|
339
|
+
If the queue reports `state: "failed"`, fix the custom `rateStrategy` and
|
|
340
|
+
create a new queue.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Lifecycle details
|
|
2
|
+
|
|
3
|
+
Read this before writing shutdown, pause/resume, or error-recovery code, or
|
|
4
|
+
when a queue stops starting work unexpectedly.
|
|
5
|
+
|
|
6
|
+
| State | Pending work and enqueue | Active work | Lifecycle calls |
|
|
7
|
+
| --- | --- | --- | --- |
|
|
8
|
+
| `running` | Starts normally; enqueues accepted | Runs | `pause()` retains work and stops starts. `stop()` retains work and clears timers. `abort()` is terminal. A strategy failure moves to `failed`. |
|
|
9
|
+
| `paused` | Pending work, new enqueues, and retries are kept but do not start | Continues and settles | `resume()` restarts pacing at the current rate with a fresh observation window. `pause()` is idempotent. `stop()` clears the paused state. |
|
|
10
|
+
| `stopped` | Pending work kept; the next enqueue restarts scheduling | Continues and settles | `pause()` and `resume()` do nothing. `stop()` is idempotent. |
|
|
11
|
+
| `aborted` | Pending work discarded; enqueues throw | Receives the aborted shared signal and may finish cooperatively | Nothing restarts scheduling. `abort()` is idempotent. |
|
|
12
|
+
| `failed` | Pending work and delayed retries discarded; enqueues throw the strategy error | Continues and settles without retries or accounting; the shared signal is **not** aborted | Terminal. All lifecycle calls do nothing. |
|
|
13
|
+
|
|
14
|
+
## Rules that are easy to miss
|
|
15
|
+
|
|
16
|
+
- While paused, outcomes do not adjust the rate. A failure can still create a
|
|
17
|
+
retry, which waits for `resume()`.
|
|
18
|
+
- `stop()` cannot cancel an active callback, and that callback settling does not
|
|
19
|
+
restart scheduling. Only a new enqueue does, and it also resumes frozen
|
|
20
|
+
delayed retries.
|
|
21
|
+
- `abort()` aborts one shared signal for fire-and-forget callbacks; `submit()`
|
|
22
|
+
callbacks get a per-item signal that `abort()` also aborts. Late outcomes are
|
|
23
|
+
discarded and never retry.
|
|
24
|
+
- On `failed`, the strategy error is rethrown where the decision ran: as an
|
|
25
|
+
uncaught exception from the adaptive-rate timer, or as an unhandled rejection
|
|
26
|
+
when a slow async callback completes a settled-timing window. Pending and
|
|
27
|
+
later `waitForIdle()` calls reject with it. Create a new queue to resume.
|
|
28
|
+
|
|
29
|
+
## Cooldown interaction
|
|
30
|
+
|
|
31
|
+
- `pause()` and `cooldownFor()` are separate. A cooldown keeps elapsing while
|
|
32
|
+
paused, its end does not resume a paused queue, and `resume()` does not end it.
|
|
33
|
+
- `stop()` clears the cooldown timer but keeps the deadline, so the restarting
|
|
34
|
+
enqueue still waits for it.
|
|
35
|
+
- `abort()` and a strategy failure clear the cooldown; later valid
|
|
36
|
+
`cooldownFor()` calls do nothing, but invalid delays still throw `RangeError`.
|
|
37
|
+
- A cooldown replaces any adaptive-rate backoff in progress. Failures that
|
|
38
|
+
settle during a cooldown count toward the first decision after it.
|
|
39
|
+
|
|
40
|
+
## Task handles across the lifecycle
|
|
41
|
+
|
|
42
|
+
- `pause()` and `stop()` leave `result` pending.
|
|
43
|
+
- `abort()` rejects every unsettled `result` with the queue signal's
|
|
44
|
+
`AbortError`; a strategy failure rejects them with the strategy error.
|
|
45
|
+
- `submit()` throws synchronously and returns no handle when the queue is full,
|
|
46
|
+
aborted, or failed.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Rate strategies and adjustment timing
|
|
2
|
+
|
|
3
|
+
Read this when the default one-step `linear` adaptation is too slow or too
|
|
4
|
+
fast, when writing a custom `RateStrategy`, or when slow callbacks make rate
|
|
5
|
+
decisions land in the wrong interval.
|
|
6
|
+
|
|
7
|
+
## Built-in strategies
|
|
8
|
+
|
|
9
|
+
| Strategy | Threshold reached | Clean interval with pending work | Otherwise |
|
|
10
|
+
| --- | --- | --- | --- |
|
|
11
|
+
| `linear` (default) | `currentRate - 1`, request backoff | `currentRate + 1` | hold |
|
|
12
|
+
| `aimd({ increaseBy = 1, decreaseFactor = 0.5 })` | `Math.floor(currentRate * decreaseFactor)`, request backoff | `currentRate + increaseBy` | hold |
|
|
13
|
+
|
|
14
|
+
A backoff request (`shouldBackOff: true`) skips an interval only when the queue
|
|
15
|
+
has `back_off: true`.
|
|
16
|
+
|
|
17
|
+
"Clean" means zero counted errors and the previous window was not a backoff.
|
|
18
|
+
Partial-error, empty, and immediately-post-backoff windows hold the rate. The
|
|
19
|
+
queue clamps every `nextRate` to `[min_rpi, max_rpi]`. `aimd()` throws unless
|
|
20
|
+
`increaseBy` is a positive integer and `0 < decreaseFactor < 1`.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { aimd, createThrottledQueue } from "dynamic-throttled-queue";
|
|
24
|
+
|
|
25
|
+
const queue = createThrottledQueue({
|
|
26
|
+
min_rpi: 1,
|
|
27
|
+
max_rpi: 100,
|
|
28
|
+
interval: 1000,
|
|
29
|
+
rateStrategy: aimd({ increaseBy: 2, decreaseFactor: 0.5 }),
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Custom strategies
|
|
34
|
+
|
|
35
|
+
A `RateStrategy` is a pure function. It receives a frozen observation
|
|
36
|
+
`{ currentRate, minRate, maxRate, errorCount, errorThreshold, hasPendingWork, wasBackedOff }`
|
|
37
|
+
and returns `{ nextRate, shouldBackOff }`.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createThrottledQueue, type RateStrategy } from "dynamic-throttled-queue";
|
|
41
|
+
|
|
42
|
+
const decreaseOnly: RateStrategy = ({ currentRate, minRate, errorCount, errorThreshold }) => ({
|
|
43
|
+
nextRate: errorCount >= errorThreshold ? Math.max(minRate, currentRate - 1) : currentRate,
|
|
44
|
+
shouldBackOff: errorCount >= errorThreshold,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const queue = createThrottledQueue({ min_rpi: 1, max_rpi: 10, interval: 1000, rateStrategy: decreaseOnly });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `nextRate` must be a finite integer and `shouldBackOff` a boolean. Anything
|
|
51
|
+
else, or a thrown error, **fails the queue permanently** (see
|
|
52
|
+
[lifecycle](lifecycle.md)). Never return `NaN`, fractions, or `undefined`.
|
|
53
|
+
- `shouldBackOff` only pauses scheduling when the queue has `back_off: true`.
|
|
54
|
+
|
|
55
|
+
## Adjustment timing
|
|
56
|
+
|
|
57
|
+
- `adjustmentTiming: "interval"` (default): each interval decides from the
|
|
58
|
+
outcomes that have settled so far. A slow callback can settle after its start
|
|
59
|
+
interval and affect a later decision.
|
|
60
|
+
- `adjustmentTiming: "settled"`: a window holds every attempt started during one
|
|
61
|
+
interval. When collection closes, no further callbacks start until every
|
|
62
|
+
attempt in the window settles, then exactly one decision is made. Use it when
|
|
63
|
+
callbacks run longer than `interval` and decisions must reflect complete
|
|
64
|
+
windows. A callback that never settles blocks later windows until it settles
|
|
65
|
+
or the queue is aborted, so give callbacks timeouts.
|
|
66
|
+
|
|
67
|
+
While paused, settled outcomes do not count toward the rate. In settled timing,
|
|
68
|
+
`pause()` discards the open window.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Retry details
|
|
2
|
+
|
|
3
|
+
Read this when choosing a `retryBackoff` policy, when `createThrottledQueue`
|
|
4
|
+
rejects retry options, or when retry counts in `getState()` look wrong.
|
|
5
|
+
|
|
6
|
+
## Eligibility
|
|
7
|
+
|
|
8
|
+
1. A failure (`false` in fire-and-forget form, a throw, or a rejection) is
|
|
9
|
+
eligible only while `retry` budget remains. `retry` must be a non-negative
|
|
10
|
+
integer.
|
|
11
|
+
2. If `retryClassifier` is set, it is called with the normalized outcome and the
|
|
12
|
+
one-based attempt that just failed. Only literal `true` retries; any other
|
|
13
|
+
value makes the failure permanent. It is not called once the budget is spent.
|
|
14
|
+
3. If the classifier throws, the queue retries within the remaining budget.
|
|
15
|
+
|
|
16
|
+
Cancelled tasks, and outcomes that settle after `abort()` or a strategy
|
|
17
|
+
failure, never retry.
|
|
18
|
+
|
|
19
|
+
## Backoff
|
|
20
|
+
|
|
21
|
+
`retryBackoff` is
|
|
22
|
+
`{ strategy: "fixed" | "linear" | "exponential"; baseDelay: number; maxDelay?: number; jitter?: number; random?: () => number }`.
|
|
23
|
+
|
|
24
|
+
For retry index `n` (first retry is `1`):
|
|
25
|
+
|
|
26
|
+
| Strategy | Delay before cap |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `fixed` | `baseDelay` |
|
|
29
|
+
| `linear` | `baseDelay × n` |
|
|
30
|
+
| `exponential` | `baseDelay × 2^(n - 1)` |
|
|
31
|
+
|
|
32
|
+
The delay is capped at `maxDelay`, symmetric percentage `jitter` (`0`–`1`) is
|
|
33
|
+
applied, then it is capped again. `baseDelay` and `maxDelay` must be finite and
|
|
34
|
+
non-negative; an unknown `strategy` throws. Pass `random` to make jitter
|
|
35
|
+
deterministic in tests; a throwing or out-of-range `random` falls back to no
|
|
36
|
+
jitter.
|
|
37
|
+
|
|
38
|
+
Backoff starts when the failed attempt settles. It is independent of the
|
|
39
|
+
adaptive-rate `back_off` option.
|
|
40
|
+
|
|
41
|
+
## Scheduling
|
|
42
|
+
|
|
43
|
+
Delayed retries count as pending work and occupy `maxQueueSize` capacity. When
|
|
44
|
+
due, they rejoin the tail of the queue and obey normal pacing; equal due times
|
|
45
|
+
keep their scheduling order. While paused or stopped, their remaining delay
|
|
46
|
+
freezes. `abort()` discards them.
|
|
47
|
+
|
|
48
|
+
## Counters
|
|
49
|
+
|
|
50
|
+
`started`, `succeeded`, and `failed` count attempts: a failure is counted even
|
|
51
|
+
when a later retry succeeds. `retried` increments only when another attempt is
|
|
52
|
+
actually scheduled.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# State and idle
|
|
2
|
+
|
|
3
|
+
Read this when reporting queue metrics, asserting on queue state in tests, or
|
|
4
|
+
waiting for work to finish.
|
|
5
|
+
|
|
6
|
+
## `getState()`
|
|
7
|
+
|
|
8
|
+
Returns a frozen snapshot; mutating it does not affect the queue.
|
|
9
|
+
|
|
10
|
+
| Field | Meaning |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `rate` | Current starts per interval |
|
|
13
|
+
| `pending` | Queued items plus delayed retries |
|
|
14
|
+
| `active` | Callbacks currently executing |
|
|
15
|
+
| `state` | `"running"`, `"paused"`, `"stopped"`, `"aborted"`, or `"failed"` |
|
|
16
|
+
| `cooldownRemaining` | Ms until the cooldown ends, rounded up; `0` when none |
|
|
17
|
+
| `started`, `succeeded`, `failed` | Attempts; a retry is a new attempt |
|
|
18
|
+
| `retried` | Retries actually scheduled |
|
|
19
|
+
| `canceled` | Submitted items whose `result` `cancel()` rejected |
|
|
20
|
+
| `rateIncreases`, `rateDecreases` | Applied rate changes; match `onRateChange` calls exactly |
|
|
21
|
+
| `cooldowns`, `cooldownTotal` | `cooldownFor()` calls that moved the deadline later, and the ms they added |
|
|
22
|
+
|
|
23
|
+
Counters are monotonic and stop changing after `abort()` or a strategy failure.
|
|
24
|
+
A cancelled active attempt counts as neither succeeded nor failed.
|
|
25
|
+
|
|
26
|
+
## `waitForIdle()`
|
|
27
|
+
|
|
28
|
+
Resolves once pending work, active callbacks, and delayed retries are all
|
|
29
|
+
terminal; immediately if already idle.
|
|
30
|
+
|
|
31
|
+
- One-shot: a later enqueue does not affect a resolved promise. Concurrent
|
|
32
|
+
callers resolve together.
|
|
33
|
+
- A failure that schedules a retry never produces a transient idle.
|
|
34
|
+
- After `stop()`, waiters stay pending until a later enqueue lets the work
|
|
35
|
+
finish. Do not use `stop()` + `waitForIdle()` as shutdown.
|
|
36
|
+
- After `abort()`, waiters resolve as soon as no callback is active.
|
|
37
|
+
- While paused, waiters resolve only when pending and active are both `0`.
|
|
38
|
+
- On `failed`, every waiter rejects with the strategy error at once.
|