diff --git a/packages/bentocache/src/bento_cache.ts b/packages/bentocache/src/bento_cache.ts index 89cf85e..c66688a 100644 --- a/packages/bentocache/src/bento_cache.ts +++ b/packages/bentocache/src/bento_cache.ts @@ -16,6 +16,7 @@ import type { DeleteOptions, DeleteManyOptions, ExpireOptions, + ExpireByTagOptions, DeleteByTagOptions, } from './types/main.js' @@ -219,7 +220,14 @@ export class BentoCache> implemen } /** - * Delete all keys with a specific tag + * Expire all keys with a specific tag + */ + async expireByTag(options: ExpireByTagOptions): Promise { + return this.use().expireByTag(options) + } + + /** + * Delete all keys with specific tags (lazy deletion) */ 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 25b83d3..589a894 100644 --- a/packages/bentocache/src/cache/cache.ts +++ b/packages/bentocache/src/cache/cache.ts @@ -17,6 +17,7 @@ import type { DeleteManyOptions, GetOrSetForeverOptions, ExpireOptions, + ExpireByTagOptions, DeleteByTagOptions, } from '../types/main.js' @@ -205,13 +206,30 @@ export class Cache implements CacheProvider { /** * Invalidate all keys with the given tags */ + async expireByTag(rawOptions: ExpireByTagOptions): Promise { + 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 { 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 } /** diff --git a/packages/bentocache/src/cache/cache_stack.ts b/packages/bentocache/src/cache/cache_stack.ts index d71e338..44ed724 100644 --- a/packages/bentocache/src/cache/cache_stack.ts +++ b/packages/bentocache/src/cache/cache_stack.ts @@ -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 { if (!item) return false @@ -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..223b1b3 100644 --- a/packages/bentocache/src/cache/tag_system.ts +++ b/packages/bentocache/src/cache/tag_system.ts @@ -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', @@ -27,6 +29,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 +43,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 +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 @@ -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 * @@ -85,4 +126,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.#getSetTagOptions) + } + + return true + } } diff --git a/packages/bentocache/src/types/options/methods_options.ts b/packages/bentocache/src/types/options/methods_options.ts index 6e0d45e..e2fc7f1 100644 --- a/packages/bentocache/src/types/options/methods_options.ts +++ b/packages/bentocache/src/types/options/methods_options.ts @@ -54,6 +54,11 @@ export type GetOptions = { key: string; defaultValue?: Factory } & Pick< export type DeleteOptions = { key: string } & Pick export type DeleteManyOptions = { keys: string[] } & Pick +/** + * Options accepted by the `expireByTag` method + */ +export type ExpireByTagOptions = { tags: string[] } & Pick + /** * Options accepted by the `deleteByTag` method */ diff --git a/packages/bentocache/src/types/provider.ts b/packages/bentocache/src/types/provider.ts index 215e440..08303bd 100644 --- a/packages/bentocache/src/types/provider.ts +++ b/packages/bentocache/src/types/provider.ts @@ -7,6 +7,7 @@ import type { GetOptions, HasOptions, SetOptions, + ExpireByTagOptions, DeleteByTagOptions, } from './main.js' @@ -71,7 +72,12 @@ export interface CacheProvider { deleteMany(options: DeleteManyOptions): Promise /** - * Delete all keys with a specific tag + * Expire all keys with a specific tag + */ + expireByTag(options: ExpireByTagOptions): Promise + + /** + * Delete all keys with specific tags */ deleteByTag(options: DeleteByTagOptions): Promise diff --git a/packages/bentocache/tests/tagging.spec.ts b/packages/bentocache/tests/tagging.spec.ts index a6c2662..ec12d42 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'] }) @@ -304,6 +304,99 @@ test.group('Tagging | deleteByTag', () => { await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + const r1 = await cache.has({ key: 'foo' }) + await cache.expireByTag({ tags: ['x'] }) + const r2 = await cache.has({ key: 'foo' }) + + assert.isTrue(r1) + assert.isFalse(r2) + }) +}) + +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.deleteByTag({ tags: ['tag1'] }) + + const r1 = await cache.get({ key: 'key1' }) + assert.deepEqual(r1, undefined) + }) + + test('deleteByTag should store deletion timestamps', async ({ assert }) => { + const now = Date.now() + + await sleep(10) + + const { cache } = new CacheFactory().withL1L2Config().create() + await cache.deleteByTag({ 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('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.deleteByTag({ 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('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.deleteByTag({ 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('deleteByTag should work with getOrSet', async ({ assert }) => { + const { cache } = new CacheFactory().withL1L2Config().create() + + await cache.set({ key: 'foo', value: 1, tags: ['x'] }) + await cache.deleteByTag({ tags: ['x'] }) + + const r1 = await cache.getOrSet({ + key: 'foo', + factory: () => 'new-value', + tags: ['x'], + }) + + assert.deepEqual(r1, 'new-value') + }) + + 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.deleteByTag({ tags: ['x'] }) const r2 = await cache.has({ key: 'foo' }) @@ -311,4 +404,103 @@ test.group('Tagging | deleteByTag', () => { assert.isTrue(r1) assert.isFalse(r2) }) + + 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.deleteByTag({ 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('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() + + 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.deleteByTag({ 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('deleteByTag 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.deleteByTag({ 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('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.deleteByTag({ tags: [] }) + const r1 = await cache.get({ key: 'foo' }) + + assert.isTrue(result) + assert.deepEqual(r1, 1) // should remain unaffected + }) + + 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.deleteByTag({ tags: ['non-existing'] }) + const r1 = await cache.get({ key: 'foo' }) + + assert.isTrue(result) + assert.deepEqual(r1, 1) // should remain unaffected + }) + + 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.deleteByTag({ 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) + }) })