diff --git a/src/hash.ts b/src/hash.ts index f947629..472a4be 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -1,63 +1,38 @@ +import { ScriptInfo } from "./lua-scripts/hash"; import { Context, RegionContext } from "./types" -type ScriptKind = "limitHash" | "getRemainingHash" | "resetHash" - -/** - * Loads the scripts to redises with SCRIPT LOAD if the first region context - * doesn't have the kind of script hash in it. - * - * @param ctx Regional or multi region context - * @param script script to load - * @param kind script kind - */ -const setHash = async ( - ctx: Context, - script: string, - kind: ScriptKind -) => { - const regionContexts = "redis" in ctx ? [ctx] : ctx.regionContexts - const hashSample = regionContexts[0].scriptHashes[kind] - if (!hashSample) { - await Promise.all(regionContexts.map(async (context) => { - context.scriptHashes[kind] = await context.redis.scriptLoad(script) - })); - }; -} - /** - * Runds the specified script with EVALSHA if ctx.cacheScripts or EVAL - * otherwise. + * Runs the specified script with EVALSHA using the scriptHash parameter. * - * If the script is not found when EVALSHA is used, it submits the script - * with LOAD SCRIPT, then calls EVALSHA again. + * If the EVALSHA fails, loads the script to redis and runs again with the + * hash returned from Redis. * * @param ctx Regional or multi region context - * @param script script to run - * @param kind script kind - * @param keys - * @param args + * @param script ScriptInfo of script to run. Contains the script and its hash + * @param keys eval keys + * @param args eval args */ export const safeEval = async ( ctx: RegionContext, - script: string, - kind: ScriptKind, + script: ScriptInfo, keys: any[], args: any[], ) => { - if (!ctx.cacheScripts) { - return await ctx.redis.eval(script, keys, args); - }; - - await setHash(ctx, script, kind); try { - return await ctx.redis.evalsha(ctx.scriptHashes[kind]!, keys, args) + return await ctx.redis.evalsha(script.hash, keys, args) } catch (error) { if (`${error}`.includes("NOSCRIPT")) { - console.log("Script with the expected hash was not found in redis db. It is probably flushed. Will load another scipt before continuing."); - ctx.scriptHashes[kind] = undefined; - await setHash(ctx, script, kind) - console.log(" New script successfully loaded.") - return await ctx.redis.evalsha(ctx.scriptHashes[kind]!, keys, args) + const hash = await ctx.redis.scriptLoad(script.script) + + if (hash !== script.hash) { + console.warn( + "Upstash Ratelimit: Expected hash and the hash received from Redis" + + " are different. Ratelimit will work as usual but performance will" + + " be reduced." + ); + } + + return await ctx.redis.evalsha(hash, keys, args) } throw error; } diff --git a/src/lua-scripts/hash.test.ts b/src/lua-scripts/hash.test.ts new file mode 100644 index 0000000..7c61e42 --- /dev/null +++ b/src/lua-scripts/hash.test.ts @@ -0,0 +1,32 @@ +import { Redis } from "@upstash/redis"; +import { describe, expect, test } from "bun:test"; +import { RESET_SCRIPT, SCRIPTS } from "./hash"; + +describe("should use correct hash for lua scripts", () => { + const redis = Redis.fromEnv(); + + const validateHash = async (script: string, expectedHash: string) => { + const hash = await redis.scriptLoad(script) + expect(hash).toBe(expectedHash) + } + + const algorithms = [ + ...Object.entries(SCRIPTS.singleRegion), ...Object.entries(SCRIPTS.multiRegion) + ] + + // for each algorithm (fixedWindow, slidingWindow etc) + for (const [algorithm, scripts] of algorithms) { + describe(`${algorithm}`, () => { + // for each method (limit & getRemaining) + for (const [method, scriptInfo] of Object.entries(scripts)) { + test(method, async () => { + await validateHash(scriptInfo.script, scriptInfo.hash) + }) + } + }) + } + + test("reset script", async () => { + await validateHash(RESET_SCRIPT.script, RESET_SCRIPT.hash) + }) +}) \ No newline at end of file diff --git a/src/lua-scripts/hash.ts b/src/lua-scripts/hash.ts new file mode 100644 index 0000000..326d090 --- /dev/null +++ b/src/lua-scripts/hash.ts @@ -0,0 +1,95 @@ +import * as Single from "./single" +import * as Multi from "./multi" +import { resetScript } from "./reset" + +export type ScriptInfo = { + script: string, + hash: string +} + +type Algorithm = { + limit: ScriptInfo, + getRemaining: ScriptInfo, +} + +type AlgorithmKind = + | "fixedWindow" + | "slidingWindow" + | "tokenBucket" + | "cachedFixedWindow" + +export const SCRIPTS: { + singleRegion: Record, + multiRegion: Record, Algorithm>, +} = { + singleRegion: { + fixedWindow: { + limit: { + script: Single.fixedWindowLimitScript, + hash: "b13943e359636db027ad280f1def143f02158c13" + }, + getRemaining: { + script: Single.fixedWindowRemainingTokensScript, + hash: "8c4c341934502aee132643ffbe58ead3450e5208" + }, + }, + slidingWindow: { + limit: { + script: Single.slidingWindowLimitScript, + hash: "e1391e429b699c780eb0480350cd5b7280fd9213" + }, + getRemaining: { + script: Single.slidingWindowRemainingTokensScript, + hash: "65a73ac5a05bf9712903bc304b77268980c1c417" + }, + }, + tokenBucket: { + limit: { + script: Single.tokenBucketLimitScript, + hash: "5bece90aeef8189a8cfd28995b479529e270b3c6" + }, + getRemaining: { + script: Single.tokenBucketRemainingTokensScript, + hash: "a15be2bb1db2a15f7c82db06146f9d08983900d0" + }, + }, + cachedFixedWindow: { + limit: { + script: Single.cachedFixedWindowLimitScript, + hash: "c26b12703dd137939b9a69a3a9b18e906a2d940f" + }, + getRemaining: { + script: Single.cachedFixedWindowRemainingTokenScript, + hash: "8e8f222ccae68b595ee6e3f3bf2199629a62b91a" + }, + } + }, + multiRegion: { + fixedWindow: { + limit: { + script: Multi.fixedWindowLimitScript, + hash: "a8c14f3835aa87bd70e5e2116081b81664abcf5c" + }, + getRemaining: { + script: Multi.fixedWindowRemainingTokensScript, + hash: "8ab8322d0ed5fe5ac8eb08f0c2e4557f1b4816fd" + }, + }, + slidingWindow: { + limit: { + script: Multi.slidingWindowLimitScript, + hash: "cb4fdc2575056df7c6d422764df0de3a08d6753b" + }, + getRemaining: { + script: Multi.slidingWindowRemainingTokensScript, + hash: "558c9306b7ec54abb50747fe0b17e5d44bd24868" + }, + }, + } +} + +/** COMMON */ +export const RESET_SCRIPT: ScriptInfo = { + script: resetScript, + hash: "54bd274ddc59fb3be0f42deee2f64322a10e2b50" +} \ No newline at end of file diff --git a/src/multi.ts b/src/multi.ts index dc29372..9980a0a 100644 --- a/src/multi.ts +++ b/src/multi.ts @@ -2,6 +2,7 @@ import { Cache } from "./cache"; import type { Duration } from "./duration"; import { ms } from "./duration"; import { safeEval } from "./hash"; +import { RESET_SCRIPT, SCRIPTS } from "./lua-scripts/hash"; import { fixedWindowLimitScript, fixedWindowRemainingTokensScript, @@ -115,8 +116,6 @@ export class MultiRegionRatelimit extends Ratelimit { ctx: { regionContexts: config.redis.map(redis => ({ redis: redis, - scriptHashes: {}, - cacheScripts: config.cacheScripts ?? true, })), cache: config.ephemeralCache ? new Cache(config.ephemeralCache) : undefined, }, @@ -178,8 +177,7 @@ export class MultiRegionRatelimit extends Ratelimit { redis: regionContext.redis, request: safeEval( regionContext, - fixedWindowLimitScript, - "limitHash", + SCRIPTS.multiRegion.fixedWindow.limit, [key], [requestId, windowDuration, incrementBy], ) as Promise, @@ -284,8 +282,7 @@ export class MultiRegionRatelimit extends Ratelimit { redis: regionContext.redis, request: safeEval( regionContext, - fixedWindowRemainingTokensScript, - "getRemainingHash", + SCRIPTS.multiRegion.fixedWindow.getRemaining, [key], [null] ) as Promise, @@ -316,8 +313,7 @@ export class MultiRegionRatelimit extends Ratelimit { await Promise.all(ctx.regionContexts.map((regionContext) => { safeEval( regionContext, - resetScript, - "resetHash", + RESET_SCRIPT, [pattern], [null] ); @@ -385,8 +381,7 @@ export class MultiRegionRatelimit extends Ratelimit { redis: regionContext.redis, request: safeEval( regionContext, - slidingWindowLimitScript, - "limitHash", + SCRIPTS.multiRegion.slidingWindow.limit, [currentKey, previousKey], [tokens, now, windowDuration, requestId, incrementBy], // lua seems to return `1` for true and `null` for false @@ -508,8 +503,7 @@ export class MultiRegionRatelimit extends Ratelimit { redis: regionContext.redis, request: safeEval( regionContext, - slidingWindowRemainingTokensScript, - "getRemainingHash", + SCRIPTS.multiRegion.slidingWindow.getRemaining, [currentKey, previousKey], [now, windowSize], // lua seems to return `1` for true and `null` for false @@ -532,8 +526,7 @@ export class MultiRegionRatelimit extends Ratelimit { await Promise.all(ctx.regionContexts.map((regionContext) => { safeEval( regionContext, - resetScript, - "resetHash", + RESET_SCRIPT, [pattern], [null] ); diff --git a/src/single.ts b/src/single.ts index 58bb725..9896ddc 100644 --- a/src/single.ts +++ b/src/single.ts @@ -1,6 +1,7 @@ import type { Duration } from "./duration"; import { ms } from "./duration"; import { safeEval } from "./hash"; +import { RESET_SCRIPT, SCRIPTS } from "./lua-scripts/hash"; import { resetScript } from "./lua-scripts/reset"; import { cachedFixedWindowLimitScript, @@ -74,8 +75,12 @@ export type RegionRatelimitConfig = { analytics?: boolean; /** - * If enabled, lua scripts will be sent to Redis with SCRIPT LOAD durint the first request. - * In the subsequent requests, hash of the script will be used to invoke it + * @deprecated Has no affect since v2.0.3. Instead, hash values of scripts are + * hardcoded in the sdk and it attempts to run the script using EVALSHA (with the hash). + * If it fails, runs script load. + * + * Previously, if enabled, lua scripts were sent to Redis with SCRIPT LOAD durint the first request. + * In the subsequent requests, hash of the script would be used to invoke the scripts * * @default true */ @@ -120,8 +125,6 @@ export class RegionRatelimit extends Ratelimit { analytics: config.analytics, ctx: { redis: config.redis, - scriptHashes: {}, - cacheScripts: config.cacheScripts ?? true, }, ephemeralCache: config.ephemeralCache, enableProtection: config.enableProtection, @@ -180,8 +183,7 @@ export class RegionRatelimit extends Ratelimit { const usedTokensAfterUpdate = await safeEval( ctx, - fixedWindowLimitScript, - "limitHash", + SCRIPTS.singleRegion.fixedWindow.limit, [key], [windowDuration, incrementBy], ) as number; @@ -209,8 +211,7 @@ export class RegionRatelimit extends Ratelimit { const usedTokens = await safeEval( ctx, - fixedWindowRemainingTokensScript, - "getRemainingHash", + SCRIPTS.singleRegion.fixedWindow.getRemaining, [key], [null], ) as number; @@ -228,8 +229,7 @@ export class RegionRatelimit extends Ratelimit { await safeEval( ctx, - resetScript, - "resetHash", + RESET_SCRIPT, [pattern], [null], ) as number; @@ -291,8 +291,7 @@ export class RegionRatelimit extends Ratelimit { const remainingTokens = await safeEval( ctx, - slidingWindowLimitScript, - "limitHash", + SCRIPTS.singleRegion.slidingWindow.limit, [currentKey, previousKey], [tokens, now, windowSize, incrementBy], ) as number; @@ -320,8 +319,7 @@ export class RegionRatelimit extends Ratelimit { const usedTokens = await safeEval( ctx, - slidingWindowRemainingTokensScript, - "getRemainingHash", + SCRIPTS.singleRegion.slidingWindow.getRemaining, [currentKey, previousKey], [now, windowSize], ) as number; @@ -339,8 +337,7 @@ export class RegionRatelimit extends Ratelimit { await safeEval( ctx, - resetScript, - "resetHash", + RESET_SCRIPT, [pattern], [null], ) as number; @@ -402,8 +399,7 @@ export class RegionRatelimit extends Ratelimit { const [remaining, reset] = await safeEval( ctx, - tokenBucketLimitScript, - "limitHash", + SCRIPTS.singleRegion.tokenBucket.limit, [identifier], [maxTokens, intervalDuration, refillRate, now, incrementBy], ) as [number, number]; @@ -425,8 +421,7 @@ export class RegionRatelimit extends Ratelimit { const [remainingTokens, refilledAt] = await safeEval( ctx, - tokenBucketRemainingTokensScript, - "getRemainingHash", + SCRIPTS.singleRegion.tokenBucket.getRemaining, [identifier], [maxTokens], ) as [number, number]; @@ -447,8 +442,7 @@ export class RegionRatelimit extends Ratelimit { await safeEval( ctx, - resetScript, - "resetHash", + RESET_SCRIPT, [pattern], [null], ) as number; @@ -509,8 +503,7 @@ export class RegionRatelimit extends Ratelimit { const pending = success ? safeEval( ctx, - cachedFixedWindowLimitScript, - "limitHash", + SCRIPTS.singleRegion.cachedFixedWindow.limit, [key], [windowDuration, incrementBy] ) @@ -527,8 +520,7 @@ export class RegionRatelimit extends Ratelimit { const usedTokensAfterUpdate = await safeEval( ctx, - cachedFixedWindowLimitScript, - "limitHash", + SCRIPTS.singleRegion.cachedFixedWindow.limit, [key], [windowDuration, incrementBy] ) as number; @@ -562,8 +554,7 @@ export class RegionRatelimit extends Ratelimit { const usedTokens = await safeEval( ctx, - cachedFixedWindowRemainingTokenScript, - "getRemainingHash", + SCRIPTS.singleRegion.cachedFixedWindow.getRemaining, [key], [null], ) as number; @@ -586,8 +577,7 @@ export class RegionRatelimit extends Ratelimit { await safeEval( ctx, - resetScript, - "resetHash", + RESET_SCRIPT, [pattern], [null], ) as number; diff --git a/src/types.ts b/src/types.ts index 9d13a57..d3346d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,12 +22,6 @@ export interface EphemeralCache { export type RegionContext = { redis: Redis; cache?: EphemeralCache, - scriptHashes: { - limitHash?: string, - getRemainingHash?: string, - resetHash?: string - }, - cacheScripts: boolean, }; export type MultiRegionContext = { regionContexts: Omit; cache?: EphemeralCache };