diff --git a/packages/utils/README.md b/packages/utils/README.md index 4714943d..78c604ab 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 Tag Service](#cache-tag-service) * [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 Tag Service + +The `CacheTagService` 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 { CacheTagService } from '@cacheable/utils'; + +const store = new Keyv(); +const tagService = new CacheTagService({ store, namespace: 'app' }); + +await tagService.setKeyTags('user:42', ['users', 'org:7'], { ttl: 3600000 }); +console.log(await tagService.isKeyFresh('user:42')); // true + +await tagService.invalidateTag('users'); +console.log(await tagService.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 { CacheTagService } from '@cacheable/utils'; + +const cache = new Cacheable(); +const tagService = new CacheTagService({ store: new Keyv() }); + +const getUser = async (id: string) => { + const key = `user:${id}`; + + if (await tagService.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 tagService.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 tagService.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 tagService.setKeyTags('user:1', ['users']); +await tagService.setKeyTags('user:2', ['users']); +const keys = await tagService.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-tag-service.ts b/packages/utils/src/cache-tag-service.ts new file mode 100644 index 00000000..8aa03e8a --- /dev/null +++ b/packages/utils/src/cache-tag-service.ts @@ -0,0 +1,149 @@ +import type { Keyv } from "keyv"; + +export type CacheTagServiceOptions = { + store: Keyv; + namespace?: string; +}; + +export type SetKeyTagsOptions = { + ttl?: number; +}; + +export type KeyTagEntry = { + tags: Record; +}; + +const RESERVED_PREFIX = "--cacheable--tags--"; +const DEFAULT_NAMESPACE = "default"; + +export class CacheTagService { + private readonly _store: Keyv; + private readonly _namespace: string; + + constructor(options: CacheTagServiceOptions) { + this._store = options.store; + this._namespace = options.namespace ?? DEFAULT_NAMESPACE; + } + + public get store(): Keyv { + return this._store; + } + + public get namespace(): string { + return this._namespace; + } + + private tagKey(tag: string): string { + return `${RESERVED_PREFIX}:${this._namespace}:tag:${tag}`; + } + + private keyEntryKey(key: string): string { + return `${RESERVED_PREFIX}:${this._namespace}:key:${key}`; + } + + private keyPrefix(): string { + return `${RESERVED_PREFIX}:${this._namespace}:key:`; + } + + private async getTagVersion(tag: string): Promise { + const version = await this._store.get(this.tagKey(tag)); + return typeof version === "number" ? version : 0; + } + + 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; + }); + } + + 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); + } + + public async removeKey(key: string): Promise { + await this._store.delete(this.keyEntryKey(key)); + } + + 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 keys referencing the given tag. O(N) — scans all key entries + * in this namespace via the Keyv iterator. Intended for debugging and tests. + */ + 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; + } + + public async invalidateTag(tag: string): Promise { + const current = await this.getTagVersion(tag); + await this._store.set(this.tagKey(tag), current + 1); + return [tag]; + } + + 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..6d86aa15 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 { + CacheTagService, + type CacheTagServiceOptions, + type KeyTagEntry, + type SetKeyTagsOptions, +} from "./cache-tag-service.js"; export type { CacheableItem, CacheableStoreItem, diff --git a/packages/utils/test/cache-tag-service.test.ts b/packages/utils/test/cache-tag-service.test.ts new file mode 100644 index 00000000..ab1955a4 --- /dev/null +++ b/packages/utils/test/cache-tag-service.test.ts @@ -0,0 +1,160 @@ +import { Keyv } from "keyv"; +import { describe, expect, test } from "vitest"; +import { CacheTagService } from "../src/cache-tag-service.js"; +import { sleep } from "../src/sleep.js"; + +const createService = (namespace?: string) => { + const store = new Keyv(); + return new CacheTagService({ store, namespace }); +}; + +describe("CacheTagService", () => { + 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 CacheTagService({ store, namespace: "ns1" }); + const ns2 = new CacheTagService({ 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 CacheTagService({ store }); + await service.setKeyTags("a", ["x"]); + expect(await service.getKeysByTag("x")).toEqual([]); + }); + + test("default namespace applied when not provided", async () => { + const service = new CacheTagService({ store: new Keyv() }); + expect(service.namespace).toBe("default"); + }); + + test("exposes provided store", async () => { + const store = new Keyv(); + const service = new CacheTagService({ 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); + }); +});