Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion packages/cacheable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,41 @@ raws.forEach((entry, idx) => {

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.

# GetOrSet

The `getOrSet` method provides a convenient way to implement the cache-aside pattern. It attempts to retrieve a value
from cache, and if not found, calls the provided function to compute the value and store it in cache before returning
it.

```typescript
import { Cacheable } from 'cacheable';

// Create a new Cacheable instance
const cache = new Cacheable();

// Use getOrSet to fetch user data
async function getUserData(userId: string) {
return await cache.getOrSet(
`user:${userId}`,
async () => {
// This function only runs if the data isn't in the cache
console.log('Fetching user from database...');
// Simulate database fetch
return { id: userId, name: 'John Doe', email: 'john@example.com' };
},
{ ttl: '30m' } // Cache for 30 minutes
);
}

// First call - will fetch from "database"
const user1 = await getUserData('123');
console.log(user1); // { id: '123', name: 'John Doe', email: 'john@example.com' }

// Second call - will retrieve from cache
const user2 = await getUserData('123');
console.log(user2); // Same data, but retrieved from cache
```

```javascript
import { Cacheable } from 'cacheable';
import {KeyvRedis} from '@keyv/redis';
Expand Down Expand Up @@ -317,6 +352,7 @@ _This does not enable statistics for your layer 2 cache as that is a distributed
* `deleteMany([keys])`: Deletes multiple values from the cache.
* `clear()`: Clears the cache stores. Be careful with this as it will clear both layer 1 and layer 2.
* `wrap(function, WrapOptions)`: Wraps an `async` function in a cache.
* `getOrSet(key, valueFunction, ttl?)`: Gets a value from cache or sets it if not found using the provided function.
* `disconnect()`: Disconnects from the cache stores.
* `onHook(hook, callback)`: Sets a hook.
* `removeHook(hook)`: Removes a hook.
Expand Down Expand Up @@ -475,4 +511,4 @@ console.log(wrappedFunction()); // error from cache
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`.

# License and Copyright
[MIT © Jared Wray](./LICENSE)
[MIT © Jared Wray](./LICENSE)
37 changes: 37 additions & 0 deletions packages/cacheable/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {type CacheableItem} from './cacheable-item-types.js';
import {hash} from './hash.js';
import {wrap, type WrapFunctionOptions} from './wrap.js';
import {getCascadingTtl, calculateTtlFromExpiration} from './ttl.js';
import {coalesceAsync} from './coalesce-async.js';

export enum CacheableHooks {
BEFORE_SET = 'BEFORE_SET',
Expand Down Expand Up @@ -59,6 +60,11 @@ export type CacheableOptions = {
cacheId?: string;
};

export type GetOrSetOptions = {
ttl?: number | string;
cacheErrors?: boolean;
};

export class Cacheable extends Hookified {
private _primary: Keyv = createKeyv();
private _secondary: Keyv | undefined;
Expand Down Expand Up @@ -675,6 +681,37 @@ export class Cacheable extends Hookified {
return wrap<T>(function_, wrapOptions);
}

/**
* Retrieves the value associated with the given key from the cache. If the key is not found,
* invokes the provided function to calculate the value, stores it in the cache, and then returns it.
*
* @param {string} key - The key to retrieve or set in the cache.
* @param {() => Promise<T>} function_ - The asynchronous function that computes the value to be cached if the key does not exist.
* @param {WrapFunctionOptions} [options] - Optional settings for caching, such as the time to live (TTL) or whether to cache errors.
* @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.
*/
public async getOrSet<T>(key: string, function_: () => Promise<T>, options?: GetOrSetOptions): Promise<T | undefined> {
let value = await this.get(key) as T | undefined;
if (value === undefined) {
const cacheId = this.cacheId ?? 'default';
const coalesceKey = `${cacheId}::${key}`;
value = await coalesceAsync(coalesceKey, async () => {
try {
const result = await function_() as T;
await this.set(key, result, options?.ttl);
return result;
} catch (error) {
this.emit('error', error);
if (options?.cacheErrors) {
await this.set(key, error, options?.ttl);
}
}
});
}

return value;
}

/**
* Will hash an object using the specified algorithm. The default algorithm is 'sha256'.
* @param {any} object the object to hash
Expand Down
29 changes: 3 additions & 26 deletions packages/cacheable/src/wrap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {hash} from './hash.js';
import {coalesceAsync} from './coalesce-async.js';
import {type Cacheable, type CacheableMemory} from './index.js';

export type WrapFunctionOptions = {
Expand Down Expand Up @@ -44,34 +43,12 @@ export function wrapSync<T>(function_: AnyFunction, options: WrapSyncOptions): A
}

export function wrap<T>(function_: AnyFunction, options: WrapOptions): AnyFunction {
const {ttl, keyPrefix, cache} = options;
const {keyPrefix, cache} = options;

return async function (...arguments_: any[]) {
let value;

const cacheKey = createWrapKey(function_, arguments_, keyPrefix);

value = await cache.get(cacheKey) as T | undefined;

if (value === undefined) {
const cacheId = options.cacheId ?? 'default';
const coalesceKey = `${cacheId}::${cacheKey}`;
value = await coalesceAsync(coalesceKey, async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const result = await function_(...arguments_) as T;
await cache.set(cacheKey, result, ttl);
return result;
} catch (error) {
cache.emit('error', error);
if (options.cacheErrors) {
await cache.set(cacheKey, error, ttl);
}
}
});
}

return value;
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return
return cache.getOrSet(cacheKey, async (): Promise<T | undefined> => function_(...arguments_), options);
};
}

Expand Down
22 changes: 22 additions & 0 deletions packages/cacheable/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,3 +696,25 @@ describe('cacheable namespace', async () => {
expect(cacheable.secondary?.namespace).toBe('test');
});
});

describe('cacheable get or set', () => {
test('should cache results', async () => {
const cacheable = new Cacheable();
const function_ = vi.fn(async () => 1 + 2);
const result = await cacheable.getOrSet('one_plus_two', function_);
await cacheable.getOrSet('one_plus_two', function_);
expect(result).toBe(3);
expect(function_).toHaveBeenCalledTimes(1);
});
test('should prevent stampede', async () => {
const cacheable = new Cacheable();
const function_ = vi.fn(async () => 42);
await Promise.all([
cacheable.getOrSet('key1', function_),
cacheable.getOrSet('key1', function_),
cacheable.getOrSet('key2', function_),
cacheable.getOrSet('key2', function_),
]);
expect(function_).toHaveBeenCalledTimes(2);
});
});
2 changes: 1 addition & 1 deletion packages/cacheable/test/wrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {
describe, it, expect, vi,
} from 'vitest';
import {Cacheable, CacheableMemory, KeyvCacheableMemory} from '../src/index.js';
import {Cacheable, CacheableMemory} from '../src/index.js';
import {
wrap, createWrapKey, wrapSync, type WrapOptions, type WrapSyncOptions,
} from '../src/wrap.js';
Expand Down