From 7e476f7c43ca4cec96f26b4d69996326ce364246 Mon Sep 17 00:00:00 2001 From: twlite <46562212+twlite@users.noreply.github.com> Date: Tue, 14 Jan 2025 00:18:16 +0545 Subject: [PATCH] feat: rework on cache --- apps/test-bot/src/commands/misc/help.ts | 23 +- apps/test-bot/src/commands/misc/xp.ts | 44 ++++ apps/test-bot/src/database/store.ts | 30 +++ .../src/events/messageCreate/give-xp.ts | 16 ++ apps/website/docs/guide/11-caching.mdx | 53 ++++- .../bin/esbuild-plugins/use-cache.mjs | 14 +- packages/commandkit/package.json | 2 + packages/commandkit/src/cache/index.ts | 217 ++++++++++++------ pnpm-lock.yaml | 6 + 9 files changed, 291 insertions(+), 114 deletions(-) create mode 100644 apps/test-bot/src/commands/misc/xp.ts create mode 100644 apps/test-bot/src/database/store.ts create mode 100644 apps/test-bot/src/events/messageCreate/give-xp.ts diff --git a/apps/test-bot/src/commands/misc/help.ts b/apps/test-bot/src/commands/misc/help.ts index a18f165..15df97b 100644 --- a/apps/test-bot/src/commands/misc/help.ts +++ b/apps/test-bot/src/commands/misc/help.ts @@ -1,37 +1,18 @@ -import { - SlashCommandProps, - CommandData, - unstable_cacheTag as cacheTag, -} from 'commandkit'; -import { setTimeout } from 'node:timers/promises'; +import { SlashCommandProps, CommandData } from 'commandkit'; export const data: CommandData = { name: 'help', description: 'This is a help command.', }; -async function someExpensiveDatabaseCall() { - 'use cache'; - - await setTimeout(5000); - - return Date.now(); -} - -cacheTag(15000, someExpensiveDatabaseCall); - export async function run({ interaction }: SlashCommandProps) { await interaction.deferReply(); - const dataRetrievalStart = Date.now(); - const time = await someExpensiveDatabaseCall(); - const dataRetrievalEnd = Date.now() - dataRetrievalStart; - return interaction.editReply({ embeds: [ { title: 'Help', - description: `This is a help command. The current time is \`${time}\`. Fetched in ${dataRetrievalEnd}ms.`, + description: `This is a help command.`, color: 0x7289da, timestamp: new Date().toISOString(), }, diff --git a/apps/test-bot/src/commands/misc/xp.ts b/apps/test-bot/src/commands/misc/xp.ts new file mode 100644 index 0000000..6f55417 --- /dev/null +++ b/apps/test-bot/src/commands/misc/xp.ts @@ -0,0 +1,44 @@ +import { + SlashCommandProps, + CommandData, + unstable_cacheTag as cacheTag, +} from 'commandkit'; +import { setTimeout } from 'node:timers/promises'; +import { database } from '../../database/store'; + +export const data: CommandData = { + name: 'xp', + description: 'This is an xp command.', +}; + +async function getUserXP(guildId: string, userId: string) { + 'use cache'; + + cacheTag(`xp:${guildId}:${userId}`); + + const xp: number = (await database.get(`${guildId}:${userId}`)) ?? 0; + + return xp; +} + +export async function run({ interaction }: SlashCommandProps) { + await interaction.deferReply(); + + const dataRetrievalStart = Date.now(); + const xp = await getUserXP(interaction.guildId!, interaction.user.id); + const dataRetrievalEnd = Date.now() - dataRetrievalStart; + + return interaction.editReply({ + embeds: [ + { + title: 'XP', + description: `Hello ${interaction.user}, your xp is ${xp}.`, + color: 0x7289da, + timestamp: new Date().toISOString(), + footer: { + text: `Data retrieval took ${dataRetrievalEnd}ms`, + }, + }, + ], + }); +} diff --git a/apps/test-bot/src/database/store.ts b/apps/test-bot/src/database/store.ts new file mode 100644 index 0000000..12c6964 --- /dev/null +++ b/apps/test-bot/src/database/store.ts @@ -0,0 +1,30 @@ +import { setTimeout } from 'node:timers/promises'; + +// Simulate a random latency between 30ms to 1.5s +const randomLatency = () => setTimeout(Math.floor(Math.random() * 1500) + 30); + +class DataStore { + private store = new Map(); + + async get(key: string) { + await randomLatency(); + return this.store.get(key); + } + + async set(key: string, value: any) { + await randomLatency(); + this.store.set(key, value); + } + + async delete(key: string) { + await randomLatency(); + this.store.delete(key); + } + + async clear() { + await randomLatency(); + this.store.clear(); + } +} + +export const database = new DataStore(); diff --git a/apps/test-bot/src/events/messageCreate/give-xp.ts b/apps/test-bot/src/events/messageCreate/give-xp.ts new file mode 100644 index 0000000..5f84a05 --- /dev/null +++ b/apps/test-bot/src/events/messageCreate/give-xp.ts @@ -0,0 +1,16 @@ +import type { Message } from 'discord.js'; +import { unstable_revalidate as revalidate } from 'commandkit'; +import { database } from '../../database/store'; + +export default async function (message: Message) { + if (message.author.bot || !message.inGuild()) return; + + const oldXp = + (await database.get(`${message.guildId}:${message.author.id}`)) ?? 0; + const xp = Math.floor(Math.random() * 10) + 1; + + await database.set(`${message.guildId}:${message.author.id}`, oldXp + xp); + + // revalidate the cache + await revalidate(`xp:${message.guildId}:${message.author.id}`); +} diff --git a/apps/website/docs/guide/11-caching.mdx b/apps/website/docs/guide/11-caching.mdx index 219def1..dc896a5 100644 --- a/apps/website/docs/guide/11-caching.mdx +++ b/apps/website/docs/guide/11-caching.mdx @@ -6,7 +6,8 @@ description: A guide on how to implement caching in your bot using CommandKit. # Caching :::warning -This feature is currently available in development version of CommandKit only. +This feature is currently available in development version of CommandKit only. Since it is an unstable feature, it may change in the future. +You need to prefix the function with `unstable_` to use this feature until it is stable. ::: Caching is a technique used to store data in a temporary storage to reduce the time it takes to fetch the data from the original source. This can be useful in Discord bots to reduce the number of database queries or external API calls. @@ -60,7 +61,7 @@ export async function run({ interaction }) { ### Using the cache manually -To use the cache manually, you can import the `unstable_cache()` function from CommandKit and use it to cache the result of a function. +To use the cache manually, you can import the `cache()` function from CommandKit and use it to cache the result of a function. ```js import { unstable_cache as cache } from 'commandkit'; @@ -83,7 +84,7 @@ export async function run({ interaction }) { } ``` -By default, the cached data will be stored forever until `unstable_revalidate()` or `unstable_invalidate()` is called on the cache object. You can also specify a custom TTL (time to live) for the cache by passing a second argument to the `cache` function. +By default, the cached data will be stored forever until `revalidate()` or `expire()` is called on the cache object. You can also specify a custom TTL (time to live) for the cache by passing a second argument to the `cache` function. ```js const fetchData = cache( @@ -100,7 +101,7 @@ const fetchData = cache( ); ``` -You may want to specify the cache parameters when using `"use cache"` directive. When using this approach, you can use `unstable_cacheTag()` to tag the cache with custom parameters. +You may want to specify the cache parameters when using `"use cache"` directive. When using this approach, you can use `cacheTag()` to tag the cache with custom parameters. ```js import { unstable_cacheTag as cacheTag } from 'commandkit'; @@ -108,17 +109,47 @@ import { unstable_cacheTag as cacheTag } from 'commandkit'; async function fetchData() { 'use cache'; + cacheTag({ + name: 'fetchData', // name of the cache + ttl: 60_000, // cache for 1 minute + }); + // Fetch data from an external source const data = await fetch('https://my-example-api.com/data'); return data.json(); } +``` -cacheTag( - { - name: 'fetchData', // name of the cache - ttl: 60_000, // cache for 1 minute - }, - fetchData, -); +:::tip +`cacheTag()` will only tag the function when it first runs. Subsequent calls to the function will not tag the cache again. +If not tagged manually, commandkit assigns random tag name with 15 minutes TTL. + +`cacheTag()` does not work with the `cache` function. It must be used with the `"use cache"` directive only. +::: + +> You can alternatively use `cacheLife()` to set the TTL of the cache. Example: `cacheLife(10_000)` would set the TTL to 10 seconds. + +## Invalidating/Revalidating the cache + +Revalidating the cache is the process of updating the cached data with fresh data from the original source on demand. You can use the `unstable_revalidate()` function to revalidate the cache. CommandKit will not immediately revalidate the cache, but it will do so the next time the cached data is requested. Because of this, we can also term it as "lazy revalidation". + +```js +import { unstable_revalidate as revalidate } from 'commandkit'; + +// Revalidate the cache +await revalidate('cache-tag-name'); +``` + +## Expire the cache + +Expiring the cache is the process of removing the cached data or resetting the TTL of the cache. Use the `unstable_expire()` function to expire the cache. + +```js +import { unstable_expire as expire } from 'commandkit'; + +// Expire the cache +await expire('cache-tag-name', /* optional ttl */ 60_000); ``` + +If no TTL is given or TTL is in the past, commandkit deletes the cache immediately. diff --git a/packages/commandkit/bin/esbuild-plugins/use-cache.mjs b/packages/commandkit/bin/esbuild-plugins/use-cache.mjs index b897d07..d816b56 100644 --- a/packages/commandkit/bin/esbuild-plugins/use-cache.mjs +++ b/packages/commandkit/bin/esbuild-plugins/use-cache.mjs @@ -8,7 +8,7 @@ const generate = _generate.default || _generate; const IMPORT_PATH = 'commandkit'; const DIRECTIVE = 'use cache'; -const CACHE_IDENTIFIER = 'unstable_cache'; +const CACHE_IDENTIFIER = 'unstable_super_duper_secret_internal_for_use_cache_directive_of_commandkit_cli_do_not_use_it_directly_or_you_will_be_fired_kthxbai'; const generateRandomString = (length = 6) => { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -46,7 +46,7 @@ export const cacheDirectivePlugin = () => { enter(path) { const binding = path.scope.getBinding(CACHE_IDENTIFIER); if (binding) { - state.cacheIdentifierName = `cache_${generateRandomString()}`; + state.cacheIdentifierName = `${CACHE_IDENTIFIER}_${generateRandomString()}`; } }, }, @@ -115,11 +115,11 @@ export const cacheDirectivePlugin = () => { // Create a new body without the 'use cache' directive const newBody = t.isBlockStatement(path.node.body) ? t.blockStatement( - path.node.body.body, - path.node.body.directives.filter( - (d) => d.value.value !== DIRECTIVE, - ), - ) + path.node.body.body, + path.node.body.directives.filter( + (d) => d.value.value !== DIRECTIVE, + ), + ) : path.node.body; const wrapped = t.callExpression( diff --git a/packages/commandkit/package.json b/packages/commandkit/package.json index 617389b..632f831 100644 --- a/packages/commandkit/package.json +++ b/packages/commandkit/package.json @@ -48,12 +48,14 @@ "@babel/types": "^7.26.5", "commander": "^12.1.0", "dotenv": "^16.4.7", + "ms": "^2.1.3", "ora": "^8.0.1", "rfdc": "^1.3.1", "rimraf": "^5.0.5", "tsup": "^8.3.5" }, "devDependencies": { + "@types/ms": "^0.7.34", "@types/node": "^22.10.2", "@types/yargs": "^17.0.32", "discord.js": "^14.16.3", diff --git a/packages/commandkit/src/cache/index.ts b/packages/commandkit/src/cache/index.ts index a421712..7f3f33e 100644 --- a/packages/commandkit/src/cache/index.ts +++ b/packages/commandkit/src/cache/index.ts @@ -1,7 +1,11 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; import { GenericFunction, getCommandKit } from '../context/async-context'; -import { COMMANDKIT_CACHE_TAG } from '../utils/constants'; import { warnUnstable } from '../utils/warn-unstable'; import { randomUUID } from 'node:crypto'; +import ms from 'ms'; + +const cacheContext = new AsyncLocalStorage(); +const CACHE_FN_STORE = new Map(); export * from './CacheProvider'; export * from './MemoryCache'; @@ -18,32 +22,50 @@ export interface CacheOptions { } /** - * Assigns cache tag parameters to the function that uses the "use cache" directive. - * @param options The cache options. - * @param fn The function to assign the cache tag. + * Assigns cache tag parameters to the current function that uses the "use cache" directive. + * @param options The cache options or the name of the cache key. */ -export function unstable_cacheTag( - options: string | number | CacheOptions, - fn: GenericFunction, -): void { +export function unstable_cacheTag(options: string | CacheOptions): void { warnUnstable('cacheTag()'); - const isCacheable = Reflect.get(fn, COMMANDKIT_CACHE_TAG); + const context = cacheContext.getStore(); - if (!isCacheable) { + if (context === undefined) { throw new Error( - 'cacheTag() can only be used with cache() functions or functions that use the "use cache" directive.', + 'cacheTag() can only be used inside a function that use the "use cache" directive.', ); } - const opt = - typeof options === 'string' - ? { name: options } - : typeof options === 'number' - ? { name: randomUUID(), ttl: options } - : options; + // already tagged + if (context === null) return; - Reflect.set(fn, '__cache_params', opt); + const opt = typeof options === 'string' ? { name: options } : options; + + if (opt.name) context.name = opt.name; + if (opt.ttl !== undefined) context.ttl = opt.ttl; +} + +/** + * Assigns cache lifetime to the current function that uses the "use cache" directive. + * @param ttl The time-to-live of the cache key in milliseconds or milliseconds parsable string. + */ +export function unstable_cacheLife(options: string | number): void { + warnUnstable('cacheLife()'); + + const context = cacheContext.getStore(); + + if (context === undefined) { + throw new Error( + 'cacheLife() can only be used inside a function that use the "use cache" directive.', + ); + } + + // already tagged + if (context === null) return; + + const ttl = typeof options === 'string' ? ms(options) : options; + + context.ttl = ttl; } /** @@ -64,11 +86,28 @@ export function unstable_cache Promise>( options ??= randomUUID(); - const params = typeof options === 'string' ? { name: options } : options; + const params = + typeof options === 'string' + ? { + name: options, + } + : options; + + // default ttl to 15 minutes + params.ttl ??= 900_000; + + let firstRun = true; const _fn = (async (...args: Parameters): Promise => { - const context = (Reflect.get(_fn, '__cache_params') || - params) as CacheOptions; + const _context = CACHE_FN_STORE.get(_fn); + + if (!firstRun && !_context) { + return await fn(...args); + } + + firstRun = false; + + const context = _context ?? params; // check cache const data = await cache.get(context.name); @@ -83,71 +122,92 @@ export function unstable_cache Promise>( return result; }) as F; - Reflect.set(_fn, '__cache_params', params); - Reflect.set(_fn, COMMANDKIT_CACHE_TAG, true); + CACHE_FN_STORE.set(_fn, params); return _fn; } /** - * Revalidate a cache by its key. - * @param cache The cache key or the function that was cached. - * @param ttl The new time-to-live of the cache key in milliseconds. If not provided, the ttl will not be set (past ttl will be ignored). + * @private + * @internal */ -export async function unstable_revalidate( - cache: string | GenericFunction, - ttl?: number, -): Promise { - warnUnstable('revalidate()'); - const commandkit = getCommandKit(true); - const cacheProvider = commandkit.getCacheProvider(); - - if (!cacheProvider) { - throw new Error('revalidate() cannot be used without a cache provider.'); - } - - const key = - typeof cache === 'string' - ? cache - : Reflect.get(cache, '__cache_params')?.name; - - if (!key) { - throw new Error('Invalid cache key.'); - } - - const data = await cacheProvider.get(key); +function use_cache Promise>(fn: F): F { + warnUnstable('"use cache"'); + + let tagged = false; + let originalFn: F; + + const memo = (async (...args) => { + if (!originalFn) { + originalFn = unstable_cache(fn); + } + + if (!tagged) { + // validate cacheTag() usage + const _params = CACHE_FN_STORE.get(originalFn); + + if (!_params) return originalFn(...args); + + const [params, data] = await cacheContext.run( + { + name: _params?.name ?? '', + ttl: _params?.ttl, + }, + async () => { + const data = await originalFn(...args); + const maybeTaggedParams = cacheContext.getStore(); + return [maybeTaggedParams, data]; + }, + ); + + if (params) { + CACHE_FN_STORE.set(originalFn, { + name: params.name ?? _params?.name ?? randomUUID(), + ttl: params.ttl ?? _params?.ttl, + }); + } + + tagged = true; + + return data; + } + + return cacheContext.run(null, () => originalFn(...args)); + }) as F; - if (data) { - const _ttl = ttl ?? undefined; - await cacheProvider.set(key, data, _ttl); - } + return memo; } +// this is intentional, do not change or you will be fired +export { use_cache as unstable_super_duper_secret_internal_for_use_cache_directive_of_commandkit_cli_do_not_use_it_directly_or_you_will_be_fired_kthxbai }; + /** - * Invalidate a cache by its key. - * @param cache The cache key or the function that was cached. + * Revalidate a cache by its key. + * @param cache The cache key to revalidate. */ -export async function unstable_invalidate( - cache: string | GenericFunction, -): Promise { - warnUnstable('invalidate()'); +export async function unstable_revalidate(cache: string): Promise { + warnUnstable('revalidate()'); const commandkit = getCommandKit(true); const cacheProvider = commandkit.getCacheProvider(); if (!cacheProvider) { - throw new Error('invalidate() cannot be used without a cache provider.'); + throw new Error('revalidate() cannot be used without a cache provider.'); } - const key = - typeof cache === 'string' - ? cache - : Reflect.get(cache, '__cache_params')?.name; + let target: GenericFunction | undefined = undefined; + + for (const [fn, params] of CACHE_FN_STORE) { + if (params.name === cache) { + target = fn; + break; + } + } - if (!key) { + if (!target) { throw new Error('Invalid cache key.'); } - await cacheProvider.delete(key); + await cacheProvider.delete(cache); } /** @@ -156,8 +216,8 @@ export async function unstable_invalidate( * @param ttl The new time-to-live of the cache key in milliseconds. If not provided, the cache key will be deleted. */ export async function unstable_expire( - cache: string | GenericFunction, - ttl?: number, + cache: string, + ttl?: number | string, ): Promise { warnUnstable('expire()'); const commandkit = getCommandKit(true); @@ -167,18 +227,25 @@ export async function unstable_expire( throw new Error('expire() cannot be used without a cache provider.'); } - const name = - typeof cache === 'string' - ? cache - : Reflect.get(cache, '__cache_params')?.name; + let target: GenericFunction | undefined = undefined; - if (!name) { + for (const [fn, params] of CACHE_FN_STORE) { + if (params.name === cache) { + target = fn; + break; + } + } + + if (!target) { throw new Error('Invalid cache key.'); } - if (typeof ttl === 'number') { - await cacheProvider.expire(name, ttl); + const _ttl = + typeof ttl === 'string' ? ms(ttl) : typeof ttl === 'number' ? ttl : null; + + if (_ttl !== null) { + await cacheProvider.expire(cache, _ttl); } else { - await cacheProvider.delete(name); + await cacheProvider.delete(cache); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4dfc75..d73b111 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.4.7 + ms: + specifier: ^2.1.3 + version: 2.1.3 ora: specifier: ^8.0.1 version: 8.1.1 @@ -265,6 +268,9 @@ importers: specifier: ^8.3.5 version: 8.3.5(jiti@1.21.7)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) devDependencies: + '@types/ms': + specifier: ^0.7.34 + version: 0.7.34 '@types/node': specifier: ^22.10.2 version: 22.10.2