From fb103c4f0f2c04fd7129eacbf68d09f065da557f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 14:28:59 +0000 Subject: [PATCH 1/5] feat(cacheable): add tag-based invalidation with CacheTags Implements tag-based invalidation for Cacheable (#1640) on top of the CacheTags service in @cacheable/utils: - set(key, value, { ttl, tags }) associates entries with tags (back-compat with ttl as the third argument) - setMany items accept per-item tags - invalidateTag / invalidateTags bump tag versions in constant time - stale entries are detected lazily on get / getMany, removed from both stores, and a delete is published via sync when enabled - tag metadata lives in the secondary store when configured so invalidations are shared across instances; tags: true option lets read-only instances honor invalidations - getTags(key), tags service getter, and tagsEnabled introspection - utils: add CacheTags.isKeyStale (safe for untagged keys) and CacheTags.getKeyTags https://claude.ai/code/session_01CEUJ2ZvfykEnuUfL43PobY --- packages/cacheable/README.md | 68 +++++- packages/cacheable/src/index.ts | 262 +++++++++++++++++++- packages/cacheable/src/types.ts | 36 +++ packages/cacheable/test/tags.test.ts | 315 +++++++++++++++++++++++++ packages/utils/README.md | 18 ++ packages/utils/src/cache-tags.ts | 43 ++++ packages/utils/test/cache-tags.test.ts | 43 ++++ 7 files changed, 774 insertions(+), 11 deletions(-) create mode 100644 packages/cacheable/test/tags.test.ts diff --git a/packages/cacheable/README.md b/packages/cacheable/README.md index 0beb6f39..8171a979 100644 --- a/packages/cacheable/README.md +++ b/packages/cacheable/README.md @@ -33,6 +33,7 @@ * [TTL Propagation and Storage Tiering](#ttl-propagation-and-storage-tiering) * [Shorthand for Time to Live (ttl)](#shorthand-for-time-to-live-ttl) * [Maximum Time to Live (maxTtl)](#maximum-time-to-live-maxttl) +* [Tag Based Invalidation](#tag-based-invalidation) * [Iteration on Primary and Secondary Stores](#iteration-on-primary-and-secondary-stores) * [Non-Blocking Operations](#non-blocking-operations) * [Non-Blocking with @keyv/redis](#non-blocking-with-keyvredis) @@ -638,6 +639,63 @@ cache.maxTtl = '10m'; // 10 minutes max cache.maxTtl = undefined; // disable maxTtl (no upper bound) ``` +# Tag Based Invalidation + +You can associate cache entries with tags and later invalidate every entry that shares a tag in a single call. This is useful for content caching where one upstream entity (a product, a user, a CMS document) is referenced by many cache entries: + +```javascript +import { Cacheable } from 'cacheable'; + +const cache = new Cacheable(); + +await cache.set('page:/products', html, { ttl: '10m', tags: ['entity:42', 'collection:products'] }); +await cache.set('page:/products/42', detailHtml, { ttl: '10m', tags: ['entity:42'] }); + +// entity 42 changed - purge everything that referenced it +await cache.invalidateTag('entity:42'); + +await cache.get('page:/products'); // undefined +await cache.get('page:/products/42'); // undefined +``` + +You can also pass tags per item with `setMany`, and invalidate several tags at once: + +```javascript +await cache.setMany([ + { key: 'user:1', value: userOne, tags: ['users'] }, + { key: 'user:2', value: userTwo, tags: ['users', 'org:7'] }, +]); + +await cache.invalidateTags(['users', 'org:7']); +``` + +Invalidation is powered by the `CacheTags` service from [`@cacheable/utils`](https://npmjs.com/package/@cacheable/utils) and uses a lazy, constant-time model: `invalidateTag` simply bumps a version counter for the tag, no matter how many entries reference it. Each tagged entry stores a snapshot of its tags' versions, and on the next `get` / `getMany` the snapshot is compared to the live versions. If any tag has been bumped since, the entry is treated as a miss and removed from both the primary and secondary stores (and a `delete` is published via [sync](#cacheablesync---distributed-updates) when enabled). The trade-off is one additional tag-store read per cache lookup while tag support is enabled. + +Tag metadata is stored in the secondary store when one is configured, otherwise in the primary store. With a shared secondary store (such as Redis), an invalidation performed by one instance is seen by every instance: + +```javascript +import { Cacheable } from 'cacheable'; +import KeyvRedis from '@keyv/redis'; + +const writer = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379') }); +// instances that only read tagged entries should opt in with `tags: true` +const reader = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379'), tags: true }); + +await writer.set('page:/products', html, { tags: ['entity:42'] }); +await writer.invalidateTag('entity:42'); +await reader.get('page:/products'); // undefined - stale copy is also purged from reader's primary +``` + +Tag support is off by default so untagged workloads pay no extra cost. It turns on automatically the first time you use a tag feature on an instance (setting a value with `tags`, calling `invalidateTag` / `invalidateTags`, or accessing the `tags` service), or explicitly via the `tags: true` option. When using tags across multiple instances that share a store, set `tags: true` on all of them so every instance honors invalidations and cleans up tag snapshots consistently. + +Helper methods: + +```javascript +await cache.getTags('page:/products'); // ['entity:42', 'collection:products'] +cache.tagsEnabled; // whether freshness checks run on get / getMany +cache.tags; // the underlying CacheTags service for advanced use (getKeysByTag, isKeyStale, ...) +``` + # Cacheable Options The following options are available for you to configure `cacheable`: @@ -650,6 +708,7 @@ The following options are available for you to configure `cacheable`: * `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Enforced on both primary and secondary stores. Default is `undefined` (no maximum). * `namespace`: The namespace for the cache. Default is `undefined`. * `cacheId`: A unique identifier for this cache instance. Used for sync filtering. Default is a random string. +* `tags`: Enables tag-based invalidation freshness checks on `get` / `getMany`. Tag support also turns on automatically the first time you use a tag feature on the instance. Default is `false`. * `sync`: Enable distributed cache synchronization. Can be: - `CacheableSync` instance - `CacheableSyncOptions` object with `{ qified: MessageProvider | MessageProvider[] | Qified }` @@ -674,8 +733,8 @@ _This does not enable statistics for your layer 2 cache as that is a distributed # Cacheable - API -* `set(key, value, ttl?)`: Sets a value in the cache. -* `setMany([{key, value, ttl?}])`: Sets multiple values in the cache. +* `set(key, value, ttlOrOptions?)`: Sets a value in the cache. The third argument can be a `ttl` or an options object such as `{ ttl: '1h', tags: ['entity:42'] }`. +* `setMany([{key, value, ttl?, tags?}])`: Sets multiple values in the cache. * `get(key)`: Gets a value from the cache. * `get(key, { raw: true })`: Gets a raw value from the cache. * `getMany([keys])`: Gets multiple values from the cache. @@ -687,6 +746,11 @@ _This does not enable statistics for your layer 2 cache as that is a distributed * `delete(key)`: Deletes a value from the cache. * `deleteMany([keys])`: Deletes multiple values from the cache. * `clear()`: Clears the cache stores. Be careful with this as it will clear both layer 1 and layer 2. +* `invalidateTag(tag)`: Invalidates every cache entry that was set with the tag. Constant-time regardless of how many entries reference it. +* `invalidateTags([tags])`: Invalidates every cache entry that was set with any of the tags. +* `getTags(key)`: Returns the tags associated with a key, or `undefined` if the key was not set with tags. +* `tags`: The underlying `CacheTags` service from `@cacheable/utils` for advanced use. +* `tagsEnabled`: Whether tag freshness checks run on `get` / `getMany`. * `wrap(function, WrapOptions)`: Wraps an `async` function in a cache. * `getOrSet(GetOrSetKey, valueFunction, GetOrSetFunctionOptions)`: Gets a value from cache or sets it if not found using the provided function. * `disconnect()`: Disconnects from the cache stores. diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index ebbfc005..e77ec18b 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -3,6 +3,7 @@ import { type CacheableItem, Stats as CacheableStats, type CacheInstance, + CacheTags, calculateTtlFromExpiration, type GetOrSetFunctionOptions, type GetOrSetKey, @@ -26,7 +27,12 @@ import { } from "keyv"; import { CacheableEvents, CacheableHooks } from "./enums.js"; import { CacheableSync, CacheableSyncEvents } from "./sync.js"; -import type { CacheableOptions, GetOptions } from "./types.js"; +import type { + CacheableOptions, + CacheableSetItem, + GetOptions, + SetOptions, +} from "./types.js"; export class Cacheable extends Hookified { private _primary: Keyv = createKeyv(); @@ -38,6 +44,8 @@ export class Cacheable extends Hookified { private _namespace?: string | (() => string); private _cacheId: string = Math.random().toString(36).slice(2); private _sync?: CacheableSync; + private _cacheTags?: CacheTags; + private _tagsEnabled = false; /** * Creates a new cacheable instance * @param {CacheableOptions} [options] The options for the cacheable instance @@ -81,6 +89,10 @@ export class Cacheable extends Hookified { } } + if (options?.tags) { + this._tagsEnabled = true; + } + if (options?.sync) { this._sync = options.sync instanceof CacheableSync @@ -142,6 +154,7 @@ export class Cacheable extends Hookified { */ public set primary(primary: Keyv) { this._primary = primary; + this._cacheTags = undefined; } /** @@ -159,6 +172,7 @@ export class Cacheable extends Hookified { */ public set secondary(secondary: Keyv | undefined) { this._secondary = secondary; + this._cacheTags = undefined; } /** @@ -301,6 +315,36 @@ export class Cacheable extends Hookified { } } + /** + * The tag service for the cacheable instance, used for tag-based invalidation. It is created + * lazily on first access and persists tag metadata in the secondary store when one is + * configured (so invalidations are shared across instances), otherwise the primary store. + * + * Accessing this getter enables tag freshness checks on `get` / `getMany` for this instance. + * + * [Learn more about tag-based invalidation](https://cacheable.org/docs/cacheable/#tag-based-invalidation). + * + * @returns {CacheTags} The tag service for the cacheable instance + */ + public get tags(): CacheTags { + this._tagsEnabled = true; + this._cacheTags ??= new CacheTags({ + store: this._secondary ?? this._primary, + }); + return this._cacheTags; + } + + /** + * Whether tag-based invalidation freshness checks are enabled on `get` / `getMany`. This is + * `false` by default and becomes `true` once a tag feature is used on this instance (setting a + * value with `tags`, calling `invalidateTag` / `invalidateTags`, or accessing the `tags` + * service) or when the `tags` option is set in the constructor. + * @returns {boolean} Whether tag support is enabled + */ + public get tagsEnabled(): boolean { + return this._tagsEnabled; + } + /** * Sets the primary store for the cacheable instance * @param {Keyv | KeyvStoreAdapter} primary The primary store for the cacheable instance @@ -318,6 +362,8 @@ export class Cacheable extends Hookified { this._primary.on("error", (error: unknown) => { this.emit(CacheableEvents.ERROR, error); }); + + this._cacheTags = undefined; } /** @@ -337,6 +383,8 @@ export class Cacheable extends Hookified { this._secondary.on("error", (error: unknown) => { this.emit(CacheableEvents.ERROR, error); }); + + this._cacheTags = undefined; } public getNameSpace(): string | undefined { @@ -430,6 +478,11 @@ export class Cacheable extends Hookified { } } + if (result && this._tagsEnabled && (await this.tags.isKeyStale(key))) { + await this.delete(key); + result = undefined; + } + await this.hook(CacheableHooks.AFTER_GET, { key, result, ttl }); } catch (error: unknown) { this.emit(CacheableEvents.ERROR, error); @@ -502,6 +555,17 @@ export class Cacheable extends Hookified { } } + if (this._tagsEnabled) { + await Promise.all( + keys.map(async (key, i) => { + if (result[i] && (await this.tags.isKeyStale(key))) { + await this.delete(key); + result[i] = undefined; + } + }), + ); + } + await this.hook(CacheableHooks.AFTER_GET_MANY, { keys, result }); } catch (error: unknown) { this.emit(CacheableEvents.ERROR, error); @@ -545,17 +609,24 @@ export class Cacheable extends Hookified { * Sets the value of the key. If the secondary store is set then it will also set the value in the secondary store. * @param {string} key the key to set the value of * @param {T} value The value to set - * @param {number | string} [ttl] set a number it is miliseconds, set a string it is a human-readable + * @param {number | string | SetOptions} [ttlOrOptions] set a number it is miliseconds, set a string it is a human-readable * format such as `1s` for 1 second or `1h` for 1 hour. Setting undefined means that it will use the default time-to-live. + * You can also pass a {@link SetOptions} object such as `{ ttl: '1h', tags: ['user:42'] }` to associate the entry with + * tags for tag-based invalidation. * @returns {boolean} Whether the value was set */ public async set( key: string, value: T, - ttl?: number | string, + ttlOrOptions?: number | string | SetOptions, ): Promise { let result = false; - const explicitTtl = shorthandToMilliseconds(ttl); + const options: SetOptions = + typeof ttlOrOptions === "object" && ttlOrOptions !== null + ? ttlOrOptions + : { ttl: ttlOrOptions ?? undefined }; + const nonBlocking = options.nonBlocking ?? this._nonBlocking; + const explicitTtl = shorthandToMilliseconds(options.ttl); const maxTtlMs = shorthandToMilliseconds(this._maxTtl); try { let primaryTtl = getCascadingTtl( @@ -564,10 +635,13 @@ export class Cacheable extends Hookified { explicitTtl, ); primaryTtl = this.capTtl(primaryTtl, maxTtlMs); - const item = { key, value, ttl: primaryTtl }; + const item = { key, value, ttl: primaryTtl, tags: options.tags }; await this.hook(CacheableHooks.BEFORE_SET, item); const hookOverridden = item.ttl !== primaryTtl; item.ttl = this.capTtl(item.ttl, maxTtlMs); + // The tag snapshot lives in the same store as the tag service, so it should + // expire alongside the copy of the value held there. + let tagTtl = item.ttl; const promises = []; promises.push(this._primary.set(item.key, item.value, item.ttl)); if (this._secondary) { @@ -576,9 +650,10 @@ export class Cacheable extends Hookified { : getCascadingTtl(this._ttl, this._secondary.ttl, explicitTtl); secondaryTtl = this.capTtl(secondaryTtl, maxTtlMs); promises.push(this._secondary.set(item.key, item.value, secondaryTtl)); + tagTtl = secondaryTtl; } - if (this._nonBlocking) { + if (nonBlocking) { result = await Promise.race(promises); // Catch any rejected promises to avoid unhandled rejections for (const promise of promises) { @@ -591,6 +666,13 @@ export class Cacheable extends Hookified { result = results[0]; } + if (item.tags && item.tags.length > 0) { + await this.setKeyTags(item.key, item.tags, tagTtl, nonBlocking); + } else if (this._tagsEnabled) { + // Remove any previous tag snapshot so a stale one cannot invalidate this fresh value + await this.removeKeyTags([item.key], nonBlocking); + } + await this.hook(CacheableHooks.AFTER_SET, item); // Publish to sync if enabled @@ -618,10 +700,11 @@ export class Cacheable extends Hookified { /** * Sets the values of the keys. If the secondary store is set then it will also set the values in the secondary store. - * @param {CacheableItem[]} items The items to set + * Items can include `tags` to associate the entry with tags for tag-based invalidation. + * @param {CacheableSetItem[]} items The items to set * @returns {boolean} Whether the values were set */ - public async setMany(items: CacheableItem[]): Promise { + public async setMany(items: CacheableSetItem[]): Promise { let result = false; try { await this.hook(CacheableHooks.BEFORE_SET_MANY, items); @@ -638,6 +721,8 @@ export class Cacheable extends Hookified { } } + await this.setManyKeyTags(items); + await this.hook(CacheableHooks.AFTER_SET_MANY, items); // Publish to sync if enabled @@ -775,6 +860,10 @@ export class Cacheable extends Hookified { result = resultAll[0]; } + if (this._tagsEnabled) { + await this.removeKeyTags([key], this.nonBlocking); + } + // Publish to sync if enabled if (this._sync && result) { await this._sync.publish(CacheableSyncEvents.DELETE, { @@ -814,6 +903,10 @@ export class Cacheable extends Hookified { } } + if (this._tagsEnabled) { + await this.removeKeyTags(keys, this._nonBlocking); + } + // Publish to sync if enabled if (this._sync && result) { for (const key of keys) { @@ -827,6 +920,64 @@ export class Cacheable extends Hookified { return result; } + /** + * Invalidates every cache entry that was set with the given tag. This is a constant-time + * operation regardless of how many entries reference the tag: the tag's version counter is + * bumped and stale entries are detected lazily (and removed) on their next `get`. + * + * [Learn more about tag-based invalidation](https://cacheable.org/docs/cacheable/#tag-based-invalidation). + * + * @param {string} tag The tag to invalidate + * @returns {Promise} + * @example + * ```typescript + * const cache = new Cacheable(); + * await cache.set('page:/products', html, { ttl: '10m', tags: ['entity:42'] }); + * await cache.invalidateTag('entity:42'); + * await cache.get('page:/products'); // undefined + * ``` + */ + public async invalidateTag(tag: string): Promise { + await this.invalidateTags([tag]); + } + + /** + * Invalidates every cache entry that was set with any of the given tags. This is a + * constant-time operation regardless of how many entries reference the tags: each tag's + * version counter is bumped and stale entries are detected lazily (and removed) on their + * next `get`. + * + * [Learn more about tag-based invalidation](https://cacheable.org/docs/cacheable/#tag-based-invalidation). + * + * @param {string[]} tags The tags to invalidate + * @returns {Promise} + */ + public async invalidateTags(tags: string[]): Promise { + if (tags.length === 0) { + return; + } + + try { + await this.tags.invalidateTags(tags); + } catch (error: unknown) { + this.emit(CacheableEvents.ERROR, error); + } + } + + /** + * Returns the tags associated with a key, or `undefined` if the key was not set with tags. + * @param {string} key The key to look up + * @returns {Promise} The tags for the key + */ + public async getTags(key: string): Promise { + try { + return await this.tags.getKeyTags(key); + } catch (error: unknown) { + this.emit(CacheableEvents.ERROR, error); + return undefined; + } + } + /** * Clears the primary store. If the secondary store is set then it will also clear the secondary store. * @returns {Promise} @@ -1005,6 +1156,90 @@ export class Cacheable extends Hookified { return true; } + /** + * Writes the tag snapshot for a key and enables tag freshness checks. In non-blocking mode the + * write is fire-and-forget and errors are emitted instead of thrown. + */ + private async setKeyTags( + key: string, + tags: string[], + ttl: number | undefined, + nonBlocking: boolean, + ): Promise { + const promise = this.tags.setKeyTags(key, tags, { ttl }); + if (nonBlocking) { + promise.catch((error) => { + this.emit(CacheableEvents.ERROR, error); + }); + return; + } + + await promise; + } + + /** + * Removes the tag snapshots for keys so a stale snapshot cannot invalidate a fresh value. In + * non-blocking mode the removal is fire-and-forget and errors are emitted instead of thrown. + */ + private async removeKeyTags( + keys: string[], + nonBlocking: boolean, + ): Promise { + const promise = Promise.all(keys.map((key) => this.tags.removeKey(key))); + if (nonBlocking) { + promise.catch((error) => { + this.emit(CacheableEvents.ERROR, error); + }); + return; + } + + await promise; + } + + /** + * Writes tag snapshots for `setMany` items that carry tags and removes any previous snapshots + * for items that do not. + */ + private async setManyKeyTags(items: CacheableSetItem[]): Promise { + const taggedItems = items.filter( + (item) => item.tags && item.tags.length > 0, + ); + if (taggedItems.length === 0 && !this._tagsEnabled) { + return; + } + + const maxTtlMs = shorthandToMilliseconds(this._maxTtl); + // The tag snapshot lives in the same store as the tag service, so it should + // expire alongside the copy of the value held there. + const tagStoreTtl = (this._secondary ?? this._primary).ttl; + const promises = []; + for (const item of taggedItems) { + let ttl = getCascadingTtl( + this._ttl, + tagStoreTtl, + shorthandToMilliseconds(item.ttl), + ); + ttl = this.capTtl(ttl, maxTtlMs); + promises.push( + this.setKeyTags( + item.key, + item.tags as string[], + ttl, + this._nonBlocking, + ), + ); + } + + const untaggedKeys = items + .filter((item) => !item.tags || item.tags.length === 0) + .map((item) => item.key); + if (untaggedKeys.length > 0) { + promises.push(this.removeKeyTags(untaggedKeys, this._nonBlocking)); + } + + await Promise.all(promises); + } + /** * Processes a single key from secondary store for getRaw operation * @param primary - the primary store to use @@ -1282,6 +1517,8 @@ export { } from "@cacheable/memory"; export { type CacheableItem, + CacheTags, + type CacheTagsOptions, calculateTtlFromExpiration, type GetOrSetFunctionOptions, type GetOrSetKey, @@ -1290,6 +1527,8 @@ export { getOrSet, HashAlgorithm, hash, + type KeyTagEntry, + type SetKeyTagsOptions, Stats as CacheableStats, shorthandToMilliseconds, shorthandToTime, @@ -1306,4 +1545,9 @@ export { type CacheableSyncItem, type CacheableSyncOptions, } from "./sync.js"; -export type { CacheableOptions } from "./types.js"; +export type { + CacheableOptions, + CacheableSetItem, + GetOptions, + SetOptions, +} from "./types.js"; diff --git a/packages/cacheable/src/types.ts b/packages/cacheable/src/types.ts index 5ddb8dac..776cfd8e 100644 --- a/packages/cacheable/src/types.ts +++ b/packages/cacheable/src/types.ts @@ -1,3 +1,4 @@ +import type { CacheableItem } from "@cacheable/utils"; import type { Keyv, KeyvStoreAdapter } from "keyv"; import type { CacheableSync, CacheableSyncOptions } from "./sync.js"; @@ -63,6 +64,14 @@ export type CacheableOptions = { * The sync instance for the cacheable instance to enable synchronization across cache instances */ sync?: CacheableSync | CacheableSyncOptions; + /** + * Enables tag-based invalidation freshness checks on every `get` / `getMany`. Tag support is + * automatically enabled the first time you use a tag feature on this instance (setting a value + * with `tags`, calling `invalidateTag` / `invalidateTags`, or accessing the `tags` service). + * Set this to `true` for instances that only read tagged entries written by other instances so + * they also honor invalidations. Default is `false`. + */ + tags?: boolean; }; export type GetOptions = { @@ -79,6 +88,33 @@ export type SetOptions = { * @type {boolean} */ nonBlocking?: boolean; + /** + * Time-to-live. If you set a number it is milliseconds, if you set a string it is a + * human-readable format such as `1s` for 1 second or `1h` for 1 hour. Setting undefined means + * that it will use the default time-to-live. + * @type {number | string} + */ + ttl?: number | string; + /** + * Tags to associate with the entry for tag-based invalidation. Invalidating any of these tags + * via `invalidateTag` / `invalidateTags` makes the entry stale, causing the next `get` to treat + * it as a miss and remove it. + * @type {string[]} + */ + tags?: string[]; +}; + +/** + * An item for `setMany` that can optionally carry tags for tag-based invalidation. + */ +export type CacheableSetItem = CacheableItem & { + /** + * Tags to associate with the entry for tag-based invalidation. Invalidating any of these tags + * via `invalidateTag` / `invalidateTags` makes the entry stale, causing the next `get` to treat + * it as a miss and remove it. + * @type {string[]} + */ + tags?: string[]; }; export type TakeOptions = { diff --git a/packages/cacheable/test/tags.test.ts b/packages/cacheable/test/tags.test.ts new file mode 100644 index 00000000..4eccfe01 --- /dev/null +++ b/packages/cacheable/test/tags.test.ts @@ -0,0 +1,315 @@ +import { faker } from "@faker-js/faker"; +import { Keyv } from "keyv"; +import { describe, expect, test, vi } from "vitest"; +import { Cacheable, CacheableEvents, CacheTags } from "../src/index.js"; + +describe("cacheable tags", () => { + test("tagsEnabled is false by default and true via the constructor option", () => { + const cacheable = new Cacheable(); + expect(cacheable.tagsEnabled).toBe(false); + const enabled = new Cacheable({ tags: true }); + expect(enabled.tagsEnabled).toBe(true); + }); + + test("tags getter returns a CacheTags service and enables tag checks", () => { + const cacheable = new Cacheable(); + expect(cacheable.tags).toBeInstanceOf(CacheTags); + expect(cacheable.tagsEnabled).toBe(true); + // same instance on repeat access + expect(cacheable.tags).toBe(cacheable.tags); + }); + + test("tags service is recreated when stores change", () => { + const cacheable = new Cacheable(); + const first = cacheable.tags; + cacheable.primary = new Keyv(); + expect(cacheable.tags).not.toBe(first); + + const second = cacheable.tags; + cacheable.secondary = new Keyv(); + expect(cacheable.tags).not.toBe(second); + + const third = cacheable.tags; + cacheable.setPrimary(new Keyv()); + expect(cacheable.tags).not.toBe(third); + + const fourth = cacheable.tags; + cacheable.setSecondary(new Keyv()); + expect(cacheable.tags).not.toBe(fourth); + }); + + test("tags service uses the secondary store when one is set", () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary }); + expect(cacheable.tags.store).toBe(secondary); + const primaryOnly = new Cacheable(); + expect(primaryOnly.tags.store).toBe(primaryOnly.primary); + }); + + test("set with tags then invalidateTag makes the entry a miss", async () => { + const cacheable = new Cacheable(); + const key = faker.string.uuid(); + const value = faker.string.alpha(10); + await cacheable.set(key, value, { tags: ["entity:42"] }); + expect(await cacheable.get(key)).toEqual(value); + await cacheable.invalidateTag("entity:42"); + expect(await cacheable.get(key)).toBeUndefined(); + }); + + test("set still supports ttl as the third argument", async () => { + const cacheable = new Cacheable(); + const key = faker.string.uuid(); + await cacheable.set(key, "value", 1000); + const raw = await cacheable.getRaw(key); + expect(raw?.expires).toBeGreaterThan(Date.now()); + }); + + test("set supports ttl inside the options object", async () => { + const cacheable = new Cacheable(); + const key = faker.string.uuid(); + await cacheable.set(key, "value", { ttl: "1h", tags: ["a"] }); + const raw = await cacheable.getRaw(key); + expect(raw?.expires).toBeGreaterThan(Date.now()); + }); + + test("invalidateTag only affects entries with that tag", async () => { + const cacheable = new Cacheable(); + await cacheable.set("tagged", "one", { tags: ["posts"] }); + await cacheable.set("other", "two", { tags: ["users"] }); + await cacheable.set("untagged", "three"); + await cacheable.invalidateTag("posts"); + expect(await cacheable.get("tagged")).toBeUndefined(); + expect(await cacheable.get("other")).toEqual("two"); + expect(await cacheable.get("untagged")).toEqual("three"); + }); + + test("invalidateTags invalidates multiple tags at once", async () => { + const cacheable = new Cacheable(); + await cacheable.set("a", 1, { tags: ["x"] }); + await cacheable.set("b", 2, { tags: ["y"] }); + await cacheable.set("c", 3, { tags: ["z"] }); + await cacheable.invalidateTags(["x", "y"]); + expect(await cacheable.getMany(["a", "b", "c"])).toEqual([ + undefined, + undefined, + 3, + ]); + }); + + test("invalidateTags with an empty list is a no-op", async () => { + const cacheable = new Cacheable(); + await cacheable.invalidateTags([]); + expect(cacheable.tagsEnabled).toBe(false); + }); + + test("stale entries are removed from the stores on get", async () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary }); + const key = faker.string.uuid(); + await cacheable.set(key, "value", { tags: ["t"] }); + await cacheable.invalidateTag("t"); + expect(await cacheable.get(key)).toBeUndefined(); + expect(await cacheable.primary.has(key)).toBe(false); + expect(await secondary.has(key)).toBe(false); + expect(await cacheable.getTags(key)).toBeUndefined(); + }); + + test("getTags returns the tags for a key", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { tags: ["a", "b"] }); + expect(await cacheable.getTags("k")).toEqual(["a", "b"]); + expect(await cacheable.getTags("missing")).toBeUndefined(); + }); + + test("re-setting a key without tags clears its previous snapshot", async () => { + const cacheable = new Cacheable(); + const key = faker.string.uuid(); + await cacheable.set(key, "tagged", { tags: ["t"] }); + await cacheable.set(key, "untagged"); + await cacheable.invalidateTag("t"); + expect(await cacheable.get(key)).toEqual("untagged"); + expect(await cacheable.getTags(key)).toBeUndefined(); + }); + + test("set with an empty tags array does not write a snapshot", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { tags: [] }); + expect(await cacheable.getTags("k")).toBeUndefined(); + expect(await cacheable.get("k")).toEqual("v"); + }); + + test("delete removes the tag snapshot", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { tags: ["t"] }); + await cacheable.delete("k"); + expect(await cacheable.getTags("k")).toBeUndefined(); + }); + + test("deleteMany removes the tag snapshots", async () => { + const cacheable = new Cacheable(); + await cacheable.setMany([ + { key: "a", value: 1, tags: ["t"] }, + { key: "b", value: 2, tags: ["t"] }, + ]); + await cacheable.deleteMany(["a", "b"]); + expect(await cacheable.getTags("a")).toBeUndefined(); + expect(await cacheable.getTags("b")).toBeUndefined(); + }); + + test("setMany supports tags per item and getMany honors invalidation", async () => { + const cacheable = new Cacheable(); + await cacheable.setMany([ + { key: "a", value: 1, tags: ["x"] }, + { key: "b", value: 2, tags: ["y"] }, + { key: "c", value: 3 }, + ]); + await cacheable.invalidateTag("x"); + expect(await cacheable.getMany(["a", "b", "c"])).toEqual([undefined, 2, 3]); + }); + + test("setMany without any tags does not enable tag checks", async () => { + const cacheable = new Cacheable(); + await cacheable.setMany([ + { key: "a", value: 1 }, + { key: "b", value: 2 }, + ]); + expect(cacheable.tagsEnabled).toBe(false); + }); + + test("setMany clears previous snapshots for items set without tags", async () => { + const cacheable = new Cacheable(); + await cacheable.set("a", "tagged", { tags: ["t"] }); + await cacheable.setMany([{ key: "a", value: "untagged" }]); + await cacheable.invalidateTag("t"); + expect(await cacheable.get("a")).toEqual("untagged"); + }); + + test("take returns undefined for a stale entry", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { tags: ["t"] }); + await cacheable.invalidateTag("t"); + expect(await cacheable.take("k")).toBeUndefined(); + }); + + test("invalidations are shared across instances via the secondary store", async () => { + const secondary = new Keyv(); + const writer = new Cacheable({ secondary }); + const reader = new Cacheable({ secondary, tags: true }); + const key = faker.string.uuid(); + + await writer.set(key, "value", { tags: ["entity:42"] }); + // reader pulls the value from the shared secondary into its own primary + expect(await reader.get(key)).toEqual("value"); + expect(await reader.primary.has(key)).toBe(true); + + await writer.invalidateTag("entity:42"); + expect(await reader.get(key)).toBeUndefined(); + // the stale copy is purged from the reader's primary as well + expect(await reader.primary.has(key)).toBe(false); + }); + + test("tag snapshots expire with the entry ttl", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { ttl: 30, tags: ["t"] }); + expect(await cacheable.getTags("k")).toEqual(["t"]); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(await cacheable.get("k")).toBeUndefined(); + expect(await cacheable.getTags("k")).toBeUndefined(); + }); + + test("set with tags in non-blocking mode still records the snapshot", async () => { + const cacheable = new Cacheable({ nonBlocking: true }); + await cacheable.set("k", "v", { tags: ["t"] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(await cacheable.getTags("k")).toEqual(["t"]); + await cacheable.invalidateTag("t"); + expect(await cacheable.get("k")).toBeUndefined(); + }); + + test("set honors a per-call nonBlocking override", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { nonBlocking: true, tags: ["t"] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(await cacheable.get("k")).toEqual("v"); + expect(await cacheable.getTags("k")).toEqual(["t"]); + }); + + test("delete in non-blocking mode removes the tag snapshot", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { tags: ["t"] }); + cacheable.nonBlocking = true; + await cacheable.delete("k"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(await cacheable.getTags("k")).toBeUndefined(); + }); + + test("setMany with tags in non-blocking mode records snapshots", async () => { + const cacheable = new Cacheable({ nonBlocking: true }); + await cacheable.setMany([{ key: "a", value: 1, tags: ["t"] }]); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(await cacheable.getTags("a")).toEqual(["t"]); + }); + + test("emits an error when invalidateTags fails", async () => { + const cacheable = new Cacheable(); + vi.spyOn(cacheable.tags, "invalidateTags").mockRejectedValueOnce( + new Error("tag store down"), + ); + let errored: unknown; + cacheable.on(CacheableEvents.ERROR, (error: unknown) => { + errored = error; + }); + await cacheable.invalidateTags(["t"]); + expect(errored).toBeInstanceOf(Error); + }); + + test("emits an error when getTags fails", async () => { + const cacheable = new Cacheable(); + vi.spyOn(cacheable.tags, "getKeyTags").mockRejectedValueOnce( + new Error("tag store down"), + ); + let errored: unknown; + cacheable.on(CacheableEvents.ERROR, (error: unknown) => { + errored = error; + }); + expect(await cacheable.getTags("k")).toBeUndefined(); + expect(errored).toBeInstanceOf(Error); + }); + + test("emits an error when a non-blocking snapshot write fails", async () => { + const cacheable = new Cacheable({ nonBlocking: true }); + vi.spyOn(cacheable.tags, "setKeyTags").mockRejectedValueOnce( + new Error("tag store down"), + ); + let errored: unknown; + cacheable.on(CacheableEvents.ERROR, (error: unknown) => { + errored = error; + }); + await cacheable.set("k", "v", { tags: ["t"] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(errored).toBeInstanceOf(Error); + }); + + test("emits an error when a non-blocking snapshot removal fails", async () => { + const cacheable = new Cacheable({ nonBlocking: true, tags: true }); + vi.spyOn(cacheable.tags, "removeKey").mockRejectedValueOnce( + new Error("tag store down"), + ); + let errored: unknown; + cacheable.on(CacheableEvents.ERROR, (error: unknown) => { + errored = error; + }); + await cacheable.delete("k"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(errored).toBeInstanceOf(Error); + }); + + test("clear removes values, snapshots, and tag versions", async () => { + const cacheable = new Cacheable(); + await cacheable.set("k", "v", { tags: ["t"] }); + await cacheable.invalidateTag("t"); + await cacheable.clear(); + await cacheable.set("k", "v2", { tags: ["t"] }); + expect(await cacheable.get("k")).toEqual("v2"); + }); +}); diff --git a/packages/utils/README.md b/packages/utils/README.md index ef493f8e..3d78e276 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -775,6 +775,24 @@ const bumped = await cacheTags.invalidateTags(['users', 'org:7']); console.log(bumped); // ['users', 'org:7'] ``` +When integrating with a cache where most keys are untagged, use `isKeyStale` instead of `isKeyFresh`. It only reports `true` when a snapshot exists for the key and one of its tags has been invalidated, so keys that were never tagged are not treated as stale: + +```typescript +console.log(await cacheTags.isKeyStale('never-tagged')); // false +await cacheTags.setKeyTags('user:42', ['users']); +console.log(await cacheTags.isKeyStale('user:42')); // false +await cacheTags.invalidateTag('users'); +console.log(await cacheTags.isKeyStale('user:42')); // true +``` + +The `getKeyTags` method returns the tags currently associated with a key, or `undefined` if the key has no snapshot: + +```typescript +await cacheTags.setKeyTags('user:42', ['users', 'org:7']); +console.log(await cacheTags.getKeyTags('user:42')); // ['users', 'org:7'] +console.log(await cacheTags.getKeyTags('missing')); // undefined +``` + The `getKeysByTag` method returns the keys currently referencing a given tag. It iterates the Keyv namespace and is therefore an `O(N)` operation. It is intended for debugging and tests rather than hot paths. ```typescript diff --git a/packages/utils/src/cache-tags.ts b/packages/utils/src/cache-tags.ts index 83a5bb92..fb87b612 100644 --- a/packages/utils/src/cache-tags.ts +++ b/packages/utils/src/cache-tags.ts @@ -217,6 +217,49 @@ export class CacheTags { return true; } + /** + * Determines whether a key's cached value is known to be stale due to tag invalidation. This is + * the complement of {@link CacheTags.isKeyFresh} for tagged keys, but treats keys without a + * snapshot as not stale — making it safe to call for every cache lookup, including keys that were + * never tagged. + * @param key - The cache key to check. + * @returns {Promise} `true` only when a snapshot exists for the key and at least one of + * its tags has been invalidated since the snapshot was taken; `false` otherwise (including when + * the key has no snapshot). + */ + public async isKeyStale(key: string): Promise { + const entry = await this._store.get(this.keyEntryKey(key)); + if (!entry?.tags) { + return false; + } + + const tags = Object.keys(entry.tags); + const currentVersions = await this.getTagVersions(tags); + + for (let i = 0; i < tags.length; i++) { + if (currentVersions[i] !== entry.tags[tags[i]]) { + return true; + } + } + + return false; + } + + /** + * Returns the tags currently associated with a key. + * @param key - The cache key to look up. + * @returns {Promise} The tag names from the key's snapshot, or `undefined` + * if the key has no snapshot. + */ + public async getKeyTags(key: string): Promise { + const entry = await this._store.get(this.keyEntryKey(key)); + if (!entry?.tags) { + return undefined; + } + + return Object.keys(entry.tags); + } + /** * Returns all cache keys whose snapshot references the given tag. This scans every key entry in * the namespace via the Keyv iterator, making it an `O(N)` operation intended for debugging and diff --git a/packages/utils/test/cache-tags.test.ts b/packages/utils/test/cache-tags.test.ts index 57f2f8cf..984b592d 100644 --- a/packages/utils/test/cache-tags.test.ts +++ b/packages/utils/test/cache-tags.test.ts @@ -157,4 +157,47 @@ describe("CacheTags", () => { await service.setKeyTags("empty", []); expect(await service.isKeyFresh("empty")).toBe(true); }); + + test("isKeyStale returns false for a key with no snapshot", async () => { + const service = createService(); + expect(await service.isKeyStale("untagged")).toBe(false); + }); + + test("isKeyStale returns false for a fresh tagged key", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users"]); + expect(await service.isKeyStale("user:1")).toBe(false); + }); + + test("isKeyStale returns true after invalidateTag", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users", "org:7"]); + await service.invalidateTag("org:7"); + expect(await service.isKeyStale("user:1")).toBe(true); + }); + + test("isKeyStale returns false again after re-tagging the key", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users"]); + await service.invalidateTag("users"); + await service.setKeyTags("user:1", ["users"]); + expect(await service.isKeyStale("user:1")).toBe(false); + }); + + test("getKeyTags returns the tags for a key", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users", "org:7"]); + expect(await service.getKeyTags("user:1")).toEqual(["users", "org:7"]); + }); + + test("getKeyTags returns undefined for an unknown key", async () => { + const service = createService(); + expect(await service.getKeyTags("nope")).toBeUndefined(); + }); + + test("getKeyTags returns empty array for a key tagged with no tags", async () => { + const service = createService(); + await service.setKeyTags("empty", []); + expect(await service.getKeyTags("empty")).toEqual([]); + }); }); From 5120c75dfaf4b0edabe4a605a9f58ae936c9cb19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 14:34:01 +0000 Subject: [PATCH 2/5] perf(cacheable): batch tag snapshot removal and stale-key deletion Addresses review feedback: - add CacheTags.removeKeys for batched snapshot deletion via deleteMany - removeKeyTags now uses a single batched delete instead of per-key deletes - getManyRaw collects stale keys and removes them with one deleteMany call https://claude.ai/code/session_01CEUJ2ZvfykEnuUfL43PobY --- packages/cacheable/src/index.ts | 8 ++++++-- packages/cacheable/test/tags.test.ts | 4 +++- packages/utils/src/cache-tags.ts | 15 +++++++++++++++ packages/utils/test/cache-tags.test.ts | 16 ++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index e77ec18b..717e9a8b 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -556,14 +556,18 @@ export class Cacheable extends Hookified { } if (this._tagsEnabled) { + const staleKeys: string[] = []; await Promise.all( keys.map(async (key, i) => { if (result[i] && (await this.tags.isKeyStale(key))) { - await this.delete(key); + staleKeys.push(key); result[i] = undefined; } }), ); + if (staleKeys.length > 0) { + await this.deleteMany(staleKeys); + } } await this.hook(CacheableHooks.AFTER_GET_MANY, { keys, result }); @@ -1185,7 +1189,7 @@ export class Cacheable extends Hookified { keys: string[], nonBlocking: boolean, ): Promise { - const promise = Promise.all(keys.map((key) => this.tags.removeKey(key))); + const promise = this.tags.removeKeys(keys); if (nonBlocking) { promise.catch((error) => { this.emit(CacheableEvents.ERROR, error); diff --git a/packages/cacheable/test/tags.test.ts b/packages/cacheable/test/tags.test.ts index 4eccfe01..51757d21 100644 --- a/packages/cacheable/test/tags.test.ts +++ b/packages/cacheable/test/tags.test.ts @@ -163,6 +163,8 @@ describe("cacheable tags", () => { { key: "b", value: 2, tags: ["y"] }, { key: "c", value: 3 }, ]); + // all entries are fresh before invalidation + expect(await cacheable.getMany(["a", "b", "c"])).toEqual([1, 2, 3]); await cacheable.invalidateTag("x"); expect(await cacheable.getMany(["a", "b", "c"])).toEqual([undefined, 2, 3]); }); @@ -292,7 +294,7 @@ describe("cacheable tags", () => { test("emits an error when a non-blocking snapshot removal fails", async () => { const cacheable = new Cacheable({ nonBlocking: true, tags: true }); - vi.spyOn(cacheable.tags, "removeKey").mockRejectedValueOnce( + vi.spyOn(cacheable.tags, "removeKeys").mockRejectedValueOnce( new Error("tag store down"), ); let errored: unknown; diff --git a/packages/utils/src/cache-tags.ts b/packages/utils/src/cache-tags.ts index fb87b612..c159b953 100644 --- a/packages/utils/src/cache-tags.ts +++ b/packages/utils/src/cache-tags.ts @@ -191,6 +191,21 @@ export class CacheTags { await this._store.delete(this.keyEntryKey(key)); } + /** + * Removes multiple keys' tag snapshots in a single batched store delete. After this, + * {@link CacheTags.isKeyFresh} returns `false` for each key. An empty list is a no-op. + * @param keys - The cache keys whose snapshots should be removed. + * @returns {Promise} Resolves once the snapshots have been deleted. + */ + public async removeKeys(keys: string[]): Promise { + if (keys.length === 0) { + return; + } + + const entryKeys = keys.map((key) => this.keyEntryKey(key)); + await this._store.deleteMany(entryKeys); + } + /** * Determines whether a key's cached value can still be trusted. A key is fresh only when a * snapshot exists for it and every tag in that snapshot still has the version it had at set time. diff --git a/packages/utils/test/cache-tags.test.ts b/packages/utils/test/cache-tags.test.ts index 984b592d..6c8b4685 100644 --- a/packages/utils/test/cache-tags.test.ts +++ b/packages/utils/test/cache-tags.test.ts @@ -42,6 +42,22 @@ describe("CacheTags", () => { expect(await service.isKeyFresh("user:1")).toBe(false); }); + test("removeKeys deletes multiple snapshots in one batch", async () => { + const service = createService(); + await service.setKeyTags("a", ["t"]); + await service.setKeyTags("b", ["t"]); + await service.removeKeys(["a", "b"]); + expect(await service.isKeyFresh("a")).toBe(false); + expect(await service.isKeyFresh("b")).toBe(false); + }); + + test("removeKeys with an empty list is a no-op", async () => { + const service = createService(); + await service.setKeyTags("a", ["t"]); + await service.removeKeys([]); + expect(await service.isKeyFresh("a")).toBe(true); + }); + test("invalidateTag returns the bumped tag", async () => { const service = createService(); const result = await service.invalidateTag("users"); From d855c2caf4e9fb859f55cca7b27e16ffa1ded8ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 15:41:29 +0000 Subject: [PATCH 3/5] refactor(cacheable): make CacheTags self-contained per review feedback - CacheTags now owns an enabled state: read methods no-op while disabled and tag writes (setKeyTags, invalidateTag, invalidateTags) auto-enable the service, so integrations are self-contained - add CacheTags.getStaleKeys for batch staleness checks (two store reads total) and use it in getManyRaw - move non-blocking (fire-and-forget) handling into CacheTags via a nonBlocking option on setKeyTags/removeKey/removeKeys with an onError callback for failures - rename getKeyTags to getTags - Cacheable creates the tag service by default in the constructor as _tags, exposes it via the tags getter, and drops the duplicated invalidateTag/invalidateTags/getTags/tagsEnabled members in favor of cacheable.tags.* https://claude.ai/code/session_01CEUJ2ZvfykEnuUfL43PobY --- packages/cacheable/README.md | 26 ++- packages/cacheable/src/index.ts | 218 +++++++----------------- packages/cacheable/src/types.ts | 6 +- packages/cacheable/test/tags.test.ts | 136 ++++++++------- packages/utils/README.md | 37 +++- packages/utils/src/cache-tags.ts | 225 +++++++++++++++++++++---- packages/utils/src/index.ts | 1 + packages/utils/test/cache-tags.test.ts | 114 ++++++++++++- 8 files changed, 482 insertions(+), 281 deletions(-) diff --git a/packages/cacheable/README.md b/packages/cacheable/README.md index 8171a979..82960f80 100644 --- a/packages/cacheable/README.md +++ b/packages/cacheable/README.md @@ -652,7 +652,7 @@ await cache.set('page:/products', html, { ttl: '10m', tags: ['entity:42', 'colle await cache.set('page:/products/42', detailHtml, { ttl: '10m', tags: ['entity:42'] }); // entity 42 changed - purge everything that referenced it -await cache.invalidateTag('entity:42'); +await cache.tags.invalidateTag('entity:42'); await cache.get('page:/products'); // undefined await cache.get('page:/products/42'); // undefined @@ -666,10 +666,10 @@ await cache.setMany([ { key: 'user:2', value: userTwo, tags: ['users', 'org:7'] }, ]); -await cache.invalidateTags(['users', 'org:7']); +await cache.tags.invalidateTags(['users', 'org:7']); ``` -Invalidation is powered by the `CacheTags` service from [`@cacheable/utils`](https://npmjs.com/package/@cacheable/utils) and uses a lazy, constant-time model: `invalidateTag` simply bumps a version counter for the tag, no matter how many entries reference it. Each tagged entry stores a snapshot of its tags' versions, and on the next `get` / `getMany` the snapshot is compared to the live versions. If any tag has been bumped since, the entry is treated as a miss and removed from both the primary and secondary stores (and a `delete` is published via [sync](#cacheablesync---distributed-updates) when enabled). The trade-off is one additional tag-store read per cache lookup while tag support is enabled. +Tag functionality lives on the `tags` service — an instance of the `CacheTags` class from [`@cacheable/utils`](https://npmjs.com/package/@cacheable/utils) that is created by default in the constructor. Invalidation uses a lazy, constant-time model: `invalidateTag` simply bumps a version counter for the tag, no matter how many entries reference it. Each tagged entry stores a snapshot of its tags' versions, and on the next `get` / `getMany` the snapshot is compared to the live versions. If any tag has been bumped since, the entry is treated as a miss and removed from both the primary and secondary stores (and a `delete` is published via [sync](#cacheablesync---distributed-updates) when enabled). The trade-off is one additional tag-store read per cache lookup while the tag service is enabled. Tag metadata is stored in the secondary store when one is configured, otherwise in the primary store. With a shared secondary store (such as Redis), an invalidation performed by one instance is seen by every instance: @@ -682,18 +682,18 @@ const writer = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379' const reader = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379'), tags: true }); await writer.set('page:/products', html, { tags: ['entity:42'] }); -await writer.invalidateTag('entity:42'); +await writer.tags.invalidateTag('entity:42'); await reader.get('page:/products'); // undefined - stale copy is also purged from reader's primary ``` -Tag support is off by default so untagged workloads pay no extra cost. It turns on automatically the first time you use a tag feature on an instance (setting a value with `tags`, calling `invalidateTag` / `invalidateTags`, or accessing the `tags` service), or explicitly via the `tags: true` option. When using tags across multiple instances that share a store, set `tags: true` on all of them so every instance honors invalidations and cleans up tag snapshots consistently. +The tag service starts disabled so untagged workloads pay no extra cost. It enables automatically the first time a tag is written (setting a value with `tags` or calling `tags.invalidateTag` / `tags.invalidateTags`), or explicitly via the `tags: true` option or the `tags.enabled` property. When using tags across multiple instances that share a store, set `tags: true` on all of them so every instance honors invalidations and cleans up tag snapshots consistently. -Helper methods: +The full `CacheTags` API is available on the service: ```javascript -await cache.getTags('page:/products'); // ['entity:42', 'collection:products'] -cache.tagsEnabled; // whether freshness checks run on get / getMany -cache.tags; // the underlying CacheTags service for advanced use (getKeysByTag, isKeyStale, ...) +await cache.tags.getTags('page:/products'); // ['entity:42', 'collection:products'] +await cache.tags.getKeysByTag('entity:42'); // keys referencing a tag (debugging / tests) +cache.tags.enabled; // whether freshness checks run on get / getMany ``` # Cacheable Options @@ -708,7 +708,7 @@ The following options are available for you to configure `cacheable`: * `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Enforced on both primary and secondary stores. Default is `undefined` (no maximum). * `namespace`: The namespace for the cache. Default is `undefined`. * `cacheId`: A unique identifier for this cache instance. Used for sync filtering. Default is a random string. -* `tags`: Enables tag-based invalidation freshness checks on `get` / `getMany`. Tag support also turns on automatically the first time you use a tag feature on the instance. Default is `false`. +* `tags`: Enables the tag service so tag-based invalidation freshness checks run on `get` / `getMany`. The service also enables automatically the first time a tag is written on the instance. Default is `false`. * `sync`: Enable distributed cache synchronization. Can be: - `CacheableSync` instance - `CacheableSyncOptions` object with `{ qified: MessageProvider | MessageProvider[] | Qified }` @@ -746,11 +746,7 @@ _This does not enable statistics for your layer 2 cache as that is a distributed * `delete(key)`: Deletes a value from the cache. * `deleteMany([keys])`: Deletes multiple values from the cache. * `clear()`: Clears the cache stores. Be careful with this as it will clear both layer 1 and layer 2. -* `invalidateTag(tag)`: Invalidates every cache entry that was set with the tag. Constant-time regardless of how many entries reference it. -* `invalidateTags([tags])`: Invalidates every cache entry that was set with any of the tags. -* `getTags(key)`: Returns the tags associated with a key, or `undefined` if the key was not set with tags. -* `tags`: The underlying `CacheTags` service from `@cacheable/utils` for advanced use. -* `tagsEnabled`: Whether tag freshness checks run on `get` / `getMany`. +* `tags`: The `CacheTags` service from `@cacheable/utils` used for tag-based invalidation, such as `tags.invalidateTag(tag)`, `tags.invalidateTags([tags])`, `tags.getTags(key)`, `tags.getKeysByTag(tag)`, and `tags.enabled`. * `wrap(function, WrapOptions)`: Wraps an `async` function in a cache. * `getOrSet(GetOrSetKey, valueFunction, GetOrSetFunctionOptions)`: Gets a value from cache or sets it if not found using the provided function. * `disconnect()`: Disconnects from the cache stores. diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index 717e9a8b..fc33a132 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -44,8 +44,7 @@ export class Cacheable extends Hookified { private _namespace?: string | (() => string); private _cacheId: string = Math.random().toString(36).slice(2); private _sync?: CacheableSync; - private _cacheTags?: CacheTags; - private _tagsEnabled = false; + private _tags: CacheTags = this.createCacheTags(); /** * Creates a new cacheable instance * @param {CacheableOptions} [options] The options for the cacheable instance @@ -90,7 +89,7 @@ export class Cacheable extends Hookified { } if (options?.tags) { - this._tagsEnabled = true; + this._tags.enabled = true; } if (options?.sync) { @@ -154,7 +153,7 @@ export class Cacheable extends Hookified { */ public set primary(primary: Keyv) { this._primary = primary; - this._cacheTags = undefined; + this._tags = this.createCacheTags(); } /** @@ -172,7 +171,7 @@ export class Cacheable extends Hookified { */ public set secondary(secondary: Keyv | undefined) { this._secondary = secondary; - this._cacheTags = undefined; + this._tags = this.createCacheTags(); } /** @@ -317,32 +316,42 @@ export class Cacheable extends Hookified { /** * The tag service for the cacheable instance, used for tag-based invalidation. It is created - * lazily on first access and persists tag metadata in the secondary store when one is + * by default in the constructor and persists tag metadata in the secondary store when one is * configured (so invalidations are shared across instances), otherwise the primary store. * - * Accessing this getter enables tag freshness checks on `get` / `getMany` for this instance. + * The service starts disabled so untagged workloads pay no extra store reads; it enables + * automatically when a value is set with `tags` or a tag is invalidated, or explicitly via the + * `tags` option or `tags.enabled` property. While enabled, `get` / `getMany` perform tag + * freshness checks and remove stale entries. * * [Learn more about tag-based invalidation](https://cacheable.org/docs/cacheable/#tag-based-invalidation). * * @returns {CacheTags} The tag service for the cacheable instance + * @example + * ```typescript + * const cache = new Cacheable(); + * await cache.set('page:/products', html, { tags: ['entity:42'] }); + * await cache.tags.invalidateTag('entity:42'); + * await cache.get('page:/products'); // undefined + * ``` */ public get tags(): CacheTags { - this._tagsEnabled = true; - this._cacheTags ??= new CacheTags({ - store: this._secondary ?? this._primary, - }); - return this._cacheTags; + return this._tags; } /** - * Whether tag-based invalidation freshness checks are enabled on `get` / `getMany`. This is - * `false` by default and becomes `true` once a tag feature is used on this instance (setting a - * value with `tags`, calling `invalidateTag` / `invalidateTags`, or accessing the `tags` - * service) or when the `tags` option is set in the constructor. - * @returns {boolean} Whether tag support is enabled + * Creates the tag service backed by the secondary store when one is configured, otherwise the + * primary store, preserving the enabled state of any previous service and reporting + * non-blocking failures as error events. */ - public get tagsEnabled(): boolean { - return this._tagsEnabled; + private createCacheTags(): CacheTags { + return new CacheTags({ + store: this._secondary ?? this._primary, + enabled: this._tags?.enabled ?? false, + onError: (error: unknown) => { + this.emit(CacheableEvents.ERROR, error); + }, + }); } /** @@ -363,7 +372,7 @@ export class Cacheable extends Hookified { this.emit(CacheableEvents.ERROR, error); }); - this._cacheTags = undefined; + this._tags = this.createCacheTags(); } /** @@ -384,7 +393,7 @@ export class Cacheable extends Hookified { this.emit(CacheableEvents.ERROR, error); }); - this._cacheTags = undefined; + this._tags = this.createCacheTags(); } public getNameSpace(): string | undefined { @@ -478,7 +487,7 @@ export class Cacheable extends Hookified { } } - if (result && this._tagsEnabled && (await this.tags.isKeyStale(key))) { + if (result && this._tags.enabled && (await this._tags.isKeyStale(key))) { await this.delete(key); result = undefined; } @@ -555,17 +564,17 @@ export class Cacheable extends Hookified { } } - if (this._tagsEnabled) { - const staleKeys: string[] = []; - await Promise.all( - keys.map(async (key, i) => { - if (result[i] && (await this.tags.isKeyStale(key))) { - staleKeys.push(key); + if (this._tags.enabled) { + const presentKeys = keys.filter((_, i) => result[i] !== undefined); + const staleKeys = await this._tags.getStaleKeys(presentKeys); + if (staleKeys.length > 0) { + const staleSet = new Set(staleKeys); + for (const [i, key] of keys.entries()) { + if (staleSet.has(key)) { result[i] = undefined; } - }), - ); - if (staleKeys.length > 0) { + } + await this.deleteMany(staleKeys); } } @@ -671,10 +680,13 @@ export class Cacheable extends Hookified { } if (item.tags && item.tags.length > 0) { - await this.setKeyTags(item.key, item.tags, tagTtl, nonBlocking); - } else if (this._tagsEnabled) { + await this._tags.setKeyTags(item.key, item.tags, { + ttl: tagTtl, + nonBlocking, + }); + } else { // Remove any previous tag snapshot so a stale one cannot invalidate this fresh value - await this.removeKeyTags([item.key], nonBlocking); + await this._tags.removeKeys([item.key], { nonBlocking }); } await this.hook(CacheableHooks.AFTER_SET, item); @@ -864,9 +876,7 @@ export class Cacheable extends Hookified { result = resultAll[0]; } - if (this._tagsEnabled) { - await this.removeKeyTags([key], this.nonBlocking); - } + await this._tags.removeKeys([key], { nonBlocking: this.nonBlocking }); // Publish to sync if enabled if (this._sync && result) { @@ -907,9 +917,7 @@ export class Cacheable extends Hookified { } } - if (this._tagsEnabled) { - await this.removeKeyTags(keys, this._nonBlocking); - } + await this._tags.removeKeys(keys, { nonBlocking: this._nonBlocking }); // Publish to sync if enabled if (this._sync && result) { @@ -924,64 +932,6 @@ export class Cacheable extends Hookified { return result; } - /** - * Invalidates every cache entry that was set with the given tag. This is a constant-time - * operation regardless of how many entries reference the tag: the tag's version counter is - * bumped and stale entries are detected lazily (and removed) on their next `get`. - * - * [Learn more about tag-based invalidation](https://cacheable.org/docs/cacheable/#tag-based-invalidation). - * - * @param {string} tag The tag to invalidate - * @returns {Promise} - * @example - * ```typescript - * const cache = new Cacheable(); - * await cache.set('page:/products', html, { ttl: '10m', tags: ['entity:42'] }); - * await cache.invalidateTag('entity:42'); - * await cache.get('page:/products'); // undefined - * ``` - */ - public async invalidateTag(tag: string): Promise { - await this.invalidateTags([tag]); - } - - /** - * Invalidates every cache entry that was set with any of the given tags. This is a - * constant-time operation regardless of how many entries reference the tags: each tag's - * version counter is bumped and stale entries are detected lazily (and removed) on their - * next `get`. - * - * [Learn more about tag-based invalidation](https://cacheable.org/docs/cacheable/#tag-based-invalidation). - * - * @param {string[]} tags The tags to invalidate - * @returns {Promise} - */ - public async invalidateTags(tags: string[]): Promise { - if (tags.length === 0) { - return; - } - - try { - await this.tags.invalidateTags(tags); - } catch (error: unknown) { - this.emit(CacheableEvents.ERROR, error); - } - } - - /** - * Returns the tags associated with a key, or `undefined` if the key was not set with tags. - * @param {string} key The key to look up - * @returns {Promise} The tags for the key - */ - public async getTags(key: string): Promise { - try { - return await this.tags.getKeyTags(key); - } catch (error: unknown) { - this.emit(CacheableEvents.ERROR, error); - return undefined; - } - } - /** * Clears the primary store. If the secondary store is set then it will also clear the secondary store. * @returns {Promise} @@ -1160,64 +1110,23 @@ export class Cacheable extends Hookified { return true; } - /** - * Writes the tag snapshot for a key and enables tag freshness checks. In non-blocking mode the - * write is fire-and-forget and errors are emitted instead of thrown. - */ - private async setKeyTags( - key: string, - tags: string[], - ttl: number | undefined, - nonBlocking: boolean, - ): Promise { - const promise = this.tags.setKeyTags(key, tags, { ttl }); - if (nonBlocking) { - promise.catch((error) => { - this.emit(CacheableEvents.ERROR, error); - }); - return; - } - - await promise; - } - - /** - * Removes the tag snapshots for keys so a stale snapshot cannot invalidate a fresh value. In - * non-blocking mode the removal is fire-and-forget and errors are emitted instead of thrown. - */ - private async removeKeyTags( - keys: string[], - nonBlocking: boolean, - ): Promise { - const promise = this.tags.removeKeys(keys); - if (nonBlocking) { - promise.catch((error) => { - this.emit(CacheableEvents.ERROR, error); - }); - return; - } - - await promise; - } - /** * Writes tag snapshots for `setMany` items that carry tags and removes any previous snapshots * for items that do not. */ private async setManyKeyTags(items: CacheableSetItem[]): Promise { - const taggedItems = items.filter( - (item) => item.tags && item.tags.length > 0, - ); - if (taggedItems.length === 0 && !this._tagsEnabled) { - return; - } - const maxTtlMs = shorthandToMilliseconds(this._maxTtl); // The tag snapshot lives in the same store as the tag service, so it should // expire alongside the copy of the value held there. const tagStoreTtl = (this._secondary ?? this._primary).ttl; const promises = []; - for (const item of taggedItems) { + const untaggedKeys: string[] = []; + for (const item of items) { + if (!item.tags || item.tags.length === 0) { + untaggedKeys.push(item.key); + continue; + } + let ttl = getCascadingTtl( this._ttl, tagStoreTtl, @@ -1225,20 +1134,19 @@ export class Cacheable extends Hookified { ); ttl = this.capTtl(ttl, maxTtlMs); promises.push( - this.setKeyTags( - item.key, - item.tags as string[], + this._tags.setKeyTags(item.key, item.tags, { ttl, - this._nonBlocking, - ), + nonBlocking: this._nonBlocking, + }), ); } - const untaggedKeys = items - .filter((item) => !item.tags || item.tags.length === 0) - .map((item) => item.key); if (untaggedKeys.length > 0) { - promises.push(this.removeKeyTags(untaggedKeys, this._nonBlocking)); + promises.push( + this._tags.removeKeys(untaggedKeys, { + nonBlocking: this._nonBlocking, + }), + ); } await Promise.all(promises); diff --git a/packages/cacheable/src/types.ts b/packages/cacheable/src/types.ts index 776cfd8e..6226fea5 100644 --- a/packages/cacheable/src/types.ts +++ b/packages/cacheable/src/types.ts @@ -65,9 +65,9 @@ export type CacheableOptions = { */ sync?: CacheableSync | CacheableSyncOptions; /** - * Enables tag-based invalidation freshness checks on every `get` / `getMany`. Tag support is - * automatically enabled the first time you use a tag feature on this instance (setting a value - * with `tags`, calling `invalidateTag` / `invalidateTags`, or accessing the `tags` service). + * Enables the tag service so freshness checks run on every `get` / `getMany`. The service is + * also enabled automatically the first time a tag is written on this instance (setting a value + * with `tags` or invalidating a tag via `tags.invalidateTag` / `tags.invalidateTags`). * Set this to `true` for instances that only read tagged entries written by other instances so * they also honor invalidations. Default is `false`. */ diff --git a/packages/cacheable/test/tags.test.ts b/packages/cacheable/test/tags.test.ts index 51757d21..006a1f81 100644 --- a/packages/cacheable/test/tags.test.ts +++ b/packages/cacheable/test/tags.test.ts @@ -3,27 +3,42 @@ import { Keyv } from "keyv"; import { describe, expect, test, vi } from "vitest"; import { Cacheable, CacheableEvents, CacheTags } from "../src/index.js"; -describe("cacheable tags", () => { - test("tagsEnabled is false by default and true via the constructor option", () => { - const cacheable = new Cacheable(); - expect(cacheable.tagsEnabled).toBe(false); - const enabled = new Cacheable({ tags: true }); - expect(enabled.tagsEnabled).toBe(true); - }); +const TAG_PREFIX = "--cacheable--tags--"; - test("tags getter returns a CacheTags service and enables tag checks", () => { +describe("cacheable tags", () => { + test("tag service is created by default and disabled until used", () => { const cacheable = new Cacheable(); expect(cacheable.tags).toBeInstanceOf(CacheTags); - expect(cacheable.tagsEnabled).toBe(true); + expect(cacheable.tags.enabled).toBe(false); + // accessing the getter does not enable the service + expect(cacheable.tags.enabled).toBe(false); // same instance on repeat access expect(cacheable.tags).toBe(cacheable.tags); }); - test("tags service is recreated when stores change", () => { + test("tags option enables the service in the constructor", () => { + const cacheable = new Cacheable({ tags: true }); + expect(cacheable.tags.enabled).toBe(true); + }); + + test("setting a value with tags enables the service", async () => { const cacheable = new Cacheable(); + await cacheable.set("k", "v", { tags: ["t"] }); + expect(cacheable.tags.enabled).toBe(true); + }); + + test("invalidating a tag enables the service", async () => { + const cacheable = new Cacheable(); + await cacheable.tags.invalidateTag("t"); + expect(cacheable.tags.enabled).toBe(true); + }); + + test("tag service is recreated when stores change and keeps enabled state", () => { + const cacheable = new Cacheable({ tags: true }); const first = cacheable.tags; cacheable.primary = new Keyv(); expect(cacheable.tags).not.toBe(first); + expect(cacheable.tags.enabled).toBe(true); const second = cacheable.tags; cacheable.secondary = new Keyv(); @@ -36,6 +51,7 @@ describe("cacheable tags", () => { const fourth = cacheable.tags; cacheable.setSecondary(new Keyv()); expect(cacheable.tags).not.toBe(fourth); + expect(cacheable.tags.enabled).toBe(true); }); test("tags service uses the secondary store when one is set", () => { @@ -52,7 +68,7 @@ describe("cacheable tags", () => { const value = faker.string.alpha(10); await cacheable.set(key, value, { tags: ["entity:42"] }); expect(await cacheable.get(key)).toEqual(value); - await cacheable.invalidateTag("entity:42"); + await cacheable.tags.invalidateTag("entity:42"); expect(await cacheable.get(key)).toBeUndefined(); }); @@ -77,7 +93,7 @@ describe("cacheable tags", () => { await cacheable.set("tagged", "one", { tags: ["posts"] }); await cacheable.set("other", "two", { tags: ["users"] }); await cacheable.set("untagged", "three"); - await cacheable.invalidateTag("posts"); + await cacheable.tags.invalidateTag("posts"); expect(await cacheable.get("tagged")).toBeUndefined(); expect(await cacheable.get("other")).toEqual("two"); expect(await cacheable.get("untagged")).toEqual("three"); @@ -88,7 +104,7 @@ describe("cacheable tags", () => { await cacheable.set("a", 1, { tags: ["x"] }); await cacheable.set("b", 2, { tags: ["y"] }); await cacheable.set("c", 3, { tags: ["z"] }); - await cacheable.invalidateTags(["x", "y"]); + await cacheable.tags.invalidateTags(["x", "y"]); expect(await cacheable.getMany(["a", "b", "c"])).toEqual([ undefined, undefined, @@ -96,10 +112,10 @@ describe("cacheable tags", () => { ]); }); - test("invalidateTags with an empty list is a no-op", async () => { + test("invalidateTags with an empty list does not enable the service", async () => { const cacheable = new Cacheable(); - await cacheable.invalidateTags([]); - expect(cacheable.tagsEnabled).toBe(false); + await cacheable.tags.invalidateTags([]); + expect(cacheable.tags.enabled).toBe(false); }); test("stale entries are removed from the stores on get", async () => { @@ -107,18 +123,18 @@ describe("cacheable tags", () => { const cacheable = new Cacheable({ secondary }); const key = faker.string.uuid(); await cacheable.set(key, "value", { tags: ["t"] }); - await cacheable.invalidateTag("t"); + await cacheable.tags.invalidateTag("t"); expect(await cacheable.get(key)).toBeUndefined(); expect(await cacheable.primary.has(key)).toBe(false); expect(await secondary.has(key)).toBe(false); - expect(await cacheable.getTags(key)).toBeUndefined(); + expect(await cacheable.tags.getTags(key)).toBeUndefined(); }); test("getTags returns the tags for a key", async () => { const cacheable = new Cacheable(); await cacheable.set("k", "v", { tags: ["a", "b"] }); - expect(await cacheable.getTags("k")).toEqual(["a", "b"]); - expect(await cacheable.getTags("missing")).toBeUndefined(); + expect(await cacheable.tags.getTags("k")).toEqual(["a", "b"]); + expect(await cacheable.tags.getTags("missing")).toBeUndefined(); }); test("re-setting a key without tags clears its previous snapshot", async () => { @@ -126,15 +142,15 @@ describe("cacheable tags", () => { const key = faker.string.uuid(); await cacheable.set(key, "tagged", { tags: ["t"] }); await cacheable.set(key, "untagged"); - await cacheable.invalidateTag("t"); + await cacheable.tags.invalidateTag("t"); expect(await cacheable.get(key)).toEqual("untagged"); - expect(await cacheable.getTags(key)).toBeUndefined(); + expect(await cacheable.tags.getTags(key)).toBeUndefined(); }); test("set with an empty tags array does not write a snapshot", async () => { const cacheable = new Cacheable(); await cacheable.set("k", "v", { tags: [] }); - expect(await cacheable.getTags("k")).toBeUndefined(); + expect(cacheable.tags.enabled).toBe(false); expect(await cacheable.get("k")).toEqual("v"); }); @@ -142,7 +158,7 @@ describe("cacheable tags", () => { const cacheable = new Cacheable(); await cacheable.set("k", "v", { tags: ["t"] }); await cacheable.delete("k"); - expect(await cacheable.getTags("k")).toBeUndefined(); + expect(await cacheable.tags.getTags("k")).toBeUndefined(); }); test("deleteMany removes the tag snapshots", async () => { @@ -152,8 +168,8 @@ describe("cacheable tags", () => { { key: "b", value: 2, tags: ["t"] }, ]); await cacheable.deleteMany(["a", "b"]); - expect(await cacheable.getTags("a")).toBeUndefined(); - expect(await cacheable.getTags("b")).toBeUndefined(); + expect(await cacheable.tags.getTags("a")).toBeUndefined(); + expect(await cacheable.tags.getTags("b")).toBeUndefined(); }); test("setMany supports tags per item and getMany honors invalidation", async () => { @@ -165,31 +181,31 @@ describe("cacheable tags", () => { ]); // all entries are fresh before invalidation expect(await cacheable.getMany(["a", "b", "c"])).toEqual([1, 2, 3]); - await cacheable.invalidateTag("x"); + await cacheable.tags.invalidateTag("x"); expect(await cacheable.getMany(["a", "b", "c"])).toEqual([undefined, 2, 3]); }); - test("setMany without any tags does not enable tag checks", async () => { + test("setMany without any tags does not enable the tag service", async () => { const cacheable = new Cacheable(); await cacheable.setMany([ { key: "a", value: 1 }, { key: "b", value: 2 }, ]); - expect(cacheable.tagsEnabled).toBe(false); + expect(cacheable.tags.enabled).toBe(false); }); test("setMany clears previous snapshots for items set without tags", async () => { const cacheable = new Cacheable(); await cacheable.set("a", "tagged", { tags: ["t"] }); await cacheable.setMany([{ key: "a", value: "untagged" }]); - await cacheable.invalidateTag("t"); + await cacheable.tags.invalidateTag("t"); expect(await cacheable.get("a")).toEqual("untagged"); }); test("take returns undefined for a stale entry", async () => { const cacheable = new Cacheable(); await cacheable.set("k", "v", { tags: ["t"] }); - await cacheable.invalidateTag("t"); + await cacheable.tags.invalidateTag("t"); expect(await cacheable.take("k")).toBeUndefined(); }); @@ -204,7 +220,7 @@ describe("cacheable tags", () => { expect(await reader.get(key)).toEqual("value"); expect(await reader.primary.has(key)).toBe(true); - await writer.invalidateTag("entity:42"); + await writer.tags.invalidateTag("entity:42"); expect(await reader.get(key)).toBeUndefined(); // the stale copy is purged from the reader's primary as well expect(await reader.primary.has(key)).toBe(false); @@ -213,18 +229,18 @@ describe("cacheable tags", () => { test("tag snapshots expire with the entry ttl", async () => { const cacheable = new Cacheable(); await cacheable.set("k", "v", { ttl: 30, tags: ["t"] }); - expect(await cacheable.getTags("k")).toEqual(["t"]); + expect(await cacheable.tags.getTags("k")).toEqual(["t"]); await new Promise((resolve) => setTimeout(resolve, 50)); expect(await cacheable.get("k")).toBeUndefined(); - expect(await cacheable.getTags("k")).toBeUndefined(); + expect(await cacheable.tags.getTags("k")).toBeUndefined(); }); test("set with tags in non-blocking mode still records the snapshot", async () => { const cacheable = new Cacheable({ nonBlocking: true }); await cacheable.set("k", "v", { tags: ["t"] }); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(await cacheable.getTags("k")).toEqual(["t"]); - await cacheable.invalidateTag("t"); + expect(await cacheable.tags.getTags("k")).toEqual(["t"]); + await cacheable.tags.invalidateTag("t"); expect(await cacheable.get("k")).toBeUndefined(); }); @@ -233,7 +249,7 @@ describe("cacheable tags", () => { await cacheable.set("k", "v", { nonBlocking: true, tags: ["t"] }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(await cacheable.get("k")).toEqual("v"); - expect(await cacheable.getTags("k")).toEqual(["t"]); + expect(await cacheable.tags.getTags("k")).toEqual(["t"]); }); test("delete in non-blocking mode removes the tag snapshot", async () => { @@ -242,46 +258,28 @@ describe("cacheable tags", () => { cacheable.nonBlocking = true; await cacheable.delete("k"); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(await cacheable.getTags("k")).toBeUndefined(); + expect(await cacheable.tags.getTags("k")).toBeUndefined(); }); test("setMany with tags in non-blocking mode records snapshots", async () => { const cacheable = new Cacheable({ nonBlocking: true }); await cacheable.setMany([{ key: "a", value: 1, tags: ["t"] }]); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(await cacheable.getTags("a")).toEqual(["t"]); - }); - - test("emits an error when invalidateTags fails", async () => { - const cacheable = new Cacheable(); - vi.spyOn(cacheable.tags, "invalidateTags").mockRejectedValueOnce( - new Error("tag store down"), - ); - let errored: unknown; - cacheable.on(CacheableEvents.ERROR, (error: unknown) => { - errored = error; - }); - await cacheable.invalidateTags(["t"]); - expect(errored).toBeInstanceOf(Error); - }); - - test("emits an error when getTags fails", async () => { - const cacheable = new Cacheable(); - vi.spyOn(cacheable.tags, "getKeyTags").mockRejectedValueOnce( - new Error("tag store down"), - ); - let errored: unknown; - cacheable.on(CacheableEvents.ERROR, (error: unknown) => { - errored = error; - }); - expect(await cacheable.getTags("k")).toBeUndefined(); - expect(errored).toBeInstanceOf(Error); + expect(await cacheable.tags.getTags("a")).toEqual(["t"]); }); test("emits an error when a non-blocking snapshot write fails", async () => { const cacheable = new Cacheable({ nonBlocking: true }); - vi.spyOn(cacheable.tags, "setKeyTags").mockRejectedValueOnce( - new Error("tag store down"), + const store = cacheable.tags.store; + const originalSet = store.set.bind(store); + vi.spyOn(store, "set").mockImplementation( + async (key: string, value: unknown, ttl?: number) => { + if (key.startsWith(TAG_PREFIX)) { + throw new Error("tag store down"); + } + + return originalSet(key, value, ttl); + }, ); let errored: unknown; cacheable.on(CacheableEvents.ERROR, (error: unknown) => { @@ -294,7 +292,7 @@ describe("cacheable tags", () => { test("emits an error when a non-blocking snapshot removal fails", async () => { const cacheable = new Cacheable({ nonBlocking: true, tags: true }); - vi.spyOn(cacheable.tags, "removeKeys").mockRejectedValueOnce( + vi.spyOn(cacheable.tags.store, "deleteMany").mockRejectedValueOnce( new Error("tag store down"), ); let errored: unknown; @@ -309,7 +307,7 @@ describe("cacheable tags", () => { test("clear removes values, snapshots, and tag versions", async () => { const cacheable = new Cacheable(); await cacheable.set("k", "v", { tags: ["t"] }); - await cacheable.invalidateTag("t"); + await cacheable.tags.invalidateTag("t"); await cacheable.clear(); await cacheable.set("k", "v2", { tags: ["t"] }); expect(await cacheable.get("k")).toEqual("v2"); diff --git a/packages/utils/README.md b/packages/utils/README.md index 3d78e276..5487d092 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -785,12 +785,43 @@ await cacheTags.invalidateTag('users'); console.log(await cacheTags.isKeyStale('user:42')); // true ``` -The `getKeyTags` method returns the tags currently associated with a key, or `undefined` if the key has no snapshot: +The `getStaleKeys` method checks many keys at once using two batched store reads regardless of how many keys are passed — one for the snapshots and one for the union of their tag versions: + +```typescript +await cacheTags.setKeyTags('a', ['x']); +await cacheTags.setKeyTags('b', ['y']); +await cacheTags.invalidateTag('x'); +console.log(await cacheTags.getStaleKeys(['a', 'b', 'untagged'])); // ['a'] +``` + +The `getTags` method returns the tags currently associated with a key, or `undefined` if the key has no snapshot: ```typescript await cacheTags.setKeyTags('user:42', ['users', 'org:7']); -console.log(await cacheTags.getKeyTags('user:42')); // ['users', 'org:7'] -console.log(await cacheTags.getKeyTags('missing')); // undefined +console.log(await cacheTags.getTags('user:42')); // ['users', 'org:7'] +console.log(await cacheTags.getTags('missing')); // undefined +``` + +The `removeKey` and `removeKeys` methods delete tag snapshots when the cached values themselves are deleted. `removeKeys` performs a single batched delete: + +```typescript +await cacheTags.removeKeys(['user:1', 'user:2']); +``` + +The service can be disabled via the `enabled` option or property so integrations pay no extra store reads for untagged workloads. While disabled, read methods are no-ops (`isKeyFresh` returns `true`, `isKeyStale` returns `false`, `getStaleKeys` returns `[]`, and so on) and snapshot removals are skipped. Tag writes — `setKeyTags`, `invalidateTag`, and `invalidateTags` — automatically re-enable the service: + +```typescript +const cacheTags = new CacheTags({ store, enabled: false }); +console.log(await cacheTags.isKeyStale('anything')); // false, no store read +await cacheTags.setKeyTags('user:42', ['users']); // re-enables the service +console.log(cacheTags.enabled); // true +``` + +`setKeyTags`, `removeKey`, and `removeKeys` accept a `nonBlocking` option to fire-and-forget the store write. Failures from non-blocking operations are reported to the `onError` constructor option since they cannot be thrown to the caller: + +```typescript +const cacheTags = new CacheTags({ store, onError: (error) => console.error(error) }); +await cacheTags.setKeyTags('user:42', ['users'], { ttl: 3600000, nonBlocking: true }); ``` The `getKeysByTag` method returns the keys currently referencing a given tag. It iterates the Keyv namespace and is therefore an `O(N)` operation. It is intended for debugging and tests rather than hot paths. diff --git a/packages/utils/src/cache-tags.ts b/packages/utils/src/cache-tags.ts index c159b953..09011f47 100644 --- a/packages/utils/src/cache-tags.ts +++ b/packages/utils/src/cache-tags.ts @@ -6,10 +6,19 @@ import type { Keyv } from "keyv"; * @property {Keyv} store - The Keyv store used to persist tag versions and key snapshots. * @property {string} [namespace] - An optional namespace that isolates this service's tags * and keys from others sharing the same store. Defaults to `"default"`. + * @property {boolean} [enabled] - Whether the service is enabled. When disabled, read methods + * are no-ops ({@link CacheTags.isKeyFresh} returns `true`, {@link CacheTags.isKeyStale} + * returns `false`, etc.) and snapshot writes/removals are skipped. Tag writes + * ({@link CacheTags.setKeyTags}, {@link CacheTags.invalidateTag}, + * {@link CacheTags.invalidateTags}) automatically re-enable the service. Defaults to `true`. + * @property {(error: unknown) => void} [onError] - Invoked with errors from non-blocking + * (fire-and-forget) operations, which cannot be thrown to the caller. Defaults to ignoring them. */ export type CacheTagsOptions = { store: Keyv; namespace?: string; + enabled?: boolean; + onError?: (error: unknown) => void; }; /** @@ -18,9 +27,22 @@ export type CacheTagsOptions = { * @property {number} [ttl] - Time-to-live in milliseconds for the key's tag snapshot. Should * match the TTL of the cached value it tracks so the snapshot expires alongside it. If omitted, * the snapshot does not expire. + * @property {boolean} [nonBlocking] - When `true`, the snapshot write is fire-and-forget: + * the call resolves immediately and failures are reported via the `onError` option. */ export type SetKeyTagsOptions = { ttl?: number; + nonBlocking?: boolean; +}; + +/** + * Options for {@link CacheTags.removeKey} and {@link CacheTags.removeKeys}. + * @typedef {Object} RemoveKeysOptions + * @property {boolean} [nonBlocking] - When `true`, the removal is fire-and-forget: + * the call resolves immediately and failures are reported via the `onError` option. + */ +export type RemoveKeysOptions = { + nonBlocking?: boolean; }; /** @@ -56,6 +78,11 @@ const DEFAULT_NAMESPACE = "default"; * This keeps invalidation constant-time regardless of how many keys reference a tag, at the cost of * one additional `isKeyFresh` read per cache lookup. * + * The service can be disabled via the `enabled` option or property so integrations pay no cost for + * untagged workloads: while disabled, read methods are no-ops and snapshot writes/removals are + * skipped. Using a tag write ({@link CacheTags.setKeyTags}, {@link CacheTags.invalidateTag}, + * {@link CacheTags.invalidateTags}) automatically re-enables it. + * * All metadata is written under a reserved prefix so it cannot collide with user keys: * - `--cacheable--tags--::tag:` → integer version counter (stored without TTL). * - `--cacheable--tags--::key:` → the {@link KeyTagEntry} snapshot. @@ -76,14 +103,19 @@ const DEFAULT_NAMESPACE = "default"; export class CacheTags { private readonly _store: Keyv; private readonly _namespace: string; + private _enabled: boolean; + private readonly _onError?: (error: unknown) => void; /** * Creates a new tag service. - * @param {CacheTagsOptions} options - The store and optional namespace to use. + * @param {CacheTagsOptions} options - The store, optional namespace, enabled state, and + * non-blocking error handler to use. */ constructor(options: CacheTagsOptions) { this._store = options.store; this._namespace = options.namespace ?? DEFAULT_NAMESPACE; + this._enabled = options.enabled ?? true; + this._onError = options.onError; } /** @@ -102,6 +134,25 @@ export class CacheTags { return this._namespace; } + /** + * Whether the service is enabled. While disabled, read methods are no-ops and snapshot + * writes/removals are skipped, so integrations pay no extra store reads for untagged + * workloads. Tag writes ({@link CacheTags.setKeyTags}, {@link CacheTags.invalidateTag}, + * {@link CacheTags.invalidateTags}) automatically re-enable the service. + * @returns {boolean} Whether the service is enabled. + */ + public get enabled(): boolean { + return this._enabled; + } + + /** + * Sets whether the service is enabled. + * @param {boolean} enabled Whether the service is enabled. + */ + public set enabled(enabled: boolean) { + this._enabled = enabled; + } + /** * Builds the reserved store key under which a tag's version counter is stored. * @param tag - The tag name. @@ -158,17 +209,24 @@ export class CacheTags { } /** - * Associates a cache key with a set of tags by recording a snapshot of each tag's current - * version. Call this whenever you write a fresh value to the cache. Duplicate tags are ignored. + * Reports a fire-and-forget failure to the `onError` handler, if one was provided. + * @param error - The error raised by the non-blocking operation. + */ + private handleNonBlockingError(error: unknown): void { + this._onError?.(error); + } + + /** + * Reads the version snapshot of each tag and writes the key's tag snapshot to the store. * @param key - The cache key to tag. * @param tags - The tags to associate with the key. - * @param {SetKeyTagsOptions} [options] - Optional settings, such as a `ttl` for the snapshot. + * @param ttl - Time-to-live in milliseconds for the snapshot. * @returns {Promise} Resolves once the snapshot has been written. */ - public async setKeyTags( + private async writeKeyTags( key: string, tags: string[], - options?: SetKeyTagsOptions, + ttl?: number, ): Promise { const uniqueTags = [...new Set(tags)]; const versions = await this.getTagVersions(uniqueTags); @@ -178,43 +236,98 @@ export class CacheTags { } const entry: KeyTagEntry = { tags: snapshot }; - await this._store.set(this.keyEntryKey(key), entry, options?.ttl); + await this._store.set(this.keyEntryKey(key), entry, ttl); + } + + /** + * Associates a cache key with a set of tags by recording a snapshot of each tag's current + * version. Call this whenever you write a fresh value to the cache. Duplicate tags are ignored. + * Automatically enables the service. + * @param key - The cache key to tag. + * @param tags - The tags to associate with the key. + * @param {SetKeyTagsOptions} [options] - Optional settings, such as a `ttl` for the snapshot or + * `nonBlocking` to fire-and-forget the write. + * @returns {Promise} Resolves once the snapshot has been written, or immediately when + * `nonBlocking` is set. + */ + public async setKeyTags( + key: string, + tags: string[], + options?: SetKeyTagsOptions, + ): Promise { + this._enabled = true; + const work = this.writeKeyTags(key, tags, options?.ttl); + if (options?.nonBlocking) { + work.catch((error) => { + this.handleNonBlockingError(error); + }); + return; + } + + await work; } /** * Removes a key's tag snapshot. After this, {@link CacheTags.isKeyFresh} returns `false` - * for the key. Use when the cached value itself is deleted. + * for the key. Use when the cached value itself is deleted. No-op while the service is + * disabled. * @param key - The cache key whose snapshot should be removed. - * @returns {Promise} Resolves once the snapshot has been deleted. + * @param {RemoveKeysOptions} [options] - Optional settings, such as `nonBlocking` to + * fire-and-forget the removal. + * @returns {Promise} Resolves once the snapshot has been deleted, or immediately when + * `nonBlocking` is set. */ - public async removeKey(key: string): Promise { - await this._store.delete(this.keyEntryKey(key)); + public async removeKey( + key: string, + options?: RemoveKeysOptions, + ): Promise { + await this.removeKeys([key], options); } /** * Removes multiple keys' tag snapshots in a single batched store delete. After this, - * {@link CacheTags.isKeyFresh} returns `false` for each key. An empty list is a no-op. + * {@link CacheTags.isKeyFresh} returns `false` for each key. An empty list is a no-op, as is + * the entire call while the service is disabled. * @param keys - The cache keys whose snapshots should be removed. - * @returns {Promise} Resolves once the snapshots have been deleted. + * @param {RemoveKeysOptions} [options] - Optional settings, such as `nonBlocking` to + * fire-and-forget the removal. + * @returns {Promise} Resolves once the snapshots have been deleted, or immediately when + * `nonBlocking` is set. */ - public async removeKeys(keys: string[]): Promise { - if (keys.length === 0) { + public async removeKeys( + keys: string[], + options?: RemoveKeysOptions, + ): Promise { + if (!this._enabled || keys.length === 0) { return; } const entryKeys = keys.map((key) => this.keyEntryKey(key)); - await this._store.deleteMany(entryKeys); + const work = this._store.deleteMany(entryKeys); + if (options?.nonBlocking) { + work.catch((error) => { + this.handleNonBlockingError(error); + }); + return; + } + + await work; } /** * Determines whether a key's cached value can still be trusted. A key is fresh only when a * snapshot exists for it and every tag in that snapshot still has the version it had at set time. * A key with no tags is trivially fresh. Call this before returning a value from your cache. + * Always returns `true` while the service is disabled. * @param key - The cache key to check. * @returns {Promise} `true` if the key is still fresh; `false` if it is unknown or any of * its tags has been invalidated since the snapshot was taken. */ public async isKeyFresh(key: string): Promise { + if (!this._enabled) { + return true; + } + const entry = await this._store.get(this.keyEntryKey(key)); if (!entry?.tags) { return false; @@ -236,37 +349,82 @@ export class CacheTags { * Determines whether a key's cached value is known to be stale due to tag invalidation. This is * the complement of {@link CacheTags.isKeyFresh} for tagged keys, but treats keys without a * snapshot as not stale — making it safe to call for every cache lookup, including keys that were - * never tagged. + * never tagged. Always returns `false` while the service is disabled. * @param key - The cache key to check. * @returns {Promise} `true` only when a snapshot exists for the key and at least one of * its tags has been invalidated since the snapshot was taken; `false` otherwise (including when * the key has no snapshot). */ public async isKeyStale(key: string): Promise { - const entry = await this._store.get(this.keyEntryKey(key)); - if (!entry?.tags) { + if (!this._enabled) { return false; } - const tags = Object.keys(entry.tags); - const currentVersions = await this.getTagVersions(tags); + const staleKeys = await this.getStaleKeys([key]); + return staleKeys.length > 0; + } + /** + * Determines which of the given keys are known to be stale due to tag invalidation, using two + * batched store reads regardless of how many keys are checked: one for the snapshots and one for + * the union of their tag versions. Keys without a snapshot are not considered stale. Returns an + * empty array while the service is disabled. + * @param keys - The cache keys to check. + * @returns {Promise} The subset of `keys` whose snapshot references at least one tag + * that has been invalidated since the snapshot was taken. + */ + public async getStaleKeys(keys: string[]): Promise { + if (!this._enabled || keys.length === 0) { + return []; + } + + const entryKeys = keys.map((key) => this.keyEntryKey(key)); + const entries = await this._store.get(entryKeys); + + const tagSet = new Set(); + for (const entry of entries) { + if (entry?.tags) { + for (const tag of Object.keys(entry.tags)) { + tagSet.add(tag); + } + } + } + + const tags = [...tagSet]; + const versions = await this.getTagVersions(tags); + const currentVersions = new Map(); for (let i = 0; i < tags.length; i++) { - if (currentVersions[i] !== entry.tags[tags[i]]) { - return true; + currentVersions.set(tags[i], versions[i]); + } + + const staleKeys: string[] = []; + for (const [i, entry] of entries.entries()) { + if (!entry?.tags) { + continue; + } + for (const [tag, version] of Object.entries(entry.tags)) { + if (currentVersions.get(tag) !== version) { + staleKeys.push(keys[i]); + break; + } } } - return false; + return staleKeys; } /** - * Returns the tags currently associated with a key. + * Returns the tags currently associated with a key. Returns `undefined` while the service is + * disabled. * @param key - The cache key to look up. * @returns {Promise} The tag names from the key's snapshot, or `undefined` * if the key has no snapshot. */ - public async getKeyTags(key: string): Promise { + public async getTags(key: string): Promise { + if (!this._enabled) { + return undefined; + } + const entry = await this._store.get(this.keyEntryKey(key)); if (!entry?.tags) { return undefined; @@ -278,12 +436,17 @@ export class CacheTags { /** * Returns all cache keys whose snapshot references the given tag. This scans every key entry in * the namespace via the Keyv iterator, making it an `O(N)` operation intended for debugging and - * tests rather than hot paths. Returns an empty array if the underlying store exposes no iterator. + * tests rather than hot paths. Returns an empty array if the underlying store exposes no iterator + * or while the service is disabled. * @param tag - The tag to search for. * @returns {Promise} The cache keys (with the reserved prefix stripped) referencing the tag. */ public async getKeysByTag(tag: string): Promise { const result: string[] = []; + if (!this._enabled) { + return result; + } + const prefix = this.keyPrefix(); const iterator = this._store.iterator?.(this._store.namespace); if (!iterator) { @@ -306,11 +469,12 @@ export class CacheTags { /** * Invalidates a single tag by incrementing its version counter. Every key whose snapshot * references this tag becomes stale immediately. Runs in constant time regardless of how many - * keys reference the tag. + * keys reference the tag. Automatically enables the service. * @param tag - The tag to invalidate. * @returns {Promise} A single-element array containing the invalidated tag. */ public async invalidateTag(tag: string): Promise { + this._enabled = true; const current = await this.getTagVersion(tag); await this._store.set(this.tagKey(tag), current + 1); return [tag]; @@ -318,7 +482,8 @@ export class CacheTags { /** * Invalidates multiple tags by incrementing each of their version counters in a single batched - * store write. Duplicate tags are bumped once. An empty list is a no-op. + * store write. Duplicate tags are bumped once. An empty list is a no-op. Automatically enables + * the service. * @param tags - The tags to invalidate. * @returns {Promise} The `tags` argument as provided (including any duplicates). */ @@ -327,6 +492,8 @@ export class CacheTags { if (uniqueTags.length === 0) { return tags; } + + this._enabled = true; const versions = await this.getTagVersions(uniqueTags); const kvPairs = []; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7d91323a..6fba730e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -6,6 +6,7 @@ export { CacheTags, type CacheTagsOptions, type KeyTagEntry, + type RemoveKeysOptions, type SetKeyTagsOptions, } from "./cache-tags.js"; export type { diff --git a/packages/utils/test/cache-tags.test.ts b/packages/utils/test/cache-tags.test.ts index 6c8b4685..52f33128 100644 --- a/packages/utils/test/cache-tags.test.ts +++ b/packages/utils/test/cache-tags.test.ts @@ -1,5 +1,5 @@ import { Keyv } from "keyv"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { CacheTags } from "../src/cache-tags.js"; import { sleep } from "../src/sleep.js"; @@ -200,20 +200,120 @@ describe("CacheTags", () => { expect(await service.isKeyStale("user:1")).toBe(false); }); - test("getKeyTags returns the tags for a key", async () => { + test("getTags returns the tags for a key", async () => { const service = createService(); await service.setKeyTags("user:1", ["users", "org:7"]); - expect(await service.getKeyTags("user:1")).toEqual(["users", "org:7"]); + expect(await service.getTags("user:1")).toEqual(["users", "org:7"]); }); - test("getKeyTags returns undefined for an unknown key", async () => { + test("getTags returns undefined for an unknown key", async () => { const service = createService(); - expect(await service.getKeyTags("nope")).toBeUndefined(); + expect(await service.getTags("nope")).toBeUndefined(); }); - test("getKeyTags returns empty array for a key tagged with no tags", async () => { + test("getTags returns empty array for a key tagged with no tags", async () => { const service = createService(); await service.setKeyTags("empty", []); - expect(await service.getKeyTags("empty")).toEqual([]); + expect(await service.getTags("empty")).toEqual([]); + }); + + test("enabled defaults to true and can be toggled", () => { + const service = createService(); + expect(service.enabled).toBe(true); + service.enabled = false; + expect(service.enabled).toBe(false); + }); + + test("disabled service treats read methods as no-ops", async () => { + const service = new CacheTags({ store: new Keyv(), enabled: false }); + expect(await service.isKeyFresh("k")).toBe(true); + expect(await service.isKeyStale("k")).toBe(false); + expect(await service.getTags("k")).toBeUndefined(); + expect(await service.getKeysByTag("t")).toEqual([]); + expect(await service.getStaleKeys(["k"])).toEqual([]); + }); + + test("disabled service skips snapshot removal", async () => { + const service = createService(); + await service.setKeyTags("k", ["t"]); + service.enabled = false; + await service.removeKeys(["k"]); + service.enabled = true; + expect(await service.isKeyFresh("k")).toBe(true); + }); + + test("setKeyTags re-enables a disabled service", async () => { + const service = new CacheTags({ store: new Keyv(), enabled: false }); + await service.setKeyTags("k", ["t"]); + expect(service.enabled).toBe(true); + expect(await service.isKeyFresh("k")).toBe(true); + }); + + test("invalidateTag and invalidateTags re-enable a disabled service", async () => { + const single = new CacheTags({ store: new Keyv(), enabled: false }); + await single.invalidateTag("t"); + expect(single.enabled).toBe(true); + + const many = new CacheTags({ store: new Keyv(), enabled: false }); + await many.invalidateTags(["a", "b"]); + expect(many.enabled).toBe(true); + }); + + test("invalidateTags with an empty list does not enable the service", async () => { + const service = new CacheTags({ store: new Keyv(), enabled: false }); + await service.invalidateTags([]); + expect(service.enabled).toBe(false); + }); + + test("getStaleKeys returns only stale keys", async () => { + const service = createService(); + await service.setKeyTags("a", ["x"]); + await service.setKeyTags("b", ["y"]); + await service.setKeyTags("c", ["x", "z"]); + await service.invalidateTag("x"); + const staleKeys = await service.getStaleKeys(["a", "b", "c", "untagged"]); + expect(staleKeys.sort()).toEqual(["a", "c"]); + }); + + test("getStaleKeys with an empty list returns an empty array", async () => { + const service = createService(); + expect(await service.getStaleKeys([])).toEqual([]); + }); + + test("non-blocking setKeyTags reports failures via onError", async () => { + const store = new Keyv(); + const errors: unknown[] = []; + const service = new CacheTags({ + store, + onError: (error) => errors.push(error), + }); + vi.spyOn(store, "set").mockRejectedValueOnce(new Error("down")); + await service.setKeyTags("k", ["t"], { nonBlocking: true }); + await sleep(10); + expect(errors).toHaveLength(1); + }); + + test("non-blocking removeKeys reports failures via onError", async () => { + const store = new Keyv(); + const errors: unknown[] = []; + const service = new CacheTags({ + store, + onError: (error) => errors.push(error), + }); + await service.setKeyTags("k", ["t"]); + vi.spyOn(store, "deleteMany").mockRejectedValueOnce(new Error("down")); + await service.removeKeys(["k"], { nonBlocking: true }); + await sleep(10); + expect(errors).toHaveLength(1); + }); + + test("non-blocking failures without onError are swallowed", async () => { + const store = new Keyv(); + const service = new CacheTags({ store }); + await service.setKeyTags("k", ["t"]); + vi.spyOn(store, "deleteMany").mockRejectedValueOnce(new Error("down")); + await service.removeKeys(["k"], { nonBlocking: true }); + await sleep(10); + expect(await service.isKeyFresh("k")).toBe(true); }); }); From 90ec859abcb79ffd4276825da0963b70007096e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 16:15:50 +0000 Subject: [PATCH 4/5] fix(cacheable): require explicit enable for tags, no auto-enable Tag writes no longer auto-enable the CacheTags service. Auto-enable only flipped the local instance, which gives inconsistent behavior across distributed instances sharing a store. Tags must now be enabled explicitly via the tags: true option or tags.enabled = true on every instance; while disabled, all tag operations are no-ops. https://claude.ai/code/session_01CEUJ2ZvfykEnuUfL43PobY --- packages/cacheable/README.md | 10 ++-- packages/cacheable/src/index.ts | 11 ++-- packages/cacheable/src/types.ts | 10 ++-- packages/cacheable/test/tags.test.ts | 79 ++++++++++++-------------- packages/utils/README.md | 6 +- packages/utils/src/cache-tags.ts | 51 ++++++++++------- packages/utils/test/cache-tags.test.ts | 29 ++++------ 7 files changed, 96 insertions(+), 100 deletions(-) diff --git a/packages/cacheable/README.md b/packages/cacheable/README.md index 82960f80..6e7f4ec7 100644 --- a/packages/cacheable/README.md +++ b/packages/cacheable/README.md @@ -646,7 +646,7 @@ You can associate cache entries with tags and later invalidate every entry that ```javascript import { Cacheable } from 'cacheable'; -const cache = new Cacheable(); +const cache = new Cacheable({ tags: true }); await cache.set('page:/products', html, { ttl: '10m', tags: ['entity:42', 'collection:products'] }); await cache.set('page:/products/42', detailHtml, { ttl: '10m', tags: ['entity:42'] }); @@ -677,8 +677,8 @@ Tag metadata is stored in the secondary store when one is configured, otherwise import { Cacheable } from 'cacheable'; import KeyvRedis from '@keyv/redis'; -const writer = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379') }); -// instances that only read tagged entries should opt in with `tags: true` +// enable tags on every instance that shares the store - writers and readers +const writer = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379'), tags: true }); const reader = new Cacheable({ secondary: new KeyvRedis('redis://localhost:6379'), tags: true }); await writer.set('page:/products', html, { tags: ['entity:42'] }); @@ -686,7 +686,7 @@ await writer.tags.invalidateTag('entity:42'); await reader.get('page:/products'); // undefined - stale copy is also purged from reader's primary ``` -The tag service starts disabled so untagged workloads pay no extra cost. It enables automatically the first time a tag is written (setting a value with `tags` or calling `tags.invalidateTag` / `tags.invalidateTags`), or explicitly via the `tags: true` option or the `tags.enabled` property. When using tags across multiple instances that share a store, set `tags: true` on all of them so every instance honors invalidations and cleans up tag snapshots consistently. +The tag service is disabled by default so untagged workloads pay no extra cost, and you have to turn it on to use it — either with the `tags: true` option or by setting `cache.tags.enabled = true`. While disabled, all tag operations are no-ops: values set with `tags` are stored without tag tracking and invalidations have no effect. The service never enables itself, which keeps behavior predictable across distributed instances — enable it on every instance that shares the store (writers and readers) so invalidations are honored and tag snapshots are cleaned up consistently. The full `CacheTags` API is available on the service: @@ -708,7 +708,7 @@ The following options are available for you to configure `cacheable`: * `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Enforced on both primary and secondary stores. Default is `undefined` (no maximum). * `namespace`: The namespace for the cache. Default is `undefined`. * `cacheId`: A unique identifier for this cache instance. Used for sync filtering. Default is a random string. -* `tags`: Enables the tag service so tag-based invalidation freshness checks run on `get` / `getMany`. The service also enables automatically the first time a tag is written on the instance. Default is `false`. +* `tags`: Enables the tag service so tag-based invalidation can be used and freshness checks run on `get` / `getMany`. Tags must be explicitly enabled — while disabled, all tag operations are no-ops. Default is `false`. * `sync`: Enable distributed cache synchronization. Can be: - `CacheableSync` instance - `CacheableSyncOptions` object with `{ qified: MessageProvider | MessageProvider[] | Qified }` diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index fc33a132..9fad3d3e 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -319,17 +319,18 @@ export class Cacheable extends Hookified { * by default in the constructor and persists tag metadata in the secondary store when one is * configured (so invalidations are shared across instances), otherwise the primary store. * - * The service starts disabled so untagged workloads pay no extra store reads; it enables - * automatically when a value is set with `tags` or a tag is invalidated, or explicitly via the - * `tags` option or `tags.enabled` property. While enabled, `get` / `getMany` perform tag - * freshness checks and remove stale entries. + * The service starts disabled so untagged workloads pay no extra store reads, and must be + * explicitly enabled to use tags — via the `tags: true` option or the `tags.enabled` + * property. While disabled, all tag operations are no-ops. Enable it on every instance that + * shares the store so behavior is consistent across distributed instances. While enabled, + * `get` / `getMany` perform tag freshness checks and remove stale entries. * * [Learn more about tag-based invalidation](https://cacheable.org/docs/cacheable/#tag-based-invalidation). * * @returns {CacheTags} The tag service for the cacheable instance * @example * ```typescript - * const cache = new Cacheable(); + * const cache = new Cacheable({ tags: true }); * await cache.set('page:/products', html, { tags: ['entity:42'] }); * await cache.tags.invalidateTag('entity:42'); * await cache.get('page:/products'); // undefined diff --git a/packages/cacheable/src/types.ts b/packages/cacheable/src/types.ts index 6226fea5..e732924c 100644 --- a/packages/cacheable/src/types.ts +++ b/packages/cacheable/src/types.ts @@ -65,11 +65,11 @@ export type CacheableOptions = { */ sync?: CacheableSync | CacheableSyncOptions; /** - * Enables the tag service so freshness checks run on every `get` / `getMany`. The service is - * also enabled automatically the first time a tag is written on this instance (setting a value - * with `tags` or invalidating a tag via `tags.invalidateTag` / `tags.invalidateTags`). - * Set this to `true` for instances that only read tagged entries written by other instances so - * they also honor invalidations. Default is `false`. + * Enables the tag service so tag-based invalidation can be used and freshness checks run on + * every `get` / `getMany`. Tags must be explicitly enabled — while disabled, all tag + * operations are no-ops and values set with `tags` are stored without tag tracking. Enable + * this on every instance that shares the store (writers and readers) so invalidations are + * honored consistently across distributed instances. Default is `false`. */ tags?: boolean; }; diff --git a/packages/cacheable/test/tags.test.ts b/packages/cacheable/test/tags.test.ts index 006a1f81..bf9e19f3 100644 --- a/packages/cacheable/test/tags.test.ts +++ b/packages/cacheable/test/tags.test.ts @@ -6,12 +6,10 @@ import { Cacheable, CacheableEvents, CacheTags } from "../src/index.js"; const TAG_PREFIX = "--cacheable--tags--"; describe("cacheable tags", () => { - test("tag service is created by default and disabled until used", () => { + test("tag service is created by default and disabled until enabled", () => { const cacheable = new Cacheable(); expect(cacheable.tags).toBeInstanceOf(CacheTags); expect(cacheable.tags.enabled).toBe(false); - // accessing the getter does not enable the service - expect(cacheable.tags.enabled).toBe(false); // same instance on repeat access expect(cacheable.tags).toBe(cacheable.tags); }); @@ -21,16 +19,15 @@ describe("cacheable tags", () => { expect(cacheable.tags.enabled).toBe(true); }); - test("setting a value with tags enables the service", async () => { + test("tags are ignored while the service is disabled", async () => { const cacheable = new Cacheable(); await cacheable.set("k", "v", { tags: ["t"] }); - expect(cacheable.tags.enabled).toBe(true); - }); - - test("invalidating a tag enables the service", async () => { - const cacheable = new Cacheable(); - await cacheable.tags.invalidateTag("t"); - expect(cacheable.tags.enabled).toBe(true); + expect(cacheable.tags.enabled).toBe(false); + expect(await cacheable.tags.invalidateTag("t")).toEqual([]); + // no snapshot was written and the value is untouched + cacheable.tags.enabled = true; + expect(await cacheable.tags.getTags("k")).toBeUndefined(); + expect(await cacheable.get("k")).toEqual("v"); }); test("tag service is recreated when stores change and keeps enabled state", () => { @@ -63,7 +60,7 @@ describe("cacheable tags", () => { }); test("set with tags then invalidateTag makes the entry a miss", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); const key = faker.string.uuid(); const value = faker.string.alpha(10); await cacheable.set(key, value, { tags: ["entity:42"] }); @@ -81,7 +78,7 @@ describe("cacheable tags", () => { }); test("set supports ttl inside the options object", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); const key = faker.string.uuid(); await cacheable.set(key, "value", { ttl: "1h", tags: ["a"] }); const raw = await cacheable.getRaw(key); @@ -89,7 +86,7 @@ describe("cacheable tags", () => { }); test("invalidateTag only affects entries with that tag", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("tagged", "one", { tags: ["posts"] }); await cacheable.set("other", "two", { tags: ["users"] }); await cacheable.set("untagged", "three"); @@ -100,7 +97,7 @@ describe("cacheable tags", () => { }); test("invalidateTags invalidates multiple tags at once", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("a", 1, { tags: ["x"] }); await cacheable.set("b", 2, { tags: ["y"] }); await cacheable.set("c", 3, { tags: ["z"] }); @@ -112,15 +109,9 @@ describe("cacheable tags", () => { ]); }); - test("invalidateTags with an empty list does not enable the service", async () => { - const cacheable = new Cacheable(); - await cacheable.tags.invalidateTags([]); - expect(cacheable.tags.enabled).toBe(false); - }); - test("stale entries are removed from the stores on get", async () => { const secondary = new Keyv(); - const cacheable = new Cacheable({ secondary }); + const cacheable = new Cacheable({ secondary, tags: true }); const key = faker.string.uuid(); await cacheable.set(key, "value", { tags: ["t"] }); await cacheable.tags.invalidateTag("t"); @@ -131,14 +122,14 @@ describe("cacheable tags", () => { }); test("getTags returns the tags for a key", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { tags: ["a", "b"] }); expect(await cacheable.tags.getTags("k")).toEqual(["a", "b"]); expect(await cacheable.tags.getTags("missing")).toBeUndefined(); }); test("re-setting a key without tags clears its previous snapshot", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); const key = faker.string.uuid(); await cacheable.set(key, "tagged", { tags: ["t"] }); await cacheable.set(key, "untagged"); @@ -148,21 +139,21 @@ describe("cacheable tags", () => { }); test("set with an empty tags array does not write a snapshot", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { tags: [] }); - expect(cacheable.tags.enabled).toBe(false); + expect(await cacheable.tags.getTags("k")).toBeUndefined(); expect(await cacheable.get("k")).toEqual("v"); }); test("delete removes the tag snapshot", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { tags: ["t"] }); await cacheable.delete("k"); expect(await cacheable.tags.getTags("k")).toBeUndefined(); }); test("deleteMany removes the tag snapshots", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.setMany([ { key: "a", value: 1, tags: ["t"] }, { key: "b", value: 2, tags: ["t"] }, @@ -173,7 +164,7 @@ describe("cacheable tags", () => { }); test("setMany supports tags per item and getMany honors invalidation", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.setMany([ { key: "a", value: 1, tags: ["x"] }, { key: "b", value: 2, tags: ["y"] }, @@ -185,17 +176,17 @@ describe("cacheable tags", () => { expect(await cacheable.getMany(["a", "b", "c"])).toEqual([undefined, 2, 3]); }); - test("setMany without any tags does not enable the tag service", async () => { + test("setMany with tags while disabled stores values without tracking", async () => { const cacheable = new Cacheable(); - await cacheable.setMany([ - { key: "a", value: 1 }, - { key: "b", value: 2 }, - ]); + await cacheable.setMany([{ key: "a", value: 1, tags: ["t"] }]); expect(cacheable.tags.enabled).toBe(false); + expect(await cacheable.get("a")).toEqual(1); + cacheable.tags.enabled = true; + expect(await cacheable.tags.getTags("a")).toBeUndefined(); }); test("setMany clears previous snapshots for items set without tags", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("a", "tagged", { tags: ["t"] }); await cacheable.setMany([{ key: "a", value: "untagged" }]); await cacheable.tags.invalidateTag("t"); @@ -203,7 +194,7 @@ describe("cacheable tags", () => { }); test("take returns undefined for a stale entry", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { tags: ["t"] }); await cacheable.tags.invalidateTag("t"); expect(await cacheable.take("k")).toBeUndefined(); @@ -211,7 +202,7 @@ describe("cacheable tags", () => { test("invalidations are shared across instances via the secondary store", async () => { const secondary = new Keyv(); - const writer = new Cacheable({ secondary }); + const writer = new Cacheable({ secondary, tags: true }); const reader = new Cacheable({ secondary, tags: true }); const key = faker.string.uuid(); @@ -227,7 +218,7 @@ describe("cacheable tags", () => { }); test("tag snapshots expire with the entry ttl", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { ttl: 30, tags: ["t"] }); expect(await cacheable.tags.getTags("k")).toEqual(["t"]); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -236,7 +227,7 @@ describe("cacheable tags", () => { }); test("set with tags in non-blocking mode still records the snapshot", async () => { - const cacheable = new Cacheable({ nonBlocking: true }); + const cacheable = new Cacheable({ nonBlocking: true, tags: true }); await cacheable.set("k", "v", { tags: ["t"] }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(await cacheable.tags.getTags("k")).toEqual(["t"]); @@ -245,7 +236,7 @@ describe("cacheable tags", () => { }); test("set honors a per-call nonBlocking override", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { nonBlocking: true, tags: ["t"] }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(await cacheable.get("k")).toEqual("v"); @@ -253,7 +244,7 @@ describe("cacheable tags", () => { }); test("delete in non-blocking mode removes the tag snapshot", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { tags: ["t"] }); cacheable.nonBlocking = true; await cacheable.delete("k"); @@ -262,14 +253,14 @@ describe("cacheable tags", () => { }); test("setMany with tags in non-blocking mode records snapshots", async () => { - const cacheable = new Cacheable({ nonBlocking: true }); + const cacheable = new Cacheable({ nonBlocking: true, tags: true }); await cacheable.setMany([{ key: "a", value: 1, tags: ["t"] }]); await new Promise((resolve) => setTimeout(resolve, 20)); expect(await cacheable.tags.getTags("a")).toEqual(["t"]); }); test("emits an error when a non-blocking snapshot write fails", async () => { - const cacheable = new Cacheable({ nonBlocking: true }); + const cacheable = new Cacheable({ nonBlocking: true, tags: true }); const store = cacheable.tags.store; const originalSet = store.set.bind(store); vi.spyOn(store, "set").mockImplementation( @@ -305,7 +296,7 @@ describe("cacheable tags", () => { }); test("clear removes values, snapshots, and tag versions", async () => { - const cacheable = new Cacheable(); + const cacheable = new Cacheable({ tags: true }); await cacheable.set("k", "v", { tags: ["t"] }); await cacheable.tags.invalidateTag("t"); await cacheable.clear(); diff --git a/packages/utils/README.md b/packages/utils/README.md index 5487d092..d2b9cbcc 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -808,13 +808,13 @@ The `removeKey` and `removeKeys` methods delete tag snapshots when the cached va await cacheTags.removeKeys(['user:1', 'user:2']); ``` -The service can be disabled via the `enabled` option or property so integrations pay no extra store reads for untagged workloads. While disabled, read methods are no-ops (`isKeyFresh` returns `true`, `isKeyStale` returns `false`, `getStaleKeys` returns `[]`, and so on) and snapshot removals are skipped. Tag writes — `setKeyTags`, `invalidateTag`, and `invalidateTags` — automatically re-enable the service: +The service can be disabled via the `enabled` option or property so integrations pay no extra store reads for untagged workloads. While disabled, every method is a no-op: read methods return their neutral value (`isKeyFresh` returns `true`, `isKeyStale` returns `false`, `getStaleKeys` returns `[]`, and so on) and writes are skipped. The service never enables itself — you have to turn it on explicitly, which keeps behavior consistent across distributed instances sharing a store: ```typescript const cacheTags = new CacheTags({ store, enabled: false }); console.log(await cacheTags.isKeyStale('anything')); // false, no store read -await cacheTags.setKeyTags('user:42', ['users']); // re-enables the service -console.log(cacheTags.enabled); // true +await cacheTags.setKeyTags('user:42', ['users']); // no-op while disabled +cacheTags.enabled = true; // turn it on to use tags ``` `setKeyTags`, `removeKey`, and `removeKeys` accept a `nonBlocking` option to fire-and-forget the store write. Failures from non-blocking operations are reported to the `onError` constructor option since they cannot be thrown to the caller: diff --git a/packages/utils/src/cache-tags.ts b/packages/utils/src/cache-tags.ts index 09011f47..38935f01 100644 --- a/packages/utils/src/cache-tags.ts +++ b/packages/utils/src/cache-tags.ts @@ -6,11 +6,10 @@ import type { Keyv } from "keyv"; * @property {Keyv} store - The Keyv store used to persist tag versions and key snapshots. * @property {string} [namespace] - An optional namespace that isolates this service's tags * and keys from others sharing the same store. Defaults to `"default"`. - * @property {boolean} [enabled] - Whether the service is enabled. When disabled, read methods - * are no-ops ({@link CacheTags.isKeyFresh} returns `true`, {@link CacheTags.isKeyStale} - * returns `false`, etc.) and snapshot writes/removals are skipped. Tag writes - * ({@link CacheTags.setKeyTags}, {@link CacheTags.invalidateTag}, - * {@link CacheTags.invalidateTags}) automatically re-enable the service. Defaults to `true`. + * @property {boolean} [enabled] - Whether the service is enabled. While disabled, every method + * is a no-op: read methods return their neutral value ({@link CacheTags.isKeyFresh} returns + * `true`, {@link CacheTags.isKeyStale} returns `false`, etc.) and writes are skipped. The + * service must be explicitly enabled to use tags. Defaults to `true`. * @property {(error: unknown) => void} [onError] - Invoked with errors from non-blocking * (fire-and-forget) operations, which cannot be thrown to the caller. Defaults to ignoring them. */ @@ -79,9 +78,9 @@ const DEFAULT_NAMESPACE = "default"; * one additional `isKeyFresh` read per cache lookup. * * The service can be disabled via the `enabled` option or property so integrations pay no cost for - * untagged workloads: while disabled, read methods are no-ops and snapshot writes/removals are - * skipped. Using a tag write ({@link CacheTags.setKeyTags}, {@link CacheTags.invalidateTag}, - * {@link CacheTags.invalidateTags}) automatically re-enables it. + * untagged workloads: while disabled, every method is a no-op — reads return their neutral value + * and writes are skipped. The service must be explicitly enabled to use tags; it never enables + * itself, which keeps behavior consistent across distributed instances sharing a store. * * All metadata is written under a reserved prefix so it cannot collide with user keys: * - `--cacheable--tags--::tag:` → integer version counter (stored without TTL). @@ -135,10 +134,10 @@ export class CacheTags { } /** - * Whether the service is enabled. While disabled, read methods are no-ops and snapshot - * writes/removals are skipped, so integrations pay no extra store reads for untagged - * workloads. Tag writes ({@link CacheTags.setKeyTags}, {@link CacheTags.invalidateTag}, - * {@link CacheTags.invalidateTags}) automatically re-enable the service. + * Whether the service is enabled. While disabled, every method is a no-op — read methods + * return their neutral value and writes are skipped — so integrations pay no extra store + * reads for untagged workloads. The service must be explicitly enabled to use tags; it never + * enables itself. * @returns {boolean} Whether the service is enabled. */ public get enabled(): boolean { @@ -242,7 +241,7 @@ export class CacheTags { /** * Associates a cache key with a set of tags by recording a snapshot of each tag's current * version. Call this whenever you write a fresh value to the cache. Duplicate tags are ignored. - * Automatically enables the service. + * No-op while the service is disabled. * @param key - The cache key to tag. * @param tags - The tags to associate with the key. * @param {SetKeyTagsOptions} [options] - Optional settings, such as a `ttl` for the snapshot or @@ -255,7 +254,9 @@ export class CacheTags { tags: string[], options?: SetKeyTagsOptions, ): Promise { - this._enabled = true; + if (!this._enabled) { + return; + } const work = this.writeKeyTags(key, tags, options?.ttl); if (options?.nonBlocking) { work.catch((error) => { @@ -469,12 +470,16 @@ export class CacheTags { /** * Invalidates a single tag by incrementing its version counter. Every key whose snapshot * references this tag becomes stale immediately. Runs in constant time regardless of how many - * keys reference the tag. Automatically enables the service. + * keys reference the tag. No-op while the service is disabled. * @param tag - The tag to invalidate. - * @returns {Promise} A single-element array containing the invalidated tag. + * @returns {Promise} A single-element array containing the invalidated tag, or an + * empty array while the service is disabled. */ public async invalidateTag(tag: string): Promise { - this._enabled = true; + if (!this._enabled) { + return []; + } + const current = await this.getTagVersion(tag); await this._store.set(this.tagKey(tag), current + 1); return [tag]; @@ -482,18 +487,22 @@ export class CacheTags { /** * Invalidates multiple tags by incrementing each of their version counters in a single batched - * store write. Duplicate tags are bumped once. An empty list is a no-op. Automatically enables - * the service. + * store write. Duplicate tags are bumped once. An empty list is a no-op, as is the entire call + * while the service is disabled. * @param tags - The tags to invalidate. - * @returns {Promise} The `tags` argument as provided (including any duplicates). + * @returns {Promise} The `tags` argument as provided (including any duplicates), or + * an empty array while the service is disabled. */ public async invalidateTags(tags: string[]): Promise { + if (!this._enabled) { + return []; + } + const uniqueTags = [...new Set(tags)]; if (uniqueTags.length === 0) { return tags; } - this._enabled = true; const versions = await this.getTagVersions(uniqueTags); const kvPairs = []; diff --git a/packages/utils/test/cache-tags.test.ts b/packages/utils/test/cache-tags.test.ts index 52f33128..cf785271 100644 --- a/packages/utils/test/cache-tags.test.ts +++ b/packages/utils/test/cache-tags.test.ts @@ -242,27 +242,22 @@ describe("CacheTags", () => { expect(await service.isKeyFresh("k")).toBe(true); }); - test("setKeyTags re-enables a disabled service", async () => { + test("setKeyTags is a no-op while the service is disabled", async () => { const service = new CacheTags({ store: new Keyv(), enabled: false }); await service.setKeyTags("k", ["t"]); - expect(service.enabled).toBe(true); - expect(await service.isKeyFresh("k")).toBe(true); - }); - - test("invalidateTag and invalidateTags re-enable a disabled service", async () => { - const single = new CacheTags({ store: new Keyv(), enabled: false }); - await single.invalidateTag("t"); - expect(single.enabled).toBe(true); - - const many = new CacheTags({ store: new Keyv(), enabled: false }); - await many.invalidateTags(["a", "b"]); - expect(many.enabled).toBe(true); + expect(service.enabled).toBe(false); + service.enabled = true; + expect(await service.getTags("k")).toBeUndefined(); }); - test("invalidateTags with an empty list does not enable the service", async () => { - const service = new CacheTags({ store: new Keyv(), enabled: false }); - await service.invalidateTags([]); - expect(service.enabled).toBe(false); + test("invalidateTag and invalidateTags are no-ops while the service is disabled", async () => { + const service = createService(); + await service.setKeyTags("k", ["t"]); + service.enabled = false; + expect(await service.invalidateTag("t")).toEqual([]); + expect(await service.invalidateTags(["t"])).toEqual([]); + service.enabled = true; + expect(await service.isKeyFresh("k")).toBe(true); }); test("getStaleKeys returns only stale keys", async () => { From 501cba199c1ae2acf715b4be78ed9b11da39f9d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 16:22:34 +0000 Subject: [PATCH 5/5] refactor(cacheable): check tags.enabled at call sites before tag operations https://claude.ai/code/session_01CEUJ2ZvfykEnuUfL43PobY --- packages/cacheable/src/index.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index 9fad3d3e..acabf484 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -680,14 +680,16 @@ export class Cacheable extends Hookified { result = results[0]; } - if (item.tags && item.tags.length > 0) { - await this._tags.setKeyTags(item.key, item.tags, { - ttl: tagTtl, - nonBlocking, - }); - } else { - // Remove any previous tag snapshot so a stale one cannot invalidate this fresh value - await this._tags.removeKeys([item.key], { nonBlocking }); + if (this._tags.enabled) { + if (item.tags && item.tags.length > 0) { + await this._tags.setKeyTags(item.key, item.tags, { + ttl: tagTtl, + nonBlocking, + }); + } else { + // Remove any previous tag snapshot so a stale one cannot invalidate this fresh value + await this._tags.removeKeys([item.key], { nonBlocking }); + } } await this.hook(CacheableHooks.AFTER_SET, item); @@ -738,7 +740,9 @@ export class Cacheable extends Hookified { } } - await this.setManyKeyTags(items); + if (this._tags.enabled) { + await this.setManyKeyTags(items); + } await this.hook(CacheableHooks.AFTER_SET_MANY, items); @@ -877,7 +881,9 @@ export class Cacheable extends Hookified { result = resultAll[0]; } - await this._tags.removeKeys([key], { nonBlocking: this.nonBlocking }); + if (this._tags.enabled) { + await this._tags.removeKeys([key], { nonBlocking: this.nonBlocking }); + } // Publish to sync if enabled if (this._sync && result) { @@ -918,7 +924,9 @@ export class Cacheable extends Hookified { } } - await this._tags.removeKeys(keys, { nonBlocking: this._nonBlocking }); + if (this._tags.enabled) { + await this._tags.removeKeys(keys, { nonBlocking: this._nonBlocking }); + } // Publish to sync if enabled if (this._sync && result) {