Skip to content

Commit

Permalink
feat!: move persistent cache to new decorator (#121)
Browse files Browse the repository at this point in the history
  • Loading branch information
exKAZUu authored Dec 29, 2024
1 parent d0eae88 commit 286a16c
Show file tree
Hide file tree
Showing 7 changed files with 551 additions and 344 deletions.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { memoize, memoizeFactory } from './memoize.js';
export { memoizeOne, memoizeOneFactory } from './memoizeOne.js';
export { sha3_512 } from './hash.js';
128 changes: 8 additions & 120 deletions src/memoize.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { sha3_512 } from './hash.js';

let globalCounter = 0;

/**
* A memoization decorator/function that caches the results of method/getter/function calls to improve performance.
* This decorator/function can be applied to methods and getters in a class as a decorator, and functions without context as a function.
Expand Down Expand Up @@ -29,158 +27,73 @@ export const memoize = memoizeFactory();
* @param {number} [options.maxCachedArgsSize=100] - The maximum number of distinct values that can be cached.
* @param {number} [options.cacheDuration=Number.POSITIVE_INFINITY] - The maximum number of milliseconds that a cached value is valid.
* @param {Function} [options.calcHash] - A function to calculate the hash for a given context and arguments. Defaults to hashing the stringified context and arguments.
* @param {Map<unknown, unknown>[]} [options.caches] - An array of maps to store cached values.
* @param {Function} [options.persistCache] - A function to store cached values persistently.
* @param {Function} [options.tryReadingCache] - A function to try reading cached values from persistent storage.
* @param {Function} [options.removeCache] - A function to remove cached values.
* @param {Map<string, [unknown, number]>[]} [options.caches] - An array of maps to store cached values.
* @returns {Function} A new memoize function with the specified cache sizes.
*/
export function memoizeFactory({
cacheDuration = Number.POSITIVE_INFINITY,
caches,
calcHash = (thisArg: unknown, counter: number, args: unknown) => sha3_512(JSON.stringify([thisArg, counter, args])),
calcHash = (self, args) => sha3_512(JSON.stringify([self, args])),
maxCachedArgsSize = 100,
persistCache,
removeCache,
tryReadingCache,
}: {
maxCachedArgsSize?: number;
cacheDuration?: number;
calcHash?: (thisArg: unknown, counter: number, args: unknown) => string;
caches?: Map<unknown, unknown>[];
persistCache?: (hash: string, currentTime: number, value: unknown) => void;
tryReadingCache?: (hash: string) => [number, unknown] | undefined;
removeCache?: (hash: string) => void;
calcHash?: (self: unknown, args: unknown) => string;
caches?: Map<string, [unknown, number]>[];
} = {}) {
return function memoize<This, Args extends unknown[], Return>(
target: ((this: This, ...args: Args) => Return) | ((...args: Args) => Return) | keyof This,
context?:
| ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
| ClassGetterDecoratorContext<This, Return>
): (this: This, ...args: Args) => Return {
const counter = globalCounter++;
if (context?.kind === 'getter') {
const cache = new Map<string, [Return, number]>();
caches?.push(cache);
return function (this: This): Return {
console.log(`Entering getter ${String(context.name)}.`);

const hash = calcHash(this, counter, []);
const hash = calcHash(this, []);
const now = Date.now();

// Check in-memory cache first
if (cache.has(hash)) {
const [cachedValue, cachedAt] = cache.get(hash) as [Return, number];
if (now - cachedAt <= cacheDuration) {
console.log(`Exiting getter ${String(context.name)}.`);
return cachedValue;
}

cache.delete(hash);
try {
const promise = removeCache?.(hash) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
}

// Try reading from persistent cache
try {
const persistentCache = tryReadingCache?.(hash);
if (persistentCache) {
const [cachedAt, cachedValue] = persistentCache;
if (now - cachedAt <= cacheDuration) {
cache.set(hash, [cachedValue as Return, cachedAt]);
console.log(`Exiting getter ${String(context.name)}.`);
return cachedValue as Return;
}

const promise = removeCache?.(hash) as unknown;
if (promise instanceof Promise) promise.catch(noop);
}
} catch {
// do nothing.
}

const result = (target as (this: This) => Return).call(this);
if (cache.size >= maxCachedArgsSize) {
const oldestKey = cache.keys().next().value as string;
cache.delete(oldestKey);
try {
const promise = removeCache?.(oldestKey) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
}
cache.set(hash, [result, now]);
if (result instanceof Promise) {
void (async () => {
try {
const promise = persistCache?.(hash, now, result) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
});
} else {
try {
const promise = persistCache?.(hash, now, result) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
}

console.log(`Exiting getter ${String(context.name)}.`);
return result as Return;
return result;
};
} else {
const cache = new Map<string, [Return, number]>();
caches?.push(cache);

return function (this: This, ...args: Args): Return {
console.log(
`Entering ${context ? `method ${String(context.name)}` : 'function'}(${calcHash(this, counter, args)}).`
`Entering ${context ? `method ${String(context.name)}` : 'function'}(${calcHash(this, args)}).`
);

const hash = calcHash(this, counter, args);
const hash = calcHash(this, args);
const now = Date.now();

// Check in-memory cache first
if (cache.has(hash)) {
const [cachedValue, cachedAt] = cache.get(hash) as [Return, number];
if (now - cachedAt <= cacheDuration) {
console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`);
return cachedValue;
}

cache.delete(hash);
try {
const promise = removeCache?.(hash) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
}

// Try reading from persistent cache
try {
const persistentCache = tryReadingCache?.(hash);
if (persistentCache) {
const [cachedAt, cachedValue] = persistentCache;
if (now - cachedAt <= cacheDuration) {
cache.set(hash, [cachedValue as Return, cachedAt]);
console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`);
return cachedValue as Return;
}

const promise = removeCache?.(hash) as unknown;
if (promise instanceof Promise) promise.catch(noop);
}
} catch {
// do nothing.
}

const result = context
Expand All @@ -190,37 +103,12 @@ export function memoizeFactory({
if (cache.size >= maxCachedArgsSize) {
const oldestKey = cache.keys().next().value as string;
cache.delete(oldestKey);
try {
const promise = removeCache?.(oldestKey) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
}
cache.set(hash, [result, now]);
if (result instanceof Promise) {
void (async () => {
try {
const promise = persistCache?.(hash, now, result) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
});
} else {
try {
const promise = persistCache?.(hash, now, result) as unknown;
if (promise instanceof Promise) promise.catch(noop);
} catch {
// do nothing.
}
}

console.log(`Exiting ${context ? `method ${String(context.name)}` : 'function'}.`);
return result;
};
}
};
}

const noop = (): void => {};
24 changes: 12 additions & 12 deletions src/memoizeOne.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,35 @@ export const memoizeOne = memoizeOneFactory();
*
* @param {Object} options - The options for the memoizeOne function.
* @param {number} [options.cacheDuration=Number.POSITIVE_INFINITY] - The maximum number of milliseconds that a cached value is valid.
* @param {Function} [options.calcHash=() => ''] - A function to calculate the hash for a given context and arguments. Defaults to returning an empty string.
*
* @returns {Function} A memoizeOne function with the specified cache duration.
* @template This - The type of the `this` context within the method, getter or function.
* @template Args - The types of the arguments to the method, getter or function.
* @template Return - The return type of the method, getter or function.
*/
export function memoizeOneFactory({ cacheDuration = Number.POSITIVE_INFINITY }: { cacheDuration?: number } = {}) {
export function memoizeOneFactory({
cacheDuration = Number.POSITIVE_INFINITY,
calcHash = () => '',
}: { cacheDuration?: number; calcHash?: (self: unknown, args: unknown) => string } = {}) {
return function <This, Args extends unknown[], Return>(
target: ((this: This, ...args: Args) => Return) | ((...args: Args) => Return) | keyof This,
context?:
| ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
| ClassGetterDecoratorContext<This, Return>
): (this: This, ...args: Args) => Return {
let lastThis: This;
let lastCache: Return;
let lastCachedAt: number;
let lastHash: string;

if (context?.kind === 'getter') {
return function (this: This): Return {
console.log(`Entering getter ${String(context.name)}.`);

const hash = calcHash(this, []);
const now = Date.now();
if (lastThis !== this || now - lastCachedAt > cacheDuration) {
// eslint-disable-next-line
lastThis = this;
if (lastHash !== hash || now - lastCachedAt > cacheDuration) {
lastHash = hash;
lastCache = (target as (this: This) => Return).call(this);
lastCachedAt = now;
}
Expand All @@ -53,17 +57,13 @@ export function memoizeOneFactory({ cacheDuration = Number.POSITIVE_INFINITY }:
};
}

let lastCacheKey: string;

return function (this: This, ...args: Args): Return {
console.log(`Entering ${context ? `method ${String(context.name)}` : 'function'}(${JSON.stringify(args)}).`);

const key = JSON.stringify(args);
const hash = calcHash(this, args);
const now = Date.now();
if (lastThis !== this || lastCacheKey !== key || now - lastCachedAt > cacheDuration) {
// eslint-disable-next-line
lastThis = this;
lastCacheKey = key;
if (lastHash !== hash || now - lastCachedAt > cacheDuration) {
lastHash = hash;
lastCache = context
? (target as (this: This, ...args: Args) => Return).call(this, ...args)
: (target as (...args: Args) => Return)(...args);
Expand Down
Loading

0 comments on commit 286a16c

Please sign in to comment.