Skip to content

Commit 0687a58

Browse files
feat: getOrSet (#1125)
* feat: get or set * feat: get or set * feat: get or set * feat: get or set
1 parent ca02913 commit 0687a58

File tree

5 files changed

+117
-28
lines changed

5 files changed

+117
-28
lines changed

packages/cacheable/README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,41 @@ raws.forEach((entry, idx) => {
252252

253253
If you want your layer 2 (secondary) store to be non-blocking you can set the `nonBlocking` property to `true` in the options. This will make the secondary store non-blocking and will not wait for the secondary store to respond on `setting data`, `deleting data`, or `clearing data`. This is useful if you want to have a faster response time and not wait for the secondary store to respond.
254254

255+
# GetOrSet
256+
257+
The `getOrSet` method provides a convenient way to implement the cache-aside pattern. It attempts to retrieve a value
258+
from cache, and if not found, calls the provided function to compute the value and store it in cache before returning
259+
it.
260+
261+
```typescript
262+
import { Cacheable } from 'cacheable';
263+
264+
// Create a new Cacheable instance
265+
const cache = new Cacheable();
266+
267+
// Use getOrSet to fetch user data
268+
async function getUserData(userId: string) {
269+
return await cache.getOrSet(
270+
`user:${userId}`,
271+
async () => {
272+
// This function only runs if the data isn't in the cache
273+
console.log('Fetching user from database...');
274+
// Simulate database fetch
275+
return { id: userId, name: 'John Doe', email: 'john@example.com' };
276+
},
277+
{ ttl: '30m' } // Cache for 30 minutes
278+
);
279+
}
280+
281+
// First call - will fetch from "database"
282+
const user1 = await getUserData('123');
283+
console.log(user1); // { id: '123', name: 'John Doe', email: 'john@example.com' }
284+
285+
// Second call - will retrieve from cache
286+
const user2 = await getUserData('123');
287+
console.log(user2); // Same data, but retrieved from cache
288+
```
289+
255290
```javascript
256291
import { Cacheable } from 'cacheable';
257292
import {KeyvRedis} from '@keyv/redis';
@@ -317,6 +352,7 @@ _This does not enable statistics for your layer 2 cache as that is a distributed
317352
* `deleteMany([keys])`: Deletes multiple values from the cache.
318353
* `clear()`: Clears the cache stores. Be careful with this as it will clear both layer 1 and layer 2.
319354
* `wrap(function, WrapOptions)`: Wraps an `async` function in a cache.
355+
* `getOrSet(key, valueFunction, ttl?)`: Gets a value from cache or sets it if not found using the provided function.
320356
* `disconnect()`: Disconnects from the cache stores.
321357
* `onHook(hook, callback)`: Sets a hook.
322358
* `removeHook(hook)`: Removes a hook.
@@ -475,4 +511,4 @@ console.log(wrappedFunction()); // error from cache
475511
You can contribute by forking the repo and submitting a pull request. Please make sure to add tests and update the documentation. To learn more about how to contribute go to our main README [https://github.com/jaredwray/cacheable](https://github.com/jaredwray/cacheable). This will talk about how to `Open a Pull Request`, `Ask a Question`, or `Post an Issue`.
476512

477513
# License and Copyright
478-
[MIT © Jared Wray](./LICENSE)
514+
[MIT © Jared Wray](./LICENSE)

packages/cacheable/src/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {createKeyv} from './keyv-memory.js';
55
import {CacheableStats} from './stats.js';
66
import {type CacheableItem} from './cacheable-item-types.js';
77
import {hash} from './hash.js';
8-
import {wrap, type WrapFunctionOptions} from './wrap.js';
8+
import {
9+
getOrSet, type GetOrSetFunctionOptions, type GetOrSetOptions, wrap, type WrapFunctionOptions,
10+
} from './wrap.js';
911
import {getCascadingTtl, calculateTtlFromExpiration} from './ttl.js';
1012

1113
export enum CacheableHooks {
@@ -675,6 +677,25 @@ export class Cacheable extends Hookified {
675677
return wrap<T>(function_, wrapOptions);
676678
}
677679

680+
/**
681+
* Retrieves the value associated with the given key from the cache. If the key is not found,
682+
* invokes the provided function to calculate the value, stores it in the cache, and then returns it.
683+
*
684+
* @param {string} key - The key to retrieve or set in the cache.
685+
* @param {() => Promise<T>} function_ - The asynchronous function that computes the value to be cached if the key does not exist.
686+
* @param {WrapFunctionOptions} [options] - Optional settings for caching, such as the time to live (TTL) or whether to cache errors.
687+
* @return {Promise<T | undefined>} - A promise that resolves to the cached or newly computed value, or undefined if an error occurs and caching is not configured for errors.
688+
*/
689+
public async getOrSet<T>(key: string, function_: () => Promise<T>, options?: GetOrSetFunctionOptions): Promise<T | undefined> {
690+
const getOrSetOptions: GetOrSetOptions = {
691+
cache: this,
692+
cacheId: this._cacheId,
693+
ttl: options?.ttl ?? this._ttl,
694+
cacheErrors: options?.cacheErrors,
695+
};
696+
return getOrSet(key, function_, getOrSetOptions);
697+
}
698+
678699
/**
679700
* Will hash an object using the specified algorithm. The default algorithm is 'sha256'.
680701
* @param {any} object the object to hash

packages/cacheable/src/wrap.ts

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import {hash} from './hash.js';
22
import {coalesceAsync} from './coalesce-async.js';
33
import {type Cacheable, type CacheableMemory} from './index.js';
44

5+
export type GetOrSetFunctionOptions = {
6+
ttl?: number | string;
7+
cacheErrors?: boolean;
8+
};
9+
10+
export type GetOrSetOptions = GetOrSetFunctionOptions & {
11+
cacheId?: string;
12+
cache: Cacheable;
13+
};
14+
515
export type WrapFunctionOptions = {
616
ttl?: number | string;
717
keyPrefix?: string;
@@ -43,35 +53,35 @@ export function wrapSync<T>(function_: AnyFunction, options: WrapSyncOptions): A
4353
};
4454
}
4555

56+
export async function getOrSet<T>(key: string, function_: () => Promise<T>, options: GetOrSetOptions): Promise<T | undefined> {
57+
let value = await options.cache.get(key) as T | undefined;
58+
if (value === undefined) {
59+
const cacheId = options.cacheId ?? 'default';
60+
const coalesceKey = `${cacheId}::${key}`;
61+
value = await coalesceAsync(coalesceKey, async () => {
62+
try {
63+
const result = await function_() as T;
64+
await options.cache.set(key, result, options.ttl);
65+
return result;
66+
} catch (error) {
67+
options.cache.emit('error', error);
68+
if (options.cacheErrors) {
69+
await options.cache.set(key, error, options.ttl);
70+
}
71+
}
72+
});
73+
}
74+
75+
return value;
76+
}
77+
4678
export function wrap<T>(function_: AnyFunction, options: WrapOptions): AnyFunction {
47-
const {ttl, keyPrefix, cache} = options;
79+
const {keyPrefix, cache} = options;
4880

4981
return async function (...arguments_: any[]) {
50-
let value;
51-
5282
const cacheKey = createWrapKey(function_, arguments_, keyPrefix);
53-
54-
value = await cache.get(cacheKey) as T | undefined;
55-
56-
if (value === undefined) {
57-
const cacheId = options.cacheId ?? 'default';
58-
const coalesceKey = `${cacheId}::${cacheKey}`;
59-
value = await coalesceAsync(coalesceKey, async () => {
60-
try {
61-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
62-
const result = await function_(...arguments_) as T;
63-
await cache.set(cacheKey, result, ttl);
64-
return result;
65-
} catch (error) {
66-
cache.emit('error', error);
67-
if (options.cacheErrors) {
68-
await cache.set(cacheKey, error, ttl);
69-
}
70-
}
71-
});
72-
}
73-
74-
return value;
83+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return
84+
return cache.getOrSet(cacheKey, async (): Promise<T | undefined> => function_(...arguments_), options);
7585
};
7686
}
7787

packages/cacheable/test/index.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,3 +696,25 @@ describe('cacheable namespace', async () => {
696696
expect(cacheable.secondary?.namespace).toBe('test');
697697
});
698698
});
699+
700+
describe('cacheable get or set', () => {
701+
test('should cache results', async () => {
702+
const cacheable = new Cacheable();
703+
const function_ = vi.fn(async () => 1 + 2);
704+
const result = await cacheable.getOrSet('one_plus_two', function_);
705+
await cacheable.getOrSet('one_plus_two', function_);
706+
expect(result).toBe(3);
707+
expect(function_).toHaveBeenCalledTimes(1);
708+
});
709+
test('should prevent stampede', async () => {
710+
const cacheable = new Cacheable();
711+
const function_ = vi.fn(async () => 42);
712+
await Promise.all([
713+
cacheable.getOrSet('key1', function_),
714+
cacheable.getOrSet('key1', function_),
715+
cacheable.getOrSet('key2', function_),
716+
cacheable.getOrSet('key2', function_),
717+
]);
718+
expect(function_).toHaveBeenCalledTimes(2);
719+
});
720+
});

packages/cacheable/test/wrap.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import {
33
describe, it, expect, vi,
44
} from 'vitest';
5-
import {Cacheable, CacheableMemory, KeyvCacheableMemory} from '../src/index.js';
5+
import {Cacheable, CacheableMemory} from '../src/index.js';
66
import {
77
wrap, createWrapKey, wrapSync, type WrapOptions, type WrapSyncOptions,
88
} from '../src/wrap.js';

0 commit comments

Comments
 (0)