Skip to content

Commit

Permalink
Add onCacheError option (#646)
Browse files Browse the repository at this point in the history
* Add ignoreCacheErrors option

* remove unwanted formatting changes

* remove unwanted formatting changes

* eventEmitter instead of onCacheError

* package.json fix

* remove files

* various refactoring and cleaning

* fix typing issue

* code coverage

---------

Co-authored-by: Adrien Eveillé <adrien.eveille@doctolib.com>
  • Loading branch information
slukes and AdrienDoctolib committed Apr 4, 2024
1 parent 511e426 commit b03c2ad
Show file tree
Hide file tree
Showing 5 changed files with 160 additions and 30 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
],
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^10.2.0",
"promise-coalesce": "^1.1.2"
Expand All @@ -63,5 +64,5 @@
"ts",
"tsx"
]
}
}
}
37 changes: 24 additions & 13 deletions src/caching.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import EventEmitter from 'eventemitter3';
import {coalesceAsync} from 'promise-coalesce';
import {
type MemoryCache, type MemoryConfig, type MemoryStore, memoryStore,
Expand Down Expand Up @@ -36,9 +37,9 @@ export type FactoryStore<S extends Store, T extends Record<string, unknown> = ne
) => S | Promise<S>;

export type Stores<S extends Store, T extends Record<string, unknown>> =
| 'memory'
| Store
| FactoryStore<S, T>;
| 'memory'
| Store
| FactoryStore<S, T>;
export type CachingConfig<T> = MemoryConfig | StoreConfig | FactoryConfig<T>;
// eslint-disable-next-line @typescript-eslint/naming-convention
export type WrapTTL<T> = Milliseconds | ((v: T) => Milliseconds);
Expand All @@ -48,6 +49,7 @@ export type Cache<S extends Store = Store> = {
get: <T>(key: string) => Promise<T | undefined>;
del: (key: string) => Promise<void>;
reset: () => Promise<void>;
on: (event: 'error', handler: (error: Error) => void) => void;
wrap<T>(key: string, function_: () => Promise<T>, ttl?: WrapTTL<T>, refreshThreshold?: Milliseconds): Promise<T>;
};

Expand Down Expand Up @@ -96,24 +98,30 @@ export function createCache<S extends Store, C extends Config>(
store: S,
arguments_?: C,
): Cache<S> {
const eventEmitter = new EventEmitter();

return {
/**
* Wraps a function in cache. I.e., the first time the function is run,
* its results are stored in cache so subsequent calls retrieve from cache
* instead of calling the function.
* @example
* const result = await cache.wrap('key', () => Promise.resolve(1));
*
*/
* Wraps a function in cache. I.e., the first time the function is run,
* its results are stored in cache so subsequent calls retrieve from cache
* instead of calling the function.
* @example
* const result = await cache.wrap('key', () => Promise.resolve(1));
*
*/
async wrap<T>(key: string, function_: () => Promise<T>, ttl?: WrapTTL<T>, refreshThreshold?: Milliseconds) {
const refreshThresholdConfig = refreshThreshold ?? arguments_?.refreshThreshold ?? 0;
return coalesceAsync(key, async () => {
const value = await store.get<T>(key);
const value = await store.get<T>(key).catch(error => {
eventEmitter.emit('error', error);
});

if (value === undefined) {
const result = await function_();

const cacheTtl = typeof ttl === 'function' ? ttl(result) : ttl;
await store.set<T>(key, result, cacheTtl);
await store.set<T>(key, result, cacheTtl).catch(error => eventEmitter.emit('error', error));
return result;
}

Expand All @@ -124,6 +132,7 @@ export function createCache<S extends Store, C extends Config>(
coalesceAsync(`+++${key}`, function_)
.then(async result => store.set<T>(key, result, cacheTtl))
.catch(async error => {
eventEmitter.emit('error', error);
if (arguments_?.onBackgroundRefreshError) {
arguments_.onBackgroundRefreshError(error);
} else {
Expand All @@ -143,5 +152,7 @@ export function createCache<S extends Store, C extends Config>(
set: async (key: string, value: unknown, ttl?: Milliseconds) =>
store.set(key, value, ttl),
reset: async () => store.reset(),
on: (event: 'error', handler: (error: Error) => void) =>
eventEmitter.on('error', handler),
};
}
29 changes: 20 additions & 9 deletions src/multi-caching.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import EventEmitter from 'eventemitter3';
import {type Cache, type Milliseconds, type WrapTTL} from './caching.js';

export type MultiCache = Omit<Cache, 'store'> &
Expand All @@ -9,6 +10,7 @@ Pick<Cache['store'], 'mset' | 'mget' | 'mdel'>;
export function multiCaching<Caches extends Cache[]>(
caches: Caches,
): MultiCache {
const eventEmitter = new EventEmitter();
const get = async <T>(key: string) => {
for (const cache of caches) {
try {
Expand All @@ -17,7 +19,9 @@ export function multiCaching<Caches extends Cache[]>(
if (value !== undefined) {
return value;
}
} catch {}
} catch (error) {
eventEmitter.emit('error', error);
}
}
};

Expand All @@ -26,14 +30,14 @@ export function multiCaching<Caches extends Cache[]>(
data: T,
ttl?: Milliseconds | undefined,
) => {
await Promise.all(caches.map(async cache => cache.set(key, data, ttl)));
await Promise.all(caches.map(async cache => cache.set(key, data, ttl))).catch(error => eventEmitter.emit('error', error));
};

return {
get,
set,
async del(key) {
await Promise.all(caches.map(async cache => cache.del(key)));
await Promise.all(caches.map(async cache => cache.del(key))).catch(error => eventEmitter.emit('error', error));
},
async wrap<T>(
key: string,
Expand All @@ -50,7 +54,9 @@ export function multiCaching<Caches extends Cache[]>(
if (value !== undefined) {
break;
}
} catch {}
} catch (error) {
eventEmitter.emit('error', error);
}
}

if (value === undefined) {
Expand All @@ -63,13 +69,14 @@ export function multiCaching<Caches extends Cache[]>(
const cacheTtl = typeof ttl === 'function' ? ttl(value) : ttl;
await Promise.all(
caches.slice(0, i).map(async cache => cache.set(key, value, cacheTtl)),
).then();
).then().catch(error => eventEmitter.emit('error', error));

await caches[i].wrap(key, function_, ttl, refreshThreshold).then(); // Call wrap for store for internal refreshThreshold logic, see: src/caching.ts caching.wrap

return value;
},
async reset() {
await Promise.all(caches.map(async x => x.reset()));
await Promise.all(caches.map(async x => x.reset())).catch(error => eventEmitter.emit('error', error));
},
async mget(...keys: string[]) {
const values = Array.from({length: keys.length}).fill(undefined);
Expand All @@ -86,16 +93,20 @@ export function multiCaching<Caches extends Cache[]>(
values[i] = v;
}
}
} catch {}
} catch (error) {
eventEmitter.emit('error', error);
}
}

return values;
},
async mset(arguments_: Array<[string, unknown]>, ttl?: Milliseconds) {
await Promise.all(caches.map(async cache => cache.store.mset(arguments_, ttl)));
await Promise.all(caches.map(async cache => cache.store.mset(arguments_, ttl))).catch(error => eventEmitter.emit('error', error));
},
async mdel(...keys: string[]) {
await Promise.all(caches.map(async cache => cache.store.mdel(...keys)));
await Promise.all(caches.map(async cache => cache.store.mdel(...keys)))
.catch(error => eventEmitter.emit('error', error));
},
on: (event: 'error', handler: (error: Error) => void) => eventEmitter.on('error', handler),
};
}
47 changes: 44 additions & 3 deletions test/caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,41 @@ describe('caching', () => {
});
}
});

describe('error handling on wrap', () => {
it('emits an error event when store.get() fails', async () => {
const error = new Error('store.get() failed');
const function_ = vi.fn().mockResolvedValue(value);
const cache = await caching('memory');
cache.store.get = vi.fn().mockRejectedValue(error);

let errorMessage;
cache.on('error', error => {
errorMessage = error;
});

await cache.wrap(key, function_);

expect(errorMessage).not.toBeUndefined();
expect(function_).toHaveBeenCalled();
});

it('emits an error event when store.set() fails', async () => {
const error = new Error('store.set() failed');
const function_ = vi.fn().mockResolvedValue(value);
const cache = await caching('memory');
cache.store.set = vi.fn().mockRejectedValue(error);

let errorMessage;
cache.on('error', error => {
errorMessage = error;
});

await cache.wrap(key, function_);

expect(errorMessage).not.toBeUndefined();
});
});
});

describe('issues', () => {
Expand Down Expand Up @@ -515,11 +550,17 @@ describe('caching', () => {

await sleep(1001);
// No background refresh with the new override params
expect(await cache.wrap('refreshThreshold', async () => 3, undefined, 500)).toEqual(1);
expect(
await cache.wrap('refreshThreshold', async () => 3, undefined, 500),
).toEqual(1);
await sleep(500);
// Background refresh, but stale value returned
expect(await cache.wrap('refreshThreshold', async () => 4, undefined, 500)).toEqual(1);
expect(await cache.wrap('refreshThreshold', async () => 5, undefined, 500)).toEqual(4);
expect(
await cache.wrap('refreshThreshold', async () => 4, undefined, 500),
).toEqual(1);
expect(
await cache.wrap('refreshThreshold', async () => 5, undefined, 500),
).toEqual(4);
});
});

Expand Down
74 changes: 70 additions & 4 deletions test/multi-caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('multiCaching', () => {
it('lets us set the ttl to be a function', async () => {
const sec = faker.number.int({min: 2, max: 4});
value = faker.string.sample(sec * 2);
const function_ = vi.fn((v: string) => 1000);
const function_ = vi.fn(() => 1000);
await multiCache.wrap(key, async () => value, function_);
await expect(memoryCache.get(key)).resolves.toEqual(value);
await expect(memoryCache2.get(key)).resolves.toEqual(value);
Expand Down Expand Up @@ -188,7 +188,7 @@ describe('multiCaching', () => {
describe('when cache fails', () => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
const empty = (async () => {}) as never;
const cache: Cache = {
const getErrorCache: Cache = {
async get() {
throw new Error('this is an error');
},
Expand All @@ -198,6 +198,20 @@ describe('multiCaching', () => {
wrap: empty,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
store: {} as Store,
on: empty,
};

const setErrorCache: Cache = {
get: empty,
async set() {
throw new Error('this is an error');
},
del: empty,
reset: empty,
wrap: empty,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
store: {} as Store,
on: empty,
};

const cacheEmpty: Cache = {
Expand All @@ -208,16 +222,17 @@ describe('multiCaching', () => {
wrap: empty,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
store: {} as Store,
on: empty,
};

it('should get error', async () => {
multiCache = multiCaching([cache, memoryCache]);
multiCache = multiCaching([getErrorCache, memoryCache]);
await multiCache.set(key, value);
await expect(multiCache.get(key)).resolves.toEqual(value);
});

it('should get all error', async () => {
multiCache = multiCaching([cache]);
multiCache = multiCaching([getErrorCache]);
await multiCache.set(key, value);
await expect(multiCache.get(key)).resolves.toBeUndefined();
});
Expand All @@ -233,6 +248,57 @@ describe('multiCaching', () => {
await multiCache.set(key, value);
await expect(multiCache.get(key)).resolves.toBeUndefined();
});

it('emits an error event when store.get() fails', async () => {
multiCache = multiCaching([getErrorCache, memoryCache]);
let errorMessage;
multiCache.on('error', error => {
errorMessage = error;
});

await multiCache.get(key);

expect(errorMessage).not.toBeUndefined();
});

it('should receive error event on set failure', async () => {
multiCache = multiCaching([setErrorCache, memoryCache]);
let errorMessage;
multiCache.on('error', error => {
errorMessage = error;
});

await multiCache.set(key, value);

expect(errorMessage).not.toBeUndefined();
});

it('should receive error event on mget failure', async () => {
multiCache = multiCaching([setErrorCache, memoryCache]);
let errorMessage;
multiCache.on('error', error => {
errorMessage = error;
});

await multiCache.mget(key, value);

expect(errorMessage).not.toBeUndefined();
});

it('should receive error event on get failure during wrap', async () => {
multiCache = multiCaching([getErrorCache, memoryCache]);
const error = new Error('store.get() failed');
const function_ = vi.fn().mockResolvedValue(value);

let errorMessage;
multiCache.on('error', error => {
errorMessage = error;
});

await multiCache.wrap(key, function_);

expect(errorMessage).not.toBeUndefined();
});
});
});

Expand Down

0 comments on commit b03c2ad

Please sign in to comment.