Skip to content

Commit bbf68e0

Browse files
committed
feat: implement timeout management with a new timing wheel for improved performance
1 parent d471c7e commit bbf68e0

File tree

8 files changed

+632
-54
lines changed

8 files changed

+632
-54
lines changed

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ export { abortRequest, getInFlightPromise } from './inflight-manager';
3737

3838
/** Network and environment utilities (Browser Only) */
3939
export { isSlowConnection } from './utils';
40+
41+
/** Timeout management for delayed operations */
42+
export { addTimeout } from './timeout-wheel';

src/react/cache-ref.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
* @see deleteCache
1313
*/
1414

15-
import { abortRequest, deleteCache } from 'fetchff';
15+
import { addTimeout, abortRequest, deleteCache } from 'fetchff';
1616

1717
export const INFINITE_CACHE_TIME = -1;
18+
export const DEFAULT_DEDUPE_TIME_MS = 2000;
1819

1920
const refs = new Map<string, number>();
2021

@@ -54,15 +55,19 @@ export const decrementRef = (
5455
new DOMException('Request to ' + url + ' aborted', 'AbortError'),
5556
);
5657

57-
setTimeout(() => {
58-
// Check if the reference count is still zero before deleting the cache as it might have been incremented again
59-
// This is to ensure that if another increment happens during the timeout, we don't delete the cache prematurely
60-
// This is particularly useful in scenarios where multiple components might be using the same cache
61-
// entry and we want to avoid unnecessary cache deletions.
62-
if (!getRefCount(key)) {
63-
deleteCache(key);
64-
}
65-
}, dedupeTime); // Delay to ensure all operations are complete before deletion
58+
addTimeout(
59+
'c:' + key,
60+
() => {
61+
// Check if the reference count is still zero before deleting the cache as it might have been incremented again
62+
// This is to ensure that if another increment happens during the timeout, we don't delete the cache prematurely
63+
// This is particularly useful in scenarios where multiple components might be using the same cache
64+
// entry and we want to avoid unnecessary cache deletions.
65+
if (!getRefCount(key)) {
66+
deleteCache(key);
67+
}
68+
},
69+
dedupeTime ?? DEFAULT_DEDUPE_TIME_MS,
70+
); // Delay to ensure all operations are complete before deletion
6671
} else {
6772
refs.set(key, newCount);
6873
}

src/revalidator-manager.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* @remarks
1717
* - Designed to be used in various environments (Deno, Node.js, Bun, Browser, etc.) to ensure cache consistency and freshness.
1818
*/
19+
import { addTimeout, removeTimeout } from './timeout-wheel';
1920
import { FetchResponse } from './types';
2021
import { isBrowser, timeNow } from './utils';
2122

@@ -35,7 +36,6 @@ type RevalidatorEntry = [
3536

3637
const DEFAULT_TTL = 3 * 60 * 1000; // Default TTL of 3 minutes
3738
const revalidators = new Map<string, RevalidatorEntry>();
38-
const staleTimers = new Map<string, ReturnType<typeof setTimeout>>();
3939

4040
/**
4141
* Stores global event handlers for cache revalidation events (e.g., focus, online).
@@ -206,24 +206,15 @@ export function addRevalidator(
206206
}
207207

208208
if (staleTime) {
209-
const timer = setTimeout(() => {
210-
revalidate(key, true).catch(() => {});
211-
}, staleTime);
212-
213-
staleTimers.set(key, timer);
209+
addTimeout('s:' + key, revalidate.bind(null, key, true), staleTime);
214210
}
215211
}
216212

217213
export function removeRevalidator(key: string) {
218214
revalidators.delete(key);
219215

220216
// Clean up stale timer
221-
const timer = staleTimers.get(key);
222-
223-
if (timer) {
224-
clearTimeout(timer);
225-
staleTimers.delete(key);
226-
}
217+
removeTimeout('s:' + key);
227218
}
228219

229220
/**

src/timeout-wheel.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @module timeout-wheel
3+
* @description
4+
* Ultra-minimal timing wheel implementation optimized for max performance & many requests.
5+
* For most of the cases it's 4-100x faster than setTimeout and setInterval alone.
6+
* Provides efficient scheduling and cancellation of timeouts using a circular array.
7+
*
8+
* Position 0 → 1 → 2 → ... → 599 → 0 → 1 → 2 ...
9+
* Time: 0s 1s 2s 599s 600s 601s 602s
10+
*
11+
* The timing wheel consists of 600 slots (one per second for 10 min).
12+
* Each slot contains a list of timeout items, each associated with a unique key and callback.
13+
* Timeouts are scheduled by placing them in the appropriate slot based on the delay in seconds.
14+
* The wheel advances every second, executing and removing callbacks as their timeouts expire.
15+
* Defaults to setTimeout if the delay exceeds 10 minutes or is not divisible by 1000.
16+
*
17+
* @remarks
18+
* - Designed for minimal footprint and simplicity.
19+
* - Only supports second-level granularity (minimum timeout: 1 second).
20+
* - Automatically stops the internal timer when no timeouts remain.
21+
*/
22+
23+
type TimeoutCallback = () => unknown | Promise<unknown>;
24+
type TimeoutItem = [string, TimeoutCallback]; // [key, callback]
25+
26+
const WHEEL_SIZE = 600; // 600 slots for 10 min (1 slot per second)
27+
const SECOND = 1000; // 1 second in milliseconds
28+
const MAX_WHEEL_MS = WHEEL_SIZE * SECOND;
29+
const wheel: TimeoutItem[][] = Array(WHEEL_SIZE)
30+
.fill(0)
31+
.map(() => []);
32+
33+
const keyMap = new Map<string, number | [NodeJS.Timeout | number]>();
34+
let position = 0;
35+
let timer: NodeJS.Timeout | null = null;
36+
37+
const handleCallback = ([key, callback]: TimeoutItem): void => {
38+
keyMap.delete(key);
39+
40+
try {
41+
const result = callback();
42+
if (result && result instanceof Promise) {
43+
// Silently ignore async errors to prevent wheel from stopping
44+
result.catch(() => {});
45+
}
46+
} catch {
47+
// Ignore callback errors to prevent wheel from stopping
48+
}
49+
};
50+
51+
export const addTimeout = (
52+
key: string,
53+
cb: TimeoutCallback,
54+
ms: number,
55+
): void => {
56+
removeTimeout(key);
57+
58+
// Fallback to setTimeout if wheel size is exceeded or ms is not divisible by SECOND
59+
if (ms > MAX_WHEEL_MS || ms % SECOND !== 0) {
60+
keyMap.set(key, [setTimeout(handleCallback.bind(null, [key, cb]), ms)]); // Store timeout ID instead of slot
61+
62+
return;
63+
}
64+
65+
// No need for Math.ceil here since ms is guaranteed by modulo above
66+
const seconds = ms / SECOND;
67+
const slot = (position + seconds) % WHEEL_SIZE;
68+
69+
wheel[slot].push([key, cb]);
70+
keyMap.set(key, slot);
71+
72+
if (!timer) {
73+
timer = setInterval(() => {
74+
position = (position + 1) % WHEEL_SIZE;
75+
wheel[position].forEach(handleCallback);
76+
wheel[position] = [];
77+
78+
if (!keyMap.size && timer) {
79+
clearInterval(timer);
80+
timer = null;
81+
}
82+
}, SECOND);
83+
}
84+
};
85+
86+
export const removeTimeout = (key: string): void => {
87+
const slotOrTimeout = keyMap.get(key);
88+
89+
if (slotOrTimeout !== undefined) {
90+
// It's a Timeout object from setTimeout
91+
if (Array.isArray(slotOrTimeout)) {
92+
clearTimeout(slotOrTimeout[0]);
93+
} else {
94+
wheel[slotOrTimeout].splice(
95+
wheel[slotOrTimeout].findIndex(([k]) => k === key),
96+
1,
97+
);
98+
}
99+
100+
keyMap.delete(key);
101+
102+
if (!keyMap.size && timer) {
103+
clearInterval(timer);
104+
timer = null;
105+
}
106+
}
107+
};
108+
109+
export const clearAllTimeouts = () => {
110+
// Clear native setTimeout timeouts first!
111+
keyMap.forEach((value) => {
112+
if (Array.isArray(value)) {
113+
clearTimeout(value[0]);
114+
}
115+
});
116+
117+
if (timer) {
118+
clearInterval(timer);
119+
timer = null;
120+
}
121+
122+
keyMap.clear();
123+
wheel.forEach((slot) => (slot.length = 0));
124+
position = 0;
125+
};

test/inflight-manager.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ describe('InFlight Request Manager', () => {
189189
2000,
190190
1000,
191191
);
192-
jest.advanceTimersByTime(1500);
192+
jest.advanceTimersByTime(1100);
193193
expect(controller1).not.toBe(controller2);
194194
expect(controller1.signal.aborted).toBe(false);
195195
expect(controller2.signal.aborted).toBe(false);
@@ -367,7 +367,9 @@ describe('InFlight Request Manager', () => {
367367
1000,
368368
false,
369369
);
370-
jest.advanceTimersByTime(1500);
370+
371+
jest.advanceTimersByTime(1200);
372+
371373
const controller2 = await markInFlight(
372374
key,
373375
'not-cancel-prev-url',

test/react/integration/error-handling.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('Error Handling Integration Tests', () => {
6969
);
7070

7171
// Advance time to trigger timeout
72-
// jest.advanceTimersByTime(5000);
72+
jest.advanceTimersByTime(5000);
7373

7474
await waitFor(() => {
7575
expect(screen.getByTestId('timeout-error')).toHaveTextContent(

test/react/integration/performance-caching.spec.tsx

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
mockFetchResponse,
1616
} from '../../utils/mockFetchResponse';
1717
import { useFetcher } from '../../../src/react/index';
18+
import { clearAllTimeouts } from '../../../src/timeout-wheel';
1819

1920
describe('Performance & Caching Integration Tests', () => {
2021
beforeEach(() => {
@@ -25,23 +26,28 @@ describe('Performance & Caching Integration Tests', () => {
2526
afterEach(() => {
2627
jest.useRealTimers();
2728
jest.resetAllMocks();
29+
clearAllTimeouts();
30+
jest.clearAllTimers();
2831
clearMockResponses();
2932
});
3033

3134
describe('Performance', () => {
3235
it('should handle many simultaneous different requests efficiently', async () => {
3336
jest.useRealTimers();
37+
const runs = 100;
3438

35-
// Mock 100 different endpoints
36-
for (let i = 0; i < 100; i++) {
39+
// Mock i different endpoints
40+
for (let i = 0; i < runs; i++) {
3741
mockFetchResponse(`/api/perf-${i}`, { body: { id: i } });
3842
}
3943

4044
const startTime = performance.now();
4145

4246
const ManyRequestsComponent = () => {
43-
const requests = Array.from({ length: 100 }, (_, i) => {
44-
const response = useFetcher(`/api/perf-${i}`);
47+
const requests = Array.from({ length: runs }, (_, i) => {
48+
const response = useFetcher(`/api/perf-${i}`, {
49+
staleTime: 10000, // Long stale time
50+
});
4551
return response;
4652
});
4753

@@ -56,14 +62,14 @@ describe('Performance & Caching Integration Tests', () => {
5662

5763
await waitFor(() => {
5864
expect(screen.getByTestId('many-requests')).toHaveTextContent(
59-
'100 loaded',
65+
runs + ' loaded',
6066
);
6167
});
6268

6369
const endTime = performance.now();
6470
// Should complete within reasonable time
6571
// It is a basic performance test, not a strict benchmark
66-
expect(endTime - startTime).toBeLessThan(350);
72+
expect(endTime - startTime).toBeLessThan(250);
6773
});
6874

6975
it('should not cause unnecessary rerenders with external store', async () => {
@@ -86,13 +92,6 @@ describe('Performance & Caching Integration Tests', () => {
8692
});
8793

8894
// Track actual data changes (not just rerenders)
89-
console.log(
90-
'🚀 ~ RenderTrackingComponent ~ data:',
91-
testId,
92-
isLoading,
93-
previousData,
94-
data,
95-
);
9695
if (data !== previousData) {
9796
dataChangeCount++;
9897
previousData = data;
@@ -137,7 +136,7 @@ describe('Performance & Caching Integration Tests', () => {
137136

138137
<button
139138
data-testid="change-unrelated-btn"
140-
onClick={() => setUnrelatedState(`changed-${Date.now()}`)}
139+
onClick={() => setUnrelatedState('changed-' + Date.now())}
141140
>
142141
Change Unrelated State
143142
</button>
@@ -223,21 +222,6 @@ describe('Performance & Caching Integration Tests', () => {
223222
),
224223
).toBe(initialDataChangeCount); // Should be 1 (initial load only)
225224

226-
console.log('🎯 Render Performance Results:', {
227-
totalRenders: parseInt(
228-
screen.getByTestId('stable-component-render-count').textContent ||
229-
'0',
230-
),
231-
dataChanges: parseInt(
232-
screen.getByTestId('stable-component-data-change-count')
233-
.textContent || '0',
234-
),
235-
parentRerenders: parseInt(
236-
screen.getByTestId('force-rerender-count').textContent || '0',
237-
),
238-
ratio: `${parseInt(screen.getByTestId('stable-component-render-count').textContent || '0')} renders / ${parseInt(screen.getByTestId('stable-component-data-change-count').textContent || '0')} data changes`,
239-
});
240-
241225
// Assertions for optimal performance
242226
expect(
243227
parseInt(

0 commit comments

Comments
 (0)