Skip to content

Commit ffdfa7f

Browse files
committed
feat: add softPurge helper
fix #46
1 parent c7f1bab commit ffdfa7f

File tree

4 files changed

+186
-4
lines changed

4 files changed

+186
-4
lines changed

readme.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,11 +375,11 @@ be migrated on read like this:
375375
```ts
376376
import type { CacheEntry } from 'cachified';
377377
import LRUCache from 'lru-cache';
378-
import { cachified } from 'cachified';
378+
import { cachified, createCacheEntry } from 'cachified';
379379

380-
const lru = new LRUCache<string, CacheEntry<string>>({ max: 1000 });
380+
const lru = new LRUCache<string, CacheEntry<string | { email: string }>>({ max: 1000 });
381381
/* Let's assume we've previously only stored emails not user objects */
382-
lru.set('user-1', { value: '[email protected]', metadata: { createdAt: Date.now() } });
382+
lru.set('user-1', createCacheEntry('[email protected]'));
383383

384384
function getUserById(userId: string) {
385385
return cachified({
@@ -403,6 +403,40 @@ function getUserById(userId: string) {
403403
- **Second Call `getUserById('1')`**:
404404
Cache is filled an valid. `getFreshValue` is not invoked, cached value is returned
405405

406+
### soft-purging entries
407+
408+
Soft-purging values has the benefit of not immediately putting pressure on the app
409+
to update all cached values at once and instead allows to get them updated over time.
410+
411+
More details: [Soft vs. hard purge](https://developer.fastly.com/reference/api/purging/#soft-vs-hard-purge)
412+
413+
```ts
414+
import type { CacheEntry } from 'cachified';
415+
import LRUCache from 'lru-cache';
416+
import { softPurge, createCacheEntry } from 'cachified';
417+
418+
const lru = new LRUCache<string, CacheEntry<string>>({ max: 1000 });
419+
lru.set('user-1', createCacheEntry('[email protected]', { ttl: 300_000 }));
420+
421+
// This effectively sets the ttl to 0 and stale while revalidate to 300_000
422+
await softPurge({
423+
cache: lru,
424+
key: 'user-1',
425+
});
426+
427+
428+
// It's also possible to manually set a new time when the entry should not be served stale
429+
await softPurge({
430+
cache: lru,
431+
key: 'user-1',
432+
// for one minute serve stale and refresh in background, afterwards get fresh value directly
433+
staleWhileRevalidate: 60_000
434+
});
435+
```
436+
437+
> ℹ️ In case we need to fully purge the value, we delete the key directly from our cache
438+
439+
406440
### Fine-tuning cache metadata based on fresh values
407441

408442
There are scenarios where we want to change the cache time based on the fresh

src/getCachedValue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { checkValue } from './checkValue';
88

99
export const CACHE_EMPTY = Symbol();
1010
export async function getCacheEntry<Value>(
11-
{ key, cache }: Context<Value>,
11+
{ key, cache }: Pick<Context<Value>, 'key' | 'cache'>,
1212
report: Reporter<Value>,
1313
): Promise<CacheEntry<unknown> | typeof CACHE_EMPTY> {
1414
report({ name: 'getCachedValueStart' });

src/softPurge.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createCacheEntry } from './common';
2+
import { softPurge } from './softPurge';
3+
4+
let currentTime = 0;
5+
beforeEach(() => {
6+
currentTime = 0;
7+
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
8+
});
9+
10+
describe('softPurge', () => {
11+
it('does not update entry when cache is outdated already', async () => {
12+
const cache = new Map();
13+
14+
cache.set('key', createCacheEntry('value', { ttl: 5 }));
15+
currentTime = 10;
16+
jest.spyOn(cache, 'set');
17+
18+
await softPurge({ cache, key: 'key' });
19+
20+
expect(cache.set).not.toHaveBeenCalled();
21+
});
22+
23+
it('does nothing when cache is empty', async () => {
24+
const cache = new Map();
25+
26+
await softPurge({ cache, key: 'key' });
27+
});
28+
29+
it('throws when entry is invalid', async () => {
30+
const cache = new Map();
31+
32+
cache.set('key', '???');
33+
34+
await expect(
35+
softPurge({ cache, key: 'key' }),
36+
).rejects.toThrowErrorMatchingInlineSnapshot(
37+
`"Cache entry for key is not a cache entry object, it's a string"`,
38+
);
39+
});
40+
41+
it('sets ttl to 0 and swr to previous ttl', async () => {
42+
const cache = new Map();
43+
44+
cache.set('key', createCacheEntry('value', { ttl: 1000 }));
45+
46+
await softPurge({ cache, key: 'key' });
47+
48+
expect(cache.get('key')).toEqual(
49+
createCacheEntry('value', { ttl: 0, swr: 1000 }),
50+
);
51+
});
52+
53+
it('sets ttl to 0 and swr to previous ttl + previous swr', async () => {
54+
const cache = new Map();
55+
56+
cache.set('key', createCacheEntry('value', { ttl: 1000, swr: 50 }));
57+
58+
await softPurge({ cache, key: 'key' });
59+
60+
expect(cache.get('key')).toEqual(
61+
createCacheEntry('value', { ttl: 0, swr: 1050 }),
62+
);
63+
});
64+
65+
it('sets ttl to 0 and swr to infinity when ttl was infinity', async () => {
66+
const cache = new Map();
67+
68+
cache.set('key', createCacheEntry('value', { ttl: Infinity }));
69+
70+
await softPurge({ cache, key: 'key' });
71+
72+
expect(cache.get('key')).toEqual(
73+
createCacheEntry('value', { ttl: 0, swr: Infinity }),
74+
);
75+
});
76+
77+
it('allows to set a custom stale while revalidate value', async () => {
78+
const cache = new Map();
79+
currentTime = 30;
80+
81+
cache.set('key', createCacheEntry('value', { ttl: Infinity }));
82+
83+
currentTime = 40;
84+
85+
await softPurge({ cache, key: 'key', staleWhileRevalidate: 50 });
86+
87+
expect(cache.get('key')).toEqual(
88+
createCacheEntry('value', { ttl: 0, swr: 60, createdTime: 30 }),
89+
);
90+
});
91+
92+
it('supports swr alias', async () => {
93+
const cache = new Map();
94+
currentTime = 30;
95+
96+
cache.set('key', createCacheEntry('value', { ttl: Infinity }));
97+
98+
currentTime = 55;
99+
100+
await softPurge({ cache, key: 'key', swr: 10 });
101+
102+
expect(cache.get('key')).toEqual(
103+
createCacheEntry('value', { ttl: 0, swr: 35, createdTime: 30 }),
104+
);
105+
});
106+
});

src/softPurge.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Cache, createCacheEntry, staleWhileRevalidate } from './common';
2+
import { CACHE_EMPTY, getCacheEntry } from './getCachedValue';
3+
import { shouldRefresh } from './shouldRefresh';
4+
5+
interface SoftPurgeOpts {
6+
cache: Cache;
7+
key: string;
8+
/**
9+
* Force the entry to outdate after ms
10+
*/
11+
staleWhileRevalidate?: number;
12+
/**
13+
* Force the entry to outdate after ms
14+
*/
15+
swr?: number;
16+
}
17+
18+
export async function softPurge({
19+
cache,
20+
key,
21+
...swrOverwrites
22+
}: SoftPurgeOpts) {
23+
const swrOverwrite = swrOverwrites.swr ?? swrOverwrites.staleWhileRevalidate;
24+
const entry = await getCacheEntry({ cache, key }, () => {});
25+
26+
if (entry === CACHE_EMPTY || shouldRefresh(entry.metadata)) {
27+
return;
28+
}
29+
30+
const ttl = entry.metadata.ttl || Infinity;
31+
const swr = staleWhileRevalidate(entry.metadata) || 0;
32+
const lt = Date.now() - entry.metadata.createdTime;
33+
34+
await cache.set(
35+
key,
36+
createCacheEntry(entry.value, {
37+
ttl: 0,
38+
swr: swrOverwrite === undefined ? ttl + swr : swrOverwrite + lt,
39+
createdTime: entry.metadata.createdTime,
40+
}),
41+
);
42+
}

0 commit comments

Comments
 (0)