diff --git a/.changeset/cyan-ladybugs-build.md b/.changeset/cyan-ladybugs-build.md new file mode 100644 index 0000000..b1a936a --- /dev/null +++ b/.changeset/cyan-ladybugs-build.md @@ -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') +``` diff --git a/packages/bentocache/src/bento_cache.ts b/packages/bentocache/src/bento_cache.ts index 90293a1..40d2310 100644 --- a/packages/bentocache/src/bento_cache.ts +++ b/packages/bentocache/src/bento_cache.ts @@ -15,6 +15,7 @@ import type { HasOptions, DeleteOptions, DeleteManyOptions, + ExpireOptions, } from './types/main.js' export class BentoCache> implements CacheProvider { @@ -208,6 +209,15 @@ export class BentoCache> 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 */ diff --git a/packages/bentocache/src/bus/bus.ts b/packages/bentocache/src/bus/bus.ts index e90ba0c..f3ca7c4 100644 --- a/packages/bentocache/src/bus/bus.ts +++ b/packages/bentocache/src/bus/bus.ts @@ -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() } diff --git a/packages/bentocache/src/bus/encoders/binary_encoder.ts b/packages/bentocache/src/bus/encoders/binary_encoder.ts index fc516ae..efce416 100644 --- a/packages/bentocache/src/bus/encoders/binary_encoder.ts +++ b/packages/bentocache/src/bus/encoders/binary_encoder.ts @@ -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}`) } /** diff --git a/packages/bentocache/src/cache/cache.ts b/packages/bentocache/src/cache/cache.ts index 77015a2..15d078e 100644 --- a/packages/bentocache/src/cache/cache.ts +++ b/packages/bentocache/src/cache/cache.ts @@ -16,6 +16,7 @@ import type { DeleteOptions, DeleteManyOptions, GetOrSetForeverOptions, + ExpireOptions, } from '../types/main.js' export class Cache implements CacheProvider { @@ -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 */ diff --git a/packages/bentocache/src/cache/facades/local_cache.ts b/packages/bentocache/src/cache/facades/local_cache.ts index d1470e6..19aa1d4 100644 --- a/packages/bentocache/src/cache/facades/local_cache.ts +++ b/packages/bentocache/src/cache/facades/local_cache.ts @@ -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 @@ -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 */ diff --git a/packages/bentocache/src/cache/facades/remote_cache.ts b/packages/bentocache/src/cache/facades/remote_cache.ts index a7da1b5..cb2b5a2 100644 --- a/packages/bentocache/src/cache/facades/remote_cache.ts +++ b/packages/bentocache/src/cache/facades/remote_cache.ts @@ -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 */ diff --git a/packages/bentocache/src/events/cache_events.ts b/packages/bentocache/src/events/cache_events.ts index ba3e561..2dadf2d 100644 --- a/packages/bentocache/src/events/cache_events.ts +++ b/packages/bentocache/src/events/cache_events.ts @@ -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 }, + } + }, } diff --git a/packages/bentocache/src/types/bus.ts b/packages/bentocache/src/types/bus.ts index 3e17e1a..7fa92d1 100644 --- a/packages/bentocache/src/types/bus.ts +++ b/packages/bentocache/src/types/bus.ts @@ -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] diff --git a/packages/bentocache/src/types/events.ts b/packages/bentocache/src/types/events.ts index f56c0e9..f564745 100644 --- a/packages/bentocache/src/types/events.ts +++ b/packages/bentocache/src/types/events.ts @@ -20,6 +20,7 @@ export type CacheEvents = { 'cache:deleted': ReturnType['data'] 'cache:hit': ReturnType['data'] 'cache:miss': ReturnType['data'] + 'cache:expire': ReturnType['data'] 'cache:written': ReturnType['data'] 'bus:message:published': ReturnType['data'] 'bus:message:received': ReturnType['data'] diff --git a/packages/bentocache/src/types/options/methods_options.ts b/packages/bentocache/src/types/options/methods_options.ts index a6295b0..af24dd4 100644 --- a/packages/bentocache/src/types/options/methods_options.ts +++ b/packages/bentocache/src/types/options/methods_options.ts @@ -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 = { key: string @@ -19,7 +19,7 @@ export type GetOrSetOptions = { } & SetCommonOptions /** - * Options accepted by the `getOrSetForever` method when passing an object + * Options accepted by the `getOrSetForever` method */ export type GetOrSetForeverOptions = { key: string @@ -35,7 +35,7 @@ export type GetOrSetForeverOptions = { 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 = { key: string; defaultValue?: Factory } & Pick< RawCommonOptions, @@ -43,13 +43,18 @@ export type GetOptions = { key: string; defaultValue?: Factory } & Pick< > /** - * Options accepted by the `delete` method when passing an object + * Options accepted by the `delete` method */ export type DeleteOptions = { key: string } & Pick export type DeleteManyOptions = { keys: string[] } & Pick /** - * Options accepted by the `has` method when passing an object + * Options accepted by the `expire` method + */ +export type ExpireOptions = { key: string } & Pick + +/** + * Options accepted by the `has` method */ export type HasOptions = { key: string } & Pick diff --git a/packages/bentocache/src/types/provider.ts b/packages/bentocache/src/types/provider.ts index 25247e6..e9ed9cd 100644 --- a/packages/bentocache/src/types/provider.ts +++ b/packages/bentocache/src/types/provider.ts @@ -69,6 +69,13 @@ export interface CacheProvider { */ deleteMany(options: DeleteManyOptions): Promise + /** + * 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 + /** * Remove all items from the cache */ diff --git a/packages/bentocache/tests/expire.spec.ts b/packages/bentocache/tests/expire.spec.ts new file mode 100644 index 0000000..9b5ba63 --- /dev/null +++ b/packages/bentocache/tests/expire.spec.ts @@ -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' }) + }) +})