@naturalcycles/js-lib 15.98.0 → 15.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/decorators/asyncMemo.decorator.d.ts +15 -7
- package/dist/decorators/asyncMemo.decorator.js +11 -6
- package/dist/decorators/memo.decorator.d.ts +6 -1
- package/dist/decorators/memo.decorator.js +19 -0
- package/dist/decorators/memo.util.d.ts +6 -4
- package/dist/decorators/memo.util.js +12 -12
- package/dist/decorators/memoized.js +14 -0
- package/dist/decorators/memoizedAsync.d.ts +7 -1
- package/dist/decorators/memoizedAsync.js +11 -4
- package/package.json +1 -1
- package/src/decorators/asyncMemo.decorator.ts +21 -9
- package/src/decorators/memo.decorator.ts +19 -1
- package/src/decorators/memo.util.ts +16 -11
- package/src/decorators/memoized.ts +13 -0
- package/src/decorators/memoizedAsync.ts +15 -4
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { CommonLogger } from '../log/commonLogger.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { AnyFunction, AnyObject, MaybeParameters } from '../types.js';
|
|
3
3
|
import type { MethodDecorator } from './decorator.util.js';
|
|
4
4
|
import type { AsyncMemoCache } from './memo.util.js';
|
|
5
5
|
export interface AsyncMemoOptions<FN> {
|
|
6
6
|
/**
|
|
7
7
|
* Provide a custom implementation of AsyncMemoCache.
|
|
8
8
|
* Function that creates an instance of `AsyncMemoCache`.
|
|
9
|
+
* Defaults to in-memory `MapAsyncMemoCache`, which never expires.
|
|
10
|
+
* e.g `LRUAsyncMemoCache` (ttl, max) from `@naturalcycles/nodejs-lib`,
|
|
11
|
+
* or `CommonKeyValueDaoMemoCache` (persisted in a DB) from `@naturalcycles/db-lib`.
|
|
9
12
|
*/
|
|
10
|
-
cacheFactory
|
|
13
|
+
cacheFactory?: () => AsyncMemoCache;
|
|
11
14
|
/**
|
|
12
15
|
* Provide a custom implementation of CacheKey function.
|
|
13
16
|
*/
|
|
@@ -23,17 +26,22 @@ export interface AsyncMemoInstance {
|
|
|
23
26
|
*/
|
|
24
27
|
clear: () => Promise<void>;
|
|
25
28
|
getInstanceCache: () => Map<AnyObject, AsyncMemoCache>;
|
|
26
|
-
getCache: (instance:
|
|
29
|
+
getCache: (instance: AnyObject) => AsyncMemoCache | undefined;
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
29
|
-
* Like `@_Memo`, but
|
|
32
|
+
* Like `@_Memo`, but awaits the method and caches the resolved **value**, not the Promise:
|
|
33
|
+
*
|
|
34
|
+
* - The cache is async (`AsyncMemoCache`), so it can be backed by e.g Redis or Datastore
|
|
35
|
+
* and survive a process restart. That's the reason to use it over `@_Memo`.
|
|
36
|
+
* - A rejection is NOT cached, so the next call retries the method (same as `@_Memo`).
|
|
37
|
+
* - Parallel calls with the same key share one in-flight Promise, also while the cache lookup is pending.
|
|
38
|
+
*
|
|
39
|
+
* Cache errors (`get`/`set`) are logged and treated as a cache miss, they never fail the method.
|
|
30
40
|
*
|
|
31
41
|
* Implementation is more complex than `@_Memo`, because it needs to handle "in-flight" Promises
|
|
32
42
|
* while waiting for cache to resolve, to prevent "async swarm" issue.
|
|
33
|
-
*
|
|
34
|
-
* @experimental consider normal `@_Memo` for most of the cases, it's stable and predictable
|
|
35
43
|
*/
|
|
36
|
-
export declare const _AsyncMemo: <FN>(opt
|
|
44
|
+
export declare const _AsyncMemo: <FN>(opt?: AsyncMemoOptions<FN>) => MethodDecorator<FN>;
|
|
37
45
|
/**
|
|
38
46
|
Call it on a method that is decorated with `@_AsyncMemo` to get access to additional functions,
|
|
39
47
|
e.g `clear` to clear the cache, or get its underlying data.
|
|
@@ -2,16 +2,21 @@ import { _assert, _assertTypeOf } from '../error/assert.js';
|
|
|
2
2
|
import { consoleLogger } from '../log/commonLogger.js';
|
|
3
3
|
import { _objectAssign, MISS } from '../types.js';
|
|
4
4
|
import { _getTargetMethodSignature } from './decorator.util.js';
|
|
5
|
-
import { jsonMemoSerializer } from './memo.util.js';
|
|
5
|
+
import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js';
|
|
6
6
|
/**
|
|
7
|
-
* Like `@_Memo`, but
|
|
7
|
+
* Like `@_Memo`, but awaits the method and caches the resolved **value**, not the Promise:
|
|
8
|
+
*
|
|
9
|
+
* - The cache is async (`AsyncMemoCache`), so it can be backed by e.g Redis or Datastore
|
|
10
|
+
* and survive a process restart. That's the reason to use it over `@_Memo`.
|
|
11
|
+
* - A rejection is NOT cached, so the next call retries the method (same as `@_Memo`).
|
|
12
|
+
* - Parallel calls with the same key share one in-flight Promise, also while the cache lookup is pending.
|
|
13
|
+
*
|
|
14
|
+
* Cache errors (`get`/`set`) are logged and treated as a cache miss, they never fail the method.
|
|
8
15
|
*
|
|
9
16
|
* Implementation is more complex than `@_Memo`, because it needs to handle "in-flight" Promises
|
|
10
17
|
* while waiting for cache to resolve, to prevent "async swarm" issue.
|
|
11
|
-
*
|
|
12
|
-
* @experimental consider normal `@_Memo` for most of the cases, it's stable and predictable
|
|
13
18
|
*/
|
|
14
|
-
export const _AsyncMemo = (opt) => (target, key, descriptor) => {
|
|
19
|
+
export const _AsyncMemo = (opt = {}) => (target, key, descriptor) => {
|
|
15
20
|
_assertTypeOf(descriptor.value, 'function', 'Memoization can be applied only to methods');
|
|
16
21
|
const originalFn = descriptor.value;
|
|
17
22
|
// Map from "instance" of the Class where @_AsyncMemo is applied to AsyncMemoCache instance.
|
|
@@ -21,7 +26,7 @@ export const _AsyncMemo = (opt) => (target, key, descriptor) => {
|
|
|
21
26
|
// It only holds values that are "in-flight", until Promise is resolved
|
|
22
27
|
// After it's resolved - it's evicted from the cache and moved to the "proper" `instanceCache`
|
|
23
28
|
const instancePromiseCache = new Map();
|
|
24
|
-
const { logger = consoleLogger, cacheFactory, cacheKeyFn = jsonMemoSerializer } = opt;
|
|
29
|
+
const { logger = consoleLogger, cacheFactory = () => new MapAsyncMemoCache(), cacheKeyFn = jsonMemoSerializer, } = opt;
|
|
25
30
|
const keyStr = String(key);
|
|
26
31
|
const methodSignature = _getTargetMethodSignature(target, keyStr);
|
|
27
32
|
// oxlint-disable-next-line @typescript-eslint/promise-function-async
|
|
@@ -24,7 +24,7 @@ export interface MemoInstance {
|
|
|
24
24
|
*/
|
|
25
25
|
clear: () => void;
|
|
26
26
|
getInstanceCache: () => Map<AnyObject, MemoCache>;
|
|
27
|
-
getCache: (instance:
|
|
27
|
+
getCache: (instance: AnyObject) => MemoCache | undefined;
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Memoizes the method of the class, so it caches the output and returns the cached version if the "key"
|
|
@@ -35,6 +35,11 @@ export interface MemoInstance {
|
|
|
35
35
|
* So, error-throwing functions will be called multiple times.
|
|
36
36
|
* Therefor, if the origin function can possibly throw - it should try to be idempotent.
|
|
37
37
|
*
|
|
38
|
+
* For an async method it's the returned **Promise** that is cached, so parallel calls with the same key
|
|
39
|
+
* share one in-flight Promise. A rejected Promise is evicted from the cache, so the next call retries
|
|
40
|
+
* the method, same as for a thrown Error.
|
|
41
|
+
* Use `@_AsyncMemo` for an async-backed cache (e.g Redis), which stores the resolved value instead.
|
|
42
|
+
*
|
|
38
43
|
* Cache is stored **per instance** - separate cache for separate instances of the class.
|
|
39
44
|
* If you don't want it that way - you can use a static method, then there will be only one "instance".
|
|
40
45
|
*
|
|
@@ -12,6 +12,11 @@ import { jsonMemoSerializer, MapMemoCache } from './memo.util.js';
|
|
|
12
12
|
* So, error-throwing functions will be called multiple times.
|
|
13
13
|
* Therefor, if the origin function can possibly throw - it should try to be idempotent.
|
|
14
14
|
*
|
|
15
|
+
* For an async method it's the returned **Promise** that is cached, so parallel calls with the same key
|
|
16
|
+
* share one in-flight Promise. A rejected Promise is evicted from the cache, so the next call retries
|
|
17
|
+
* the method, same as for a thrown Error.
|
|
18
|
+
* Use `@_AsyncMemo` for an async-backed cache (e.g Redis), which stores the resolved value instead.
|
|
19
|
+
*
|
|
15
20
|
* Cache is stored **per instance** - separate cache for separate instances of the class.
|
|
16
21
|
* If you don't want it that way - you can use a static method, then there will be only one "instance".
|
|
17
22
|
*
|
|
@@ -57,6 +62,20 @@ export const _Memo = (opt = {}) => (target, key, descriptor) => {
|
|
|
57
62
|
catch (err) {
|
|
58
63
|
logger.error({ err });
|
|
59
64
|
}
|
|
65
|
+
if (value instanceof Promise) {
|
|
66
|
+
// Don't cache a rejection: evict it, so the next call retries the method.
|
|
67
|
+
// The derived Promise is handled, so it adds no unhandled rejection.
|
|
68
|
+
value.catch(() => {
|
|
69
|
+
try {
|
|
70
|
+
// Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
|
|
71
|
+
if (cache.get(cacheKey) === value)
|
|
72
|
+
cache.delete(cacheKey);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.error({ err });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
60
79
|
return value;
|
|
61
80
|
};
|
|
62
81
|
_objectAssign(descriptor.value, {
|
|
@@ -19,6 +19,7 @@ export interface MemoCache<KEY = any, VALUE = any> {
|
|
|
19
19
|
*/
|
|
20
20
|
get: (k: KEY) => VALUE;
|
|
21
21
|
set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => void;
|
|
22
|
+
delete: (k: KEY) => void;
|
|
22
23
|
/**
|
|
23
24
|
* Clear is only called when `_getMemoCache().clear()` is called.
|
|
24
25
|
* Otherwise the Cache is "persistent" (never cleared).
|
|
@@ -33,6 +34,7 @@ export interface AsyncMemoCache<KEY = any, VALUE = any> {
|
|
|
33
34
|
*/
|
|
34
35
|
get: (k: KEY) => Promise<VALUE | typeof MISS>;
|
|
35
36
|
set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => Promise<void>;
|
|
37
|
+
delete: (k: KEY) => Promise<void>;
|
|
36
38
|
/**
|
|
37
39
|
* Clear is only called when `_getAsyncMemo().clear()` is called.
|
|
38
40
|
* Otherwise the Cache is "persistent" (never cleared).
|
|
@@ -44,18 +46,18 @@ export declare class MapMemoCache<KEY = any, VALUE = any> implements MemoCache<K
|
|
|
44
46
|
has(k: KEY): boolean;
|
|
45
47
|
get(k: KEY): VALUE;
|
|
46
48
|
set(k: KEY, v: VALUE): void;
|
|
49
|
+
delete(k: KEY): void;
|
|
47
50
|
clear(): void;
|
|
48
51
|
}
|
|
49
52
|
/**
|
|
50
53
|
* Implementation of AsyncMemoCache backed by a synchronous Map.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* The default cache of `@_AsyncMemo`: in-memory, never expires.
|
|
55
|
+
* For an **async** backed cache (Redis, Datastore) use another implementation.
|
|
53
56
|
*/
|
|
54
57
|
export declare class MapAsyncMemoCache<KEY = any, VALUE = any> implements AsyncMemoCache<KEY, VALUE> {
|
|
55
|
-
private delay;
|
|
56
|
-
constructor(delay?: number);
|
|
57
58
|
private m;
|
|
58
59
|
get(k: KEY): Promise<VALUE | typeof MISS>;
|
|
59
60
|
set(k: KEY, v: VALUE): Promise<void>;
|
|
61
|
+
delete(k: KEY): Promise<void>;
|
|
60
62
|
clear(): Promise<void>;
|
|
61
63
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { _isPrimitive } from '../is.util.js';
|
|
2
|
-
import { pDelay } from '../promise/pDelay.js';
|
|
3
2
|
import { MISS } from '../types.js';
|
|
4
3
|
export const jsonMemoSerializer = args => {
|
|
5
4
|
if (args.length === 0)
|
|
@@ -64,33 +63,34 @@ export class MapMemoCache {
|
|
|
64
63
|
set(k, v) {
|
|
65
64
|
this.m.set(k, v);
|
|
66
65
|
}
|
|
66
|
+
delete(k) {
|
|
67
|
+
this.m.delete(k);
|
|
68
|
+
}
|
|
67
69
|
clear() {
|
|
68
70
|
this.m.clear();
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
/**
|
|
72
74
|
* Implementation of AsyncMemoCache backed by a synchronous Map.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
+
* The default cache of `@_AsyncMemo`: in-memory, never expires.
|
|
76
|
+
* For an **async** backed cache (Redis, Datastore) use another implementation.
|
|
75
77
|
*/
|
|
76
78
|
export class MapAsyncMemoCache {
|
|
77
|
-
delay;
|
|
78
|
-
constructor(delay = 0) {
|
|
79
|
-
this.delay = delay;
|
|
80
|
-
}
|
|
81
79
|
m = new Map();
|
|
82
80
|
async get(k) {
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
const v = this.m.get(k);
|
|
82
|
+
// `undefined` is a valid VALUE, only then `has` tells it apart from a miss
|
|
83
|
+
if (v === undefined && !this.m.has(k))
|
|
85
84
|
return MISS;
|
|
86
|
-
return
|
|
85
|
+
return v;
|
|
87
86
|
}
|
|
88
87
|
async set(k, v) {
|
|
89
|
-
await pDelay(this.delay);
|
|
90
88
|
this.m.set(k, v);
|
|
91
89
|
}
|
|
90
|
+
async delete(k) {
|
|
91
|
+
this.m.delete(k);
|
|
92
|
+
}
|
|
92
93
|
async clear() {
|
|
93
|
-
await pDelay(this.delay);
|
|
94
94
|
this.m.clear();
|
|
95
95
|
}
|
|
96
96
|
}
|
|
@@ -21,6 +21,20 @@ export function _memoized(fn, opt = {}) {
|
|
|
21
21
|
catch (err) {
|
|
22
22
|
logger.error({ err });
|
|
23
23
|
}
|
|
24
|
+
if (value instanceof Promise) {
|
|
25
|
+
// Don't cache a rejection: evict it, so the next call retries the function.
|
|
26
|
+
// The derived Promise is handled, so it adds no unhandled rejection.
|
|
27
|
+
value.catch(() => {
|
|
28
|
+
try {
|
|
29
|
+
// Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
|
|
30
|
+
if (cache.get(cacheKey) === value)
|
|
31
|
+
cache.delete(cacheKey);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
logger.error({ err });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
24
38
|
return value;
|
|
25
39
|
};
|
|
26
40
|
Object.assign(memoizedFn, { cache });
|
|
@@ -4,4 +4,10 @@ import type { AsyncMemoCache } from './memo.util.js';
|
|
|
4
4
|
export interface MemoizedAsyncFunction {
|
|
5
5
|
cache: AsyncMemoCache;
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Function version of `@_AsyncMemo`: awaits `fn` and caches the resolved value, not the Promise,
|
|
9
|
+
* so a rejection is not cached. Cache errors are logged and treated as a miss.
|
|
10
|
+
*
|
|
11
|
+
* Unlike the decorator, it does NOT dedupe parallel calls with the same key.
|
|
12
|
+
*/
|
|
13
|
+
export declare function _memoizedAsync<FN extends AnyAsyncFunction>(fn: FN, opt?: AsyncMemoOptions<FN>): FN & MemoizedAsyncFunction;
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { consoleLogger } from '../log/commonLogger.js';
|
|
2
2
|
import { MISS } from '../types.js';
|
|
3
|
-
import { jsonMemoSerializer } from './memo.util.js';
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js';
|
|
4
|
+
/**
|
|
5
|
+
* Function version of `@_AsyncMemo`: awaits `fn` and caches the resolved value, not the Promise,
|
|
6
|
+
* so a rejection is not cached. Cache errors are logged and treated as a miss.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the decorator, it does NOT dedupe parallel calls with the same key.
|
|
9
|
+
*/
|
|
10
|
+
export function _memoizedAsync(fn, opt = {}) {
|
|
11
|
+
const { logger = consoleLogger, cacheFactory = () => new MapAsyncMemoCache(), cacheKeyFn = jsonMemoSerializer, } = opt;
|
|
6
12
|
const cache = cacheFactory();
|
|
7
13
|
const memoizedFn = async function (...args) {
|
|
8
14
|
const ctx = this;
|
|
9
15
|
const cacheKey = cacheKeyFn(args);
|
|
10
|
-
|
|
16
|
+
// MISS, so that a failed cache `get` is a miss too (and not a cached `undefined`)
|
|
17
|
+
let value = MISS;
|
|
11
18
|
try {
|
|
12
19
|
value = await cache.get(cacheKey);
|
|
13
20
|
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { _assert, _assertTypeOf } from '../error/assert.js'
|
|
2
2
|
import { consoleLogger } from '../log/commonLogger.js'
|
|
3
3
|
import type { CommonLogger } from '../log/commonLogger.js'
|
|
4
|
-
import type {
|
|
4
|
+
import type { AnyFunction, AnyObject, MaybeParameters } from '../types.js'
|
|
5
5
|
import { _objectAssign, MISS } from '../types.js'
|
|
6
6
|
import type { MethodDecorator } from './decorator.util.js'
|
|
7
7
|
import { _getTargetMethodSignature } from './decorator.util.js'
|
|
8
8
|
import type { AsyncMemoCache } from './memo.util.js'
|
|
9
|
-
import { jsonMemoSerializer } from './memo.util.js'
|
|
9
|
+
import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js'
|
|
10
10
|
|
|
11
11
|
export interface AsyncMemoOptions<FN> {
|
|
12
12
|
/**
|
|
13
13
|
* Provide a custom implementation of AsyncMemoCache.
|
|
14
14
|
* Function that creates an instance of `AsyncMemoCache`.
|
|
15
|
+
* Defaults to in-memory `MapAsyncMemoCache`, which never expires.
|
|
16
|
+
* e.g `LRUAsyncMemoCache` (ttl, max) from `@naturalcycles/nodejs-lib`,
|
|
17
|
+
* or `CommonKeyValueDaoMemoCache` (persisted in a DB) from `@naturalcycles/db-lib`.
|
|
15
18
|
*/
|
|
16
|
-
cacheFactory
|
|
19
|
+
cacheFactory?: () => AsyncMemoCache
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Provide a custom implementation of CacheKey function.
|
|
@@ -34,19 +37,24 @@ export interface AsyncMemoInstance {
|
|
|
34
37
|
|
|
35
38
|
getInstanceCache: () => Map<AnyObject, AsyncMemoCache>
|
|
36
39
|
|
|
37
|
-
getCache: (instance:
|
|
40
|
+
getCache: (instance: AnyObject) => AsyncMemoCache | undefined
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
|
-
* Like `@_Memo`, but
|
|
44
|
+
* Like `@_Memo`, but awaits the method and caches the resolved **value**, not the Promise:
|
|
45
|
+
*
|
|
46
|
+
* - The cache is async (`AsyncMemoCache`), so it can be backed by e.g Redis or Datastore
|
|
47
|
+
* and survive a process restart. That's the reason to use it over `@_Memo`.
|
|
48
|
+
* - A rejection is NOT cached, so the next call retries the method (same as `@_Memo`).
|
|
49
|
+
* - Parallel calls with the same key share one in-flight Promise, also while the cache lookup is pending.
|
|
50
|
+
*
|
|
51
|
+
* Cache errors (`get`/`set`) are logged and treated as a cache miss, they never fail the method.
|
|
42
52
|
*
|
|
43
53
|
* Implementation is more complex than `@_Memo`, because it needs to handle "in-flight" Promises
|
|
44
54
|
* while waiting for cache to resolve, to prevent "async swarm" issue.
|
|
45
|
-
*
|
|
46
|
-
* @experimental consider normal `@_Memo` for most of the cases, it's stable and predictable
|
|
47
55
|
*/
|
|
48
56
|
export const _AsyncMemo =
|
|
49
|
-
<FN>(opt: AsyncMemoOptions<FN>): MethodDecorator<FN> =>
|
|
57
|
+
<FN>(opt: AsyncMemoOptions<FN> = {}): MethodDecorator<FN> =>
|
|
50
58
|
(target, key, descriptor) => {
|
|
51
59
|
_assertTypeOf<AnyFunction>(
|
|
52
60
|
descriptor.value,
|
|
@@ -65,7 +73,11 @@ export const _AsyncMemo =
|
|
|
65
73
|
// After it's resolved - it's evicted from the cache and moved to the "proper" `instanceCache`
|
|
66
74
|
const instancePromiseCache = new Map<AnyObject, Map<any, Promise<any>>>()
|
|
67
75
|
|
|
68
|
-
const {
|
|
76
|
+
const {
|
|
77
|
+
logger = consoleLogger,
|
|
78
|
+
cacheFactory = () => new MapAsyncMemoCache(),
|
|
79
|
+
cacheKeyFn = jsonMemoSerializer,
|
|
80
|
+
} = opt
|
|
69
81
|
|
|
70
82
|
const keyStr = String(key)
|
|
71
83
|
const methodSignature = _getTargetMethodSignature(target, keyStr)
|
|
@@ -35,7 +35,7 @@ export interface MemoInstance {
|
|
|
35
35
|
|
|
36
36
|
getInstanceCache: () => Map<AnyObject, MemoCache>
|
|
37
37
|
|
|
38
|
-
getCache: (instance:
|
|
38
|
+
getCache: (instance: AnyObject) => MemoCache | undefined
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
@@ -47,6 +47,11 @@ export interface MemoInstance {
|
|
|
47
47
|
* So, error-throwing functions will be called multiple times.
|
|
48
48
|
* Therefor, if the origin function can possibly throw - it should try to be idempotent.
|
|
49
49
|
*
|
|
50
|
+
* For an async method it's the returned **Promise** that is cached, so parallel calls with the same key
|
|
51
|
+
* share one in-flight Promise. A rejected Promise is evicted from the cache, so the next call retries
|
|
52
|
+
* the method, same as for a thrown Error.
|
|
53
|
+
* Use `@_AsyncMemo` for an async-backed cache (e.g Redis), which stores the resolved value instead.
|
|
54
|
+
*
|
|
50
55
|
* Cache is stored **per instance** - separate cache for separate instances of the class.
|
|
51
56
|
* If you don't want it that way - you can use a static method, then there will be only one "instance".
|
|
52
57
|
*
|
|
@@ -111,6 +116,19 @@ export const _Memo =
|
|
|
111
116
|
logger.error({ err })
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
if (value instanceof Promise) {
|
|
120
|
+
// Don't cache a rejection: evict it, so the next call retries the method.
|
|
121
|
+
// The derived Promise is handled, so it adds no unhandled rejection.
|
|
122
|
+
value.catch(() => {
|
|
123
|
+
try {
|
|
124
|
+
// Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
|
|
125
|
+
if (cache.get(cacheKey) === value) cache.delete(cacheKey)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.error({ err })
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
114
132
|
return value
|
|
115
133
|
} as any
|
|
116
134
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { _isPrimitive } from '../is.util.js'
|
|
2
|
-
import { pDelay } from '../promise/pDelay.js'
|
|
3
2
|
import type { UnixTimestamp } from '../types.js'
|
|
4
3
|
import { MISS } from '../types.js'
|
|
5
4
|
|
|
@@ -29,7 +28,7 @@ export interface MemoCache<KEY = any, VALUE = any> {
|
|
|
29
28
|
*/
|
|
30
29
|
get: (k: KEY) => VALUE
|
|
31
30
|
set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => void
|
|
32
|
-
|
|
31
|
+
delete: (k: KEY) => void
|
|
33
32
|
/**
|
|
34
33
|
* Clear is only called when `_getMemoCache().clear()` is called.
|
|
35
34
|
* Otherwise the Cache is "persistent" (never cleared).
|
|
@@ -47,6 +46,7 @@ export interface AsyncMemoCache<KEY = any, VALUE = any> {
|
|
|
47
46
|
*/
|
|
48
47
|
get: (k: KEY) => Promise<VALUE | typeof MISS>
|
|
49
48
|
set: (k: KEY, v: VALUE, opt?: MemoCacheOptions) => Promise<void>
|
|
49
|
+
delete: (k: KEY) => Promise<void>
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Clear is only called when `_getAsyncMemo().clear()` is called.
|
|
@@ -116,6 +116,10 @@ export class MapMemoCache<KEY = any, VALUE = any> implements MemoCache<KEY, VALU
|
|
|
116
116
|
this.m.set(k, v)
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
delete(k: KEY): void {
|
|
120
|
+
this.m.delete(k)
|
|
121
|
+
}
|
|
122
|
+
|
|
119
123
|
clear(): void {
|
|
120
124
|
this.m.clear()
|
|
121
125
|
}
|
|
@@ -123,27 +127,28 @@ export class MapMemoCache<KEY = any, VALUE = any> implements MemoCache<KEY, VALU
|
|
|
123
127
|
|
|
124
128
|
/**
|
|
125
129
|
* Implementation of AsyncMemoCache backed by a synchronous Map.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
130
|
+
* The default cache of `@_AsyncMemo`: in-memory, never expires.
|
|
131
|
+
* For an **async** backed cache (Redis, Datastore) use another implementation.
|
|
128
132
|
*/
|
|
129
133
|
export class MapAsyncMemoCache<KEY = any, VALUE = any> implements AsyncMemoCache<KEY, VALUE> {
|
|
130
|
-
constructor(private delay = 0) {}
|
|
131
|
-
|
|
132
134
|
private m = new Map<KEY, VALUE>()
|
|
133
135
|
|
|
134
136
|
async get(k: KEY): Promise<VALUE | typeof MISS> {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
const v = this.m.get(k)
|
|
138
|
+
// `undefined` is a valid VALUE, only then `has` tells it apart from a miss
|
|
139
|
+
if (v === undefined && !this.m.has(k)) return MISS
|
|
140
|
+
return v!
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
async set(k: KEY, v: VALUE): Promise<void> {
|
|
141
|
-
await pDelay(this.delay)
|
|
142
144
|
this.m.set(k, v)
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
async delete(k: KEY): Promise<void> {
|
|
148
|
+
this.m.delete(k)
|
|
149
|
+
}
|
|
150
|
+
|
|
145
151
|
async clear(): Promise<void> {
|
|
146
|
-
await pDelay(this.delay)
|
|
147
152
|
this.m.clear()
|
|
148
153
|
}
|
|
149
154
|
}
|
|
@@ -41,6 +41,19 @@ export function _memoized<FN extends AnyFunction>(
|
|
|
41
41
|
logger.error({ err })
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
if (value instanceof Promise) {
|
|
45
|
+
// Don't cache a rejection: evict it, so the next call retries the function.
|
|
46
|
+
// The derived Promise is handled, so it adds no unhandled rejection.
|
|
47
|
+
value.catch(() => {
|
|
48
|
+
try {
|
|
49
|
+
// Only if the key still holds this very Promise (and not a newer one, cached after an eviction)
|
|
50
|
+
if (cache.get(cacheKey) === value) cache.delete(cacheKey)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
logger.error({ err })
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
return value
|
|
45
58
|
}
|
|
46
59
|
|
|
@@ -3,24 +3,35 @@ import type { AnyAsyncFunction, MaybeParameters } from '../types.js'
|
|
|
3
3
|
import { MISS } from '../types.js'
|
|
4
4
|
import type { AsyncMemoOptions } from './asyncMemo.decorator.js'
|
|
5
5
|
import type { AsyncMemoCache } from './memo.util.js'
|
|
6
|
-
import { jsonMemoSerializer } from './memo.util.js'
|
|
6
|
+
import { jsonMemoSerializer, MapAsyncMemoCache } from './memo.util.js'
|
|
7
7
|
|
|
8
8
|
export interface MemoizedAsyncFunction {
|
|
9
9
|
cache: AsyncMemoCache
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Function version of `@_AsyncMemo`: awaits `fn` and caches the resolved value, not the Promise,
|
|
14
|
+
* so a rejection is not cached. Cache errors are logged and treated as a miss.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the decorator, it does NOT dedupe parallel calls with the same key.
|
|
17
|
+
*/
|
|
12
18
|
export function _memoizedAsync<FN extends AnyAsyncFunction>(
|
|
13
19
|
fn: FN,
|
|
14
|
-
opt: AsyncMemoOptions<FN
|
|
20
|
+
opt: AsyncMemoOptions<FN> = {},
|
|
15
21
|
): FN & MemoizedAsyncFunction {
|
|
16
|
-
const {
|
|
22
|
+
const {
|
|
23
|
+
logger = consoleLogger,
|
|
24
|
+
cacheFactory = () => new MapAsyncMemoCache(),
|
|
25
|
+
cacheKeyFn = jsonMemoSerializer,
|
|
26
|
+
} = opt
|
|
17
27
|
|
|
18
28
|
const cache = cacheFactory()
|
|
19
29
|
|
|
20
30
|
const memoizedFn = async function (this: any, ...args: MaybeParameters<FN>): Promise<any> {
|
|
21
31
|
const ctx = this
|
|
22
32
|
const cacheKey = cacheKeyFn(args)
|
|
23
|
-
|
|
33
|
+
// MISS, so that a failed cache `get` is a miss too (and not a cached `undefined`)
|
|
34
|
+
let value: any = MISS
|
|
24
35
|
|
|
25
36
|
try {
|
|
26
37
|
value = await cache.get(cacheKey)
|