diff --git a/packages/cacheable/README.md b/packages/cacheable/README.md index 0beb6f39..6e7f4ec7 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({ 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'] }); + +// entity 42 changed - purge everything that referenced it +await cache.tags.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.tags.invalidateTags(['users', 'org:7']); +``` + +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: + +```javascript +import { Cacheable } from 'cacheable'; +import KeyvRedis from '@keyv/redis'; + +// 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'] }); +await writer.tags.invalidateTag('entity:42'); +await reader.get('page:/products'); // undefined - stale copy is also purged from reader's primary +``` + +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: + +```javascript +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 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 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 }` @@ -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,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. +* `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 ebbfc005..acabf484 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,7 @@ export class Cacheable extends Hookified { private _namespace?: string | (() => string); private _cacheId: string = Math.random().toString(36).slice(2); private _sync?: CacheableSync; + private _tags: CacheTags = this.createCacheTags(); /** * Creates a new cacheable instance * @param {CacheableOptions} [options] The options for the cacheable instance @@ -81,6 +88,10 @@ export class Cacheable extends Hookified { } } + if (options?.tags) { + this._tags.enabled = true; + } + if (options?.sync) { this._sync = options.sync instanceof CacheableSync @@ -142,6 +153,7 @@ export class Cacheable extends Hookified { */ public set primary(primary: Keyv) { this._primary = primary; + this._tags = this.createCacheTags(); } /** @@ -159,6 +171,7 @@ export class Cacheable extends Hookified { */ public set secondary(secondary: Keyv | undefined) { this._secondary = secondary; + this._tags = this.createCacheTags(); } /** @@ -301,6 +314,47 @@ export class Cacheable extends Hookified { } } + /** + * The tag service for the cacheable instance, used for tag-based invalidation. It is created + * 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, 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({ tags: true }); + * 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 { + return this._tags; + } + + /** + * 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. + */ + private createCacheTags(): CacheTags { + return new CacheTags({ + store: this._secondary ?? this._primary, + enabled: this._tags?.enabled ?? false, + onError: (error: unknown) => { + this.emit(CacheableEvents.ERROR, error); + }, + }); + } + /** * Sets the primary store for the cacheable instance * @param {Keyv | KeyvStoreAdapter} primary The primary store for the cacheable instance @@ -318,6 +372,8 @@ export class Cacheable extends Hookified { this._primary.on("error", (error: unknown) => { this.emit(CacheableEvents.ERROR, error); }); + + this._tags = this.createCacheTags(); } /** @@ -337,6 +393,8 @@ export class Cacheable extends Hookified { this._secondary.on("error", (error: unknown) => { this.emit(CacheableEvents.ERROR, error); }); + + this._tags = this.createCacheTags(); } public getNameSpace(): string | undefined { @@ -430,6 +488,11 @@ export class Cacheable extends Hookified { } } + if (result && this._tags.enabled && (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 +565,21 @@ export class Cacheable extends Hookified { } } + 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; + } + } + + await this.deleteMany(staleKeys); + } + } + await this.hook(CacheableHooks.AFTER_GET_MANY, { keys, result }); } catch (error: unknown) { this.emit(CacheableEvents.ERROR, error); @@ -545,17 +623,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 +649,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 +664,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 +680,18 @@ export class Cacheable extends Hookified { result = results[0]; } + 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); // Publish to sync if enabled @@ -618,10 +719,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 +740,10 @@ export class Cacheable extends Hookified { } } + if (this._tags.enabled) { + await this.setManyKeyTags(items); + } + await this.hook(CacheableHooks.AFTER_SET_MANY, items); // Publish to sync if enabled @@ -775,6 +881,10 @@ export class Cacheable extends Hookified { result = resultAll[0]; } + if (this._tags.enabled) { + await this._tags.removeKeys([key], { nonBlocking: this.nonBlocking }); + } + // Publish to sync if enabled if (this._sync && result) { await this._sync.publish(CacheableSyncEvents.DELETE, { @@ -814,6 +924,10 @@ export class Cacheable extends Hookified { } } + if (this._tags.enabled) { + await this._tags.removeKeys(keys, { nonBlocking: this._nonBlocking }); + } + // Publish to sync if enabled if (this._sync && result) { for (const key of keys) { @@ -1005,6 +1119,48 @@ export class Cacheable extends Hookified { return true; } + /** + * 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 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 = []; + 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, + shorthandToMilliseconds(item.ttl), + ); + ttl = this.capTtl(ttl, maxTtlMs); + promises.push( + this._tags.setKeyTags(item.key, item.tags, { + ttl, + nonBlocking: this._nonBlocking, + }), + ); + } + + if (untaggedKeys.length > 0) { + promises.push( + this._tags.removeKeys(untaggedKeys, { + nonBlocking: 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 +1438,8 @@ export { } from "@cacheable/memory"; export { type CacheableItem, + CacheTags, + type CacheTagsOptions, calculateTtlFromExpiration, type GetOrSetFunctionOptions, type GetOrSetKey, @@ -1290,6 +1448,8 @@ export { getOrSet, HashAlgorithm, hash, + type KeyTagEntry, + type SetKeyTagsOptions, Stats as CacheableStats, shorthandToMilliseconds, shorthandToTime, @@ -1306,4 +1466,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..e732924c 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 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; }; 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..bf9e19f3 --- /dev/null +++ b/packages/cacheable/test/tags.test.ts @@ -0,0 +1,306 @@ +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"; + +const TAG_PREFIX = "--cacheable--tags--"; + +describe("cacheable tags", () => { + 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); + // same instance on repeat access + expect(cacheable.tags).toBe(cacheable.tags); + }); + + test("tags option enables the service in the constructor", () => { + const cacheable = new Cacheable({ tags: true }); + expect(cacheable.tags.enabled).toBe(true); + }); + + 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(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", () => { + 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(); + 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); + expect(cacheable.tags.enabled).toBe(true); + }); + + 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({ tags: true }); + 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.tags.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({ tags: true }); + 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({ tags: true }); + await cacheable.set("tagged", "one", { tags: ["posts"] }); + await cacheable.set("other", "two", { tags: ["users"] }); + await cacheable.set("untagged", "three"); + 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"); + }); + + test("invalidateTags invalidates multiple tags at once", async () => { + 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"] }); + await cacheable.tags.invalidateTags(["x", "y"]); + expect(await cacheable.getMany(["a", "b", "c"])).toEqual([ + undefined, + undefined, + 3, + ]); + }); + + test("stale entries are removed from the stores on get", async () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary, tags: true }); + const key = faker.string.uuid(); + await cacheable.set(key, "value", { tags: ["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.tags.getTags(key)).toBeUndefined(); + }); + + test("getTags returns the tags for a key", async () => { + 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({ tags: true }); + const key = faker.string.uuid(); + await cacheable.set(key, "tagged", { tags: ["t"] }); + await cacheable.set(key, "untagged"); + await cacheable.tags.invalidateTag("t"); + expect(await cacheable.get(key)).toEqual("untagged"); + expect(await cacheable.tags.getTags(key)).toBeUndefined(); + }); + + test("set with an empty tags array does not write a snapshot", async () => { + const cacheable = new Cacheable({ tags: true }); + await cacheable.set("k", "v", { tags: [] }); + 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({ 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({ tags: true }); + await cacheable.setMany([ + { key: "a", value: 1, tags: ["t"] }, + { key: "b", value: 2, tags: ["t"] }, + ]); + await cacheable.deleteMany(["a", "b"]); + 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 () => { + const cacheable = new Cacheable({ tags: true }); + await cacheable.setMany([ + { key: "a", value: 1, tags: ["x"] }, + { 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.tags.invalidateTag("x"); + expect(await cacheable.getMany(["a", "b", "c"])).toEqual([undefined, 2, 3]); + }); + + test("setMany with tags while disabled stores values without tracking", async () => { + const cacheable = new Cacheable(); + 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({ tags: true }); + await cacheable.set("a", "tagged", { tags: ["t"] }); + await cacheable.setMany([{ key: "a", value: "untagged" }]); + 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({ tags: true }); + await cacheable.set("k", "v", { tags: ["t"] }); + await cacheable.tags.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, tags: true }); + 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.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); + }); + + test("tag snapshots expire with the entry ttl", async () => { + 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)); + expect(await cacheable.get("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, tags: true }); + await cacheable.set("k", "v", { tags: ["t"] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(await cacheable.tags.getTags("k")).toEqual(["t"]); + await cacheable.tags.invalidateTag("t"); + expect(await cacheable.get("k")).toBeUndefined(); + }); + + test("set honors a per-call nonBlocking override", async () => { + 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"); + expect(await cacheable.tags.getTags("k")).toEqual(["t"]); + }); + + test("delete in non-blocking mode removes the tag snapshot", async () => { + const cacheable = new Cacheable({ tags: true }); + await cacheable.set("k", "v", { tags: ["t"] }); + cacheable.nonBlocking = true; + await cacheable.delete("k"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(await cacheable.tags.getTags("k")).toBeUndefined(); + }); + + test("setMany with tags in non-blocking mode records snapshots", async () => { + 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, tags: true }); + 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) => { + 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.store, "deleteMany").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({ tags: true }); + await cacheable.set("k", "v", { tags: ["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 ef493f8e..d2b9cbcc 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -775,6 +775,55 @@ 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 `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.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, 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']); // 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: + +```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. ```typescript diff --git a/packages/utils/src/cache-tags.ts b/packages/utils/src/cache-tags.ts index 83a5bb92..38935f01 100644 --- a/packages/utils/src/cache-tags.ts +++ b/packages/utils/src/cache-tags.ts @@ -6,10 +6,18 @@ 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. 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. */ export type CacheTagsOptions = { store: Keyv; namespace?: string; + enabled?: boolean; + onError?: (error: unknown) => void; }; /** @@ -18,9 +26,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 +77,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, 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). * - `--cacheable--tags--::key:` → the {@link KeyTagEntry} snapshot. @@ -76,14 +102,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 +133,25 @@ export class CacheTags { return this._namespace; } + /** + * 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 { + 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 +208,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,28 +235,100 @@ 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. + * 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 + * `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 { + if (!this._enabled) { + return; + } + 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, as is + * the entire call while the service is disabled. + * @param keys - The cache keys whose snapshots should be removed. + * @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[], + options?: RemoveKeysOptions, + ): Promise { + if (!this._enabled || keys.length === 0) { + return; + } + + const entryKeys = keys.map((key) => this.keyEntryKey(key)); + 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; @@ -217,15 +346,108 @@ 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. 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 { + if (!this._enabled) { + return false; + } + + 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++) { + 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 staleKeys; + } + + /** + * 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 getTags(key: string): Promise { + if (!this._enabled) { + return undefined; + } + + 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 - * 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) { @@ -248,11 +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. + * 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 { + if (!this._enabled) { + return []; + } + const current = await this.getTagVersion(tag); await this._store.set(this.tagKey(tag), current + 1); return [tag]; @@ -260,15 +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. + * 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; } + 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 57f2f8cf..cf785271 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"; @@ -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"); @@ -157,4 +173,142 @@ 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("getTags returns the tags for a key", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users", "org:7"]); + expect(await service.getTags("user:1")).toEqual(["users", "org:7"]); + }); + + test("getTags returns undefined for an unknown key", async () => { + const service = createService(); + expect(await service.getTags("nope")).toBeUndefined(); + }); + + test("getTags returns empty array for a key tagged with no tags", async () => { + const service = createService(); + await service.setKeyTags("empty", []); + 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 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(false); + service.enabled = true; + expect(await service.getTags("k")).toBeUndefined(); + }); + + 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 () => { + 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); + }); });