This repository was archived by the owner on Mar 1, 2026. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 17
feat: query caching plugin #608
Closed
Closed
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
657f52b
feat: setup cache plugin
sanny-io 63df283
feat: add memory provider
sanny-io 2182aa9
chore: memory provider tests
sanny-io 55d4b7c
chore: add `invalidateAll` tests
sanny-io c289d0b
feat: configurable `checkInterval`
sanny-io 6c86f17
feat: tag invalidation
sanny-io 526fe90
chore: tag invalidation tests
sanny-io 2f37344
Use seconds for `checkInterval`, add documentation.
sanny-io fc2f9ee
Add murmurhash.
sanny-io 6b9cc95
`swr` support
sanny-io 930f89f
`ttl` and `swr` simultaneous tests.
sanny-io efbf09e
Cleanup.
sanny-io 10d8267
Add `onIntervalExpiration` and tests.
sanny-io 6bd35e9
Add `status` and `revalidation` checks.
sanny-io 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import config from '@zenstackhq/eslint-config/base.js'; | ||
|
|
||
| /** @type {import("eslint").Linter.Config} */ | ||
| export default config; |
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,56 @@ | ||
| { | ||
| "name": "@zenstackhq/plugin-cache", | ||
| "version": "3.2.1", | ||
| "description": "ZenStack Cache Plugin", | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "tsc --noEmit && tsup-node", | ||
| "watch": "tsup-node --watch", | ||
| "lint": "eslint src --ext ts", | ||
| "pack": "pnpm pack" | ||
| }, | ||
| "keywords": [], | ||
| "author": "ZenStack Team", | ||
| "license": "MIT", | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "exports": { | ||
| ".": { | ||
| "import": { | ||
| "types": "./dist/index.d.ts", | ||
| "default": "./dist/index.js" | ||
| }, | ||
| "require": { | ||
| "types": "./dist/index.d.cts", | ||
| "default": "./dist/index.cjs" | ||
| } | ||
| }, | ||
| "./providers/memory": { | ||
| "import": { | ||
| "types": "./dist/providers/memory.d.ts", | ||
| "default": "./dist/providers/memory.js" | ||
| }, | ||
| "require": { | ||
| "types": "./dist/providers/memory.d.cts", | ||
| "default": "./dist/providers/memory.cjs" | ||
| } | ||
| }, | ||
| "./package.json": { | ||
| "import": "./package.json", | ||
| "require": "./package.json" | ||
| } | ||
| }, | ||
| "dependencies": { | ||
| "@zenstackhq/common-helpers": "workspace:*", | ||
| "@zenstackhq/orm": "workspace:*", | ||
| "json-stable-stringify": "^1.3.0", | ||
| "murmurhash": "^2.0.1", | ||
| "zod": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@zenstackhq/eslint-config": "workspace:*", | ||
| "@zenstackhq/typescript-config": "workspace:*", | ||
| "@zenstackhq/vitest-config": "workspace:*" | ||
| } | ||
| } |
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 @@ | ||
| export * from './plugin'; |
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,109 @@ | ||
| import { lowerCaseFirst } from '@zenstackhq/common-helpers'; | ||
| import { definePlugin } from '@zenstackhq/orm'; | ||
| import stableStringify from 'json-stable-stringify'; | ||
| import murmurhash from 'murmurhash'; | ||
| import { cacheEnvelopeSchema } from './schemas'; | ||
| import type { CacheEnvelope, CacheInvalidationOptions, CachePluginOptions, CacheStatus } from './types'; | ||
| import { entryIsFresh, entryIsStale } from './utils' | ||
|
|
||
| export function defineCachePlugin(pluginOptions: CachePluginOptions) { | ||
| let status: CacheStatus | null = null; | ||
| let revalidation: Promise<unknown> | null = null; | ||
|
|
||
| return definePlugin({ | ||
| id: 'cache', | ||
| name: 'Cache', | ||
| description: 'Optionally caches read queries.', | ||
|
|
||
| queryArgs: { | ||
| $read: cacheEnvelopeSchema, | ||
| }, | ||
|
|
||
| client: { | ||
| $cache: { | ||
| invalidate: (options: CacheInvalidationOptions) => { | ||
| return pluginOptions.provider.invalidate(options); | ||
| }, | ||
|
|
||
| invalidateAll() { | ||
| return pluginOptions.provider.invalidateAll(); | ||
| }, | ||
|
|
||
| /** | ||
| * Returns the status of the last result returned, or `null` | ||
| * if a result has yet to be returned. | ||
| */ | ||
| get status() { | ||
| return status; | ||
| }, | ||
|
|
||
| /** | ||
| * Returns a `Promise` that fulfills when the last stale result | ||
| * returned has been revalidated, or `null` if a stale result has | ||
| * yet to be returned. | ||
| */ | ||
| get revalidation() { | ||
| return revalidation; | ||
| } | ||
| }, | ||
| }, | ||
|
|
||
| onQuery: async ({ args, model, operation, proceed }) => { | ||
| if (args && 'cache' in args) { | ||
| const json = stableStringify({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache options is now part of the cache key. I guess it's fine, just wanted to make sure it's intentional. |
||
| args, | ||
| model, | ||
| operation, | ||
| }); | ||
|
|
||
| if (!json) { | ||
| throw new Error(`Failed to serialize cache entry for ${lowerCaseFirst(model)}.${operation}`); | ||
| } | ||
|
|
||
| const cache = pluginOptions.provider; | ||
| const options = (args as CacheEnvelope).cache!; | ||
| const key = murmurhash.v3(json).toString(); | ||
| const queryResultEntry = await cache.get(key); | ||
|
|
||
| if (queryResultEntry) { | ||
| if (entryIsFresh(queryResultEntry)) { | ||
| status = 'hit'; | ||
| return queryResultEntry.result; | ||
| } else if (entryIsStale(queryResultEntry)) { | ||
| revalidation = proceed(args).then(async (result) => { | ||
| try { | ||
| await cache.set(key, { | ||
| createdAt: Date.now(), | ||
| options, | ||
| result, | ||
| }); | ||
|
|
||
| return result; | ||
| } | ||
| catch (err) { | ||
| console.error(`Failed to cache query result: ${err}`); | ||
| return null; | ||
| } | ||
| }); | ||
|
|
||
| status = 'stale'; | ||
| return queryResultEntry.result; | ||
| } | ||
| } | ||
|
|
||
| const result = await proceed(args); | ||
|
|
||
| cache.set(key, { | ||
| createdAt: Date.now(), | ||
| options, | ||
| result, | ||
| }).catch((err) => console.error(`Failed to cache query result: ${err}`)); | ||
|
|
||
| status = 'miss'; | ||
| return result; | ||
| } | ||
|
|
||
| return proceed(args); | ||
| }, | ||
| }); | ||
| } | ||
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,96 @@ | ||
| import type { CacheInvalidationOptions, CacheProvider, CacheEntry } from '../types'; | ||
| import { entryIsExpired } from '../utils'; | ||
|
|
||
| export class MemoryCacheProvider implements CacheProvider { | ||
| private readonly entryStore: Map<string, CacheEntry>; | ||
| private readonly tagStore: Map<string, Set<string>>; | ||
|
|
||
| constructor(private readonly options?: MemoryCacheOptions) { | ||
| this.entryStore = new Map<string, CacheEntry>(); | ||
| this.tagStore = new Map<string, Set<string>>; | ||
|
|
||
| setInterval(() => { | ||
| this.checkExpiration(); | ||
| }, (this.options?.checkInterval ?? 60) * 1000).unref(); | ||
| } | ||
|
|
||
| private checkExpiration() { | ||
| for (const [key, entry] of this.entryStore) { | ||
| if (entryIsExpired(entry)) { | ||
| this.entryStore.delete(key); | ||
| this.options?.onIntervalExpiration?.(entry); | ||
| } | ||
| } | ||
|
|
||
| for (const [tag, keys] of this.tagStore) { | ||
| for (const key of keys) { | ||
| if (!this.entryStore.has(key)) { | ||
| keys.delete(key); | ||
| } | ||
| } | ||
|
|
||
| if (keys.size === 0) { | ||
| this.tagStore.delete(tag); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| get(key: string) { | ||
| return Promise.resolve(this.entryStore.get(key)); | ||
| } | ||
|
|
||
| set(key: string, entry: CacheEntry) { | ||
| this.entryStore.set(key, entry); | ||
|
|
||
| if (entry.options.tags) { | ||
| for (const tag of entry.options.tags) { | ||
| let keys = this.tagStore.get(tag); | ||
|
|
||
| if (!keys) { | ||
| keys = new Set<string>(); | ||
| this.tagStore.set(tag, keys); | ||
| } | ||
|
|
||
| keys.add(key); | ||
| } | ||
| } | ||
|
|
||
| return Promise.resolve(); | ||
| } | ||
|
|
||
| invalidate(options: CacheInvalidationOptions) { | ||
| if (options.tags) { | ||
| for (const tag of options.tags) { | ||
| const keys = this.tagStore.get(tag); | ||
|
|
||
| if (keys) { | ||
| for (const key of keys) { | ||
| this.entryStore.delete(key); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return Promise.resolve(); | ||
| } | ||
|
|
||
| invalidateAll() { | ||
| this.entryStore.clear(); | ||
| this.tagStore.clear(); | ||
| return Promise.resolve(); | ||
| } | ||
| } | ||
|
|
||
| export type MemoryCacheOptions = { | ||
| /** | ||
| * How often, in seconds, entries will be checked for expiration. | ||
| * | ||
| * @default 60 | ||
| */ | ||
| checkInterval?: number; | ||
|
|
||
| /** | ||
| * Called when an entry has expired via the interval check. | ||
| */ | ||
| onIntervalExpiration?: (entry: CacheEntry) => void, | ||
| }; |
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,11 @@ | ||
| import z from 'zod'; | ||
|
|
||
| export const cacheOptionsSchema = z.strictObject({ | ||
| ttl: z.number().min(1).optional(), | ||
| swr: z.number().min(1).optional(), | ||
| tags: z.string().array().optional(), | ||
| }); | ||
|
|
||
| export const cacheEnvelopeSchema = z.object({ | ||
| cache: cacheOptionsSchema.optional(), | ||
| }); |
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,39 @@ | ||
| import type z from 'zod'; | ||
| import type { cacheEnvelopeSchema, cacheOptionsSchema } from './schemas'; | ||
|
|
||
| export type CacheEnvelope = z.infer<typeof cacheEnvelopeSchema>; | ||
| export type CacheOptions = z.infer<typeof cacheOptionsSchema>; | ||
|
|
||
| export interface CacheProvider { | ||
| get: (key: string) => Promise<CacheEntry | undefined>; | ||
| set: (key: string, entry: CacheEntry) => Promise<void>; | ||
| invalidate: (options: CacheInvalidationOptions) => Promise<void>; | ||
| invalidateAll: () => Promise<void>; | ||
| }; | ||
|
|
||
| export type CacheInvalidationOptions = { | ||
| tags?: string[]; | ||
| }; | ||
|
|
||
| export type CacheEntry = { | ||
| /** | ||
| * In unix epoch milliseconds. | ||
| */ | ||
| createdAt: number; | ||
|
|
||
| /** | ||
| * The caching options that were passed to the query. | ||
| */ | ||
| options: CacheOptions; | ||
|
|
||
| /** | ||
| * The result of executing the query. | ||
| */ | ||
| result: unknown; | ||
| }; | ||
|
|
||
| export type CachePluginOptions = { | ||
| provider: CacheProvider; | ||
| }; | ||
|
|
||
| export type CacheStatus = 'hit' | 'miss' | 'stale'; |
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,21 @@ | ||
| import type { CacheEntry } from './types'; | ||
|
|
||
| export function getTotalTTL(entry: CacheEntry) { | ||
| return (entry.options.ttl ?? 0) + (entry.options.swr ?? 0); | ||
| } | ||
|
|
||
| export function entryIsFresh(entry: CacheEntry) { | ||
| return entry.options.ttl | ||
| ? Date.now() <= (entry.createdAt + ((entry.options.ttl ?? 0) * 1000)) | ||
| : false | ||
| } | ||
|
|
||
| export function entryIsStale(entry: CacheEntry) { | ||
| return entry.options.swr | ||
| ? Date.now() <= entry.createdAt + (getTotalTTL(entry) * 1000) | ||
| : false; | ||
| } | ||
|
|
||
| export function entryIsExpired(entry: CacheEntry) { | ||
| return Date.now() > entry.createdAt + (getTotalTTL(entry) * 1000); | ||
| } |
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,4 @@ | ||
| { | ||
| "extends": "@zenstackhq/typescript-config/base.json", | ||
| "include": ["src/**/*"] | ||
| } |
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,13 @@ | ||
| import { defineConfig } from 'tsup'; | ||
|
|
||
| export default defineConfig({ | ||
| entry: { | ||
| index: 'src/index.ts', | ||
| 'providers/memory': 'src/providers/memory.ts', | ||
| }, | ||
| outDir: 'dist', | ||
| splitting: false, | ||
| sourcemap: true, | ||
| dts: true, | ||
| format: ['cjs', 'esm'], | ||
| }); |
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,4 @@ | ||
| import base from '@zenstackhq/vitest-config/base'; | ||
| import { defineConfig, mergeConfig } from 'vitest/config'; | ||
|
|
||
| export default mergeConfig(base, defineConfig({})); |
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cache options is now part of the cache key. I guess it's fine, just wanted to make sure it's intentional.