Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fluffy-spies-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/pacer': patch
---

Guard retryer key propagation in AsyncQueuer, AsyncThrottler, AsyncRateLimiter, and AsyncDebouncer to prevent child AsyncRetryer instances from receiving a truthy key when the parent has no key, which caused unbounded devtools event accumulation and memory leaks in Node.js environments.
2 changes: 1 addition & 1 deletion packages/pacer/src/async-debouncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ export class AsyncDebouncer<TFn extends AnyAsyncFunction> {
this.#setState({ isExecuting: true })
const currentAsyncRetryer = new AsyncRetryer(this.fn, {
...this.options.asyncRetryerOptions,
key: `${this.key}-retryer-${currentMaybeExecuteCount}`,
key: this.key ? `${this.key}-retryer-${currentMaybeExecuteCount}` : undefined,
})
this.asyncRetryers.set(currentMaybeExecuteCount, currentAsyncRetryer)
const result = await currentAsyncRetryer.execute(...args) // EXECUTE!
Expand Down
2 changes: 1 addition & 1 deletion packages/pacer/src/async-queuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ export class AsyncQueuer<TValue> {
try {
const currentAsyncRetryer = new AsyncRetryer(this.fn, {
...this.options.asyncRetryerOptions,
key: `${this.key}-retryer-${currentExecuteCount}`,
key: this.key ? `${this.key}-retryer-${currentExecuteCount}` : undefined,
})
this.asyncRetryers.set(currentExecuteCount, currentAsyncRetryer)
const lastResult = await currentAsyncRetryer.execute(item) // EXECUTE!
Expand Down
2 changes: 1 addition & 1 deletion packages/pacer/src/async-rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export class AsyncRateLimiter<TFn extends AnyAsyncFunction> {
// Create a new AsyncRetryer for this execution to avoid cancelling concurrent executions
const currentAsyncRetryer = new AsyncRetryer(this.fn, {
...this.options.asyncRetryerOptions,
key: `${this.key}-retryer-${currentMaybeExecute}`,
key: this.key ? `${this.key}-retryer-${currentMaybeExecute}` : undefined,
})
this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer)
const result = await currentAsyncRetryer.execute(...args) // EXECUTE!
Expand Down
2 changes: 1 addition & 1 deletion packages/pacer/src/async-throttler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
this.#setState({ isExecuting: true })
const currentAsyncRetryer = new AsyncRetryer(this.fn, {
...this.options.asyncRetryerOptions,
key: `${this.key}-retryer-${currentMaybeExecute}`,
key: this.key ? `${this.key}-retryer-${currentMaybeExecute}` : undefined,
})
this.asyncRetryers.set(currentMaybeExecute, currentAsyncRetryer)
const result = await currentAsyncRetryer.execute(...args) // EXECUTE!
Expand Down
85 changes: 85 additions & 0 deletions packages/pacer/tests/async-debouncer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AsyncDebouncer, asyncDebounce } from '../src/async-debouncer'
import { pacerEventClient } from '../src'

describe('AsyncDebouncer', () => {
beforeEach(() => {
Expand Down Expand Up @@ -1359,4 +1360,88 @@ describe('asyncDebounce helper function', () => {
expect(debouncer.getAbortSignal()).toBeNull()
})
})

describe('retryer key propagation', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('should give child AsyncRetryer key: undefined when debouncer has no key', async () => {
let resolve!: (v: string) => void
const gate = new Promise<string>((r) => { resolve = r })

const debouncer = new AsyncDebouncer(
async () => { await gate },
{ wait: 100 },
)

debouncer.maybeExecute()
await vi.advanceTimersByTimeAsync(100)

const retryer = debouncer.asyncRetryers.get(2)
expect(retryer).toBeDefined()
expect(retryer!.key).toBeUndefined()

resolve('done')
await vi.advanceTimersByTimeAsync(0)
})

it('should give child AsyncRetryer a namespaced key when debouncer has a key', async () => {
let resolve!: (v: string) => void
const gate = new Promise<string>((r) => { resolve = r })

const debouncer = new AsyncDebouncer(
async () => { await gate },
{ wait: 100, key: 'my-debouncer' },
)

debouncer.maybeExecute()
await vi.advanceTimersByTimeAsync(100)

const retryer = debouncer.asyncRetryers.get(2)
expect(retryer).toBeDefined()
expect(retryer!.key).toBe('my-debouncer-retryer-2')

resolve('done')
await vi.advanceTimersByTimeAsync(0)
})

it('should not queue devtools events when debouncer has no key', async () => {
const emitSpy = vi.spyOn(pacerEventClient, 'emit')

const debouncer = new AsyncDebouncer(
async () => 'result',
{ wait: 100 },
)

emitSpy.mockClear()

debouncer.maybeExecute()
await vi.advanceTimersByTimeAsync(100)

const retryerEmits = emitSpy.mock.calls.filter(
([event]) => event === 'AsyncRetryer',
)
expect(retryerEmits).toHaveLength(0)
})

it('should queue devtools events when debouncer has a key', async () => {
const emitSpy = vi.spyOn(pacerEventClient, 'emit')

const debouncer = new AsyncDebouncer(
async () => 'result',
{ wait: 100, key: 'my-debouncer' },
)

emitSpy.mockClear()

debouncer.maybeExecute()
await vi.advanceTimersByTimeAsync(100)

const retryerEmits = emitSpy.mock.calls.filter(
([event]) => event === 'AsyncRetryer',
)
expect(retryerEmits.length).toBeGreaterThan(0)
})
})
})
88 changes: 87 additions & 1 deletion packages/pacer/tests/async-queuer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AsyncQueuer } from '../src'
import { AsyncQueuer, pacerEventClient } from '../src'

describe('AsyncQueuer', () => {
beforeEach(() => {
Expand Down Expand Up @@ -1095,4 +1095,90 @@ describe('AsyncQueuer', () => {
expect(asyncQueuer.getAbortSignal()).toBeNull()
})
})

describe('retryer key propagation', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('should give child AsyncRetryer key: undefined when queuer has no key', async () => {
let resolve!: (v: string) => void
const item = new Promise<string>((r) => { resolve = r })

const asyncQueuer = new AsyncQueuer<Promise<string>>(
(p) => p,
{ started: false },
)

asyncQueuer.addItem(item)
const executePromise = asyncQueuer.execute()

const retryer = asyncQueuer.asyncRetryers.get(1)
expect(retryer).toBeDefined()
expect(retryer!.key).toBeUndefined()

resolve('done')
await executePromise
})

it('should give child AsyncRetryer a namespaced key when queuer has a key', async () => {
let resolve!: (v: string) => void
const item = new Promise<string>((r) => { resolve = r })

const asyncQueuer = new AsyncQueuer<Promise<string>>(
(p) => p,
{ started: false, key: 'my-queue' },
)

asyncQueuer.addItem(item)
const executePromise = asyncQueuer.execute()

const retryer = asyncQueuer.asyncRetryers.get(1)
expect(retryer).toBeDefined()
expect(retryer!.key).toBe('my-queue-retryer-1')

resolve('done')
await executePromise
})

it('should not queue devtools events when queuer has no key', async () => {
const emitSpy = vi.spyOn(pacerEventClient, 'emit')

const asyncQueuer = new AsyncQueuer<string>(
(item) => Promise.resolve(item),
{ started: false },
)

emitSpy.mockClear()

asyncQueuer.addItem('a')
asyncQueuer.addItem('b')
await asyncQueuer.execute()
await asyncQueuer.execute()

const retryerEmits = emitSpy.mock.calls.filter(
([event]) => event === 'AsyncRetryer',
)
expect(retryerEmits).toHaveLength(0)
})

it('should queue devtools events when queuer has a key', async () => {
const emitSpy = vi.spyOn(pacerEventClient, 'emit')

const asyncQueuer = new AsyncQueuer<string>(
(item) => Promise.resolve(item),
{ started: false, key: 'my-queue' },
)

emitSpy.mockClear()

asyncQueuer.addItem('a')
await asyncQueuer.execute()

const retryerEmits = emitSpy.mock.calls.filter(
([event]) => event === 'AsyncRetryer',
)
expect(retryerEmits.length).toBeGreaterThan(0)
})
})
})
83 changes: 83 additions & 0 deletions packages/pacer/tests/async-rate-limiter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AsyncRateLimiter, asyncRateLimit } from '../src/async-rate-limiter'
import { pacerEventClient } from '../src'

describe('AsyncRateLimiter', () => {
beforeEach(() => {
Expand Down Expand Up @@ -775,4 +776,86 @@ describe('asyncRateLimit', () => {
expect(rateLimiter.getAbortSignal()).toBeNull()
})
})

describe('retryer key propagation', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('should give child AsyncRetryer key: undefined when rate limiter has no key', async () => {
let resolve!: (v: string) => void
const gate = new Promise<string>((r) => { resolve = r })

const rateLimiter = new AsyncRateLimiter(
async () => { await gate },
{ limit: 5, window: 1000 },
)

const executePromise = rateLimiter.maybeExecute()
await vi.advanceTimersByTimeAsync(0)

const retryer = rateLimiter.asyncRetryers.get(1)
expect(retryer).toBeDefined()
expect(retryer!.key).toBeUndefined()

resolve('done')
await executePromise
})

it('should give child AsyncRetryer a namespaced key when rate limiter has a key', async () => {
let resolve!: (v: string) => void
const gate = new Promise<string>((r) => { resolve = r })

const rateLimiter = new AsyncRateLimiter(
async () => { await gate },
{ limit: 5, window: 1000, key: 'my-limiter' },
)

const executePromise = rateLimiter.maybeExecute()
await vi.advanceTimersByTimeAsync(0)

const retryer = rateLimiter.asyncRetryers.get(1)
expect(retryer).toBeDefined()
expect(retryer!.key).toBe('my-limiter-retryer-1')

resolve('done')
await executePromise
})

it('should not queue devtools events when rate limiter has no key', async () => {
const emitSpy = vi.spyOn(pacerEventClient, 'emit')

const rateLimiter = new AsyncRateLimiter(
async () => 'result',
{ limit: 5, window: 1000 },
)

emitSpy.mockClear()

await rateLimiter.maybeExecute()

const retryerEmits = emitSpy.mock.calls.filter(
([event]) => event === 'AsyncRetryer',
)
expect(retryerEmits).toHaveLength(0)
})

it('should queue devtools events when rate limiter has a key', async () => {
const emitSpy = vi.spyOn(pacerEventClient, 'emit')

const rateLimiter = new AsyncRateLimiter(
async () => 'result',
{ limit: 5, window: 1000, key: 'my-limiter' },
)

emitSpy.mockClear()

await rateLimiter.maybeExecute()

const retryerEmits = emitSpy.mock.calls.filter(
([event]) => event === 'AsyncRetryer',
)
expect(retryerEmits.length).toBeGreaterThan(0)
})
})
})
Loading