@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
112 lines (111 loc) • 4.9 kB
JavaScript
import { _assert, _assertTypeOf } from '../error/assert.js';
import { _objectAssign, MISS } from '../types.js';
import { _getTargetMethodSignature } from './decorator.util.js';
import { jsonMemoSerializer } from './memo.util.js';
/**
* Like @_Memo, but allowing async MemoCache implementation.
*
* Implementation is more complex than @_Memo, because it needs to handle "in-flight" Promises
* while waiting for cache to resolve, to prevent "async swarm" issue.
*
* @experimental consider normal @_Memo for most of the cases, it's stable and predictable
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const _AsyncMemo = (opt) => (target, key, descriptor) => {
_assertTypeOf(descriptor.value, 'function', 'Memoization can be applied only to methods');
const originalFn = descriptor.value;
// Map from "instance" of the Class where @_AsyncMemo is applied to AsyncMemoCache instance.
const instanceCache = new Map();
// Cache from Instance to Map<key, Promise>
// This cache is temporary, with only one purpose - to prevent "async swarm"
// It only holds values that are "in-flight", until Promise is resolved
// After it's resolved - it's evicted from the cache and moved to the "proper" `instanceCache`
const instancePromiseCache = new Map();
const { logger = console, cacheFactory, cacheKeyFn = jsonMemoSerializer } = opt;
const keyStr = String(key);
const methodSignature = _getTargetMethodSignature(target, keyStr);
// eslint-disable-next-line @typescript-eslint/promise-function-async
descriptor.value = function (...args) {
const ctx = this;
const cacheKey = cacheKeyFn(args);
let cache = instanceCache.get(ctx);
let promiseCache = instancePromiseCache.get(ctx);
if (!cache) {
cache = cacheFactory();
instanceCache.set(ctx, cache);
// here, no need to check the cache. It's definitely a miss, because the cacheLayers is just created
// UPD: no! AsyncMemo supports "persistent caches" (e.g Database-backed cache)
}
if (!promiseCache) {
promiseCache = new Map();
instancePromiseCache.set(ctx, promiseCache);
}
let promise = promiseCache.get(cacheKey);
// If there's already "in-flight" cache request - return that, to avoid "async swarm"
if (promise) {
// console.log('return promise', promiseCache.size)
return promise;
}
promise = cache.get(cacheKey).then(async (value) => {
if (value !== MISS) {
// console.log('hit', promiseCache.size)
promiseCache.delete(cacheKey);
return value;
}
// Miss
// console.log('miss', promiseCache.size)
return await onMiss();
}, async (err) => {
// Log the cache error and proceed "as cache Miss"
logger.error(err);
return await onMiss();
});
promiseCache.set(cacheKey, promise);
return promise;
//
async function onMiss() {
try {
const value = await originalFn.apply(ctx, args);
// Save the value in the Cache, in parallel,
// not to slow down the main function execution
// and not to fail on possible cache issues
void (async () => {
try {
await cache.set(cacheKey, value);
}
catch (err) {
logger.error(err); // log and ignore the error
}
finally {
// Clear the "in-flight" promise cache entry, as we now have a "permanent" cache entry
promiseCache.delete(cacheKey);
// console.log('cache set and cleared', promiseCache!.size)
}
})();
return value;
}
catch (err) {
promiseCache.delete(cacheKey);
throw err;
}
}
};
_objectAssign(descriptor.value, {
clear: async () => {
logger.log(`${methodSignature} @_AsyncMemo.clear()`);
await Promise.all([...instanceCache.values()].map(c => c.clear()));
instanceCache.clear();
},
getInstanceCache: () => instanceCache,
getCache: instance => instanceCache.get(instance),
});
return descriptor;
};
/**
Call it on a method that is decorated with `@_AsyncMemo` to get access to additional functions,
e.g `clear` to clear the cache, or get its underlying data.
*/
export function _getAsyncMemo(method) {
_assert(typeof method?.getInstanceCache === 'function', 'method is not an AsyncMemo instance');
return method;
}