Skip to content

Commit

Permalink
Use custom cache expiration logic (#94)
Browse files Browse the repository at this point in the history
  • Loading branch information
sindresorhus committed Nov 14, 2023
1 parent 3afdfaf commit 8f207cf
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 32 deletions.
37 changes: 32 additions & 5 deletions index.ts
@@ -1,9 +1,9 @@
import mimicFunction from 'mimic-function';
import mapAgeCleaner from 'map-age-cleaner';

type AnyFunction = (...arguments_: readonly any[]) => any;
type AnyFunction = (...arguments_: readonly any[]) => unknown;

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

type CacheStorageContent<ValueType> = {
data: ValueType;
Expand Down Expand Up @@ -104,8 +104,19 @@ export default function mem<
maxAge,
}: Options<FunctionToMemoize, CacheKeyType> = {},
): FunctionToMemoize {
if (maxAge === 0) {
return fn;
}

if (typeof maxAge === 'number') {
mapAgeCleaner(cache as unknown as Map<CacheKeyType, ReturnType<FunctionToMemoize>>);
const maxSetIntervalValue = 2_147_483_647;
if (maxAge > maxSetIntervalValue) {
throw new TypeError(`The \`maxAge\` option cannot exceed ${maxSetIntervalValue}.`);
}

if (maxAge < 0) {
throw new TypeError('The `maxAge` option should not be a negative number.');
}
}

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

if (typeof maxAge === 'number' && maxAge !== Number.POSITIVE_INFINITY) {
const timer = setTimeout(() => {
cache.delete(key);
}, maxAge);

timer.unref?.();

const timers = cacheTimerStore.get(fn) ?? new Set<number>();
timers.add(timer as unknown as number);
cacheTimerStore.set(fn, timers);
}

return result;
} as FunctionToMemoize;

Expand Down Expand Up @@ -198,7 +221,7 @@ export function memDecorator<
/**
Clear all cached data of a memoized function.
@param fn - Memoized function.
@param fn - The memoized function.
*/
export function memClear(fn: AnyFunction): void {
const cache = cacheStore.get(fn);
Expand All @@ -210,5 +233,9 @@ export function memClear(fn: AnyFunction): void {
throw new TypeError('The cache Map can\'t be cleared!');
}

cache.clear();
cache.clear?.();

for (const timer of cacheTimerStore.get(fn) ?? []) {
clearTimeout(timer);
}
}
1 change: 0 additions & 1 deletion package.json
Expand Up @@ -41,7 +41,6 @@
"promise"
],
"dependencies": {
"map-age-cleaner": "^0.2.0",
"mimic-function": "^5.0.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Expand Up @@ -244,7 +244,7 @@ Clear all cached data of a memoized function.

Type: `Function`

Memoized function.
The memoized function.

## Tips

Expand Down
149 changes: 124 additions & 25 deletions test.ts
Expand Up @@ -4,8 +4,8 @@ import serializeJavascript from 'serialize-javascript';
import mem, {memDecorator, memClear} from './index.js';

test('memoize', t => {
let i = 0;
const fixture = (a?: unknown, b?: unknown) => i++;
let index = 0;
const fixture = (a?: unknown, b?: unknown) => index++;
const memoized = mem(fixture);
t.is(memoized(), 0);
t.is(memoized(), 0);
Expand All @@ -28,13 +28,13 @@ test('memoize', t => {
t.is(memoized(true), 5);

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

test('cacheKey option', t => {
let i = 0;
const fixture = (..._arguments: any) => i++;
let index = 0;
const fixture = (..._arguments: any) => index++;
const memoized = mem(fixture, {cacheKey: ([firstArgument]) => String(firstArgument)});
t.is(memoized(1), 0);
t.is(memoized(1), 0);
Expand All @@ -44,8 +44,8 @@ test('cacheKey option', t => {
});

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

test('memoize with regexp arguments', t => {
let i = 0;
const memoized = mem((a?: unknown) => i++, {cacheKey: serializeJavascript});
let index = 0;
const memoized = mem((a?: unknown) => index++, {cacheKey: serializeJavascript});
t.is(memoized(), 0);
t.is(memoized(), 0);
t.is(memoized(/Sindre Sorhus/), 1);
Expand All @@ -66,10 +66,10 @@ test('memoize with regexp arguments', t => {
});

test('memoize with Symbol arguments', t => {
let i = 0;
let index = 0;
const argument1 = Symbol('fixture1');
const argument2 = Symbol('fixture2');
const memoized = mem((a?: unknown) => i++);
const memoized = mem((a?: unknown) => index++);
t.is(memoized(), 0);
t.is(memoized(), 0);
t.is(memoized(argument1), 1);
Expand All @@ -79,8 +79,8 @@ test('memoize with Symbol arguments', t => {
});

test('maxAge option', async t => {
let i = 0;
const fixture = (a?: unknown) => i++;
let index = 0;
const fixture = (a?: unknown) => index++;
const memoized = mem(fixture, {maxAge: 100});
t.is(memoized(1), 0);
t.is(memoized(1), 0);
Expand All @@ -91,8 +91,8 @@ test('maxAge option', async t => {
});

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

test('maxAge items are deleted even if function throws', async t => {
let i = 0;
let index = 0;
const fixture = (a?: unknown) => {
if (i === 1) {
if (index === 1) {
throw new Error('failure');
}

return i++;
return index++;
};

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

test('cache option', t => {
let i = 0;
const fixture = (..._arguments: any) => i++;
let index = 0;
const fixture = (..._arguments: any) => index++;
const memoized = mem(fixture, {
cache: new WeakMap(),
cacheKey: <ReturnValue>([firstArgument]: [ReturnValue]): ReturnValue => firstArgument,
Expand All @@ -154,8 +154,8 @@ test('cache option', t => {
});

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

test('.clear()', t => {
let i = 0;
const fixture = () => i++;
let index = 0;
const fixture = () => index++;
const memoized = mem(fixture);
t.is(memoized(), 0);
t.is(memoized(), 0);
Expand Down Expand Up @@ -240,3 +240,102 @@ test('memClear() throws when called on an unclearable cache', t => {
instanceOf: TypeError,
});
});

test('maxAge - cache item expires after specified duration', async t => {
let index = 0;
const fixture = () => index++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized(), 0); // Initial call, cached
t.is(memoized(), 0); // Subsequent call, still cached
await delay(150); // Wait for longer than maxAge
t.is(memoized(), 1); // Cache expired, should compute again
});

test('maxAge - cache expiration timing is accurate', async t => {
let index = 0;
const fixture = () => index++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized(), 0);
await delay(90); // Wait for slightly less than maxAge
t.is(memoized(), 0); // Should still be cached
await delay(20); // Total delay now exceeds maxAge
t.is(memoized(), 1); // Should recompute as cache has expired
});

test('maxAge - expired items are not present in cache', async t => {
let index = 0;
const fixture = () => index++;
const cache = new Map();
const memoized = mem(fixture, {maxAge: 100, cache});

memoized(); // Call to cache the result
await delay(150); // Wait for cache to expire
memoized(); // Recompute and recache
t.is(cache.size, 1); // Only one item should be in the cache
});

test('maxAge - complex arguments and cache expiration', async t => {
let index = 0;
const fixture = object => index++;
const memoized = mem(fixture, {maxAge: 100, cacheKey: JSON.stringify});

const arg = {key: 'value'};
t.is(memoized(arg), 0);
await delay(150);
t.is(memoized(arg), 1); // Argument is the same, but should recompute due to expiration
});

test('maxAge - concurrent calls return cached value', async t => {
let index = 0;
const fixture = () => index++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized(), 0);
await delay(50); // Delay less than maxAge
t.is(memoized(), 0); // Should return cached value
});

test('maxAge - different arguments have separate expirations', async t => {
let index = 0;
const fixture = x => index++;
const memoized = mem(fixture, {maxAge: 100});

t.is(memoized('a'), 0);
await delay(150); // Expire the cache for 'a'
t.is(memoized('b'), 1); // 'b' should be a separate cache entry
t.is(memoized('a'), 2); // 'a' should be recomputed
});

test('maxAge - zero maxAge means no caching', t => {
let index = 0;
const fixture = () => index++;
const memoized = mem(fixture, {maxAge: 0});

t.is(memoized(), 0);
t.is(memoized(), 1); // No caching, should increment
});

test('maxAge - immediate expiration', async t => {
let index = 0;
const fixture = () => index++;
const memoized = mem(fixture, {maxAge: 1});
t.is(memoized(), 0);
await delay(10);
t.is(memoized(), 1); // Cache should expire immediately
});

test('maxAge - high concurrency', async t => {
let index = 0;
const fixture = () => index++;
const memoized = mem(fixture, {maxAge: 50});

// Simulate concurrent calls
for (let job = 0; job < 10_000; job++) {
memoized();
}

await delay(100);
t.is(memoized(), 1);
});

0 comments on commit 8f207cf

Please sign in to comment.