Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use custom cache expiration logic #94

Merged
merged 5 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 32 additions & 5 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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;
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved

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;
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved
}

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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);
});