-
Notifications
You must be signed in to change notification settings - Fork 207
feat: Add CacheTagService to @cacheable/utils #1646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+529
−2
Merged
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
dac7d03
Add CacheTagService to @cacheable/utils
cupofjoakim b983302
Act on code feedback and increase performance
cupofjoakim f13280e
Dedup before store query
cupofjoakim 3aa20da
Merge branch 'main' into main
jaredwray 1cd334e
moving to CacheTags
jaredwray 9bd1ec3
fix tsconfig
jaredwray File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import type { Keyv } from "keyv"; | ||
|
|
||
| export type CacheTagServiceOptions = { | ||
| store: Keyv; | ||
| namespace?: string; | ||
| }; | ||
|
|
||
| export type SetKeyTagsOptions = { | ||
| ttl?: number; | ||
| }; | ||
|
|
||
| export type KeyTagEntry = { | ||
| tags: Record<string, number>; | ||
| }; | ||
|
|
||
| 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<number> { | ||
| const version = await this._store.get<number>(this.tagKey(tag)); | ||
| return typeof version === "number" ? version : 0; | ||
| } | ||
|
|
||
| public async setKeyTags( | ||
| key: string, | ||
| tags: string[], | ||
| options?: SetKeyTagsOptions, | ||
| ): Promise<void> { | ||
| const snapshot: Record<string, number> = {}; | ||
| for (const tag of tags) { | ||
| snapshot[tag] = await this.getTagVersion(tag); | ||
| } | ||
|
|
||
| const entry: KeyTagEntry = { tags: snapshot }; | ||
| await this._store.set(this.keyEntryKey(key), entry, options?.ttl); | ||
| } | ||
|
|
||
| public async removeKey(key: string): Promise<void> { | ||
| await this._store.delete(this.keyEntryKey(key)); | ||
| } | ||
|
|
||
| public async isKeyFresh(key: string): Promise<boolean> { | ||
| const entry = await this._store.get<KeyTagEntry>(this.keyEntryKey(key)); | ||
| if (!entry?.tags) { | ||
| return false; | ||
| } | ||
|
|
||
| for (const [tag, snapshotVersion] of Object.entries(entry.tags)) { | ||
| const currentVersion = await this.getTagVersion(tag); | ||
| if (currentVersion !== snapshotVersion) { | ||
| return false; | ||
| } | ||
| } | ||
|
cupofjoakim marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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<string[]> { | ||
| 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<string[]> { | ||
| const current = await this.getTagVersion(tag); | ||
| await this._store.set(this.tagKey(tag), current + 1); | ||
| return [tag]; | ||
| } | ||
|
|
||
| public async invalidateTags(tags: string[]): Promise<string[]> { | ||
| const bumped: string[] = []; | ||
| for (const tag of tags) { | ||
| const [name] = await this.invalidateTag(tag); | ||
| bumped.push(name); | ||
| } | ||
| return bumped; | ||
| } | ||
|
cupofjoakim marked this conversation as resolved.
Outdated
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| 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("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); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.