Skip to content

Commit 8bb87b6

Browse files
committed
feat: add expire method
1 parent a57b8f6 commit 8bb87b6

File tree

13 files changed

+202
-21
lines changed

13 files changed

+202
-21
lines changed

.changeset/cyan-ladybugs-build.md

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'bentocache': minor
3+
---
4+
5+
Add a new `expire` method.
6+
7+
This method is slightly different from `delete`:
8+
9+
When we delete a key, it is completely removed and forgotten. This means that even if we use grace periods, the value will no longer be available.
10+
11+
`expire` works like `delete`, except that instead of completely removing the value, we just mark it as expired but keep it for the grace period. For example:
12+
13+
```ts
14+
// Set a value with a grace period of 6 minutes
15+
await cache.set({
16+
key: 'hello',
17+
value: 'world',
18+
grace: '6m'
19+
})
20+
21+
// Expire the value. It is kept in the cache but marked as STALE for 6 minutes
22+
await cache.expire({ key: 'hello' })
23+
24+
// Here, a get with grace: false will return nothing, because the value is stale
25+
const r1 = await cache.get({ key: 'hello', grace: false })
26+
27+
// Here, a get with grace: true will return the value, because it is still within the grace period
28+
const r2 = await cache.get({ key: 'hello' })
29+
30+
assert.deepEqual(r1, undefined)
31+
assert.deepEqual(r2, 'world')
32+
```

packages/bentocache/src/bento_cache.ts

+10
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
HasOptions,
1616
DeleteOptions,
1717
DeleteManyOptions,
18+
ExpireOptions,
1819
} from './types/main.js'
1920

2021
export class BentoCache<KnownCaches extends Record<string, BentoStore>> implements CacheProvider {
@@ -208,6 +209,15 @@ export class BentoCache<KnownCaches extends Record<string, BentoStore>> implemen
208209
return this.use().deleteMany(options)
209210
}
210211

212+
/**
213+
* Expire a key from the cache.
214+
* Entry will not be fully deleted but expired and
215+
* retained for the grace period if enabled.
216+
*/
217+
async expire(options: ExpireOptions) {
218+
return this.use().expire(options)
219+
}
220+
211221
/**
212222
* Remove all items from the cache
213223
*/

packages/bentocache/src/bus/bus.ts

+4
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export class Bus {
7777
for (const key of message.keys) cache?.logicallyExpire(key)
7878
}
7979

80+
if (message.type === CacheBusMessageType.Expire) {
81+
for (const key of message.keys) cache?.logicallyExpire(key)
82+
}
83+
8084
if (message.type === CacheBusMessageType.Clear) {
8185
cache?.clear()
8286
}

packages/bentocache/src/bus/encoders/binary_encoder.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,19 @@ export class BinaryEncoder implements TransportEncoder {
3333
protected busMessageTypeToNum(type: CacheBusMessageType): number {
3434
if (type === CacheBusMessageType.Set) return 0x01
3535
if (type === CacheBusMessageType.Clear) return 0x02
36-
return 0x03
36+
if (type === CacheBusMessageType.Delete) return 0x03
37+
if (type === CacheBusMessageType.Expire) return 0x04
38+
39+
throw new Error(`Unknown message type: ${type}`)
3740
}
3841

3942
protected numToBusMessageType(num: number): CacheBusMessageType {
4043
if (num === 0x01) return CacheBusMessageType.Set
4144
if (num === 0x02) return CacheBusMessageType.Clear
42-
return CacheBusMessageType.Delete
45+
if (num === 0x03) return CacheBusMessageType.Delete
46+
if (num === 0x04) return CacheBusMessageType.Expire
47+
48+
throw new Error(`Unknown message type: ${num}`)
4349
}
4450

4551
/**

packages/bentocache/src/cache/cache.ts

+19
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
DeleteOptions,
1717
DeleteManyOptions,
1818
GetOrSetForeverOptions,
19+
ExpireOptions,
1920
} from '../types/main.js'
2021

2122
export class Cache implements CacheProvider {
@@ -216,6 +217,24 @@ export class Cache implements CacheProvider {
216217
return true
217218
}
218219

220+
/**
221+
* Expire a key from the cache.
222+
* Entry will not be fully deleted but expired and
223+
* retained for the grace period if enabled.
224+
*/
225+
async expire(rawOptions: ExpireOptions) {
226+
const key = rawOptions.key
227+
const options = this.#stack.defaultOptions.cloneWith(rawOptions)
228+
this.#options.logger.logMethod({ method: 'expire', cacheName: this.name, key, options })
229+
230+
this.#stack.l1?.logicallyExpire(key, options)
231+
await this.#stack.l2?.logicallyExpire(key, options)
232+
await this.#stack.publish({ type: CacheBusMessageType.Expire, keys: [key] })
233+
234+
this.#stack.emit(cacheEvents.expire(key, this.name))
235+
return true
236+
}
237+
219238
/**
220239
* Remove all items from the cache
221240
*/

packages/bentocache/src/cache/facades/local_cache.ts

+10-14
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,19 @@ export class LocalCache {
7474
return this.#driver.delete(key)
7575
}
7676

77+
/**
78+
* Delete many item from the local cache
79+
*/
80+
deleteMany(keys: string[], options: CacheEntryOptions) {
81+
this.#logger.debug({ keys, options, opId: options.id }, 'deleting items')
82+
this.#driver.deleteMany(keys)
83+
}
84+
7785
/**
7886
* Make an item logically expire in the local cache
79-
*
80-
* That means that the item will be expired but kept in the cache
81-
* in order to be able to return it to the user if the remote cache
82-
* is down and the grace period is enabled
8387
*/
84-
logicallyExpire(key: string) {
85-
this.#logger.debug({ key }, 'logically expiring item')
88+
logicallyExpire(key: string, options?: CacheEntryOptions) {
89+
this.#logger.debug({ key, opId: options?.id }, 'logically expiring item')
8690

8791
const value = this.#driver.get(key)
8892
if (value === undefined) return
@@ -91,14 +95,6 @@ export class LocalCache {
9195
return this.#driver.set(key, newEntry as any, this.#driver.getRemainingTtl(key))
9296
}
9397

94-
/**
95-
* Delete many item from the local cache
96-
*/
97-
deleteMany(keys: string[], options: CacheEntryOptions) {
98-
this.#logger.debug({ keys, options, opId: options.id }, 'deleting items')
99-
this.#driver.deleteMany(keys)
100-
}
101-
10298
/**
10399
* Create a new namespace for the local cache
104100
*/

packages/bentocache/src/cache/facades/remote_cache.ts

+15
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ export class RemoteCache {
123123
})
124124
}
125125

126+
/**
127+
* Make an item logically expire in the remote cache
128+
*/
129+
async logicallyExpire(key: string, options: CacheEntryOptions) {
130+
return await this.#tryCacheOperation('logicallyExpire', options, false, async () => {
131+
this.#logger.debug({ key, opId: options.id }, 'logically expiring item')
132+
133+
const value = await this.#driver.get(key)
134+
if (value === undefined) return
135+
136+
const entry = CacheEntry.fromDriver(key, value, this.#options.serializer).expire().serialize()
137+
return await this.#driver.set(key, entry as any, options.getPhysicalTtl())
138+
})
139+
}
140+
126141
/**
127142
* Create a new namespace for the remote cache
128143
*/

packages/bentocache/src/events/cache_events.ts

+6
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,10 @@ export const cacheEvents = {
2929
data: { key, value, store },
3030
}
3131
},
32+
expire(key: string, store: string) {
33+
return {
34+
name: 'cache:expire' as const,
35+
data: { key, store },
36+
}
37+
},
3238
}

packages/bentocache/src/types/bus.ts

+5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export const CacheBusMessageType = {
3131
* An item was deleted from the cache
3232
*/
3333
Delete: 'delete',
34+
35+
/**
36+
* An item was logically expired
37+
*/
38+
Expire: 'expire',
3439
}
3540
export type CacheBusMessageType = (typeof CacheBusMessageType)[keyof typeof CacheBusMessageType]
3641

packages/bentocache/src/types/events.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type CacheEvents = {
2020
'cache:deleted': ReturnType<typeof cacheEvents.deleted>['data']
2121
'cache:hit': ReturnType<typeof cacheEvents.hit>['data']
2222
'cache:miss': ReturnType<typeof cacheEvents.miss>['data']
23+
'cache:expire': ReturnType<typeof cacheEvents.expire>['data']
2324
'cache:written': ReturnType<typeof cacheEvents.written>['data']
2425
'bus:message:published': ReturnType<typeof busEvents.messagePublished>['data']
2526
'bus:message:received': ReturnType<typeof busEvents.messageReceived>['data']

packages/bentocache/src/types/options/methods_options.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type SetCommonOptions = Pick<
1010
>
1111

1212
/**
13-
* Options accepted by the `getOrSet` method when passing an object
13+
* Options accepted by the `getOrSet` method
1414
*/
1515
export type GetOrSetOptions<T> = {
1616
key: string
@@ -19,7 +19,7 @@ export type GetOrSetOptions<T> = {
1919
} & SetCommonOptions
2020

2121
/**
22-
* Options accepted by the `getOrSetForever` method when passing an object
22+
* Options accepted by the `getOrSetForever` method
2323
*/
2424
export type GetOrSetForeverOptions<T> = {
2525
key: string
@@ -35,21 +35,26 @@ export type GetOrSetForeverOptions<T> = {
3535
export type SetOptions = { key: string; value: any } & SetCommonOptions
3636

3737
/**
38-
* Options accepted by the `get` method when passing an object
38+
* Options accepted by the `get` method
3939
*/
4040
export type GetOptions<T> = { key: string; defaultValue?: Factory<T> } & Pick<
4141
RawCommonOptions,
4242
'grace' | 'graceBackoff' | 'suppressL2Errors'
4343
>
4444

4545
/**
46-
* Options accepted by the `delete` method when passing an object
46+
* Options accepted by the `delete` method
4747
*/
4848
export type DeleteOptions = { key: string } & Pick<RawCommonOptions, 'suppressL2Errors'>
4949
export type DeleteManyOptions = { keys: string[] } & Pick<RawCommonOptions, 'suppressL2Errors'>
5050

5151
/**
52-
* Options accepted by the `has` method when passing an object
52+
* Options accepted by the `expire` method
53+
*/
54+
export type ExpireOptions = { key: string } & Pick<RawCommonOptions, 'suppressL2Errors'>
55+
56+
/**
57+
* Options accepted by the `has` method
5358
*/
5459
export type HasOptions = { key: string } & Pick<RawCommonOptions, 'suppressL2Errors'>
5560

packages/bentocache/src/types/provider.ts

+7
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ export interface CacheProvider {
6969
*/
7070
deleteMany(options: DeleteManyOptions): Promise<boolean>
7171

72+
/**
73+
* Expire a key from the cache.
74+
* Entry will not be fully deleted but expired and
75+
* retained for the grace period if enabled.
76+
*/
77+
expire(options: DeleteOptions): Promise<boolean>
78+
7279
/**
7380
* Remove all items from the cache
7481
*/
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { pEvent } from 'p-event'
2+
import { test } from '@japa/runner'
3+
import EventEmitter from 'node:events'
4+
5+
import { CacheFactory } from '../factories/cache_factory.js'
6+
7+
test.group('Expire', () => {
8+
test('[{name}] - expire a key from the cache')
9+
.with([
10+
{
11+
name: 'l1',
12+
factory: () => new CacheFactory().merge({ grace: '2m' }).withMemoryL1().create(),
13+
},
14+
{
15+
name: 'l2',
16+
factory: () => new CacheFactory().merge({ grace: '2m' }).withRedisL2().create(),
17+
},
18+
{
19+
name: 'l1/l2',
20+
factory: () => new CacheFactory().merge({ grace: '2m' }).withL1L2Config().create(),
21+
},
22+
])
23+
.run(async ({ assert }, { factory }) => {
24+
const { cache } = factory()
25+
26+
await cache.set({ key: 'hello', value: 'world' })
27+
await cache.expire({ key: 'hello' })
28+
29+
const r1 = await cache.get({ key: 'hello', grace: false })
30+
const r2 = await cache.get({ key: 'hello' })
31+
32+
assert.deepEqual(r1, undefined)
33+
assert.deepEqual(r2, 'world')
34+
})
35+
36+
test('expire should publish an message to the bus', async ({ assert }) => {
37+
const [cache1] = new CacheFactory().merge({ grace: '3m' }).withL1L2Config().create()
38+
const [cache2] = new CacheFactory().merge({ grace: '3m' }).withL1L2Config().create()
39+
const [cache3] = new CacheFactory().merge({ grace: '3m' }).withL1L2Config().create()
40+
41+
await cache1.set({ key: 'hello', value: 'world' })
42+
await cache2.get({ key: 'hello' })
43+
await cache3.get({ key: 'hello' })
44+
45+
await cache1.expire({ key: 'hello' })
46+
47+
const r1 = await cache1.get({ key: 'hello', grace: false })
48+
const r2 = await cache2.get({ key: 'hello', grace: false })
49+
const r3 = await cache3.get({ key: 'hello', grace: false })
50+
51+
const r4 = await cache1.get({ key: 'hello' })
52+
const r5 = await cache2.get({ key: 'hello' })
53+
const r6 = await cache3.get({ key: 'hello' })
54+
55+
assert.deepEqual(r1, undefined)
56+
assert.deepEqual(r2, undefined)
57+
assert.deepEqual(r3, undefined)
58+
59+
assert.deepEqual(r4, 'world')
60+
assert.deepEqual(r5, 'world')
61+
assert.deepEqual(r6, 'world')
62+
})
63+
64+
test('expire should emit an event', async ({ assert }) => {
65+
const emitter = new EventEmitter()
66+
const [cache] = new CacheFactory().merge({ grace: '3m', emitter }).withL1L2Config().create()
67+
68+
const eventPromise = pEvent(emitter, 'cache:expire')
69+
70+
await cache.expire({ key: 'hello' })
71+
72+
const event = await eventPromise
73+
assert.deepEqual(event, { key: 'hello', store: 'primary' })
74+
})
75+
})

0 commit comments

Comments
 (0)