diff --git a/packages/utils/README.md b/packages/utils/README.md index 4714943d..2f7640cd 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -17,6 +17,7 @@ * Sleep / Delay for Testing and Timing * Memoization for wraping or get / set options * Time to Live (TTL) Helpers +* Tag-Based Cache Invalidation # Table of Contents * [Getting Started](#getting-started) @@ -32,6 +33,7 @@ * [Is Object Helper](#is-object-helper) * [Wrap / Memoization for Sync and Async Functions](#wrap--memoization-for-sync-and-async-functions) * [Get Or Set Memoization Function](#get-or-set-memoization-function) +* [Cache Tags](#cache-tags) * [How to Contribute](#how-to-contribute) * [License and Copyright](#license-and-copyright) @@ -512,6 +514,84 @@ const function_ = async () => Math.random() * 100; const value = await getOrSet(generateKey(), function_, { ttl: '1h', cache }); ``` +# Cache Tags + +The `CacheTags` service provides tag-based invalidation on top of any [Keyv](https://github.com/jaredwray/keyv) store. It is store-agnostic and does not require any adapter changes. + +The service uses a lazy invalidation model. Instead of scanning and deleting keys, `invalidateTag` increments a per-tag version counter. Each cached key stores a snapshot of its tag versions at the time it was written, and `isKeyFresh` compares that snapshot to the current versions. If any tag version has been incremented since the snapshot was taken, the key is considered stale. Stale entries are not deleted explicitly and are expected to fall out of the cache via their TTL. + +This approach keeps invalidation constant-time regardless of how many keys reference a tag. The trade-off is one additional `isKeyFresh` read per cache lookup. + +```typescript +import { Keyv } from 'keyv'; +import { CacheTags } from '@cacheable/utils'; + +const store = new Keyv(); +const cacheTags = new CacheTags({ store, namespace: 'app' }); + +await cacheTags.setKeyTags('user:42', ['users', 'org:7'], { ttl: 3600000 }); +console.log(await cacheTags.isKeyFresh('user:42')); // true + +await cacheTags.invalidateTag('users'); +console.log(await cacheTags.isKeyFresh('user:42')); // false +``` + +The recommended pattern is to call `isKeyFresh` before trusting a value returned from your cache, and to refresh the tag snapshot whenever you write a new value: + +```typescript +import { Cacheable } from 'cacheable'; +import { Keyv } from 'keyv'; +import { CacheTags } from '@cacheable/utils'; + +const cache = new Cacheable(); +const cacheTags = new CacheTags({ store: new Keyv() }); + +const getUser = async (id: string) => { + const key = `user:${id}`; + + if (await cacheTags.isKeyFresh(key)) { + const cached = await cache.get(key); + if (cached !== undefined) { + return cached; + } + } + + const fresh = await loadUser(id); + await cache.set(key, fresh, '1h'); + await cacheTags.setKeyTags(key, ['users', `org:${fresh.orgId}`], { ttl: 3600000 }); + return fresh; +}; +``` + +You can invalidate one or many tags at a time. Both methods return the names of the tags that were bumped: + +```typescript +const bumped = await cacheTags.invalidateTags(['users', 'org:7']); +console.log(bumped); // ['users', 'org:7'] +``` + +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 +await cacheTags.setKeyTags('user:1', ['users']); +await cacheTags.setKeyTags('user:2', ['users']); +const keys = await cacheTags.getKeysByTag('users'); +console.log(keys); // ['user:1', 'user:2'] +``` + +The service stores its metadata under a reserved prefix so that it cannot collide with user keys: + +``` +--cacheable--tags--::tag: → integer version counter +--cacheable--tags--::key: → { tags: { [tag]: versionAtSetTime } } +``` + +Tag version counters are stored without a TTL because they must outlive any key that references them. Key entries respect the `ttl` passed to `setKeyTags`, which should be set to match the TTL of the cached value it tracks. + +The namespace defaults to `default` and can be set via the constructor. Two services configured with different namespaces can share the same store without seeing each other's tags or keys. + +The read-version then write-snapshot sequence in `setKeyTags` is not atomic across processes. A concurrent `invalidateTag` that runs between the read and the write can leave a freshly written key referencing a stale version. An atomic Redis fast path using `MULTI` or Lua is a planned future enhancement. + # How to Contribute You can contribute by forking the repo and submitting a pull request. Please make sure to add tests and update the documentation. To learn more about how to contribute go to our main README [https://github.com/jaredwray/cacheable](https://github.com/jaredwray/cacheable). This will talk about how to `Open a Pull Request`, `Ask a Question`, or `Post an Issue`. diff --git a/packages/utils/src/cache-tags.ts b/packages/utils/src/cache-tags.ts new file mode 100644 index 00000000..83a5bb92 --- /dev/null +++ b/packages/utils/src/cache-tags.ts @@ -0,0 +1,282 @@ +import type { Keyv } from "keyv"; + +/** + * Options for constructing a {@link CacheTags}. + * @typedef {Object} CacheTagsOptions + * @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"`. + */ +export type CacheTagsOptions = { + store: Keyv; + namespace?: string; +}; + +/** + * Options for {@link CacheTags.setKeyTags}. + * @typedef {Object} SetKeyTagsOptions + * @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. + */ +export type SetKeyTagsOptions = { + ttl?: number; +}; + +/** + * The metadata stored for a tagged key. It records the version of each tag at the moment the key + * was written, allowing {@link CacheTags.isKeyFresh} to detect later invalidations. + * @typedef {Object} KeyTagEntry + * @property {Record} tags - A snapshot mapping each tag name to its version at set time. + */ +export type KeyTagEntry = { + tags: Record; +}; + +/** + * Prefix applied to every store key written by the service so its metadata cannot collide with + * user-supplied cache keys. + */ +const RESERVED_PREFIX = "--cacheable--tags--"; + +/** Namespace used when none is supplied to the constructor. */ +const DEFAULT_NAMESPACE = "default"; + +/** + * Provides tag-based cache invalidation on top of any {@link Keyv} store. It is store-agnostic and + * requires no adapter changes. + * + * The service uses a lazy invalidation model rather than scanning and deleting keys. Each tag has a + * monotonically increasing version counter; {@link CacheTags.invalidateTag} simply increments + * it. When a key is tagged via {@link CacheTags.setKeyTags}, a snapshot of its tags' current + * versions is stored alongside it. {@link CacheTags.isKeyFresh} compares that snapshot against + * the live versions — if any tag has been incremented since, the key is considered stale. Stale + * entries are not deleted explicitly; they are expected to fall out of the cache via their TTL. + * + * This keeps invalidation constant-time regardless of how many keys reference a tag, at the cost of + * one additional `isKeyFresh` read per cache lookup. + * + * 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. + * + * Note: the read-version-then-write-snapshot sequence in `setKeyTags` is not atomic across + * processes. A concurrent `invalidateTag` running between the read and the write can leave a freshly + * written key referencing a stale version. + * + * @example + * ```typescript + * const cacheTags = new CacheTags({ store: new Keyv(), namespace: 'app' }); + * await cacheTags.setKeyTags('user:42', ['users', 'org:7'], { ttl: 3600000 }); + * await cacheTags.isKeyFresh('user:42'); // true + * await cacheTags.invalidateTag('users'); + * await cacheTags.isKeyFresh('user:42'); // false + * ``` + */ +export class CacheTags { + private readonly _store: Keyv; + private readonly _namespace: string; + + /** + * Creates a new tag service. + * @param {CacheTagsOptions} options - The store and optional namespace to use. + */ + constructor(options: CacheTagsOptions) { + this._store = options.store; + this._namespace = options.namespace ?? DEFAULT_NAMESPACE; + } + + /** + * The Keyv store backing this service. + * @returns {Keyv} The store provided to the constructor. + */ + public get store(): Keyv { + return this._store; + } + + /** + * The namespace isolating this service's tags and keys within the store. + * @returns {string} The configured namespace, or `"default"` if none was provided. + */ + public get namespace(): string { + return this._namespace; + } + + /** + * Builds the reserved store key under which a tag's version counter is stored. + * @param tag - The tag name. + * @returns {string} The namespaced store key for the tag's version. + */ + private tagKey(tag: string): string { + return `${RESERVED_PREFIX}:${this._namespace}:tag:${tag}`; + } + + /** + * Builds the reserved store key under which a cache key's tag snapshot is stored. + * @param key - The cache key being tagged. + * @returns {string} The namespaced store key for the key's snapshot. + */ + private keyEntryKey(key: string): string { + return `${RESERVED_PREFIX}:${this._namespace}:key:${key}`; + } + + /** + * Builds the common prefix shared by every key-snapshot entry in this namespace. Used to filter + * key entries when iterating the store. + * @returns {string} The namespaced key-entry prefix. + */ + private keyPrefix(): string { + return `${RESERVED_PREFIX}:${this._namespace}:key:`; + } + + /** + * Reads the current version of a single tag. + * @param tag - The tag name. + * @returns {Promise} The tag's version, or `0` if it has never been invalidated. + */ + private async getTagVersion(tag: string): Promise { + const version = await this._store.get(this.tagKey(tag)); + return typeof version === "number" ? version : 0; + } + + /** + * Reads the current versions of multiple tags in a single batched store read. + * @param tags - The tag names to look up. + * @returns {Promise} The versions in the same order as `tags`; entries that have never + * been invalidated resolve to `0`. Returns an empty array when `tags` is empty. + */ + private async getTagVersions(tags: string[]): Promise { + if (tags.length === 0) { + return []; + } + const tagKeys = tags.map((tag) => this.tagKey(tag)); + const raw = await this._store.get(tagKeys); + return tags.map((_, i) => { + const value = raw?.[i]; + return typeof value === "number" ? value : 0; + }); + } + + /** + * 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. + * @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. + * @returns {Promise} Resolves once the snapshot has been written. + */ + public async setKeyTags( + key: string, + tags: string[], + options?: SetKeyTagsOptions, + ): Promise { + const uniqueTags = [...new Set(tags)]; + const versions = await this.getTagVersions(uniqueTags); + const snapshot: Record = {}; + for (let i = 0; i < uniqueTags.length; i++) { + snapshot[uniqueTags[i]] = versions[i]; + } + + const entry: KeyTagEntry = { tags: snapshot }; + await this._store.set(this.keyEntryKey(key), entry, options?.ttl); + } + + /** + * Removes a key's tag snapshot. After this, {@link CacheTags.isKeyFresh} returns `false` + * for the key. Use when the cached value itself is deleted. + * @param key - The cache key whose snapshot should be removed. + * @returns {Promise} Resolves once the snapshot has been deleted. + */ + public async removeKey(key: string): Promise { + await this._store.delete(this.keyEntryKey(key)); + } + + /** + * 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. + * @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 { + 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 false; + } + } + + return true; + } + + /** + * 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. + * @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[] = []; + const prefix = this.keyPrefix(); + const iterator = this._store.iterator?.(this._store.namespace); + if (!iterator) { + return result; + } + + for await (const [storedKey, value] of iterator) { + if (typeof storedKey !== "string" || !storedKey.startsWith(prefix)) { + continue; + } + const entry = value as KeyTagEntry | undefined; + if (entry?.tags && Object.hasOwn(entry.tags, tag)) { + result.push(storedKey.slice(prefix.length)); + } + } + + return result; + } + + /** + * 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. + * @param tag - The tag to invalidate. + * @returns {Promise} A single-element array containing the invalidated tag. + */ + public async invalidateTag(tag: string): Promise { + const current = await this.getTagVersion(tag); + await this._store.set(this.tagKey(tag), current + 1); + return [tag]; + } + + /** + * 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. + * @param tags - The tags to invalidate. + * @returns {Promise} The `tags` argument as provided (including any duplicates). + */ + public async invalidateTags(tags: string[]): Promise { + const uniqueTags = [...new Set(tags)]; + if (uniqueTags.length === 0) { + return tags; + } + const versions = await this.getTagVersions(uniqueTags); + + const kvPairs = []; + for (let i = 0; i < uniqueTags.length; i++) { + kvPairs.push({ key: this.tagKey(uniqueTags[i]), value: versions[i] + 1 }); + } + + await this._store.setMany(kvPairs); + return tags; + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ddfe62d4..e65f0e8a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,6 +2,12 @@ export { shorthandToMilliseconds, shorthandToTime, } from "../src/shorthand-time.js"; +export { + CacheTags, + type CacheTagsOptions, + type KeyTagEntry, + type SetKeyTagsOptions, +} from "./cache-tags.js"; export type { CacheableItem, CacheableStoreItem, diff --git a/packages/utils/test/cache-tags.test.ts b/packages/utils/test/cache-tags.test.ts new file mode 100644 index 00000000..57f2f8cf --- /dev/null +++ b/packages/utils/test/cache-tags.test.ts @@ -0,0 +1,160 @@ +import { Keyv } from "keyv"; +import { describe, expect, test } from "vitest"; +import { CacheTags } from "../src/cache-tags.js"; +import { sleep } from "../src/sleep.js"; + +const createService = (namespace?: string) => { + const store = new Keyv(); + return new CacheTags({ store, namespace }); +}; + +describe("CacheTags", () => { + test("isKeyFresh returns true after setKeyTags", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users"]); + expect(await service.isKeyFresh("user:1")).toBe(true); + }); + + test("isKeyFresh returns false after invalidateTag", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users"]); + await service.invalidateTag("users"); + expect(await service.isKeyFresh("user:1")).toBe(false); + }); + + test("invalidating one of multiple tags stales the key", async () => { + const service = createService(); + await service.setKeyTags("post:1", ["posts", "authors", "feed"]); + expect(await service.isKeyFresh("post:1")).toBe(true); + await service.invalidateTag("authors"); + expect(await service.isKeyFresh("post:1")).toBe(false); + }); + + test("isKeyFresh on unknown key returns false", async () => { + const service = createService(); + expect(await service.isKeyFresh("nope")).toBe(false); + }); + + test("removeKey then isKeyFresh returns false", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users"]); + await service.removeKey("user:1"); + expect(await service.isKeyFresh("user:1")).toBe(false); + }); + + test("invalidateTag returns the bumped tag", async () => { + const service = createService(); + const result = await service.invalidateTag("users"); + expect(result).toEqual(["users"]); + }); + + test("invalidateTags returns all bumped tag names", async () => { + const service = createService(); + const result = await service.invalidateTags(["a", "b", "c"]); + expect(result).toEqual(["a", "b", "c"]); + }); + + test("invalidateTags with empty list is a no-op", async () => { + const service = createService(); + await service.setKeyTags("k", ["t"]); + const result = await service.invalidateTags([]); + expect(result).toEqual([]); + expect(await service.isKeyFresh("k")).toBe(true); + }); + + test("namespace isolation: tags do not leak across namespaces", async () => { + const store = new Keyv(); + const ns1 = new CacheTags({ store, namespace: "ns1" }); + const ns2 = new CacheTags({ store, namespace: "ns2" }); + + await ns1.setKeyTags("user:1", ["users"]); + await ns2.setKeyTags("user:1", ["users"]); + + await ns1.invalidateTag("users"); + + expect(await ns1.isKeyFresh("user:1")).toBe(false); + expect(await ns2.isKeyFresh("user:1")).toBe(true); + }); + + test("ttl on setKeyTags expires key entry", async () => { + const service = createService(); + await service.setKeyTags("user:1", ["users"], { ttl: 50 }); + expect(await service.isKeyFresh("user:1")).toBe(true); + await sleep(75); + expect(await service.isKeyFresh("user:1")).toBe(false); + }); + + test("invalidation bumps remain in effect across re-checks", async () => { + const service = createService(); + await service.setKeyTags("k", ["t"]); + await service.invalidateTag("t"); + await service.invalidateTag("t"); + expect(await service.isKeyFresh("k")).toBe(false); + }); + + test("re-setting key after invalidation makes it fresh again", async () => { + const service = createService(); + await service.setKeyTags("k", ["t"]); + await service.invalidateTag("t"); + expect(await service.isKeyFresh("k")).toBe(false); + await service.setKeyTags("k", ["t"]); + expect(await service.isKeyFresh("k")).toBe(true); + }); + + test("getKeysByTag returns keys referencing the tag", async () => { + const service = createService(); + await service.setKeyTags("a", ["x", "y"]); + await service.setKeyTags("b", ["y"]); + await service.setKeyTags("c", ["z"]); + + const xKeys = await service.getKeysByTag("x"); + const yKeys = (await service.getKeysByTag("y")).sort(); + const zKeys = await service.getKeysByTag("z"); + + expect(xKeys).toEqual(["a"]); + expect(yKeys).toEqual(["a", "b"]); + expect(zKeys).toEqual(["c"]); + }); + + test("getKeysByTag returns empty when no keys reference tag", async () => { + const service = createService(); + await service.setKeyTags("a", ["x"]); + expect(await service.getKeysByTag("missing")).toEqual([]); + }); + + test("getKeysByTag skips tag-version entries during iteration", async () => { + const service = createService(); + await service.setKeyTags("a", ["x"]); + // invalidateTag writes a tag-version entry under the same namespace — + // iterator should skip it because it doesn't match the key-entry prefix. + await service.invalidateTag("x"); + await service.setKeyTags("a", ["x"]); + expect(await service.getKeysByTag("x")).toEqual(["a"]); + }); + + test("getKeysByTag returns [] when store has no iterator", async () => { + const store = new Keyv(); + // Simulate a store that does not expose iterator + (store as unknown as { iterator?: unknown }).iterator = undefined; + const service = new CacheTags({ store }); + await service.setKeyTags("a", ["x"]); + expect(await service.getKeysByTag("x")).toEqual([]); + }); + + test("default namespace applied when not provided", async () => { + const service = new CacheTags({ store: new Keyv() }); + expect(service.namespace).toBe("default"); + }); + + test("exposes provided store", async () => { + const store = new Keyv(); + const service = new CacheTags({ store }); + expect(service.store).toBe(store); + }); + + test("setKeyTags with no tags makes key trivially fresh", async () => { + const service = createService(); + await service.setKeyTags("empty", []); + expect(await service.isKeyFresh("empty")).toBe(true); + }); +}); diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index d6359664..56271f68 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -2,8 +2,7 @@ "compilerOptions": { "target": "ESNext", "module": "ESNext", - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ /* Emit */ "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */