From 24b02eb2191cc82f6abdb493a3678955e7274d27 Mon Sep 17 00:00:00 2001 From: Yannick Stachelscheid Date: Wed, 2 Jul 2025 08:56:15 +0200 Subject: [PATCH 1/4] rename deleteByTag to expireByTag --- packages/bentocache/src/bento_cache.ts | 8 ++--- packages/bentocache/src/cache/cache.ts | 6 ++-- .../src/types/options/methods_options.ts | 4 +-- packages/bentocache/src/types/provider.ts | 6 ++-- packages/bentocache/tests/tagging.spec.ts | 36 +++++++++---------- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/bentocache/src/bento_cache.ts b/packages/bentocache/src/bento_cache.ts index 89cf85e..5fca7c6 100644 --- a/packages/bentocache/src/bento_cache.ts +++ b/packages/bentocache/src/bento_cache.ts @@ -16,7 +16,7 @@ import type { DeleteOptions, DeleteManyOptions, ExpireOptions, - DeleteByTagOptions, + ExpireByTagOptions, } from './types/main.js' export class BentoCache> implements CacheProvider { @@ -219,10 +219,10 @@ export class BentoCache> implemen } /** - * Delete all keys with a specific tag + * Expire all keys with a specific tag */ - async deleteByTag(options: DeleteByTagOptions): Promise { - return this.use().deleteByTag(options) + async expireByTag(options: ExpireByTagOptions): Promise { + return this.use().expireByTag(options) } /** diff --git a/packages/bentocache/src/cache/cache.ts b/packages/bentocache/src/cache/cache.ts index 25b83d3..f53f4e2 100644 --- a/packages/bentocache/src/cache/cache.ts +++ b/packages/bentocache/src/cache/cache.ts @@ -17,7 +17,7 @@ import type { DeleteManyOptions, GetOrSetForeverOptions, ExpireOptions, - DeleteByTagOptions, + ExpireByTagOptions, } from '../types/main.js' export class Cache implements CacheProvider { @@ -205,11 +205,11 @@ export class Cache implements CacheProvider { /** * Invalidate all keys with the given tags */ - async deleteByTag(rawOptions: DeleteByTagOptions): Promise { + async expireByTag(rawOptions: ExpireByTagOptions): Promise { const tags = rawOptions.tags const options = this.#stack.defaultOptions.cloneWith(rawOptions) - this.#options.logger.logMethod({ method: 'deleteByTag', cacheName: this.name, tags, options }) + this.#options.logger.logMethod({ method: 'expireByTag', cacheName: this.name, tags, options }) return await this.#stack.createTagInvalidations(tags) } diff --git a/packages/bentocache/src/types/options/methods_options.ts b/packages/bentocache/src/types/options/methods_options.ts index 6e0d45e..2c4a52c 100644 --- a/packages/bentocache/src/types/options/methods_options.ts +++ b/packages/bentocache/src/types/options/methods_options.ts @@ -55,9 +55,9 @@ export type DeleteOptions = { key: string } & Pick /** - * Options accepted by the `deleteByTag` method + * Options accepted by the `expireByTag` method */ -export type DeleteByTagOptions = { tags: string[] } & Pick +export type ExpireByTagOptions = { tags: string[] } & Pick /** * Options accepted by the `expire` method diff --git a/packages/bentocache/src/types/provider.ts b/packages/bentocache/src/types/provider.ts index 215e440..54a4a4b 100644 --- a/packages/bentocache/src/types/provider.ts +++ b/packages/bentocache/src/types/provider.ts @@ -7,7 +7,7 @@ import type { GetOptions, HasOptions, SetOptions, - DeleteByTagOptions, + ExpireByTagOptions, } from './main.js' /** @@ -71,9 +71,9 @@ export interface CacheProvider { deleteMany(options: DeleteManyOptions): Promise /** - * Delete all keys with a specific tag + * Expire all keys with a specific tag */ - deleteByTag(options: DeleteByTagOptions): Promise + expireByTag(options: ExpireByTagOptions): Promise /** * Expire a key from the cache. diff --git a/packages/bentocache/tests/tagging.spec.ts b/packages/bentocache/tests/tagging.spec.ts index a6c2662..10da5a9 100644 --- a/packages/bentocache/tests/tagging.spec.ts +++ b/packages/bentocache/tests/tagging.spec.ts @@ -5,9 +5,9 @@ import { CacheFactory } from '../factories/cache_factory.js' import { createCacheEntryOptions } from '../src/cache/cache_entry/cache_entry_options.js' test.group('Tagging | Internals', () => { - test('deleteByTag should store invalidated tags with timestamps', async ({ assert }) => { + test('expireByTag should store invalidated tags with timestamps', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() - await cache.deleteByTag({ tags: ['tag1', 'tag2'] }) + await cache.expireByTag({ tags: ['tag1', 'tag2'] }) const r1 = await cache.get({ key: '___bc:t:tag1' }) const r2 = await cache.get({ key: '___bc:t:tag1' }) @@ -28,12 +28,12 @@ test.group('Tagging | Internals', () => { }) }) -test.group('Tagging | deleteByTag', () => { +test.group('Tagging | expireByTag', () => { test('basic', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'key1', value: 'value1', tags: ['tag1'] }) - await cache.deleteByTag({ tags: ['tag1'] }) + await cache.expireByTag({ tags: ['tag1'] }) const r1 = await cache.get({ key: 'key1' }) assert.deepEqual(r1, undefined) @@ -60,7 +60,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(r2, 2) assert.deepEqual(r3, 3) - await cache.deleteByTag({ tags: ['x'] }) + await cache.expireByTag({ tags: ['x'] }) const r4 = await cache.get({ key: 'foo' }) const r5 = await cache.getOrSet({ key: 'bar', factory: () => 222, tags: ['y', 'z'] }) @@ -70,7 +70,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(r5, 2) assert.deepEqual(r6, 333) - await cache.deleteByTag({ tags: ['y'] }) + await cache.expireByTag({ tags: ['y'] }) const r7 = await cache.getOrSet({ key: 'foo', factory: () => 1111, tags: ['x', 'y'] }) const r8 = await cache.getOrSet({ key: 'bar', factory: () => 2222, tags: ['y', 'z'] }) @@ -96,7 +96,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(bar1, 2) assert.deepEqual(baz1, 3) - await cache.deleteByTag({ tags: ['x', 'z'] }) + await cache.expireByTag({ tags: ['x', 'z'] }) const foo2 = await cache.get({ key: 'foo' }) const bar2 = await cache.get({ key: 'bar' }) @@ -106,7 +106,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(bar2, 2) assert.isUndefined(baz2) - await cache.deleteByTag({ tags: [] }) + await cache.expireByTag({ tags: [] }) const foo4 = await cache.get({ key: 'foo' }) const bar4 = await cache.get({ key: 'bar' }) @@ -116,7 +116,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(bar4, 2) assert.isUndefined(baz4) - await cache.deleteByTag({ tags: ['y', 'non-existing'] }) + await cache.expireByTag({ tags: ['y', 'non-existing'] }) const foo5 = await cache.get({ key: 'foo' }) const bar5 = await cache.get({ key: 'bar' }) @@ -144,7 +144,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(bar1, 2) assert.deepEqual(baz1, 3) - await cache1.deleteByTag({ tags: ['x'] }) + await cache1.expireByTag({ tags: ['x'] }) const foo2 = await cache1.get({ key: 'foo' }) const bar2 = await cache2.getOrSet({ key: 'bar', factory: () => 222, tags: ['y', 'z'] }) @@ -154,7 +154,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(bar2, 2) assert.deepEqual(baz2, 333) - await cache2.deleteByTag({ tags: ['y'] }) + await cache2.expireByTag({ tags: ['y'] }) const foo3 = await cache1.getOrSet({ key: 'foo', factory: () => 1111, tags: ['x', 'y'] }) const bar3 = await cache2.getOrSet({ key: 'bar', factory: () => 2222, tags: ['y', 'z'] }) @@ -198,7 +198,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(cache3bar1, 2) assert.deepEqual(cache3baz1, 3) - await cache1.deleteByTag({ tags: ['x', 'z'] }) + await cache1.expireByTag({ tags: ['x', 'z'] }) const cache2foo2 = await cache2.get({ key: 'foo' }) const cache2bar2 = await cache2.get({ key: 'bar' }) @@ -216,7 +216,7 @@ test.group('Tagging | deleteByTag', () => { assert.deepEqual(cache3bar2, 2) assert.isUndefined(cache3baz2) - await cache3.deleteByTag({ tags: ['y', 'non-existing'] }) + await cache3.expireByTag({ tags: ['y', 'non-existing'] }) const cache1foo3 = await cache1.get({ key: 'foo' }) const cache1bar3 = await cache1.get({ key: 'bar' }) @@ -253,7 +253,7 @@ test.group('Tagging | deleteByTag', () => { await cache1.set({ key: 'bar', value: 2, tags: ['x'] }) await posts.set({ key: 'baz', value: 3, tags: ['x'] }) - await posts.deleteByTag({ tags: ['x'] }) + await posts.expireByTag({ tags: ['x'] }) const userFoo1 = await users.get({ key: 'foo' }) const postFoo1 = await posts.get({ key: 'baz' }) @@ -263,7 +263,7 @@ test.group('Tagging | deleteByTag', () => { assert.isUndefined(postFoo1) assert.deepEqual(cacheFoo1, 2) - await cache1.deleteByTag({ tags: ['x'] }) + await cache1.expireByTag({ tags: ['x'] }) const usersFoo2 = await users.get({ key: 'foo' }) const postsFoo2 = await posts.get({ key: 'baz' }) @@ -273,7 +273,7 @@ test.group('Tagging | deleteByTag', () => { assert.isUndefined(postsFoo2) assert.isUndefined(cacheFoo2) - await users.deleteByTag({ tags: ['x'] }) + await users.expireByTag({ tags: ['x'] }) const usersFoo3 = await users.get({ key: 'foo' }) const postsFoo3 = await posts.get({ key: 'baz' }) @@ -288,7 +288,7 @@ test.group('Tagging | deleteByTag', () => { const [cache] = new CacheFactory().withMemoryL1().create() await cache.set({ key: 'foo', value: 1, tags: ['x'] }) - await cache.deleteByTag({ tags: ['x', 'foo'] }) + await cache.expireByTag({ tags: ['x', 'foo'] }) await sleep(10) await cache.set({ key: 'bar', value: 2, tags: ['x'] }) @@ -305,7 +305,7 @@ test.group('Tagging | deleteByTag', () => { await cache.set({ key: 'foo', value: 1, tags: ['x'] }) const r1 = await cache.has({ key: 'foo' }) - await cache.deleteByTag({ tags: ['x'] }) + await cache.expireByTag({ tags: ['x'] }) const r2 = await cache.has({ key: 'foo' }) assert.isTrue(r1) From cf6a1a6d1e7652fc5f9e61ed5759a42150ebb046 Mon Sep 17 00:00:00 2001 From: Yannick Stachelscheid Date: Thu, 3 Jul 2025 21:27:16 +0200 Subject: [PATCH 2/4] first draft of new deleteByTag operation --- packages/bentocache/src/bento_cache.ts | 8 + packages/bentocache/src/cache/cache.ts | 18 ++ packages/bentocache/src/cache/cache_stack.ts | 43 +++- packages/bentocache/src/cache/tag_system.ts | 62 +++++- packages/bentocache/src/types/main.ts | 14 ++ .../src/types/options/methods_options.ts | 5 + packages/bentocache/src/types/provider.ts | 6 + packages/bentocache/tests/tagging.spec.ts | 192 ++++++++++++++++++ 8 files changed, 345 insertions(+), 3 deletions(-) diff --git a/packages/bentocache/src/bento_cache.ts b/packages/bentocache/src/bento_cache.ts index 5fca7c6..ec532bd 100644 --- a/packages/bentocache/src/bento_cache.ts +++ b/packages/bentocache/src/bento_cache.ts @@ -17,6 +17,7 @@ import type { DeleteManyOptions, ExpireOptions, ExpireByTagOptions, + DeleteByTagsOptions, } from './types/main.js' export class BentoCache> implements CacheProvider { @@ -225,6 +226,13 @@ export class BentoCache> implemen return this.use().expireByTag(options) } + /** + * Delete all keys with specific tags (lazy deletion) + */ + async deleteByTags(options: DeleteByTagsOptions): Promise { + return this.use().deleteByTags(options) + } + /** * Expire a key from the cache. * Entry will not be fully deleted but expired and diff --git a/packages/bentocache/src/cache/cache.ts b/packages/bentocache/src/cache/cache.ts index f53f4e2..0c0b186 100644 --- a/packages/bentocache/src/cache/cache.ts +++ b/packages/bentocache/src/cache/cache.ts @@ -18,6 +18,7 @@ import type { GetOrSetForeverOptions, ExpireOptions, ExpireByTagOptions, + DeleteByTagsOptions, } from '../types/main.js' export class Cache implements CacheProvider { @@ -214,6 +215,23 @@ export class Cache implements CacheProvider { return await this.#stack.createTagInvalidations(tags) } + /** + * Delete all keys with specific tags (lazy deletion) + */ + async deleteByTags(rawOptions: DeleteByTagsOptions): Promise { + const tags = rawOptions.tags + const options = this.#stack.defaultOptions.cloneWith(rawOptions) + + this.#options.logger.logMethod({ method: 'deleteByTags', cacheName: this.name, tags, options }) + + 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 + } + /** * Delete multiple keys from local and remote cache * Then emit cache:deleted events for each key diff --git a/packages/bentocache/src/cache/cache_stack.ts b/packages/bentocache/src/cache/cache_stack.ts index d71e338..7cf145c 100644 --- a/packages/bentocache/src/cache/cache_stack.ts +++ b/packages/bentocache/src/cache/cache_stack.ts @@ -178,8 +178,9 @@ 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 { + isEntryValid(item: GetCacheValueReturn | undefined): boolean | Promise { if (!item) return false const isGraced = item?.isGraced === true @@ -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()) + 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 + } } diff --git a/packages/bentocache/src/cache/tag_system.ts b/packages/bentocache/src/cache/tag_system.ts index 203e7a8..c47218c 100644 --- a/packages/bentocache/src/cache/tag_system.ts +++ b/packages/bentocache/src/cache/tag_system.ts @@ -7,6 +7,7 @@ import { createCacheEntryOptions } from './cache_entry/cache_entry_options.js' export class TagSystem { #getSetHandler!: GetSetHandler #kTagPrefix = '___bc:t:' + #kDeletionTagPrefix = '___bc:d:' #expireOptions = createCacheEntryOptions({}) #getSetTagOptions = createCacheEntryOptions({ @@ -14,6 +15,11 @@ export class TagSystem { grace: '10d', }) + #getSetDeletionTagOptions = createCacheEntryOptions({ + ttl: '30d', + grace: '30d', + }) + constructor(private stack: CacheStack) {} setGetSetHandler(handler: GetSetHandler) { @@ -27,6 +33,13 @@ 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 */ @@ -34,6 +47,13 @@ export class TagSystem { 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. */ @@ -49,7 +69,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 @@ -68,6 +88,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.#getSetDeletionTagOptions.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 * @@ -85,4 +130,19 @@ export class TagSystem { return true } + + /** + * Create hard deletion marks for a list of tags. + * We write a `__bc:d:` 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.#getSetDeletionTagOptions) + } + + return true + } } diff --git a/packages/bentocache/src/types/main.ts b/packages/bentocache/src/types/main.ts index 67edeb6..fb740d8 100644 --- a/packages/bentocache/src/types/main.ts +++ b/packages/bentocache/src/types/main.ts @@ -71,3 +71,17 @@ export interface BentoCachePlugin { * Dialect available for the SQL driver */ export type DialectName = 'pg' | 'mysql2' | 'better-sqlite3' | 'sqlite3' + +export type { + ClearOptions, + DeleteManyOptions, + DeleteOptions, + ExpireByTagOptions, + DeleteByTagsOptions, + ExpireOptions, + GetOptions, + GetOrSetForeverOptions, + GetOrSetOptions, + HasOptions, + SetOptions, +} from './options/methods_options.js' diff --git a/packages/bentocache/src/types/options/methods_options.ts b/packages/bentocache/src/types/options/methods_options.ts index 2c4a52c..cbdb72a 100644 --- a/packages/bentocache/src/types/options/methods_options.ts +++ b/packages/bentocache/src/types/options/methods_options.ts @@ -59,6 +59,11 @@ export type DeleteManyOptions = { keys: string[] } & Pick +/** + * Options accepted by the `deleteByTags` method + */ +export type DeleteByTagsOptions = { tags: string[] } & Pick + /** * Options accepted by the `expire` method */ diff --git a/packages/bentocache/src/types/provider.ts b/packages/bentocache/src/types/provider.ts index 54a4a4b..35495fb 100644 --- a/packages/bentocache/src/types/provider.ts +++ b/packages/bentocache/src/types/provider.ts @@ -8,6 +8,7 @@ import type { HasOptions, SetOptions, ExpireByTagOptions, + DeleteByTagsOptions, } from './main.js' /** @@ -75,6 +76,11 @@ export interface CacheProvider { */ expireByTag(options: ExpireByTagOptions): Promise + /** + * Delete all keys with specific tags + */ + deleteByTags(options: DeleteByTagsOptions): Promise + /** * Expire a key from the cache. * Entry will not be fully deleted but expired and diff --git a/packages/bentocache/tests/tagging.spec.ts b/packages/bentocache/tests/tagging.spec.ts index 10da5a9..2da63a6 100644 --- a/packages/bentocache/tests/tagging.spec.ts +++ b/packages/bentocache/tests/tagging.spec.ts @@ -312,3 +312,195 @@ test.group('Tagging | expireByTag', () => { assert.isFalse(r2) }) }) + +test.group('Tagging | deleteByTags', () => { + test('basic deleteByTags should mark entries for deletion', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'key1', value: 'value1', tags: ['tag1'] }) + await cache.deleteByTags({ tags: ['tag1'] }) + + const r1 = await cache.get({ key: 'key1' }) + assert.deepEqual(r1, undefined) + }) + + test('deleteByTags should store deletion timestamps', async ({ assert }) => { + const now = Date.now() + + await sleep(10) + + const { cache } = new CacheFactory().withL1L2Config().create() + await cache.deleteByTags({ tags: ['tag1', 'tag2'] }) + + const r1 = await cache.get({ key: '___bc:d:tag1' }) + const r2 = await cache.get({ key: '___bc:d:tag2' }) + + assert.isNotNull(r1) + assert.isNotNull(r2) + assert.isTrue(r1 > now) + assert.isTrue(r2 > now) + }) + + test('deleteByTags should delete entries with matching tags', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x', 'y'] }) + await cache.set({ key: 'bar', value: 2, tags: ['y', 'z'] }) + await cache.set({ key: 'baz', value: 3, tags: ['z'] }) + + await cache.deleteByTags({ tags: ['x'] }) + + const r1 = await cache.get({ key: 'foo' }) + const r2 = await cache.get({ key: 'bar' }) + const r3 = await cache.get({ key: 'baz' }) + + assert.isUndefined(r1) // has tag 'x', should be deleted + assert.deepEqual(r2, 2) // doesn't have tag 'x', should remain + assert.deepEqual(r3, 3) // doesn't have tag 'x', should remain + }) + + test('deleteByTags should delete entries with multiple matching tags', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x', 'y'] }) + await cache.set({ key: 'bar', value: 2, tags: ['y'] }) + await cache.set({ key: 'baz', value: 3, tags: ['z'] }) + + await cache.deleteByTags({ tags: ['x', 'z'] }) + + const r1 = await cache.get({ key: 'foo' }) + const r2 = await cache.get({ key: 'bar' }) + const r3 = await cache.get({ key: 'baz' }) + + assert.isUndefined(r1) // has tag 'x', should be deleted + assert.deepEqual(r2, 2) // doesn't have tags 'x' or 'z', should remain + assert.isUndefined(r3) // has tag 'z', should be deleted + }) + + test('deleteByTags should work with getOrSet', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + await cache.deleteByTags({ tags: ['x'] }) + + const r1 = await cache.getOrSet({ + key: 'foo', + factory: () => 'new-value', + tags: ['x'], + }) + + assert.deepEqual(r1, 'new-value') + }) + + test('deleteByTags should work with has method', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + + const r1 = await cache.has({ key: 'foo' }) + await cache.deleteByTags({ tags: ['x'] }) + const r2 = await cache.has({ key: 'foo' }) + + assert.isTrue(r1) + assert.isFalse(r2) + }) + + test('entries created after deleteByTags should not be deleted', async ({ assert }) => { + const { cache } = new CacheFactory().withMemoryL1().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + await cache.deleteByTags({ tags: ['x'] }) + await sleep(10) + await cache.set({ key: 'bar', value: 2, tags: ['x'] }) + + const r1 = await cache.get({ key: 'foo' }) + const r2 = await cache.get({ key: 'bar' }) + + assert.isUndefined(r1) + assert.deepEqual(r2, 2) + }) + + test('deleteByTags should work with bus notifications', async ({ assert }) => { + const [cache1] = new CacheFactory().withL1L2Config().create() + const [cache2] = new CacheFactory().withL1L2Config().create() + const [cache3] = new CacheFactory().withL1L2Config().create() + + await cache1.set({ key: 'foo', value: 1, tags: ['x', 'y'] }) + await cache2.set({ key: 'bar', value: 2, tags: ['y', 'z'] }) + await cache3.set({ key: 'baz', value: 3, tags: ['x', 'z'] }) + + // Delete from cache1 should affect all caches + await cache1.deleteByTags({ tags: ['x'] }) + + const r1 = await cache1.get({ key: 'foo' }) + const r2 = await cache2.get({ key: 'bar' }) + const r3 = await cache3.get({ key: 'baz' }) + + assert.isUndefined(r1) // cache1: 'foo' has tag 'x', should be deleted + assert.deepEqual(r2, 2) // cache2: 'bar' doesn't have tag 'x', should remain + assert.isUndefined(r3) // cache3: 'baz' has tag 'x', should be deleted + }) + + test('deleteByTags should work with namespaces', async ({ assert }) => { + const [cache1] = new CacheFactory().withL1L2Config().create() + + const users = cache1.namespace('users') + const posts = cache1.namespace('posts') + + await users.set({ key: 'foo', value: 1, tags: ['x'] }) + await cache1.set({ key: 'bar', value: 2, tags: ['x'] }) + await posts.set({ key: 'baz', value: 3, tags: ['x'] }) + + // Delete from posts namespace should only affect posts + await posts.deleteByTags({ tags: ['x'] }) + + const userFoo = await users.get({ key: 'foo' }) + const cacheFoo = await cache1.get({ key: 'bar' }) + const postFoo = await posts.get({ key: 'baz' }) + + assert.deepEqual(userFoo, 1) // users namespace unaffected + assert.deepEqual(cacheFoo, 2) // main cache unaffected + assert.isUndefined(postFoo) // posts namespace affected + }) + + test('deleteByTags should handle empty tags array', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + + const result = await cache.deleteByTags({ tags: [] }) + const r1 = await cache.get({ key: 'foo' }) + + assert.isTrue(result) + assert.deepEqual(r1, 1) // should remain unaffected + }) + + test('deleteByTags should handle non-existing tags', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + + const result = await cache.deleteByTags({ tags: ['non-existing'] }) + const r1 = await cache.get({ key: 'foo' }) + + assert.isTrue(result) + assert.deepEqual(r1, 1) // should remain unaffected + }) + + test('deleteByTags should delete from all cache layers', async ({ assert }) => { + const { cache, local, remote, stack } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + await cache.deleteByTags({ tags: ['x'] }) + + // Trigger deletion by accessing the key + await cache.get({ key: 'foo' }) + + // Check that it's deleted from both layers + const r1 = local.get('foo', stack.defaultOptions) + const r2 = await remote.get('foo', stack.defaultOptions) + + assert.isUndefined(r1) + assert.isUndefined(r2) + }) +}) From 48d57f5d9a47484bb9fe819d15303377e8983cdc Mon Sep 17 00:00:00 2001 From: Yannick Stachelscheid Date: Thu, 3 Jul 2025 21:45:25 +0200 Subject: [PATCH 3/4] rename deleteByTags to deleteByTag --- packages/bentocache/src/bento_cache.ts | 6 +-- packages/bentocache/src/cache/cache.ts | 6 +-- packages/bentocache/src/types/main.ts | 2 +- .../src/types/options/methods_options.ts | 4 +- packages/bentocache/src/types/provider.ts | 4 +- packages/bentocache/tests/tagging.spec.ts | 50 +++++++++---------- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/bentocache/src/bento_cache.ts b/packages/bentocache/src/bento_cache.ts index ec532bd..c66688a 100644 --- a/packages/bentocache/src/bento_cache.ts +++ b/packages/bentocache/src/bento_cache.ts @@ -17,7 +17,7 @@ import type { DeleteManyOptions, ExpireOptions, ExpireByTagOptions, - DeleteByTagsOptions, + DeleteByTagOptions, } from './types/main.js' export class BentoCache> implements CacheProvider { @@ -229,8 +229,8 @@ export class BentoCache> implemen /** * Delete all keys with specific tags (lazy deletion) */ - async deleteByTags(options: DeleteByTagsOptions): Promise { - return this.use().deleteByTags(options) + async deleteByTag(options: DeleteByTagOptions): Promise { + return this.use().deleteByTag(options) } /** diff --git a/packages/bentocache/src/cache/cache.ts b/packages/bentocache/src/cache/cache.ts index 0c0b186..589a894 100644 --- a/packages/bentocache/src/cache/cache.ts +++ b/packages/bentocache/src/cache/cache.ts @@ -18,7 +18,7 @@ import type { GetOrSetForeverOptions, ExpireOptions, ExpireByTagOptions, - DeleteByTagsOptions, + DeleteByTagOptions, } from '../types/main.js' export class Cache implements CacheProvider { @@ -218,11 +218,11 @@ export class Cache implements CacheProvider { /** * Delete all keys with specific tags (lazy deletion) */ - async deleteByTags(rawOptions: DeleteByTagsOptions): Promise { + async deleteByTag(rawOptions: DeleteByTagOptions): Promise { const tags = rawOptions.tags const options = this.#stack.defaultOptions.cloneWith(rawOptions) - this.#options.logger.logMethod({ method: 'deleteByTags', cacheName: this.name, tags, options }) + this.#options.logger.logMethod({ method: 'deleteByTag', cacheName: this.name, tags, options }) const result = await this.#stack.createTagDeletionTimestamps(tags) if (result) { diff --git a/packages/bentocache/src/types/main.ts b/packages/bentocache/src/types/main.ts index fb740d8..7a61de0 100644 --- a/packages/bentocache/src/types/main.ts +++ b/packages/bentocache/src/types/main.ts @@ -77,7 +77,7 @@ export type { DeleteManyOptions, DeleteOptions, ExpireByTagOptions, - DeleteByTagsOptions, + DeleteByTagOptions, ExpireOptions, GetOptions, GetOrSetForeverOptions, diff --git a/packages/bentocache/src/types/options/methods_options.ts b/packages/bentocache/src/types/options/methods_options.ts index cbdb72a..e2fc7f1 100644 --- a/packages/bentocache/src/types/options/methods_options.ts +++ b/packages/bentocache/src/types/options/methods_options.ts @@ -60,9 +60,9 @@ export type DeleteManyOptions = { keys: string[] } & Pick /** - * Options accepted by the `deleteByTags` method + * Options accepted by the `deleteByTag` method */ -export type DeleteByTagsOptions = { tags: string[] } & Pick +export type DeleteByTagOptions = { tags: string[] } & Pick /** * Options accepted by the `expire` method diff --git a/packages/bentocache/src/types/provider.ts b/packages/bentocache/src/types/provider.ts index 35495fb..08303bd 100644 --- a/packages/bentocache/src/types/provider.ts +++ b/packages/bentocache/src/types/provider.ts @@ -8,7 +8,7 @@ import type { HasOptions, SetOptions, ExpireByTagOptions, - DeleteByTagsOptions, + DeleteByTagOptions, } from './main.js' /** @@ -79,7 +79,7 @@ export interface CacheProvider { /** * Delete all keys with specific tags */ - deleteByTags(options: DeleteByTagsOptions): Promise + deleteByTag(options: DeleteByTagOptions): Promise /** * Expire a key from the cache. diff --git a/packages/bentocache/tests/tagging.spec.ts b/packages/bentocache/tests/tagging.spec.ts index 2da63a6..ec12d42 100644 --- a/packages/bentocache/tests/tagging.spec.ts +++ b/packages/bentocache/tests/tagging.spec.ts @@ -313,24 +313,24 @@ test.group('Tagging | expireByTag', () => { }) }) -test.group('Tagging | deleteByTags', () => { - test('basic deleteByTags should mark entries for deletion', async ({ assert }) => { +test.group('Tagging | deleteByTag', () => { + test('basic deleteByTag should mark entries for deletion', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'key1', value: 'value1', tags: ['tag1'] }) - await cache.deleteByTags({ tags: ['tag1'] }) + await cache.deleteByTag({ tags: ['tag1'] }) const r1 = await cache.get({ key: 'key1' }) assert.deepEqual(r1, undefined) }) - test('deleteByTags should store deletion timestamps', async ({ assert }) => { + test('deleteByTag should store deletion timestamps', async ({ assert }) => { const now = Date.now() await sleep(10) const { cache } = new CacheFactory().withL1L2Config().create() - await cache.deleteByTags({ tags: ['tag1', 'tag2'] }) + await cache.deleteByTag({ tags: ['tag1', 'tag2'] }) const r1 = await cache.get({ key: '___bc:d:tag1' }) const r2 = await cache.get({ key: '___bc:d:tag2' }) @@ -341,14 +341,14 @@ test.group('Tagging | deleteByTags', () => { assert.isTrue(r2 > now) }) - test('deleteByTags should delete entries with matching tags', async ({ assert }) => { + test('deleteByTag should delete entries with matching tags', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'foo', value: 1, tags: ['x', 'y'] }) await cache.set({ key: 'bar', value: 2, tags: ['y', 'z'] }) await cache.set({ key: 'baz', value: 3, tags: ['z'] }) - await cache.deleteByTags({ tags: ['x'] }) + await cache.deleteByTag({ tags: ['x'] }) const r1 = await cache.get({ key: 'foo' }) const r2 = await cache.get({ key: 'bar' }) @@ -359,14 +359,14 @@ test.group('Tagging | deleteByTags', () => { assert.deepEqual(r3, 3) // doesn't have tag 'x', should remain }) - test('deleteByTags should delete entries with multiple matching tags', async ({ assert }) => { + test('deleteByTag should delete entries with multiple matching tags', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'foo', value: 1, tags: ['x', 'y'] }) await cache.set({ key: 'bar', value: 2, tags: ['y'] }) await cache.set({ key: 'baz', value: 3, tags: ['z'] }) - await cache.deleteByTags({ tags: ['x', 'z'] }) + await cache.deleteByTag({ tags: ['x', 'z'] }) const r1 = await cache.get({ key: 'foo' }) const r2 = await cache.get({ key: 'bar' }) @@ -377,11 +377,11 @@ test.group('Tagging | deleteByTags', () => { assert.isUndefined(r3) // has tag 'z', should be deleted }) - test('deleteByTags should work with getOrSet', async ({ assert }) => { + test('deleteByTag should work with getOrSet', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'foo', value: 1, tags: ['x'] }) - await cache.deleteByTags({ tags: ['x'] }) + await cache.deleteByTag({ tags: ['x'] }) const r1 = await cache.getOrSet({ key: 'foo', @@ -392,24 +392,24 @@ test.group('Tagging | deleteByTags', () => { assert.deepEqual(r1, 'new-value') }) - test('deleteByTags should work with has method', async ({ assert }) => { + test('deleteByTag should work with has method', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'foo', value: 1, tags: ['x'] }) const r1 = await cache.has({ key: 'foo' }) - await cache.deleteByTags({ tags: ['x'] }) + await cache.deleteByTag({ tags: ['x'] }) const r2 = await cache.has({ key: 'foo' }) assert.isTrue(r1) assert.isFalse(r2) }) - test('entries created after deleteByTags should not be deleted', async ({ assert }) => { + test('entries created after deleteByTag should not be deleted', async ({ assert }) => { const { cache } = new CacheFactory().withMemoryL1().create() await cache.set({ key: 'foo', value: 1, tags: ['x'] }) - await cache.deleteByTags({ tags: ['x'] }) + await cache.deleteByTag({ tags: ['x'] }) await sleep(10) await cache.set({ key: 'bar', value: 2, tags: ['x'] }) @@ -420,7 +420,7 @@ test.group('Tagging | deleteByTags', () => { assert.deepEqual(r2, 2) }) - test('deleteByTags should work with bus notifications', async ({ assert }) => { + test('deleteByTag should work with bus notifications', async ({ assert }) => { const [cache1] = new CacheFactory().withL1L2Config().create() const [cache2] = new CacheFactory().withL1L2Config().create() const [cache3] = new CacheFactory().withL1L2Config().create() @@ -430,7 +430,7 @@ test.group('Tagging | deleteByTags', () => { await cache3.set({ key: 'baz', value: 3, tags: ['x', 'z'] }) // Delete from cache1 should affect all caches - await cache1.deleteByTags({ tags: ['x'] }) + await cache1.deleteByTag({ tags: ['x'] }) const r1 = await cache1.get({ key: 'foo' }) const r2 = await cache2.get({ key: 'bar' }) @@ -441,7 +441,7 @@ test.group('Tagging | deleteByTags', () => { assert.isUndefined(r3) // cache3: 'baz' has tag 'x', should be deleted }) - test('deleteByTags should work with namespaces', async ({ assert }) => { + test('deleteByTag should work with namespaces', async ({ assert }) => { const [cache1] = new CacheFactory().withL1L2Config().create() const users = cache1.namespace('users') @@ -452,7 +452,7 @@ test.group('Tagging | deleteByTags', () => { await posts.set({ key: 'baz', value: 3, tags: ['x'] }) // Delete from posts namespace should only affect posts - await posts.deleteByTags({ tags: ['x'] }) + await posts.deleteByTag({ tags: ['x'] }) const userFoo = await users.get({ key: 'foo' }) const cacheFoo = await cache1.get({ key: 'bar' }) @@ -463,35 +463,35 @@ test.group('Tagging | deleteByTags', () => { assert.isUndefined(postFoo) // posts namespace affected }) - test('deleteByTags should handle empty tags array', async ({ assert }) => { + test('deleteByTag should handle empty tags array', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'foo', value: 1, tags: ['x'] }) - const result = await cache.deleteByTags({ tags: [] }) + const result = await cache.deleteByTag({ tags: [] }) const r1 = await cache.get({ key: 'foo' }) assert.isTrue(result) assert.deepEqual(r1, 1) // should remain unaffected }) - test('deleteByTags should handle non-existing tags', async ({ assert }) => { + test('deleteByTag should handle non-existing tags', async ({ assert }) => { const { cache } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'foo', value: 1, tags: ['x'] }) - const result = await cache.deleteByTags({ tags: ['non-existing'] }) + const result = await cache.deleteByTag({ tags: ['non-existing'] }) const r1 = await cache.get({ key: 'foo' }) assert.isTrue(result) assert.deepEqual(r1, 1) // should remain unaffected }) - test('deleteByTags should delete from all cache layers', async ({ assert }) => { + test('deleteByTag should delete from all cache layers', async ({ assert }) => { const { cache, local, remote, stack } = new CacheFactory().withL1L2Config().create() await cache.set({ key: 'foo', value: 1, tags: ['x'] }) - await cache.deleteByTags({ tags: ['x'] }) + await cache.deleteByTag({ tags: ['x'] }) // Trigger deletion by accessing the key await cache.get({ key: 'foo' }) From ce6b2c197d2b701669a83e4abbd17e37495f4f9a Mon Sep 17 00:00:00 2001 From: Yannick Stachelscheid Date: Thu, 3 Jul 2025 21:58:48 +0200 Subject: [PATCH 4/4] polish --- packages/bentocache/src/cache/cache_stack.ts | 2 +- packages/bentocache/src/cache/tag_system.ts | 10 +++------- packages/bentocache/src/types/main.ts | 14 -------------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/bentocache/src/cache/cache_stack.ts b/packages/bentocache/src/cache/cache_stack.ts index 7cf145c..44ed724 100644 --- a/packages/bentocache/src/cache/cache_stack.ts +++ b/packages/bentocache/src/cache/cache_stack.ts @@ -180,7 +180,7 @@ export class CacheStack extends BaseDriver { * - Not invalidated by a tag * - Not marked for hard deletion by a tag */ - isEntryValid(item: GetCacheValueReturn | undefined): boolean | Promise { + isEntryValid(item: GetCacheValueReturn | undefined): Promise | boolean { if (!item) return false const isGraced = item?.isGraced === true diff --git a/packages/bentocache/src/cache/tag_system.ts b/packages/bentocache/src/cache/tag_system.ts index c47218c..223b1b3 100644 --- a/packages/bentocache/src/cache/tag_system.ts +++ b/packages/bentocache/src/cache/tag_system.ts @@ -10,16 +10,12 @@ export class TagSystem { #kDeletionTagPrefix = '___bc:d:' #expireOptions = createCacheEntryOptions({}) + #getSetTagOptions = createCacheEntryOptions({ ttl: '10d', grace: '10d', }) - #getSetDeletionTagOptions = createCacheEntryOptions({ - ttl: '30d', - grace: '30d', - }) - constructor(private stack: CacheStack) {} setGetSetHandler(handler: GetSetHandler) { @@ -102,7 +98,7 @@ export class TagSystem { const tagDeletionTimestamp = await this.#getSetHandler.handle( this.getDeletionTagCacheKey(tag), this.#getTagFactory, - this.#getSetDeletionTagOptions.cloneWith({}), + this.#getSetTagOptions.cloneWith({}), ) // If a deletion timestamp exists and the entry was created before or at it, it's hard deleted @@ -140,7 +136,7 @@ export class TagSystem { for (const tag of new Set(tags)) { const key = this.getDeletionTagCacheKey(tag) - await this.stack.set(key, now, this.#getSetDeletionTagOptions) + await this.stack.set(key, now, this.#getSetTagOptions) } return true diff --git a/packages/bentocache/src/types/main.ts b/packages/bentocache/src/types/main.ts index 7a61de0..67edeb6 100644 --- a/packages/bentocache/src/types/main.ts +++ b/packages/bentocache/src/types/main.ts @@ -71,17 +71,3 @@ export interface BentoCachePlugin { * Dialect available for the SQL driver */ export type DialectName = 'pg' | 'mysql2' | 'better-sqlite3' | 'sqlite3' - -export type { - ClearOptions, - DeleteManyOptions, - DeleteOptions, - ExpireByTagOptions, - DeleteByTagOptions, - ExpireOptions, - GetOptions, - GetOrSetForeverOptions, - GetOrSetOptions, - HasOptions, - SetOptions, -} from './options/methods_options.js'