Skip to content

Commit

Permalink
feat: add expire method
Browse files Browse the repository at this point in the history
  • Loading branch information
Julien-R44 committed Feb 11, 2025
1 parent a57b8f6 commit 5e88efe
Show file tree
Hide file tree
Showing 13 changed files with 202 additions and 21 deletions.
32 changes: 32 additions & 0 deletions .changeset/cyan-ladybugs-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'bentocache': minor
---

Add a new `expire` method.

This method is slightly different from `delete`:

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.

`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:

```ts
// Set a value with a grace period of 6 minutes
await cache.set({
key: 'hello',
value: 'world',
grace: '6m'
})

// Expire the value. It is kept in the cache but marked as STALE for 6 minutes
await cache.expire({ key: 'hello' })

// Here, a get with grace: false will return nothing, because the value is stale
const r1 = await cache.get({ key: 'hello', grace: false })

// Here, a get with grace: true will return the value, because it is still within the grace period
const r2 = await cache.get({ key: 'hello' })

assert.deepEqual(r1, undefined)
assert.deepEqual(r2, 'world')
```
10 changes: 10 additions & 0 deletions packages/bentocache/src/bento_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
HasOptions,
DeleteOptions,
DeleteManyOptions,
ExpireOptions,
} from './types/main.js'

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

/**
* Expire a key from the cache.
* Entry will not be fully deleted but expired and
* retained for the grace period if enabled.
*/
async expire(options: ExpireOptions) {
return this.use().expire(options)
}

/**
* Remove all items from the cache
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/bentocache/src/bus/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export class Bus {
for (const key of message.keys) cache?.logicallyExpire(key)
}

if (message.type === CacheBusMessageType.Expire) {
for (const key of message.keys) cache?.logicallyExpire(key)
}

if (message.type === CacheBusMessageType.Clear) {
cache?.clear()
}
Expand Down
10 changes: 8 additions & 2 deletions packages/bentocache/src/bus/encoders/binary_encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,19 @@ export class BinaryEncoder implements TransportEncoder {
protected busMessageTypeToNum(type: CacheBusMessageType): number {
if (type === CacheBusMessageType.Set) return 0x01
if (type === CacheBusMessageType.Clear) return 0x02
return 0x03
if (type === CacheBusMessageType.Delete) return 0x03
if (type === CacheBusMessageType.Expire) return 0x04

throw new Error(`Unknown message type: ${type}`)
}

protected numToBusMessageType(num: number): CacheBusMessageType {
if (num === 0x01) return CacheBusMessageType.Set
if (num === 0x02) return CacheBusMessageType.Clear
return CacheBusMessageType.Delete
if (num === 0x03) return CacheBusMessageType.Delete
if (num === 0x04) return CacheBusMessageType.Expire

throw new Error(`Unknown message type: ${num}`)
}

/**
Expand Down
19 changes: 19 additions & 0 deletions packages/bentocache/src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
DeleteOptions,
DeleteManyOptions,
GetOrSetForeverOptions,
ExpireOptions,
} from '../types/main.js'

export class Cache implements CacheProvider {
Expand Down Expand Up @@ -216,6 +217,24 @@ export class Cache implements CacheProvider {
return true
}

/**
* Expire a key from the cache.
* Entry will not be fully deleted but expired and
* retained for the grace period if enabled.
*/
async expire(rawOptions: ExpireOptions) {
const key = rawOptions.key
const options = this.#stack.defaultOptions.cloneWith(rawOptions)
this.#options.logger.logMethod({ method: 'expire', cacheName: this.name, key, options })

this.#stack.l1?.logicallyExpire(key, options)
await this.#stack.l2?.logicallyExpire(key, options)
await this.#stack.publish({ type: CacheBusMessageType.Expire, keys: [key] })

this.#stack.emit(cacheEvents.expire(key, this.name))
return true
}

/**
* Remove all items from the cache
*/
Expand Down
24 changes: 10 additions & 14 deletions packages/bentocache/src/cache/facades/local_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,19 @@ export class LocalCache {
return this.#driver.delete(key)
}

/**
* Delete many item from the local cache
*/
deleteMany(keys: string[], options: CacheEntryOptions) {
this.#logger.debug({ keys, options, opId: options.id }, 'deleting items')
this.#driver.deleteMany(keys)
}

/**
* Make an item logically expire in the local cache
*
* That means that the item will be expired but kept in the cache
* in order to be able to return it to the user if the remote cache
* is down and the grace period is enabled
*/
logicallyExpire(key: string) {
this.#logger.debug({ key }, 'logically expiring item')
logicallyExpire(key: string, options?: CacheEntryOptions) {
this.#logger.debug({ key, opId: options?.id }, 'logically expiring item')

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

/**
* Delete many item from the local cache
*/
deleteMany(keys: string[], options: CacheEntryOptions) {
this.#logger.debug({ keys, options, opId: options.id }, 'deleting items')
this.#driver.deleteMany(keys)
}

/**
* Create a new namespace for the local cache
*/
Expand Down
15 changes: 15 additions & 0 deletions packages/bentocache/src/cache/facades/remote_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ export class RemoteCache {
})
}

/**
* Make an item logically expire in the remote cache
*/
async logicallyExpire(key: string, options: CacheEntryOptions) {
return await this.#tryCacheOperation('logicallyExpire', options, false, async () => {
this.#logger.debug({ key, opId: options.id }, 'logically expiring item')

const value = await this.#driver.get(key)
if (value === undefined) return

const entry = CacheEntry.fromDriver(key, value, this.#options.serializer).expire().serialize()
return await this.#driver.set(key, entry as any, options.getPhysicalTtl())
})
}

/**
* Create a new namespace for the remote cache
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/bentocache/src/events/cache_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ export const cacheEvents = {
data: { key, value, store },
}
},
expire(key: string, store: string) {
return {
name: 'cache:expire' as const,
data: { key, store },
}
},
}
5 changes: 5 additions & 0 deletions packages/bentocache/src/types/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export const CacheBusMessageType = {
* An item was deleted from the cache
*/
Delete: 'delete',

/**
* An item was logically expired
*/
Expire: 'expire',
}
export type CacheBusMessageType = (typeof CacheBusMessageType)[keyof typeof CacheBusMessageType]

Expand Down
1 change: 1 addition & 0 deletions packages/bentocache/src/types/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type CacheEvents = {
'cache:deleted': ReturnType<typeof cacheEvents.deleted>['data']
'cache:hit': ReturnType<typeof cacheEvents.hit>['data']
'cache:miss': ReturnType<typeof cacheEvents.miss>['data']
'cache:expire': ReturnType<typeof cacheEvents.expire>['data']
'cache:written': ReturnType<typeof cacheEvents.written>['data']
'bus:message:published': ReturnType<typeof busEvents.messagePublished>['data']
'bus:message:received': ReturnType<typeof busEvents.messageReceived>['data']
Expand Down
15 changes: 10 additions & 5 deletions packages/bentocache/src/types/options/methods_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type SetCommonOptions = Pick<
>

/**
* Options accepted by the `getOrSet` method when passing an object
* Options accepted by the `getOrSet` method
*/
export type GetOrSetOptions<T> = {
key: string
Expand All @@ -19,7 +19,7 @@ export type GetOrSetOptions<T> = {
} & SetCommonOptions

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

/**
* Options accepted by the `get` method when passing an object
* Options accepted by the `get` method
*/
export type GetOptions<T> = { key: string; defaultValue?: Factory<T> } & Pick<
RawCommonOptions,
'grace' | 'graceBackoff' | 'suppressL2Errors'
>

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

/**
* Options accepted by the `has` method when passing an object
* Options accepted by the `expire` method
*/
export type ExpireOptions = { key: string } & Pick<RawCommonOptions, 'suppressL2Errors'>

/**
* Options accepted by the `has` method
*/
export type HasOptions = { key: string } & Pick<RawCommonOptions, 'suppressL2Errors'>

Expand Down
7 changes: 7 additions & 0 deletions packages/bentocache/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ export interface CacheProvider {
*/
deleteMany(options: DeleteManyOptions): Promise<boolean>

/**
* Expire a key from the cache.
* Entry will not be fully deleted but expired and
* retained for the grace period if enabled.
*/
expire(options: DeleteOptions): Promise<boolean>

/**
* Remove all items from the cache
*/
Expand Down
75 changes: 75 additions & 0 deletions packages/bentocache/tests/expire.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { pEvent } from 'p-event'
import { test } from '@japa/runner'
import EventEmitter from 'node:events'

import { CacheFactory } from '../factories/cache_factory.js'

test.group('Expire', () => {
test('[{name}] - expire a key from the cache')
.with([
{
name: 'l1',
factory: () => new CacheFactory().merge({ grace: '2m' }).withMemoryL1().create(),
},
{
name: 'l2',
factory: () => new CacheFactory().merge({ grace: '2m' }).withRedisL2().create(),
},
{
name: 'l1/l2',
factory: () => new CacheFactory().merge({ grace: '2m' }).withL1L2Config().create(),
},
])
.run(async ({ assert }, { factory }) => {
const { cache } = factory()

await cache.set({ key: 'hello', value: 'world' })
await cache.expire({ key: 'hello' })

const r1 = await cache.get({ key: 'hello', grace: false })
const r2 = await cache.get({ key: 'hello' })

assert.deepEqual(r1, undefined)
assert.deepEqual(r2, 'world')
})

test('expire should publish an message to the bus', async ({ assert }) => {
const [cache1] = new CacheFactory().merge({ grace: '3m' }).withL1L2Config().create()
const [cache2] = new CacheFactory().merge({ grace: '3m' }).withL1L2Config().create()
const [cache3] = new CacheFactory().merge({ grace: '3m' }).withL1L2Config().create()

await cache1.set({ key: 'hello', value: 'world' })
await cache2.get({ key: 'hello' })
await cache3.get({ key: 'hello' })

await cache1.expire({ key: 'hello' })

const r1 = await cache1.get({ key: 'hello', grace: false })
const r2 = await cache2.get({ key: 'hello', grace: false })
const r3 = await cache3.get({ key: 'hello', grace: false })

const r4 = await cache1.get({ key: 'hello' })
const r5 = await cache2.get({ key: 'hello' })
const r6 = await cache3.get({ key: 'hello' })

assert.deepEqual(r1, undefined)
assert.deepEqual(r2, undefined)
assert.deepEqual(r3, undefined)

assert.deepEqual(r4, 'world')
assert.deepEqual(r5, 'world')
assert.deepEqual(r6, 'world')
})

test('expire should emit an event', async ({ assert }) => {
const emitter = new EventEmitter()
const [cache] = new CacheFactory().merge({ grace: '3m', emitter }).withL1L2Config().create()

const eventPromise = pEvent(emitter, 'cache:expire')

await cache.expire({ key: 'hello' })

const event = await eventPromise
assert.deepEqual(event, { key: 'hello', store: 'primary' })
})
})

0 comments on commit 5e88efe

Please sign in to comment.