@tanstack/pacer 0.9.1 → 0.10.0
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/cjs/async-batcher.cjs +1 -12
- package/dist/cjs/async-batcher.cjs.map +1 -1
- package/dist/cjs/async-batcher.d.cts +4 -16
- package/dist/cjs/async-debouncer.cjs +25 -8
- package/dist/cjs/async-debouncer.cjs.map +1 -1
- package/dist/cjs/async-debouncer.d.cts +1 -1
- package/dist/cjs/async-queuer.cjs +15 -4
- package/dist/cjs/async-queuer.cjs.map +1 -1
- package/dist/cjs/async-queuer.d.cts +10 -5
- package/dist/cjs/async-rate-limiter.cjs +53 -12
- package/dist/cjs/async-rate-limiter.cjs.map +1 -1
- package/dist/cjs/async-rate-limiter.d.cts +8 -0
- package/dist/cjs/async-throttler.cjs +17 -6
- package/dist/cjs/async-throttler.cjs.map +1 -1
- package/dist/cjs/async-throttler.d.cts +1 -1
- package/dist/cjs/batcher.cjs +1 -12
- package/dist/cjs/batcher.cjs.map +1 -1
- package/dist/cjs/batcher.d.cts +4 -16
- package/dist/cjs/queuer.cjs +11 -0
- package/dist/cjs/queuer.cjs.map +1 -1
- package/dist/cjs/queuer.d.cts +13 -8
- package/dist/cjs/rate-limiter.cjs +53 -12
- package/dist/cjs/rate-limiter.cjs.map +1 -1
- package/dist/cjs/rate-limiter.d.cts +8 -0
- package/dist/cjs/throttler.cjs.map +1 -1
- package/dist/cjs/throttler.d.cts +4 -4
- package/dist/esm/async-batcher.d.ts +4 -16
- package/dist/esm/async-batcher.js +1 -12
- package/dist/esm/async-batcher.js.map +1 -1
- package/dist/esm/async-debouncer.d.ts +1 -1
- package/dist/esm/async-debouncer.js +25 -8
- package/dist/esm/async-debouncer.js.map +1 -1
- package/dist/esm/async-queuer.d.ts +10 -5
- package/dist/esm/async-queuer.js +15 -4
- package/dist/esm/async-queuer.js.map +1 -1
- package/dist/esm/async-rate-limiter.d.ts +8 -0
- package/dist/esm/async-rate-limiter.js +53 -12
- package/dist/esm/async-rate-limiter.js.map +1 -1
- package/dist/esm/async-throttler.d.ts +1 -1
- package/dist/esm/async-throttler.js +17 -6
- package/dist/esm/async-throttler.js.map +1 -1
- package/dist/esm/batcher.d.ts +4 -16
- package/dist/esm/batcher.js +1 -12
- package/dist/esm/batcher.js.map +1 -1
- package/dist/esm/queuer.d.ts +13 -8
- package/dist/esm/queuer.js +11 -0
- package/dist/esm/queuer.js.map +1 -1
- package/dist/esm/rate-limiter.d.ts +8 -0
- package/dist/esm/rate-limiter.js +53 -12
- package/dist/esm/rate-limiter.js.map +1 -1
- package/dist/esm/throttler.d.ts +4 -4
- package/dist/esm/throttler.js.map +1 -1
- package/package.json +1 -1
- package/src/async-batcher.ts +5 -28
- package/src/async-debouncer.ts +27 -8
- package/src/async-queuer.ts +27 -9
- package/src/async-rate-limiter.ts +72 -12
- package/src/async-throttler.ts +19 -6
- package/src/batcher.ts +5 -28
- package/src/queuer.ts +24 -8
- package/src/rate-limiter.ts +71 -11
- package/src/throttler.ts +4 -4
package/src/async-debouncer.ts
CHANGED
|
@@ -176,6 +176,7 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
|
|
|
176
176
|
#resolvePreviousPromise:
|
|
177
177
|
| ((value?: ReturnType<TFn> | undefined) => void)
|
|
178
178
|
| null = null
|
|
179
|
+
#rejectPreviousPromise: ((reason?: unknown) => void) | null = null
|
|
179
180
|
|
|
180
181
|
constructor(
|
|
181
182
|
private fn: TFn,
|
|
@@ -270,8 +271,9 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
|
|
|
270
271
|
this.#setState({ isPending: true })
|
|
271
272
|
}
|
|
272
273
|
|
|
273
|
-
return new Promise((resolve) => {
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
274
275
|
this.#resolvePreviousPromise = resolve
|
|
276
|
+
this.#rejectPreviousPromise = reject
|
|
275
277
|
this.#timeoutId = setTimeout(async () => {
|
|
276
278
|
// Execute trailing if enabled
|
|
277
279
|
if (this.options.trailing && this.store.state.lastArgs) {
|
|
@@ -305,7 +307,7 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
|
|
|
305
307
|
})
|
|
306
308
|
this.options.onError?.(error, this)
|
|
307
309
|
if (this.options.throwOnError) {
|
|
308
|
-
|
|
310
|
+
this.#rejectPreviousPromiseInternal(error)
|
|
309
311
|
}
|
|
310
312
|
} finally {
|
|
311
313
|
this.#setState({
|
|
@@ -322,11 +324,31 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
|
|
|
322
324
|
/**
|
|
323
325
|
* Processes the current pending execution immediately
|
|
324
326
|
*/
|
|
325
|
-
flush = ():
|
|
327
|
+
flush = async (): Promise<ReturnType<TFn> | undefined> => {
|
|
326
328
|
if (this.store.state.isPending && this.store.state.lastArgs) {
|
|
327
329
|
this.#abortExecution() // abort any current execution
|
|
328
330
|
this.#clearTimeout() // clear any existing timeout
|
|
329
|
-
this.#execute(...this.store.state.lastArgs)
|
|
331
|
+
const result = await this.#execute(...this.store.state.lastArgs)
|
|
332
|
+
|
|
333
|
+
// Resolve any pending promise from maybeExecute
|
|
334
|
+
this.#resolvePreviousPromiseInternal()
|
|
335
|
+
|
|
336
|
+
return result
|
|
337
|
+
}
|
|
338
|
+
return undefined
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#resolvePreviousPromiseInternal = (): void => {
|
|
342
|
+
if (this.#resolvePreviousPromise) {
|
|
343
|
+
this.#resolvePreviousPromise(this.store.state.lastResult)
|
|
344
|
+
this.#resolvePreviousPromise = null
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#rejectPreviousPromiseInternal = (error: unknown): void => {
|
|
349
|
+
if (this.#rejectPreviousPromise) {
|
|
350
|
+
this.#rejectPreviousPromise(error)
|
|
351
|
+
this.#rejectPreviousPromise = null
|
|
330
352
|
}
|
|
331
353
|
}
|
|
332
354
|
|
|
@@ -339,10 +361,7 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
|
|
|
339
361
|
|
|
340
362
|
#cancelPendingExecution = (): void => {
|
|
341
363
|
this.#clearTimeout()
|
|
342
|
-
|
|
343
|
-
this.#resolvePreviousPromise(this.store.state.lastResult)
|
|
344
|
-
this.#resolvePreviousPromise = null
|
|
345
|
-
}
|
|
364
|
+
this.#resolvePreviousPromiseInternal()
|
|
346
365
|
this.#setState({
|
|
347
366
|
isPending: false,
|
|
348
367
|
isExecuting: false,
|
package/src/async-queuer.ts
CHANGED
|
@@ -32,14 +32,14 @@ export interface AsyncQueuerState<TValue> {
|
|
|
32
32
|
* Whether the queuer is active and will process items automatically
|
|
33
33
|
*/
|
|
34
34
|
isRunning: boolean
|
|
35
|
-
/**
|
|
36
|
-
* Timestamps when items were added to the queue for expiration tracking
|
|
37
|
-
*/
|
|
38
|
-
itemTimestamps: Array<number>
|
|
39
35
|
/**
|
|
40
36
|
* Array of items currently waiting to be processed
|
|
41
37
|
*/
|
|
42
38
|
items: Array<TValue>
|
|
39
|
+
/**
|
|
40
|
+
* Timestamps when items were added to the queue for expiration tracking
|
|
41
|
+
*/
|
|
42
|
+
itemTimestamps: Array<number>
|
|
43
43
|
/**
|
|
44
44
|
* The result from the most recent task execution
|
|
45
45
|
*/
|
|
@@ -507,6 +507,12 @@ export class AsyncQueuer<TValue> {
|
|
|
507
507
|
return item
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
#getAllItems = (): Array<TValue> => {
|
|
511
|
+
const items = this.peekAllItems()
|
|
512
|
+
this.clear()
|
|
513
|
+
return items
|
|
514
|
+
}
|
|
515
|
+
|
|
510
516
|
/**
|
|
511
517
|
* Removes and returns the next item from the queue and executes the task function with it.
|
|
512
518
|
*
|
|
@@ -552,14 +558,26 @@ export class AsyncQueuer<TValue> {
|
|
|
552
558
|
* Processes a specified number of items to execute immediately with no wait time
|
|
553
559
|
* If no numberOfItems is provided, all items will be processed
|
|
554
560
|
*/
|
|
555
|
-
flush = (
|
|
561
|
+
flush = async (
|
|
556
562
|
numberOfItems: number = this.store.state.items.length,
|
|
557
563
|
position?: QueuePosition,
|
|
558
|
-
): void => {
|
|
564
|
+
): Promise<void> => {
|
|
559
565
|
this.#clearTimeouts() // clear any pending timeouts
|
|
560
|
-
|
|
561
|
-
this.execute(position)
|
|
562
|
-
|
|
566
|
+
await Promise.all(
|
|
567
|
+
Array.from({ length: numberOfItems }, () => this.execute(position)),
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Processes all items in the queue as a batch using the provided function
|
|
573
|
+
* The queue is cleared after processing
|
|
574
|
+
*/
|
|
575
|
+
flushAsBatch = async (
|
|
576
|
+
batchFunction: (items: Array<TValue>) => Promise<any>,
|
|
577
|
+
): Promise<void> => {
|
|
578
|
+
this.#clearTimeouts() // clear any pending timeouts
|
|
579
|
+
const items = this.#getAllItems()
|
|
580
|
+
await batchFunction(items)
|
|
563
581
|
}
|
|
564
582
|
|
|
565
583
|
/**
|
|
@@ -11,6 +11,10 @@ export interface AsyncRateLimiterState<TFn extends AnyAsyncFunction> {
|
|
|
11
11
|
* Array of timestamps when executions occurred for rate limiting calculations
|
|
12
12
|
*/
|
|
13
13
|
executionTimes: Array<number>
|
|
14
|
+
/**
|
|
15
|
+
* Whether the rate limiter has exceeded the limit
|
|
16
|
+
*/
|
|
17
|
+
isExceeded: boolean
|
|
14
18
|
/**
|
|
15
19
|
* Whether the rate-limited function is currently executing asynchronously
|
|
16
20
|
*/
|
|
@@ -27,6 +31,10 @@ export interface AsyncRateLimiterState<TFn extends AnyAsyncFunction> {
|
|
|
27
31
|
* Number of function executions that have completed (either successfully or with errors)
|
|
28
32
|
*/
|
|
29
33
|
settleCount: number
|
|
34
|
+
/**
|
|
35
|
+
* Current execution status - 'disabled' when not active, 'executing' when executing, 'idle' when not executing, 'exceeded' when rate limit is exceeded
|
|
36
|
+
*/
|
|
37
|
+
status: 'disabled' | 'executing' | 'exceeded' | 'idle'
|
|
30
38
|
/**
|
|
31
39
|
* Number of function executions that have completed successfully
|
|
32
40
|
*/
|
|
@@ -39,11 +47,13 @@ function getDefaultAsyncRateLimiterState<
|
|
|
39
47
|
return {
|
|
40
48
|
errorCount: 0,
|
|
41
49
|
executionTimes: [],
|
|
50
|
+
isExceeded: false,
|
|
42
51
|
isExecuting: false,
|
|
43
52
|
lastResult: undefined,
|
|
44
53
|
rejectionCount: 0,
|
|
45
54
|
settleCount: 0,
|
|
46
55
|
successCount: 0,
|
|
56
|
+
status: 'idle',
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
|
|
@@ -189,6 +199,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
189
199
|
AsyncRateLimiterState<TFn>
|
|
190
200
|
>(getDefaultAsyncRateLimiterState<TFn>())
|
|
191
201
|
options: AsyncRateLimiterOptions<TFn>
|
|
202
|
+
#timeoutIds: Set<NodeJS.Timeout> = new Set()
|
|
192
203
|
|
|
193
204
|
constructor(
|
|
194
205
|
private fn: TFn,
|
|
@@ -200,6 +211,9 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
200
211
|
throwOnError: initialOptions.throwOnError ?? !initialOptions.onError,
|
|
201
212
|
}
|
|
202
213
|
this.#setState(this.options.initialState ?? {})
|
|
214
|
+
for (const executionTime of this.#getExecutionTimesInWindow()) {
|
|
215
|
+
this.#setCleanupTimeout(executionTime)
|
|
216
|
+
}
|
|
203
217
|
}
|
|
204
218
|
|
|
205
219
|
/**
|
|
@@ -215,7 +229,19 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
215
229
|
...state,
|
|
216
230
|
...newState,
|
|
217
231
|
}
|
|
218
|
-
|
|
232
|
+
const isExceeded = combinedState.executionTimes.length >= this.#getLimit()
|
|
233
|
+
const status = !this.#getEnabled()
|
|
234
|
+
? 'disabled'
|
|
235
|
+
: combinedState.isExecuting
|
|
236
|
+
? 'executing'
|
|
237
|
+
: isExceeded
|
|
238
|
+
? 'exceeded'
|
|
239
|
+
: 'idle'
|
|
240
|
+
return {
|
|
241
|
+
...combinedState,
|
|
242
|
+
isExceeded,
|
|
243
|
+
status,
|
|
244
|
+
}
|
|
219
245
|
})
|
|
220
246
|
}
|
|
221
247
|
|
|
@@ -270,7 +296,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
270
296
|
): Promise<ReturnType<TFn> | undefined> => {
|
|
271
297
|
this.#cleanupOldExecutions()
|
|
272
298
|
|
|
273
|
-
const relevantExecutionTimes = this.#
|
|
299
|
+
const relevantExecutionTimes = this.#getExecutionTimesInWindow()
|
|
274
300
|
|
|
275
301
|
if (relevantExecutionTimes.length < this.#getLimit()) {
|
|
276
302
|
await this.#execute(...args)
|
|
@@ -297,7 +323,8 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
297
323
|
})
|
|
298
324
|
|
|
299
325
|
try {
|
|
300
|
-
const result = await this.fn(...args)
|
|
326
|
+
const result = await this.fn(...args) // EXECUTE!
|
|
327
|
+
this.#setCleanupTimeout(now)
|
|
301
328
|
this.#setState({
|
|
302
329
|
successCount: this.store.state.successCount + 1,
|
|
303
330
|
lastResult: result,
|
|
@@ -322,7 +349,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
322
349
|
return this.store.state.lastResult
|
|
323
350
|
}
|
|
324
351
|
|
|
325
|
-
#
|
|
352
|
+
#getExecutionTimesInWindow = (): Array<number> => {
|
|
326
353
|
if (this.options.windowType === 'sliding') {
|
|
327
354
|
// For sliding window, return all executions within the current window
|
|
328
355
|
return this.store.state.executionTimes.filter(
|
|
@@ -331,22 +358,54 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
331
358
|
} else {
|
|
332
359
|
// For fixed window, return all executions in the current window
|
|
333
360
|
// The window starts from the oldest execution time
|
|
361
|
+
if (this.store.state.executionTimes.length === 0) {
|
|
362
|
+
return []
|
|
363
|
+
}
|
|
334
364
|
const oldestExecution = Math.min(...this.store.state.executionTimes)
|
|
335
365
|
const windowStart = oldestExecution
|
|
366
|
+
const windowEnd = windowStart + this.#getWindow()
|
|
367
|
+
const now = Date.now()
|
|
368
|
+
|
|
369
|
+
// If the window has expired, return empty array
|
|
370
|
+
if (now > windowEnd) {
|
|
371
|
+
return []
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Otherwise, return all executions in the current window
|
|
336
375
|
return this.store.state.executionTimes.filter(
|
|
337
|
-
(time) =>
|
|
338
|
-
time >= windowStart && time <= windowStart + this.#getWindow(),
|
|
376
|
+
(time) => time >= windowStart && time <= windowEnd,
|
|
339
377
|
)
|
|
340
378
|
}
|
|
341
379
|
}
|
|
342
380
|
|
|
381
|
+
#setCleanupTimeout = (executionTime: number): void => {
|
|
382
|
+
if (
|
|
383
|
+
this.options.windowType === 'sliding' ||
|
|
384
|
+
this.#timeoutIds.size === 0 // new fixed window
|
|
385
|
+
) {
|
|
386
|
+
const now = Date.now()
|
|
387
|
+
const timeUntilExpiration = executionTime - now + this.#getWindow() + 1
|
|
388
|
+
const timeoutId = setTimeout(() => {
|
|
389
|
+
this.#cleanupOldExecutions()
|
|
390
|
+
this.#clearTimeout(timeoutId)
|
|
391
|
+
}, timeUntilExpiration)
|
|
392
|
+
this.#timeoutIds.add(timeoutId)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#clearTimeout = (timeoutId: NodeJS.Timeout): void => {
|
|
397
|
+
clearTimeout(timeoutId)
|
|
398
|
+
this.#timeoutIds.delete(timeoutId)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#clearTimeouts = (): void => {
|
|
402
|
+
this.#timeoutIds.forEach((timeoutId) => clearTimeout(timeoutId))
|
|
403
|
+
this.#timeoutIds.clear()
|
|
404
|
+
}
|
|
405
|
+
|
|
343
406
|
#cleanupOldExecutions = (): void => {
|
|
344
|
-
const now = Date.now()
|
|
345
|
-
const windowStart = now - this.#getWindow()
|
|
346
407
|
this.#setState({
|
|
347
|
-
executionTimes: this
|
|
348
|
-
(time) => time > windowStart,
|
|
349
|
-
),
|
|
408
|
+
executionTimes: this.#getExecutionTimesInWindow(),
|
|
350
409
|
})
|
|
351
410
|
}
|
|
352
411
|
|
|
@@ -354,7 +413,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
354
413
|
* Returns the number of remaining executions allowed in the current window
|
|
355
414
|
*/
|
|
356
415
|
getRemainingInWindow = (): number => {
|
|
357
|
-
const relevantExecutionTimes = this.#
|
|
416
|
+
const relevantExecutionTimes = this.#getExecutionTimesInWindow()
|
|
358
417
|
return Math.max(0, this.#getLimit() - relevantExecutionTimes.length)
|
|
359
418
|
}
|
|
360
419
|
|
|
@@ -376,6 +435,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
|
|
|
376
435
|
*/
|
|
377
436
|
reset = (): void => {
|
|
378
437
|
this.#setState(getDefaultAsyncRateLimiterState())
|
|
438
|
+
this.#clearTimeouts()
|
|
379
439
|
}
|
|
380
440
|
}
|
|
381
441
|
|
package/src/async-throttler.ts
CHANGED
|
@@ -187,6 +187,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
187
187
|
#resolvePreviousPromise:
|
|
188
188
|
| ((value?: ReturnType<TFn> | undefined) => void)
|
|
189
189
|
| null = null
|
|
190
|
+
#rejectPreviousPromise: ((reason?: unknown) => void) | null = null
|
|
190
191
|
|
|
191
192
|
constructor(
|
|
192
193
|
private fn: TFn,
|
|
@@ -287,8 +288,9 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
287
288
|
await this.#execute(...args)
|
|
288
289
|
return this.store.state.lastResult
|
|
289
290
|
} else {
|
|
290
|
-
return new Promise((resolve) => {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
291
292
|
this.#resolvePreviousPromise = resolve
|
|
293
|
+
this.#rejectPreviousPromise = reject
|
|
292
294
|
// Clear any existing timeout to ensure we use the latest arguments
|
|
293
295
|
this.#clearTimeout()
|
|
294
296
|
|
|
@@ -330,9 +332,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
330
332
|
})
|
|
331
333
|
this.options.onError?.(error, this)
|
|
332
334
|
if (this.options.throwOnError) {
|
|
333
|
-
|
|
334
|
-
} else {
|
|
335
|
-
console.error(error)
|
|
335
|
+
this.#rejectPreviousPromiseInternal(error)
|
|
336
336
|
}
|
|
337
337
|
} finally {
|
|
338
338
|
const lastExecutionTime = Date.now()
|
|
@@ -353,12 +353,18 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
353
353
|
/**
|
|
354
354
|
* Processes the current pending execution immediately
|
|
355
355
|
*/
|
|
356
|
-
flush = ():
|
|
356
|
+
flush = async (): Promise<ReturnType<TFn> | undefined> => {
|
|
357
357
|
if (this.store.state.isPending && this.store.state.lastArgs) {
|
|
358
358
|
this.#abortExecution() // abort any current execution
|
|
359
359
|
this.#clearTimeout() // clear any existing timeout
|
|
360
|
-
this.#execute(...this.store.state.lastArgs)
|
|
360
|
+
const result = await this.#execute(...this.store.state.lastArgs)
|
|
361
|
+
|
|
362
|
+
// Resolve any pending promise from maybeExecute
|
|
363
|
+
this.#resolvePreviousPromiseInternal()
|
|
364
|
+
|
|
365
|
+
return result
|
|
361
366
|
}
|
|
367
|
+
return undefined
|
|
362
368
|
}
|
|
363
369
|
|
|
364
370
|
#resolvePreviousPromiseInternal = (): void => {
|
|
@@ -368,6 +374,13 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
|
|
|
368
374
|
}
|
|
369
375
|
}
|
|
370
376
|
|
|
377
|
+
#rejectPreviousPromiseInternal = (error: unknown): void => {
|
|
378
|
+
if (this.#rejectPreviousPromise) {
|
|
379
|
+
this.#rejectPreviousPromise(error)
|
|
380
|
+
this.#rejectPreviousPromise = null
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
371
384
|
#clearTimeout = (): void => {
|
|
372
385
|
if (this.#timeoutId) {
|
|
373
386
|
clearTimeout(this.#timeoutId)
|
package/src/batcher.ts
CHANGED
|
@@ -15,14 +15,6 @@ export interface BatcherState<TValue> {
|
|
|
15
15
|
* Whether the batcher is waiting for the timeout to trigger batch processing
|
|
16
16
|
*/
|
|
17
17
|
isPending: boolean
|
|
18
|
-
/**
|
|
19
|
-
* Whether the batcher is active and will process items automatically
|
|
20
|
-
*/
|
|
21
|
-
isRunning: boolean
|
|
22
|
-
/**
|
|
23
|
-
* Total number of items that have been processed across all batches
|
|
24
|
-
*/
|
|
25
|
-
totalItemsProcessed: number
|
|
26
18
|
/**
|
|
27
19
|
* Array of items currently queued for batch processing
|
|
28
20
|
*/
|
|
@@ -35,6 +27,10 @@ export interface BatcherState<TValue> {
|
|
|
35
27
|
* Current processing status - 'idle' when not processing, 'pending' when waiting for timeout
|
|
36
28
|
*/
|
|
37
29
|
status: 'idle' | 'pending'
|
|
30
|
+
/**
|
|
31
|
+
* Total number of items that have been processed across all batches
|
|
32
|
+
*/
|
|
33
|
+
totalItemsProcessed: number
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
function getDefaultBatcherState<TValue>(): BatcherState<TValue> {
|
|
@@ -42,7 +38,6 @@ function getDefaultBatcherState<TValue>(): BatcherState<TValue> {
|
|
|
42
38
|
executionCount: 0,
|
|
43
39
|
isEmpty: true,
|
|
44
40
|
isPending: false,
|
|
45
|
-
isRunning: true,
|
|
46
41
|
totalItemsProcessed: 0,
|
|
47
42
|
items: [],
|
|
48
43
|
size: 0,
|
|
@@ -204,7 +199,7 @@ export class Batcher<TValue> {
|
|
|
204
199
|
|
|
205
200
|
if (shouldProcess) {
|
|
206
201
|
this.#execute()
|
|
207
|
-
} else if (this.
|
|
202
|
+
} else if (this.options.wait !== Infinity) {
|
|
208
203
|
this.#clearTimeout() // clear any pending timeout to replace it with a new one
|
|
209
204
|
this.#timeoutId = setTimeout(() => this.#execute(), this.#getWait())
|
|
210
205
|
}
|
|
@@ -244,24 +239,6 @@ export class Batcher<TValue> {
|
|
|
244
239
|
this.#execute() // execute immediately
|
|
245
240
|
}
|
|
246
241
|
|
|
247
|
-
/**
|
|
248
|
-
* Stops the batcher from processing batches
|
|
249
|
-
*/
|
|
250
|
-
stop = (): void => {
|
|
251
|
-
this.#setState({ isRunning: false })
|
|
252
|
-
this.#clearTimeout()
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Starts the batcher and processes any pending items
|
|
257
|
-
*/
|
|
258
|
-
start = (): void => {
|
|
259
|
-
this.#setState({ isRunning: true })
|
|
260
|
-
if (this.store.state.items.length > 0) {
|
|
261
|
-
this.#execute()
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
242
|
/**
|
|
266
243
|
* Returns a copy of all items in the batcher
|
|
267
244
|
*/
|
package/src/queuer.ts
CHANGED
|
@@ -26,14 +26,14 @@ export interface QueuerState<TValue> {
|
|
|
26
26
|
* Whether the queuer is active and will process items automatically
|
|
27
27
|
*/
|
|
28
28
|
isRunning: boolean
|
|
29
|
-
/**
|
|
30
|
-
* Timestamps when items were added to the queue for expiration tracking
|
|
31
|
-
*/
|
|
32
|
-
itemTimestamps: Array<number>
|
|
33
29
|
/**
|
|
34
30
|
* Array of items currently waiting to be processed
|
|
35
31
|
*/
|
|
36
32
|
items: Array<TValue>
|
|
33
|
+
/**
|
|
34
|
+
* Timestamps when items were added to the queue for expiration tracking
|
|
35
|
+
*/
|
|
36
|
+
itemTimestamps: Array<number>
|
|
37
37
|
/**
|
|
38
38
|
* Whether the queuer has a pending timeout for processing the next item
|
|
39
39
|
*/
|
|
@@ -112,14 +112,14 @@ export interface QueuerOptions<TValue> {
|
|
|
112
112
|
* Maximum number of items allowed in the queuer
|
|
113
113
|
*/
|
|
114
114
|
maxSize?: number
|
|
115
|
-
/**
|
|
116
|
-
* Callback fired whenever an item expires in the queuer
|
|
117
|
-
*/
|
|
118
|
-
onExpire?: (item: TValue, queuer: Queuer<TValue>) => void
|
|
119
115
|
/**
|
|
120
116
|
* Callback fired whenever an item is removed from the queuer
|
|
121
117
|
*/
|
|
122
118
|
onExecute?: (item: TValue, queuer: Queuer<TValue>) => void
|
|
119
|
+
/**
|
|
120
|
+
* Callback fired whenever an item expires in the queuer
|
|
121
|
+
*/
|
|
122
|
+
onExpire?: (item: TValue, queuer: Queuer<TValue>) => void
|
|
123
123
|
/**
|
|
124
124
|
* Callback fired whenever an item is added or removed from the queuer
|
|
125
125
|
*/
|
|
@@ -472,6 +472,12 @@ export class Queuer<TValue> {
|
|
|
472
472
|
return item
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
#getAllItems = (): Array<TValue> => {
|
|
476
|
+
const items = this.peekAllItems()
|
|
477
|
+
this.clear()
|
|
478
|
+
return items
|
|
479
|
+
}
|
|
480
|
+
|
|
475
481
|
/**
|
|
476
482
|
* Removes and returns the next item from the queue and processes it using the provided function.
|
|
477
483
|
*
|
|
@@ -509,6 +515,16 @@ export class Queuer<TValue> {
|
|
|
509
515
|
this.#tick()
|
|
510
516
|
}
|
|
511
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Processes all items in the queue as a batch using the provided function
|
|
520
|
+
* The queue is cleared after processing
|
|
521
|
+
*/
|
|
522
|
+
flushAsBatch = (batchFunction: (items: Array<TValue>) => void): void => {
|
|
523
|
+
const items = this.#getAllItems()
|
|
524
|
+
this.clear()
|
|
525
|
+
batchFunction(items)
|
|
526
|
+
}
|
|
527
|
+
|
|
512
528
|
/**
|
|
513
529
|
* Checks for expired items in the queue and removes them. Calls onExpire for each expired item.
|
|
514
530
|
* Internal use only.
|
package/src/rate-limiter.ts
CHANGED
|
@@ -11,17 +11,27 @@ export interface RateLimiterState {
|
|
|
11
11
|
* Array of timestamps when executions occurred for rate limiting calculations
|
|
12
12
|
*/
|
|
13
13
|
executionTimes: Array<number>
|
|
14
|
+
/**
|
|
15
|
+
* Whether the rate limiter has exceeded the limit
|
|
16
|
+
*/
|
|
17
|
+
isExceeded: boolean
|
|
14
18
|
/**
|
|
15
19
|
* Number of function executions that have been rejected due to rate limiting
|
|
16
20
|
*/
|
|
17
21
|
rejectionCount: number
|
|
22
|
+
/**
|
|
23
|
+
* Current execution status - 'disabled' when not active, 'executing' when executing, 'idle' when not executing, 'exceeded' when rate limit is exceeded
|
|
24
|
+
*/
|
|
25
|
+
status: 'disabled' | 'exceeded' | 'idle'
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
function getDefaultRateLimiterState(): RateLimiterState {
|
|
21
29
|
return structuredClone({
|
|
22
30
|
executionCount: 0,
|
|
23
31
|
executionTimes: [],
|
|
32
|
+
isExceeded: false,
|
|
24
33
|
rejectionCount: 0,
|
|
34
|
+
status: 'idle',
|
|
25
35
|
})
|
|
26
36
|
}
|
|
27
37
|
|
|
@@ -123,6 +133,7 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
123
133
|
readonly store: Store<Readonly<RateLimiterState>> =
|
|
124
134
|
new Store<RateLimiterState>(getDefaultRateLimiterState())
|
|
125
135
|
options: RateLimiterOptions<TFn>
|
|
136
|
+
#timeoutIds: Set<NodeJS.Timeout> = new Set()
|
|
126
137
|
|
|
127
138
|
constructor(
|
|
128
139
|
private fn: TFn,
|
|
@@ -133,6 +144,9 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
133
144
|
...initialOptions,
|
|
134
145
|
}
|
|
135
146
|
this.#setState(this.options.initialState ?? {})
|
|
147
|
+
for (const executionTime of this.#getExecutionTimesInWindow()) {
|
|
148
|
+
this.#setCleanupTimeout(executionTime)
|
|
149
|
+
}
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
/**
|
|
@@ -148,7 +162,17 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
148
162
|
...state,
|
|
149
163
|
...newState,
|
|
150
164
|
}
|
|
151
|
-
|
|
165
|
+
const isExceeded = combinedState.executionTimes.length >= this.#getLimit()
|
|
166
|
+
const status = !this.#getEnabled()
|
|
167
|
+
? 'disabled'
|
|
168
|
+
: isExceeded
|
|
169
|
+
? 'exceeded'
|
|
170
|
+
: 'idle'
|
|
171
|
+
return {
|
|
172
|
+
...combinedState,
|
|
173
|
+
isExceeded,
|
|
174
|
+
status,
|
|
175
|
+
}
|
|
152
176
|
})
|
|
153
177
|
}
|
|
154
178
|
|
|
@@ -191,7 +215,7 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
191
215
|
maybeExecute = (...args: Parameters<TFn>): boolean => {
|
|
192
216
|
this.#cleanupOldExecutions()
|
|
193
217
|
|
|
194
|
-
const relevantExecutionTimes = this.#
|
|
218
|
+
const relevantExecutionTimes = this.#getExecutionTimesInWindow()
|
|
195
219
|
|
|
196
220
|
if (relevantExecutionTimes.length < this.#getLimit()) {
|
|
197
221
|
this.#execute(...args)
|
|
@@ -210,13 +234,16 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
210
234
|
const now = Date.now()
|
|
211
235
|
this.fn(...args) // EXECUTE!
|
|
212
236
|
this.store.state.executionTimes.push(now) // mutate state directly for performance
|
|
237
|
+
|
|
238
|
+
this.#setCleanupTimeout(now)
|
|
239
|
+
|
|
213
240
|
this.#setState({
|
|
214
241
|
executionCount: this.store.state.executionCount + 1,
|
|
215
242
|
})
|
|
216
243
|
this.options.onExecute?.(this)
|
|
217
244
|
}
|
|
218
245
|
|
|
219
|
-
#
|
|
246
|
+
#getExecutionTimesInWindow = (): Array<number> => {
|
|
220
247
|
if (this.options.windowType === 'sliding') {
|
|
221
248
|
// For sliding window, return all executions within the current window
|
|
222
249
|
return this.store.state.executionTimes.filter(
|
|
@@ -225,22 +252,54 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
225
252
|
} else {
|
|
226
253
|
// For fixed window, return all executions in the current window
|
|
227
254
|
// The window starts from the oldest execution time
|
|
255
|
+
if (this.store.state.executionTimes.length === 0) {
|
|
256
|
+
return []
|
|
257
|
+
}
|
|
228
258
|
const oldestExecution = Math.min(...this.store.state.executionTimes)
|
|
229
259
|
const windowStart = oldestExecution
|
|
260
|
+
const windowEnd = windowStart + this.#getWindow()
|
|
261
|
+
const now = Date.now()
|
|
262
|
+
|
|
263
|
+
// If the window has expired, return empty array
|
|
264
|
+
if (now > windowEnd) {
|
|
265
|
+
return []
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Otherwise, return all executions in the current window
|
|
230
269
|
return this.store.state.executionTimes.filter(
|
|
231
|
-
(time) =>
|
|
232
|
-
time >= windowStart && time <= windowStart + this.#getWindow(),
|
|
270
|
+
(time) => time >= windowStart && time <= windowEnd,
|
|
233
271
|
)
|
|
234
272
|
}
|
|
235
273
|
}
|
|
236
274
|
|
|
275
|
+
#setCleanupTimeout = (executionTime: number): void => {
|
|
276
|
+
if (
|
|
277
|
+
this.options.windowType === 'sliding' ||
|
|
278
|
+
this.#timeoutIds.size === 0 // new fixed window
|
|
279
|
+
) {
|
|
280
|
+
const now = Date.now()
|
|
281
|
+
const timeUntilExpiration = executionTime - now + this.#getWindow() + 1
|
|
282
|
+
const timeoutId = setTimeout(() => {
|
|
283
|
+
this.#cleanupOldExecutions()
|
|
284
|
+
this.#clearTimeout(timeoutId)
|
|
285
|
+
}, timeUntilExpiration)
|
|
286
|
+
this.#timeoutIds.add(timeoutId)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#clearTimeout = (timeoutId: NodeJS.Timeout): void => {
|
|
291
|
+
clearTimeout(timeoutId)
|
|
292
|
+
this.#timeoutIds.delete(timeoutId)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#clearTimeouts = (): void => {
|
|
296
|
+
this.#timeoutIds.forEach((timeoutId) => clearTimeout(timeoutId))
|
|
297
|
+
this.#timeoutIds.clear()
|
|
298
|
+
}
|
|
299
|
+
|
|
237
300
|
#cleanupOldExecutions = (): void => {
|
|
238
|
-
const now = Date.now()
|
|
239
|
-
const windowStart = now - this.#getWindow()
|
|
240
301
|
this.#setState({
|
|
241
|
-
executionTimes: this
|
|
242
|
-
(time) => time > windowStart,
|
|
243
|
-
),
|
|
302
|
+
executionTimes: this.#getExecutionTimesInWindow(),
|
|
244
303
|
})
|
|
245
304
|
}
|
|
246
305
|
|
|
@@ -248,7 +307,7 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
248
307
|
* Returns the number of remaining executions allowed in the current window
|
|
249
308
|
*/
|
|
250
309
|
getRemainingInWindow = (): number => {
|
|
251
|
-
const relevantExecutionTimes = this.#
|
|
310
|
+
const relevantExecutionTimes = this.#getExecutionTimesInWindow()
|
|
252
311
|
return Math.max(0, this.#getLimit() - relevantExecutionTimes.length)
|
|
253
312
|
}
|
|
254
313
|
|
|
@@ -268,6 +327,7 @@ export class RateLimiter<TFn extends AnyFunction> {
|
|
|
268
327
|
*/
|
|
269
328
|
reset = (): void => {
|
|
270
329
|
this.#setState(getDefaultRateLimiterState())
|
|
330
|
+
this.#clearTimeouts()
|
|
271
331
|
}
|
|
272
332
|
}
|
|
273
333
|
|