Skip to content

Commit 8f207cf

Browse files
authored
Use custom cache expiration logic (#94)
1 parent 3afdfaf commit 8f207cf

File tree

4 files changed

+157
-32
lines changed

4 files changed

+157
-32
lines changed

index.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import mimicFunction from 'mimic-function';
2-
import mapAgeCleaner from 'map-age-cleaner';
32

4-
type AnyFunction = (...arguments_: readonly any[]) => any;
3+
type AnyFunction = (...arguments_: readonly any[]) => unknown;
54

65
const cacheStore = new WeakMap<AnyFunction, CacheStorage<any, any>>();
6+
const cacheTimerStore = new WeakMap<AnyFunction, Set<number>>();
77

88
type CacheStorageContent<ValueType> = {
99
data: ValueType;
@@ -104,8 +104,19 @@ export default function mem<
104104
maxAge,
105105
}: Options<FunctionToMemoize, CacheKeyType> = {},
106106
): FunctionToMemoize {
107+
if (maxAge === 0) {
108+
return fn;
109+
}
110+
107111
if (typeof maxAge === 'number') {
108-
mapAgeCleaner(cache as unknown as Map<CacheKeyType, ReturnType<FunctionToMemoize>>);
112+
const maxSetIntervalValue = 2_147_483_647;
113+
if (maxAge > maxSetIntervalValue) {
114+
throw new TypeError(`The \`maxAge\` option cannot exceed ${maxSetIntervalValue}.`);
115+
}
116+
117+
if (maxAge < 0) {
118+
throw new TypeError('The `maxAge` option should not be a negative number.');
119+
}
109120
}
110121

111122
const memoized = function (this: any, ...arguments_: Parameters<FunctionToMemoize>): ReturnType<FunctionToMemoize> {
@@ -123,6 +134,18 @@ export default function mem<
123134
maxAge: maxAge ? Date.now() + maxAge : Number.POSITIVE_INFINITY,
124135
});
125136

137+
if (typeof maxAge === 'number' && maxAge !== Number.POSITIVE_INFINITY) {
138+
const timer = setTimeout(() => {
139+
cache.delete(key);
140+
}, maxAge);
141+
142+
timer.unref?.();
143+
144+
const timers = cacheTimerStore.get(fn) ?? new Set<number>();
145+
timers.add(timer as unknown as number);
146+
cacheTimerStore.set(fn, timers);
147+
}
148+
126149
return result;
127150
} as FunctionToMemoize;
128151

@@ -198,7 +221,7 @@ export function memDecorator<
198221
/**
199222
Clear all cached data of a memoized function.
200223
201-
@param fn - Memoized function.
224+
@param fn - The memoized function.
202225
*/
203226
export function memClear(fn: AnyFunction): void {
204227
const cache = cacheStore.get(fn);
@@ -210,5 +233,9 @@ export function memClear(fn: AnyFunction): void {
210233
throw new TypeError('The cache Map can\'t be cleared!');
211234
}
212235

213-
cache.clear();
236+
cache.clear?.();
237+
238+
for (const timer of cacheTimerStore.get(fn) ?? []) {
239+
clearTimeout(timer);
240+
}
214241
}

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
"promise"
4242
],
4343
"dependencies": {
44-
"map-age-cleaner": "^0.2.0",
4544
"mimic-function": "^5.0.0"
4645
},
4746
"devDependencies": {

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ Clear all cached data of a memoized function.
244244

245245
Type: `Function`
246246

247-
Memoized function.
247+
The memoized function.
248248

249249
## Tips
250250

test.ts

Lines changed: 124 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import serializeJavascript from 'serialize-javascript';
44
import mem, {memDecorator, memClear} from './index.js';
55

66
test('memoize', t => {
7-
let i = 0;
8-
const fixture = (a?: unknown, b?: unknown) => i++;
7+
let index = 0;
8+
const fixture = (a?: unknown, b?: unknown) => index++;
99
const memoized = mem(fixture);
1010
t.is(memoized(), 0);
1111
t.is(memoized(), 0);
@@ -28,13 +28,13 @@ test('memoize', t => {
2828
t.is(memoized(true), 5);
2929

3030
// Ensure that functions are stored by reference and not by "value" (e.g. their `.toString()` representation)
31-
t.is(memoized(() => i++), 6);
32-
t.is(memoized(() => i++), 7);
31+
t.is(memoized(() => index++), 6);
32+
t.is(memoized(() => index++), 7);
3333
});
3434

3535
test('cacheKey option', t => {
36-
let i = 0;
37-
const fixture = (..._arguments: any) => i++;
36+
let index = 0;
37+
const fixture = (..._arguments: any) => index++;
3838
const memoized = mem(fixture, {cacheKey: ([firstArgument]) => String(firstArgument)});
3939
t.is(memoized(1), 0);
4040
t.is(memoized(1), 0);
@@ -44,8 +44,8 @@ test('cacheKey option', t => {
4444
});
4545

4646
test('memoize with multiple non-primitive arguments', t => {
47-
let i = 0;
48-
const memoized = mem((a?: unknown, b?: unknown, c?: unknown) => i++, {cacheKey: JSON.stringify});
47+
let index = 0;
48+
const memoized = mem((a?: unknown, b?: unknown, c?: unknown) => index++, {cacheKey: JSON.stringify});
4949
t.is(memoized(), 0);
5050
t.is(memoized(), 0);
5151
t.is(memoized({foo: true}, {bar: false}), 1);
@@ -55,8 +55,8 @@ test('memoize with multiple non-primitive arguments', t => {
5555
});
5656

5757
test('memoize with regexp arguments', t => {
58-
let i = 0;
59-
const memoized = mem((a?: unknown) => i++, {cacheKey: serializeJavascript});
58+
let index = 0;
59+
const memoized = mem((a?: unknown) => index++, {cacheKey: serializeJavascript});
6060
t.is(memoized(), 0);
6161
t.is(memoized(), 0);
6262
t.is(memoized(/Sindre Sorhus/), 1);
@@ -66,10 +66,10 @@ test('memoize with regexp arguments', t => {
6666
});
6767

6868
test('memoize with Symbol arguments', t => {
69-
let i = 0;
69+
let index = 0;
7070
const argument1 = Symbol('fixture1');
7171
const argument2 = Symbol('fixture2');
72-
const memoized = mem((a?: unknown) => i++);
72+
const memoized = mem((a?: unknown) => index++);
7373
t.is(memoized(), 0);
7474
t.is(memoized(), 0);
7575
t.is(memoized(argument1), 1);
@@ -79,8 +79,8 @@ test('memoize with Symbol arguments', t => {
7979
});
8080

8181
test('maxAge option', async t => {
82-
let i = 0;
83-
const fixture = (a?: unknown) => i++;
82+
let index = 0;
83+
const fixture = (a?: unknown) => index++;
8484
const memoized = mem(fixture, {maxAge: 100});
8585
t.is(memoized(1), 0);
8686
t.is(memoized(1), 0);
@@ -91,8 +91,8 @@ test('maxAge option', async t => {
9191
});
9292

9393
test('maxAge option deletes old items', async t => {
94-
let i = 0;
95-
const fixture = (a?: unknown) => i++;
94+
let index = 0;
95+
const fixture = (a?: unknown) => index++;
9696
const cache = new Map<number, number>();
9797
const deleted: number[] = [];
9898
const _delete = cache.delete.bind(cache);
@@ -115,13 +115,13 @@ test('maxAge option deletes old items', async t => {
115115
});
116116

117117
test('maxAge items are deleted even if function throws', async t => {
118-
let i = 0;
118+
let index = 0;
119119
const fixture = (a?: unknown) => {
120-
if (i === 1) {
120+
if (index === 1) {
121121
throw new Error('failure');
122122
}
123123

124-
return i++;
124+
return index++;
125125
};
126126

127127
const cache = new Map();
@@ -139,8 +139,8 @@ test('maxAge items are deleted even if function throws', async t => {
139139
});
140140

141141
test('cache option', t => {
142-
let i = 0;
143-
const fixture = (..._arguments: any) => i++;
142+
let index = 0;
143+
const fixture = (..._arguments: any) => index++;
144144
const memoized = mem(fixture, {
145145
cache: new WeakMap(),
146146
cacheKey: <ReturnValue>([firstArgument]: [ReturnValue]): ReturnValue => firstArgument,
@@ -154,8 +154,8 @@ test('cache option', t => {
154154
});
155155

156156
test('promise support', async t => {
157-
let i = 0;
158-
const memoized = mem(async (a?: unknown) => i++);
157+
let index = 0;
158+
const memoized = mem(async (a?: unknown) => index++);
159159
t.is(await memoized(), 0);
160160
t.is(await memoized(), 0);
161161
t.is(await memoized(10), 1);
@@ -166,8 +166,8 @@ test('preserves the original function name', t => {
166166
});
167167

168168
test('.clear()', t => {
169-
let i = 0;
170-
const fixture = () => i++;
169+
let index = 0;
170+
const fixture = () => index++;
171171
const memoized = mem(fixture);
172172
t.is(memoized(), 0);
173173
t.is(memoized(), 0);
@@ -240,3 +240,102 @@ test('memClear() throws when called on an unclearable cache', t => {
240240
instanceOf: TypeError,
241241
});
242242
});
243+
244+
test('maxAge - cache item expires after specified duration', async t => {
245+
let index = 0;
246+
const fixture = () => index++;
247+
const memoized = mem(fixture, {maxAge: 100});
248+
249+
t.is(memoized(), 0); // Initial call, cached
250+
t.is(memoized(), 0); // Subsequent call, still cached
251+
await delay(150); // Wait for longer than maxAge
252+
t.is(memoized(), 1); // Cache expired, should compute again
253+
});
254+
255+
test('maxAge - cache expiration timing is accurate', async t => {
256+
let index = 0;
257+
const fixture = () => index++;
258+
const memoized = mem(fixture, {maxAge: 100});
259+
260+
t.is(memoized(), 0);
261+
await delay(90); // Wait for slightly less than maxAge
262+
t.is(memoized(), 0); // Should still be cached
263+
await delay(20); // Total delay now exceeds maxAge
264+
t.is(memoized(), 1); // Should recompute as cache has expired
265+
});
266+
267+
test('maxAge - expired items are not present in cache', async t => {
268+
let index = 0;
269+
const fixture = () => index++;
270+
const cache = new Map();
271+
const memoized = mem(fixture, {maxAge: 100, cache});
272+
273+
memoized(); // Call to cache the result
274+
await delay(150); // Wait for cache to expire
275+
memoized(); // Recompute and recache
276+
t.is(cache.size, 1); // Only one item should be in the cache
277+
});
278+
279+
test('maxAge - complex arguments and cache expiration', async t => {
280+
let index = 0;
281+
const fixture = object => index++;
282+
const memoized = mem(fixture, {maxAge: 100, cacheKey: JSON.stringify});
283+
284+
const arg = {key: 'value'};
285+
t.is(memoized(arg), 0);
286+
await delay(150);
287+
t.is(memoized(arg), 1); // Argument is the same, but should recompute due to expiration
288+
});
289+
290+
test('maxAge - concurrent calls return cached value', async t => {
291+
let index = 0;
292+
const fixture = () => index++;
293+
const memoized = mem(fixture, {maxAge: 100});
294+
295+
t.is(memoized(), 0);
296+
await delay(50); // Delay less than maxAge
297+
t.is(memoized(), 0); // Should return cached value
298+
});
299+
300+
test('maxAge - different arguments have separate expirations', async t => {
301+
let index = 0;
302+
const fixture = x => index++;
303+
const memoized = mem(fixture, {maxAge: 100});
304+
305+
t.is(memoized('a'), 0);
306+
await delay(150); // Expire the cache for 'a'
307+
t.is(memoized('b'), 1); // 'b' should be a separate cache entry
308+
t.is(memoized('a'), 2); // 'a' should be recomputed
309+
});
310+
311+
test('maxAge - zero maxAge means no caching', t => {
312+
let index = 0;
313+
const fixture = () => index++;
314+
const memoized = mem(fixture, {maxAge: 0});
315+
316+
t.is(memoized(), 0);
317+
t.is(memoized(), 1); // No caching, should increment
318+
});
319+
320+
test('maxAge - immediate expiration', async t => {
321+
let index = 0;
322+
const fixture = () => index++;
323+
const memoized = mem(fixture, {maxAge: 1});
324+
t.is(memoized(), 0);
325+
await delay(10);
326+
t.is(memoized(), 1); // Cache should expire immediately
327+
});
328+
329+
test('maxAge - high concurrency', async t => {
330+
let index = 0;
331+
const fixture = () => index++;
332+
const memoized = mem(fixture, {maxAge: 50});
333+
334+
// Simulate concurrent calls
335+
for (let job = 0; job < 10_000; job++) {
336+
memoized();
337+
}
338+
339+
await delay(100);
340+
t.is(memoized(), 1);
341+
});

0 commit comments

Comments
 (0)