Skip to content

Commit 6b96743

Browse files
committed
feat: working implementation
1 parent cad5051 commit 6b96743

File tree

7 files changed

+514
-28
lines changed

7 files changed

+514
-28
lines changed

.changeset/rich-mugs-prove.md

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'bentocache': minor
3+
---
4+
5+
Add **experimental** tagging support. See https://github.com/Julien-R44/bentocache/issues/53
6+
7+
```ts
8+
await bento.getOrSet({
9+
key: 'foo',
10+
factory: getFromDb(),
11+
tags: ['tag-1', 'tag-2']
12+
});
13+
14+
await bento.set({
15+
key: 'foo',
16+
tags: ['tag-1']
17+
});
18+
```
19+
20+
Then, we can delete all entries tagged with tag-1 using:
21+
22+
```ts
23+
await bento.deleteByTags({ tags: ['tag-1'] });
24+
```
25+
26+
As this is a rather complex feature, let's consider it experimental for now. Please report any bugs on Github issues

packages/bentocache/src/cache/cache.ts

+25-14
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
DeleteManyOptions,
1818
GetOrSetForeverOptions,
1919
ExpireOptions,
20+
DeleteByTagOptions,
2021
} from '../types/main.js'
2122

2223
export class Cache implements CacheProvider {
@@ -35,6 +36,7 @@ export class Cache implements CacheProvider {
3536
this.#stack = stack
3637
this.#options = stack.options
3738
this.#getSetHandler = new GetSetHandler(this.#stack)
39+
this.#stack.setTagSystemGetSetHandler(this.#getSetHandler)
3840
}
3941

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

5961
const localItem = this.#stack.l1?.get(key, options)
60-
if (localItem?.isGraced === false) {
61-
this.#stack.emit(cacheEvents.hit(key, localItem.entry.getValue(), this.name))
62+
const isLocalItemValid = await this.#stack.isEntryValid(localItem)
63+
if (isLocalItemValid) {
64+
this.#stack.emit(cacheEvents.hit(key, localItem!.entry.getValue(), this.name))
6265
this.#options.logger.logL1Hit({ cacheName: this.name, key, options })
63-
return localItem.entry.getValue()
66+
return localItem!.entry.getValue()
6467
}
6568

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

68-
if (remoteItem?.isGraced === false) {
69-
this.#stack.l1?.set(key, remoteItem.entry.serialize(), options)
70-
this.#stack.emit(cacheEvents.hit(key, remoteItem.entry.getValue(), this.name))
72+
if (isRemoteItemValid) {
73+
this.#stack.l1?.set(key, remoteItem!.entry.serialize(), options)
74+
this.#stack.emit(cacheEvents.hit(key, remoteItem!.entry.getValue(), this.name))
7175
this.#options.logger.logL2Hit({ cacheName: this.name, key, options })
72-
return remoteItem.entry.getValue()
76+
return remoteItem!.entry.getValue()
7377
}
7478

7579
if (remoteItem && options.isGraceEnabled()) {
@@ -193,6 +197,18 @@ export class Cache implements CacheProvider {
193197
return true
194198
}
195199

200+
/**
201+
* Invalidate all keys with the given tags
202+
*/
203+
async deleteByTag(rawOptions: DeleteByTagOptions): Promise<boolean> {
204+
const tags = rawOptions.tags
205+
const options = this.#stack.defaultOptions.cloneWith(rawOptions)
206+
207+
this.#options.logger.logMethod({ method: 'deleteByTag', cacheName: this.name, tags, options })
208+
209+
return await this.#stack.createTagInvalidations(tags)
210+
}
211+
196212
/**
197213
* Delete multiple keys from local and remote cache
198214
* Then emit cache:deleted events for each key
@@ -222,17 +238,12 @@ export class Cache implements CacheProvider {
222238
* Entry will not be fully deleted but expired and
223239
* retained for the grace period if enabled.
224240
*/
225-
async expire(rawOptions: ExpireOptions) {
241+
expire(rawOptions: ExpireOptions) {
226242
const key = rawOptions.key
227243
const options = this.#stack.defaultOptions.cloneWith(rawOptions)
228244
this.#options.logger.logMethod({ method: 'expire', cacheName: this.name, key, options })
229245

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
246+
return this.#stack.expire(key, options)
236247
}
237248

238249
/**

packages/bentocache/src/cache/cache_stack.ts

+49
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import lodash from '@poppinss/utils/lodash'
33

44
import { Bus } from '../bus/bus.js'
55
import type { Logger } from '../logger.js'
6+
import { TagSystem } from './tag_system.js'
67
import { UndefinedValueError } from '../errors.js'
78
import { LocalCache } from './facades/local_cache.js'
89
import { BaseDriver } from '../drivers/base_driver.js'
910
import { RemoteCache } from './facades/remote_cache.js'
1011
import { cacheEvents } from '../events/cache_events.js'
12+
import type { GetSetHandler } from './get_set/get_set_handler.js'
1113
import type { BentoCacheOptions } from '../bento_cache_options.js'
14+
import type { GetCacheValueReturn } from '../types/internals/index.js'
1215
import type { CacheEntryOptions } from './cache_entry/cache_entry_options.js'
1316
import { createCacheEntryOptions } from './cache_entry/cache_entry_options.js'
1417
import {
@@ -28,6 +31,7 @@ export class CacheStack extends BaseDriver {
2831
logger: Logger
2932
#busDriver?: BusDriver
3033
#busOptions?: BusOptions
34+
#tagSystem: TagSystem
3135
#namespaceCache: Map<string, CacheStack> = new Map()
3236

3337
constructor(
@@ -51,6 +55,7 @@ export class CacheStack extends BaseDriver {
5155
this.bus = bus ? bus : this.#createBus(drivers.busDriver, drivers.busOptions)
5256
if (this.l1) this.bus?.manageCache(this.prefix, this.l1)
5357

58+
this.#tagSystem = new TagSystem(this)
5459
this.defaultOptions = createCacheEntryOptions(this.options)
5560
}
5661

@@ -70,6 +75,10 @@ export class CacheStack extends BaseDriver {
7075
return new Bus(this.name, this.#busDriver, this.logger, this.emitter, this.#busOptions)
7176
}
7277

78+
setTagSystemGetSetHandler(getSetHandler: GetSetHandler) {
79+
this.#tagSystem.setGetSetHandler(getSetHandler)
80+
}
81+
7382
namespace(namespace: string): CacheStack {
7483
if (!this.#namespaceCache.has(namespace)) {
7584
this.#namespaceCache.set(
@@ -143,4 +152,44 @@ export class CacheStack extends BaseDriver {
143152
this.emit(cacheEvents.written(key, value, this.name))
144153
return true
145154
}
155+
156+
/**
157+
* Expire a key from the cache.
158+
* Entry will not be fully deleted but expired and
159+
* retained for the grace period if enabled.
160+
*/
161+
async expire(key: string, options: CacheEntryOptions) {
162+
this.l1?.logicallyExpire(key, options)
163+
await this.l2?.logicallyExpire(key, options)
164+
await this.publish({ type: CacheBusMessageType.Expire, keys: [key] })
165+
166+
this.emit(cacheEvents.expire(key, this.name))
167+
return true
168+
}
169+
170+
/**
171+
* Check if an item is valid.
172+
* Valid means :
173+
* - Logically not expired ( not graced )
174+
* - Not invalidated by a tag
175+
*/
176+
isEntryValid(item: GetCacheValueReturn | undefined): Promise<boolean> | boolean {
177+
if (!item) return false
178+
179+
const isGraced = item?.isGraced === true
180+
if (isGraced) return false
181+
182+
if (item.entry.getTags().length === 0) return true
183+
184+
return this.#tagSystem.isTagInvalidated(item.entry).then((isTagInvalidated) => {
185+
return !isTagInvalidated
186+
})
187+
}
188+
189+
/**
190+
* Create invalidation keys for a list of tags
191+
*/
192+
async createTagInvalidations(tags: string[]) {
193+
return this.#tagSystem.createTagInvalidations(tags)
194+
}
146195
}

packages/bentocache/src/cache/get_set/single_tier_handler.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,13 @@ export class SingleTierHandler {
9595
}
9696

9797
async handle(key: string, factory: Factory, options: CacheEntryOptions) {
98-
let remoteItem: GetCacheValueReturn | undefined
99-
10098
/**
10199
* Check in the remote cache first if we have something
102100
*/
103-
remoteItem = await this.stack.l2?.get(key, options)
104-
if (remoteItem?.isGraced === false) {
105-
return this.#returnRemoteCacheValue(key, remoteItem, options)
101+
let remoteItem = await this.stack.l2?.get(key, options)
102+
let isRemoteItemValid = await this.stack.isEntryValid(remoteItem)
103+
if (isRemoteItemValid) {
104+
return this.#returnRemoteCacheValue(key, remoteItem!, options)
106105
}
107106

108107
/**
@@ -121,9 +120,10 @@ export class SingleTierHandler {
121120
* already set the value
122121
*/
123122
remoteItem = await this.stack.l2?.get(key, options)
124-
if (remoteItem?.isGraced === false) {
123+
isRemoteItemValid = await this.stack.isEntryValid(remoteItem)
124+
if (isRemoteItemValid) {
125125
this.#locks.release(key, releaser)
126-
return this.#returnRemoteCacheValue(key, remoteItem, options)
126+
return this.#returnRemoteCacheValue(key, remoteItem!, options)
127127
}
128128

129129
try {

packages/bentocache/src/cache/get_set/two_tier_handler.ts

+20-7
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,10 @@ export class TwoTierHandler {
122122
* could have written a value while we were waiting for the lock.
123123
*/
124124
localItem = this.stack.l1?.get(key, options)
125-
if (localItem?.isGraced === false) {
125+
const isLocalItemValid = await this.stack.isEntryValid(localItem)
126+
if (isLocalItemValid) {
126127
this.#locks.release(key, releaser)
127-
return this.#returnL1Value(key, localItem)
128+
return this.#returnL1Value(key, localItem!)
128129
}
129130

130131
/**
@@ -133,9 +134,10 @@ export class TwoTierHandler {
133134
* and returns it.
134135
*/
135136
const remoteItem = await this.stack.l2?.get(key, options)
136-
if (remoteItem?.isGraced === false) {
137+
const isRemoteItemValid = await this.stack.isEntryValid(remoteItem)
138+
if (isRemoteItemValid) {
137139
this.#locks.release(key, releaser)
138-
return this.#returnRemoteCacheValue(key, remoteItem, options)
140+
return this.#returnRemoteCacheValue(key, remoteItem!, options)
139141
}
140142

141143
try {
@@ -174,14 +176,25 @@ export class TwoTierHandler {
174176
* First we check the local cache. If we have a valid item, just
175177
* returns it without acquiring a lock.
176178
*/
177-
const item = this.stack.l1?.get(key, options)
178-
if (item?.isGraced === false) return this.#returnL1Value(key, item)
179+
const localItem = this.stack.l1?.get(key, options)
180+
const isLocalItemValid = this.stack.isEntryValid(localItem)
181+
182+
// A bit nasty, but to keep maximum performance, we avoid async/await here.
183+
// Let's check for a better way to handle this later.
184+
if (isLocalItemValid instanceof Promise) {
185+
return isLocalItemValid.then((valid) => {
186+
if (valid) return this.#returnL1Value(key, localItem!)
187+
return this.#lockAndHandle(key, factory, options, localItem)
188+
})
189+
}
190+
191+
if (isLocalItemValid) return this.#returnL1Value(key, localItem!)
179192

180193
/**
181194
* Next, delegate to the lock-and-handle async method so we can keep
182195
* this method synchronous and avoid an overhead of async/await
183196
* in case we have a valid item in the local cache.
184197
*/
185-
return this.#lockAndHandle(key, factory, options, item)
198+
return this.#lockAndHandle(key, factory, options, localItem)
186199
}
187200
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { CacheStack } from './cache_stack.js'
2+
import type { CacheEntry } from './cache_entry/cache_entry.js'
3+
import type { GetSetFactoryContext } from '../types/helpers.js'
4+
import type { GetSetHandler } from './get_set/get_set_handler.js'
5+
import { createCacheEntryOptions } from './cache_entry/cache_entry_options.js'
6+
7+
export class TagSystem {
8+
#getSetHandler!: GetSetHandler
9+
#kTagPrefix = '___bc:t:'
10+
11+
#expireOptions = createCacheEntryOptions({})
12+
#getSetTagOptions = createCacheEntryOptions({
13+
ttl: '10d',
14+
grace: '10d',
15+
})
16+
17+
constructor(private stack: CacheStack) {}
18+
19+
setGetSetHandler(handler: GetSetHandler) {
20+
this.#getSetHandler = handler
21+
}
22+
23+
/**
24+
* Get the cache key for a tag
25+
*/
26+
getTagCacheKey(tag: string) {
27+
return this.#kTagPrefix + tag
28+
}
29+
30+
/**
31+
* Check if a key is a tag key
32+
*/
33+
isTagKey(key: string) {
34+
return key.startsWith(this.#kTagPrefix)
35+
}
36+
37+
/**
38+
* The GetSet factory when getting a tag from the cache.
39+
*/
40+
#getTagFactory(ctx: GetSetFactoryContext) {
41+
const result = ctx.gracedEntry?.value ?? 0
42+
if (result === 0) ctx.setOptions({ skipBusNotify: true, skipL2Write: true })
43+
44+
return result
45+
}
46+
47+
/**
48+
* Check if an entry is invalidated by a tag and return true if it is.
49+
*/
50+
async isTagInvalidated(entry?: CacheEntry) {
51+
if (!entry) return
52+
if (this.isTagKey(entry.getKey())) return false
53+
54+
const tags = entry.getTags()
55+
if (!tags.length) return false
56+
57+
for (const tag of tags) {
58+
const tagExpiration = await this.#getSetHandler.handle(
59+
this.getTagCacheKey(tag),
60+
this.#getTagFactory,
61+
this.#getSetTagOptions.cloneWith({}),
62+
)
63+
64+
if (entry.getCreatedAt() <= tagExpiration) {
65+
await this.stack.expire(entry.getKey(), this.#expireOptions)
66+
return true
67+
}
68+
}
69+
}
70+
71+
/**
72+
* Create invalidation keys for a list of tags
73+
*
74+
* We write a `__bc:t:<tag>` key with the current timestamp as value.
75+
* When we check if a key is invalidated by a tag, we check if the key
76+
* was created before the tag key value.
77+
*/
78+
async createTagInvalidations(tags: string[]) {
79+
const now = Date.now()
80+
81+
for (const tag of new Set(tags)) {
82+
const key = this.getTagCacheKey(tag)
83+
await this.stack.set(key, now, this.#getSetTagOptions)
84+
}
85+
86+
return true
87+
}
88+
}

0 commit comments

Comments
 (0)