Skip to content
Open
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
10 changes: 9 additions & 1 deletion 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,
ExpireByTagOptions,
DeleteByTagOptions,
} from './types/main.js'

Expand Down Expand Up @@ -219,7 +220,14 @@ export class BentoCache<KnownCaches extends Record<string, BentoStore>> implemen
}

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

/**
* Delete all keys with specific tags (lazy deletion)
*/
async deleteByTag(options: DeleteByTagOptions): Promise<boolean> {
return this.use().deleteByTag(options)
Expand Down
20 changes: 19 additions & 1 deletion 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,
ExpireByTagOptions,
DeleteByTagOptions,
} from '../types/main.js'

Expand Down Expand Up @@ -205,13 +206,30 @@ export class Cache implements CacheProvider {
/**
* Invalidate all keys with the given tags
*/
async expireByTag(rawOptions: ExpireByTagOptions): Promise<boolean> {
const tags = rawOptions.tags
const options = this.#stack.defaultOptions.cloneWith(rawOptions)

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

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

/**
* Delete all keys with specific tags (lazy deletion)
*/
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)
const result = await this.#stack.createTagDeletionTimestamps(tags)
if (result) {
// Emit a 'deleted' event, reflecting the intent even if deletion is lazy
this.#stack.emit(cacheEvents.deleted('tags:' + tags.join(','), this.name))
}
return result
}

/**
Expand Down
41 changes: 40 additions & 1 deletion packages/bentocache/src/cache/cache_stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export class CacheStack extends BaseDriver {
* Valid means :
* - Logically not expired ( not graced )
* - Not invalidated by a tag
* - Not marked for hard deletion by a tag
*/
isEntryValid(item: GetCacheValueReturn | undefined): Promise<boolean> | boolean {
if (!item) return false
Expand All @@ -187,15 +188,53 @@ export class CacheStack extends BaseDriver {

if (item.entry.getTags().length === 0) return true

return this.#tagSystem.isTagInvalidated(item.entry).then((isTagInvalidated) => {
// If we have tags, we need to check both hard deletion and soft invalidation
// Run both checks in parallel for better performance
return Promise.all([
this.#tagSystem.isTagHardDeleted(item.entry),
this.#tagSystem.isTagInvalidated(item.entry),
]).then(async ([isHardDeleted, isTagInvalidated]) => {
if (isHardDeleted) {
// Immediately delete from all layers and return false
await this.#deleteFromAllLayers(item.entry.getKey())
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not super happy about performing a side-effect in a method which main purpose is to return a boolean indicating whether an entry is still valid.
If you have a better idea let me know :)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge problem imo. We are also doing this in isTagInvalidated. But agree with you that its semantically incorrect

return false
}

return !isTagInvalidated
})
}

/**
* Helper method to delete a key from all cache layers
*/
async #deleteFromAllLayers(key: string) {
this.l1?.delete(key)
await this.l2?.delete(key, this.defaultOptions)
await this.publish({ type: CacheBusMessageType.Delete, keys: [key] })
this.emit(cacheEvents.deleted(key, this.name))
}

/**
* Create invalidation keys for a list of tags
*/
async createTagInvalidations(tags: string[]) {
return this.#tagSystem.createTagInvalidations(tags)
}

/**
* Create hard deletion marks for a list of tags
*/
async createTagDeletionTimestamps(tags: string[]) {
const result = await this.#tagSystem.createTagDeletionTimestamps(tags)

// Also notify other instances via bus that these tags have been marked for deletion
if (this.bus) {
await this.publish({
type: 'cache:tags:deletion-marked' as any,
keys: tags.map((tag) => this.#tagSystem.getDeletionTagCacheKey(tag)),
})
}

return result
}
}
58 changes: 57 additions & 1 deletion packages/bentocache/src/cache/tag_system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { createCacheEntryOptions } from './cache_entry/cache_entry_options.js'
export class TagSystem {
#getSetHandler!: GetSetHandler
#kTagPrefix = '___bc:t:'
#kDeletionTagPrefix = '___bc:d:'

#expireOptions = createCacheEntryOptions({})

#getSetTagOptions = createCacheEntryOptions({
ttl: '10d',
grace: '10d',
Expand All @@ -27,13 +29,27 @@ export class TagSystem {
return this.#kTagPrefix + tag
}

/**
* Get the cache key for a deletion tag
*/
getDeletionTagCacheKey(tag: string) {
return this.#kDeletionTagPrefix + tag
}

/**
* Check if a key is a tag key
*/
isTagKey(key: string) {
return key.startsWith(this.#kTagPrefix)
}

/**
* Check if a key is a deletion tag key
*/
isDeletionTagKey(key: string) {
return key.startsWith(this.#kDeletionTagPrefix)
}

/**
* The GetSet factory when getting a tag from the cache.
*/
Expand All @@ -49,7 +65,7 @@ export class TagSystem {
*/
async isTagInvalidated(entry?: CacheEntry) {
if (!entry) return
if (this.isTagKey(entry.getKey())) return false
if (this.isTagKey(entry.getKey()) || this.isDeletionTagKey(entry.getKey())) return false

const tags = entry.getTags()
if (!tags.length) return false
Expand All @@ -68,6 +84,31 @@ export class TagSystem {
}
}

/**
* Check if an entry is marked for hard deletion by a tag and return true if it is.
*/
async isTagHardDeleted(entry?: CacheEntry) {
if (!entry) return false
if (this.isTagKey(entry.getKey()) || this.isDeletionTagKey(entry.getKey())) return false

const tags = entry.getTags()
if (!tags.length) return false

for (const tag of tags) {
const tagDeletionTimestamp = await this.#getSetHandler.handle(
this.getDeletionTagCacheKey(tag),
this.#getTagFactory,
this.#getSetTagOptions.cloneWith({}),
)

// If a deletion timestamp exists and the entry was created before or at it, it's hard deleted
if (tagDeletionTimestamp > 0 && entry.getCreatedAt() <= tagDeletionTimestamp) {
return true
}
}
return false
}

/**
* Create invalidation keys for a list of tags
*
Expand All @@ -85,4 +126,19 @@ export class TagSystem {

return true
}

/**
* Create hard deletion marks for a list of tags.
* We write a `__bc:d:<tag>` key with the current timestamp as value.
*/
async createTagDeletionTimestamps(tags: string[]) {
const now = Date.now()

for (const tag of new Set(tags)) {
const key = this.getDeletionTagCacheKey(tag)
await this.stack.set(key, now, this.#getSetTagOptions)
}

return true
}
}
5 changes: 5 additions & 0 deletions packages/bentocache/src/types/options/methods_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export type GetOptions<T> = { key: string; defaultValue?: Factory<T> } & Pick<
export type DeleteOptions = { key: string } & Pick<RawCommonOptions, 'suppressL2Errors'>
export type DeleteManyOptions = { keys: string[] } & Pick<RawCommonOptions, 'suppressL2Errors'>

/**
* Options accepted by the `expireByTag` method
*/
export type ExpireByTagOptions = { tags: string[] } & Pick<RawCommonOptions, 'suppressL2Errors'>

/**
* Options accepted by the `deleteByTag` method
*/
Expand Down
8 changes: 7 additions & 1 deletion packages/bentocache/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
GetOptions,
HasOptions,
SetOptions,
ExpireByTagOptions,
DeleteByTagOptions,
} from './main.js'

Expand Down Expand Up @@ -71,7 +72,12 @@ export interface CacheProvider {
deleteMany(options: DeleteManyOptions): Promise<boolean>

/**
* Delete all keys with a specific tag
* Expire all keys with a specific tag
*/
expireByTag(options: ExpireByTagOptions): Promise<boolean>

/**
* Delete all keys with specific tags
*/
deleteByTag(options: DeleteByTagOptions): Promise<boolean>

Expand Down
Loading