Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add expire method #56

Merged
merged 1 commit into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
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' })
})
})