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: experimental tagging support #57

Merged
merged 10 commits into from
Feb 16, 2025
28 changes: 28 additions & 0 deletions .changeset/hip-cameras-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'bentocache': minor
---

Enhance Factory Context by adding some new props.

```ts
await cache.getOrSet({
key: 'foo',
factory: (ctx) => {
// You can access the graced entry, if any, from the context
if (ctx.gracedEntry?.value === 'bar') {
return 'foo'
}

// You should now use `setOptions` to update cache entry options
ctx.setOptions({
tags: ['foo'],
ttl: '2s',
skipL2Write: true,
})

return 'foo';
}
})
```

`setTtl` has been deprecated in favor of `setOptions` and will be removed in the next major version.
26 changes: 26 additions & 0 deletions .changeset/rich-mugs-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'bentocache': minor
---

Add **experimental** tagging support. See https://github.com/Julien-R44/bentocache/issues/53

```ts
await bento.getOrSet({
key: 'foo',
factory: getFromDb(),
tags: ['tag-1', 'tag-2']
});

await bento.set({
key: 'foo',
tags: ['tag-1']
});
```

Then, we can delete all entries tagged with tag-1 using:

```ts
await bento.deleteByTags({ tags: ['tag-1'] });
```

As this is a rather complex feature, let's consider it experimental for now. Please report any bugs on Github issues
16 changes: 16 additions & 0 deletions .changeset/stupid-moose-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'bentocache': minor
---

Add `skipL2Write` and `skipBusNotify` options.

```ts
await cache.getOrSet({
key: 'foo',
skipL2Write: true,
skipBusNotify: true,
factory: () => 'foo'
})
```

When enabled, `skipL2Write` will prevent the entry from being written to L2 cache, and `skipBusNotify` will prevent any notification from being sent to the bus. You will probably never need to use these options, but they were useful for internal code, so decided to expose them.
2 changes: 1 addition & 1 deletion packages/bentocache/factories/cache_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class CacheFactory {
* Adds a cache stack preset with Memory + Redis + Memory Bus
*/
withL1L2Config() {
this.#parameters.l1Driver ??= new MemoryDriver({ maxSize: 100, prefix: 'test' })
this.#parameters.l1Driver ??= new MemoryDriver({ maxItems: 100, prefix: 'test' })
this.#parameters.l2Driver ??= new RedisDriver({ connection: { host: '127.0.0.1', port: 6379 } })
this.#parameters.busDriver ??= new MemoryTransport()

Expand Down
8 changes: 4 additions & 4 deletions packages/bentocache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,26 +73,26 @@
},
"dependencies": {
"@boringnode/bus": "^0.7.0",
"@julr/utils": "^1.6.2",
"@julr/utils": "^1.8.0",
"@poppinss/utils": "^6.9.2",
"async-mutex": "^0.5.0",
"lru-cache": "^11.0.2",
"p-timeout": "^6.1.4"
},
"devDependencies": {
"@aws-sdk/client-dynamodb": "^3.741.0",
"@aws-sdk/client-dynamodb": "^3.749.0",
"@types/better-sqlite3": "^7.6.12",
"@types/pg": "^8.11.11",
"better-sqlite3": "^11.8.1",
"dayjs": "^1.11.13",
"emittery": "^1.1.0",
"ioredis": "^5.4.2",
"ioredis": "^5.5.0",
"knex": "^3.1.0",
"kysely": "^0.27.5",
"mysql2": "^3.12.0",
"orchid-orm": "1.40.2",
"p-event": "^6.0.1",
"pg": "^8.13.1",
"pg": "^8.13.3",
"pino": "^9.6.0",
"pino-loki": "^2.5.0",
"sqlite3": "^5.1.7",
Expand Down
8 changes: 8 additions & 0 deletions packages/bentocache/src/bento_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
DeleteOptions,
DeleteManyOptions,
ExpireOptions,
DeleteByTagOptions,
} from './types/main.js'

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

/**
* Delete all keys with a specific tag
*/
async deleteByTag(options: DeleteByTagOptions): Promise<boolean> {
return this.use().deleteByTag(options)
}

/**
* Expire a key from the cache.
* Entry will not be fully deleted but expired and
Expand Down
39 changes: 25 additions & 14 deletions packages/bentocache/src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
DeleteManyOptions,
GetOrSetForeverOptions,
ExpireOptions,
DeleteByTagOptions,
} from '../types/main.js'

export class Cache implements CacheProvider {
Expand All @@ -35,6 +36,7 @@ export class Cache implements CacheProvider {
this.#stack = stack
this.#options = stack.options
this.#getSetHandler = new GetSetHandler(this.#stack)
this.#stack.setTagSystemGetSetHandler(this.#getSetHandler)
}

#resolveDefaultValue(defaultValue?: Factory) {
Expand All @@ -57,19 +59,21 @@ export class Cache implements CacheProvider {
this.#options.logger.logMethod({ method: 'get', key, options, cacheName: this.name })

const localItem = this.#stack.l1?.get(key, options)
if (localItem?.isGraced === false) {
this.#stack.emit(cacheEvents.hit(key, localItem.entry.getValue(), this.name))
const isLocalItemValid = await this.#stack.isEntryValid(localItem)
if (isLocalItemValid) {
this.#stack.emit(cacheEvents.hit(key, localItem!.entry.getValue(), this.name))
this.#options.logger.logL1Hit({ cacheName: this.name, key, options })
return localItem.entry.getValue()
return localItem!.entry.getValue()
}

const remoteItem = await this.#stack.l2?.get(key, options)
const isRemoteItemValid = await this.#stack.isEntryValid(remoteItem)

if (remoteItem?.isGraced === false) {
this.#stack.l1?.set(key, remoteItem.entry.serialize(), options)
this.#stack.emit(cacheEvents.hit(key, remoteItem.entry.getValue(), this.name))
if (isRemoteItemValid) {
this.#stack.l1?.set(key, remoteItem!.entry.serialize(), options)
this.#stack.emit(cacheEvents.hit(key, remoteItem!.entry.getValue(), this.name))
this.#options.logger.logL2Hit({ cacheName: this.name, key, options })
return remoteItem.entry.getValue()
return remoteItem!.entry.getValue()
}

if (remoteItem && options.isGraceEnabled()) {
Expand Down Expand Up @@ -193,6 +197,18 @@ export class Cache implements CacheProvider {
return true
}

/**
* Invalidate all keys with the given tags
*/
async deleteByTag(rawOptions: DeleteByTagOptions): Promise<boolean> {
const tags = rawOptions.tags
const options = this.#stack.defaultOptions.cloneWith(rawOptions)

this.#options.logger.logMethod({ method: 'deleteByTag', cacheName: this.name, tags, options })

return await this.#stack.createTagInvalidations(tags)
}

/**
* Delete multiple keys from local and remote cache
* Then emit cache:deleted events for each key
Expand Down Expand Up @@ -222,17 +238,12 @@ export class Cache implements CacheProvider {
* Entry will not be fully deleted but expired and
* retained for the grace period if enabled.
*/
async expire(rawOptions: ExpireOptions) {
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
return this.#stack.expire(key, options)
}

/**
Expand Down
23 changes: 22 additions & 1 deletion packages/bentocache/src/cache/cache_entry/cache_entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class CacheEntry {
* The value of the item.
*/
#value: any
#tags: string[]

/**
* The logical expiration is the time in miliseconds when the item
Expand All @@ -21,13 +22,20 @@ export class CacheEntry {
*/
#logicalExpiration: number

/**
* The time when the item was created.
*/
#createdAt: number

#serializer?: CacheSerializer

constructor(key: string, item: Record<string, any>, serializer?: CacheSerializer) {
this.#key = key
this.#value = item.value
this.#tags = item.tags ?? []
this.#logicalExpiration = item.logicalExpiration
this.#serializer = serializer
this.#createdAt = item.createdAt
}

getValue() {
Expand All @@ -38,10 +46,18 @@ export class CacheEntry {
return this.#key
}

getCreatedAt() {
return this.#createdAt
}

getLogicalExpiration() {
return this.#logicalExpiration
}

getTags() {
return this.#tags
}

isLogicallyExpired() {
return Date.now() >= this.#logicalExpiration
}
Expand All @@ -63,7 +79,12 @@ export class CacheEntry {
}

serialize() {
const raw = { value: this.#value, logicalExpiration: this.#logicalExpiration }
const raw = {
value: this.#value,
createdAt: this.#createdAt,
logicalExpiration: this.#logicalExpiration,
...(this.#tags.length > 0 && { tags: this.#tags }),
}

if (this.#serializer) return this.#serializer.serialize(raw)
return raw
Expand Down
11 changes: 11 additions & 0 deletions packages/bentocache/src/cache/cache_entry/cache_entry_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export function createCacheEntryOptions(
timeout,
hardTimeout,

/**
* Tags to associate with the cache entry
*/
tags: options.tags ?? [],

/**
* Skip options
*/
skipL2Write: options.skipL2Write ?? false,
skipBusNotify: options.skipBusNotify ?? false,

/**
* Max time to wait for the lock to be acquired
*/
Expand Down
Loading