Skip to content

Commit 1ec8c29

Browse files
committed
feat: add adaptive caching
Close #8
1 parent 0068d36 commit 1ec8c29

13 files changed

+155
-11
lines changed

docs/content/docs/adaptive_caching.md

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
summary: Dynamically change the cache strategy based on the data being cached.
3+
---
4+
5+
# Adaptive Caching
6+
7+
Adaptive caching is a method to dynamically change the cache options based on the data being cached. This approach is particularly useful when caching options depend on the value itself.
8+
9+
For example, authentication tokens are a perfect example of this use case. Consider the following scenario:
10+
11+
```ts
12+
const authToken = await bento.getOrSet('token', async () => {
13+
const token = await fetchAccessToken()
14+
return token
15+
}, { ttl: '10m' })
16+
```
17+
18+
In this example, we are fetching an authentication token that will expire after some time. The problem is, we have no idea when the token will expire until we fetch it. So, we decide to cache the token for 10 minutes, but this approach has multiple issues:
19+
20+
- First, if the token expires before 10 minutes, we will still use the expired token.
21+
- Second, if the token expires after 10 minutes, we will fetch a new token even if the old one is still valid.
22+
23+
This is where adaptive caching comes in. Instead of setting a fixed TTL, we can set it dynamically based on the token's expiration time:
24+
25+
```ts
26+
const authToken = await bento.getOrSet('token', async (options) => {
27+
const token = await fetchAccessToken();
28+
options.setTtl(token.expiresIn);
29+
return token;
30+
});
31+
```
32+
33+
And that's it! Now, the token will be removed from the cache when it expires, and a new one will be fetched.
34+
35+
There are other use cases for adaptive caching. For example, consider managing a news feed with BentoCache. You may want to cache the freshest articles for a short period of time and the older articles for a much longer period.
36+
37+
Because the freshest articles are more likely to change: they may have typos, require updates, etc., whereas the older articles are less likely to change and may not have been updated for years.
38+
39+
Let's see how we can achieve this with BentoCache:
40+
41+
```ts
42+
const namespace = bento.namespace('news');
43+
const news = await namespace.getOrSet(newsId, async (options) => {
44+
const newsItem = await fetchNews(newsId);
45+
46+
if (newsItem.hasBeenUpdatedRecently) {
47+
options.setTtl('5m');
48+
} else {
49+
options.setTtl('2d');
50+
}
51+
52+
return newsItem;
53+
});
54+
```

docs/content/docs/db.json

+6
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@
7171
"contentPath": "./stampede_protection.md",
7272
"category": "Guides"
7373
},
74+
{
75+
"permalink": "adaptive-caching",
76+
"title": "Adaptive caching",
77+
"contentPath": "./adaptive_caching.md",
78+
"category": "Guides"
79+
},
7480
{
7581
"permalink": "events",
7682
"title": "Events",

docs/content/docs/methods.md

+12
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,18 @@ const products = await bento.getOrSet('products', () => fetchProducts(), {
8989
})
9090
```
9191

92+
The `getOrSet` factory function accepts an `options` object as argument that can be used to dynamically set some cache options. This can be particulary useful when caching options depends on the value itself.
93+
94+
```ts
95+
const products = await bento.getOrSet('token', (options) => {
96+
const token = await fetchAccessToken()
97+
options.setTtl(token.expiresIn)
98+
return token
99+
})
100+
```
101+
102+
Auth tokens are a perfect example of this use case. The cached token should expire when the token itself expires. And we know the expiration time only after fetching the token.
103+
92104
### getOrSetForever
93105

94106
Same as `getOrSet`, but the value will never expire.

packages/bentocache/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"better-sqlite3": "^9.4.5",
8686
"cache-manager": "^5.5.0",
8787
"cache-manager-ioredis-yet": "^1.2.2",
88+
"dayjs": "^1.11.10",
8889
"defu": "^6.1.4",
8990
"emittery": "^1.0.3",
9091
"ioredis": "^5.3.2",

packages/bentocache/src/bento_cache.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
BentoCachePlugin,
1515
HasOptions,
1616
ClearOptions,
17+
GetSetFactory,
1718
} from './types/main.js'
1819

1920
export class BentoCache<KnownCaches extends Record<string, BentoStore>> implements CacheProvider {
@@ -156,15 +157,15 @@ export class BentoCache<KnownCaches extends Record<string, BentoStore>> implemen
156157
* Retrieve an item from the cache if it exists, otherwise store the value
157158
* provided by the factory and return it
158159
*/
159-
async getOrSet<T>(key: string, factory: Factory<T>, options?: GetOrSetOptions): Promise<T> {
160+
async getOrSet<T>(key: string, factory: GetSetFactory<T>, options?: GetOrSetOptions): Promise<T> {
160161
return this.use().getOrSet(key, factory, options)
161162
}
162163

163164
/**
164165
* Retrieve an item from the cache if it exists, otherwise store the value
165166
* provided by the factory forever and return it
166167
*/
167-
getOrSetForever<T>(key: string, cb: Factory<T>, opts?: GetOrSetOptions): Promise<T> {
168+
getOrSetForever<T>(key: string, cb: GetSetFactory<T>, opts?: GetOrSetOptions): Promise<T> {
168169
return this.use().getOrSetForever(key, cb, opts)
169170
}
170171

packages/bentocache/src/cache/cache.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
SetOptions,
1313
HasOptions,
1414
ClearOptions,
15+
GetSetFactory,
1516
} from '../types/main.js'
1617

1718
export class Cache implements CacheProvider {
@@ -105,7 +106,7 @@ export class Cache implements CacheProvider {
105106
* Retrieve an item from the cache if it exists, otherwise store the value
106107
* provided by the factory and return it
107108
*/
108-
async getOrSet<T>(key: string, factory: Factory<T>, options?: GetOrSetOptions): Promise<T> {
109+
async getOrSet<T>(key: string, factory: GetSetFactory<T>, options?: GetOrSetOptions): Promise<T> {
109110
const cacheOptions = this.#stack.defaultOptions.cloneWith(options)
110111
return this.#getSetHandler.handle(key, factory, cacheOptions)
111112
}
@@ -116,7 +117,7 @@ export class Cache implements CacheProvider {
116117
*/
117118
async getOrSetForever<T>(
118119
key: string,
119-
factory: Factory<T>,
120+
factory: GetSetFactory<T>,
120121
options?: GetOrSetOptions,
121122
): Promise<T> {
122123
const cacheOptions = this.#stack.defaultOptions.cloneWith({ ttl: null, ...options })

packages/bentocache/src/cache/cache_entry/cache_entry_options.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import hexoid from 'hexoid'
22

33
import { resolveTtl } from '../../helpers.js'
4-
import type { RawCommonOptions } from '../../types/main.js'
4+
import type { Duration, RawCommonOptions } from '../../types/main.js'
55

66
// @ts-expect-error wrongly typed
77
const toId = hexoid(12)
@@ -165,6 +165,19 @@ export class CacheEntryOptions {
165165
return this.#options.suppressL2Errors
166166
}
167167

168+
/**
169+
* Set a new logical TTL
170+
*/
171+
setLogicalTtl(ttl: Duration) {
172+
this.#options.ttl = ttl
173+
174+
this.logicalTtl = this.#resolveLogicalTtl()
175+
this.physicalTtl = this.#resolvePhysicalTtl()
176+
this.earlyExpireTtl = this.#resolveEarlyExpireTtl()
177+
178+
return this
179+
}
180+
168181
/**
169182
* Compute the logical TTL timestamp from now
170183
*/

packages/bentocache/src/cache/factory_runner.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import type { MutexInterface } from 'async-mutex'
44
import type { Locks } from './locks.js'
55
import * as exceptions from '../errors.js'
66
import { events } from '../events/index.js'
7-
import type { Factory } from '../types/helpers.js'
87
import type { CacheStack } from './stack/cache_stack.js'
8+
import type { GetSetFactory } from '../types/helpers.js'
99
import type { CacheStackWriter } from './stack/cache_stack_writer.js'
1010
import type { CacheEntryOptions } from './cache_entry/cache_entry_options.js'
1111

@@ -48,7 +48,7 @@ export class FactoryRunner {
4848

4949
async run(
5050
key: string,
51-
factory: Factory,
51+
factory: GetSetFactory,
5252
hasFallback: boolean,
5353
options: CacheEntryOptions,
5454
lockReleaser: MutexInterface.Releaser,
@@ -59,7 +59,9 @@ export class FactoryRunner {
5959
? exceptions.E_FACTORY_HARD_TIMEOUT
6060
: exceptions.E_FACTORY_SOFT_TIMEOUT
6161

62-
const promisifiedFactory = async () => await factory()
62+
const promisifiedFactory = async () => {
63+
return await factory({ setTtl: (ttl) => options.setLogicalTtl(ttl) })
64+
}
6365

6466
const factoryPromise = promisifiedFactory()
6567

packages/bentocache/src/types/helpers.ts

+13
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ export type MaybePromise<T> = T | Promise<T>
1717
*/
1818
export type Factory<T = any> = T | (() => T) | Promise<T> | (() => Promise<T>)
1919

20+
export type GetSetFactoryOptions = {
21+
/**
22+
* Set dynamically the TTL
23+
* See Adaptive caching documentation for more information
24+
*/
25+
setTtl: (ttl: Duration) => void
26+
}
27+
28+
/**
29+
* GetOrSet Factory
30+
*/
31+
export type GetSetFactory<T = any> = (options: GetSetFactoryOptions) => T | Promise<T>
32+
2033
/**
2134
* Logger interface
2235
*/

packages/bentocache/src/types/provider.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Factory } from './helpers.js'
1+
import type { Factory, GetSetFactory } from './helpers.js'
22
import type {
33
ClearOptions,
44
DeleteOptions,
@@ -44,12 +44,16 @@ export interface CacheProvider {
4444
/**
4545
* Get or set a value in the cache
4646
*/
47-
getOrSet<T>(key: string, factory: Factory<T>, options?: Factory<T> | GetOrSetOptions): Promise<T>
47+
getOrSet<T>(
48+
key: string,
49+
factory: GetSetFactory<T>,
50+
options?: GetSetFactory<T> | GetOrSetOptions,
51+
): Promise<T>
4852

4953
/**
5054
* Get or set a value in the cache forever
5155
*/
52-
getOrSetForever<T>(key: string, cb: Factory<T>, opts?: GetOrSetOptions): Promise<T>
56+
getOrSetForever<T>(key: string, cb: GetSetFactory<T>, opts?: GetOrSetOptions): Promise<T>
5357

5458
/**
5559
* Returns a new instance of the driver namespaced

packages/bentocache/tests/cache/cache_entry_options.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,14 @@ test.group('Cache Entry Options', () => {
149149
hard: string.milliseconds.parse('4m'),
150150
})
151151
})
152+
153+
test('setTtl should re-compute physical ttl and early expiration', ({ assert }) => {
154+
const options = new CacheEntryOptions({ ttl: '10m', earlyExpiration: 0.5 })
155+
156+
options.setLogicalTtl(string.milliseconds.parse('5m'))
157+
158+
assert.equal(options.logicalTtl, string.milliseconds.parse('5m'))
159+
assert.equal(options.physicalTtl, string.milliseconds.parse('5m'))
160+
assert.equal(options.earlyExpireTtl, string.milliseconds.parse('2.5m'))
161+
})
152162
})

packages/bentocache/tests/cache/one_tier_local.spec.ts

+20
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import dayjs from 'dayjs'
12
import { test } from '@japa/runner'
23
import { setTimeout } from 'node:timers/promises'
34

@@ -594,4 +595,23 @@ test.group('One tier tests', () => {
594595
assert.deepEqual(result1.value, 'value')
595596
assert.equal(result2.status, 'rejected')
596597
})
598+
599+
test('adaptive caching', async ({ assert }) => {
600+
const { cache, local, stack } = new CacheFactory().withMemoryL1().merge({ ttl: '10m' }).create()
601+
602+
await cache.getOrSet(
603+
'key1',
604+
(options) => {
605+
options.setTtl('2d')
606+
return { foo: 'bar' }
607+
},
608+
{ ttl: '4m' },
609+
)
610+
611+
const res = local.get('key1', stack.defaultOptions)!
612+
const logicalExpiration = res.getLogicalExpiration()
613+
614+
const inTwoDays = dayjs().add(2, 'day')
615+
assert.isTrue(dayjs(logicalExpiration).isSame(inTwoDays, 'day'))
616+
})
597617
})

pnpm-lock.yaml

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)