From 8dd9c9a2ba7b8609fc388a1af00c427fcd7ea205 Mon Sep 17 00:00:00 2001 From: TonyRL Date: Wed, 8 Jul 2026 23:40:52 +0800 Subject: [PATCH] perf: use only one WASM alloc for xxhash --- lib/api/route/status.ts | 3 ++- lib/middleware/cache.ts | 22 +++++++++++++------- lib/routes/economist/espresso.ts | 3 ++- lib/utils/cache/index.ts | 35 ++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/lib/api/route/status.ts b/lib/api/route/status.ts index f7b2e1ca0c..bcc44f5632 100644 --- a/lib/api/route/status.ts +++ b/lib/api/route/status.ts @@ -4,6 +4,8 @@ import xxhash from 'xxhash-wasm'; import cacheModule from '@/utils/cache/index'; +const { h64ToString } = await xxhash(); + const QuerySchema = z.object({ requestPath: z.string().openapi({ param: { @@ -62,7 +64,6 @@ const handler: RouteHandler = async (ctx) => { } const { requestPath } = ctx.req.valid('query'); - const { h64ToString } = await xxhash(); const key = 'rsshub:koa-redis-cache:' + h64ToString(requestPath + ':rss'); const cached = await cacheModule.globalCache.has(key); diff --git a/lib/middleware/cache.ts b/lib/middleware/cache.ts index 3deaa14da7..6609fed952 100644 --- a/lib/middleware/cache.ts +++ b/lib/middleware/cache.ts @@ -7,6 +7,8 @@ import type { Data } from '@/types'; import cacheModule from '@/utils/cache/index'; const bypassList = new Set(['/', '/robots.txt', '/logo.png', '/favicon.ico']); + +const { h64ToString } = await xxhash(); // only give cache string, as the `!` condition tricky // XXH64 is used to shrink key size // plz, write these tips in comments! @@ -19,13 +21,18 @@ const middleware: MiddlewareHandler = async (ctx, next) => { const requestPath = ctx.req.path; const format = `:${ctx.req.query('format') || 'rss'}`; const limit = ctx.req.query('limit') ? `:${ctx.req.query('limit')}` : ''; - const { h64ToString } = await xxhash(); const key = 'rsshub:koa-redis-cache:' + h64ToString(requestPath + format + limit); const controlKey = 'rsshub:path-requested:' + h64ToString(requestPath + format + limit); - const isRequesting = await cacheModule.globalCache.get(controlKey); + let value = await cacheModule.globalCache.get(key); - if (isRequesting === '1') { + // Doesn't hit the cache? Try to become the fetcher and let others know! + let claimed = false; + if (!value) { + claimed = await cacheModule.globalCache.claim(controlKey, config.cache.requestTimeout); + } + + if (!value && !claimed) { let retryTimes = process.env.NODE_ENV === 'test' ? 1 : 10; let bypass = false; while (retryTimes > 0) { @@ -41,10 +48,9 @@ const middleware: MiddlewareHandler = async (ctx, next) => { if (!bypass) { throw new RequestInProgressError('This path is currently fetching, please come back later!'); } + value = await cacheModule.globalCache.get(key); } - const value = await cacheModule.globalCache.get(key); - if (value) { ctx.status(200); ctx.header('RSSHub-Cache-Status', 'HIT'); @@ -53,8 +59,10 @@ const middleware: MiddlewareHandler = async (ctx, next) => { return; } - // Doesn't hit the cache? We need to let others know! - await cacheModule.globalCache.set(controlKey, '1', config.cache.requestTimeout); + if (!claimed) { + // waited out a stale claim without finding a cache entry, take over the fetch + await cacheModule.globalCache.set(controlKey, '1', config.cache.requestTimeout); + } // let routers control cache ctx.set('cacheKey', key); diff --git a/lib/routes/economist/espresso.ts b/lib/routes/economist/espresso.ts index bce577e324..56addc313a 100644 --- a/lib/routes/economist/espresso.ts +++ b/lib/routes/economist/espresso.ts @@ -11,6 +11,8 @@ import { parseDate } from '@/utils/parse-date'; const link = 'https://www.economist.com/the-world-in-brief'; +const { h64ToString } = await xxhash(); + export const route: Route = { path: '/espresso', categories: ['traditional-media'], @@ -37,7 +39,6 @@ export const route: Route = { }; async function handler() { - const { h64ToString } = await xxhash(); const nextData = await cache.tryGet( link, async () => { diff --git a/lib/utils/cache/index.ts b/lib/utils/cache/index.ts index f1a05e3b3a..6d32b34def 100644 --- a/lib/utils/cache/index.ts +++ b/lib/utils/cache/index.ts @@ -11,10 +11,16 @@ const globalCache: { get: (key: string) => Promise | string | null | undefined; has: (key: string) => Promise | boolean; set: (key: string, value?: string | Record, maxAge?: number) => any; + /** + * Atomically set `key` to '1' and return true, unless it is already '1' (return false). + * A get-then-set in the caller races: two same-tick requests would both read "not '1'". + */ + claim: (key: string, maxAge: number) => Promise | boolean; } = { get: () => null, has: () => false, set: () => null, + claim: () => true, }; const noopCacheModule: CacheModule = { @@ -54,6 +60,13 @@ if (isWorker) { return false; }; globalCache.set = cacheModule.set; + globalCache.claim = async (key, maxAge) => { + if (!key || !cacheModule.status.available || !redisClient) { + return true; + } + const result = await redisClient.eval("if redis.call('GET', KEYS[1]) == '1' then return 0 end redis.call('SET', KEYS[1], '1', 'EX', ARGV[1]) return 1", 1, key, maxAge); + return result === 1; + }; break; } case 'http': @@ -75,6 +88,17 @@ if (isWorker) { return cacheModule.set(key, value, maxAge); } }; + globalCache.claim = async (key, maxAge) => { + if (!key || !cacheModule.status.available) { + return true; + } + // best effort: the HTTP cache protocol has no atomic operation + if ((await cacheModule.get(key, false)) === '1') { + return false; + } + await cacheModule.set(key, '1', maxAge); + return true; + }; break; case 'memory': { cacheModule = memory; @@ -102,6 +126,17 @@ if (isWorker) { return memoryCache.set(key, value, { ttl: maxAge * 1000 }); } }; + // fully synchronous, so nothing can interleave between the read and the write + globalCache.claim = (key, maxAge) => { + if (!key || !cacheModule.status.available || !memoryCache) { + return true; + } + if (memoryCache.get(key, { updateAgeOnGet: false }) === '1') { + return false; + } + memoryCache.set(key, '1', { ttl: maxAge * 1000 }); + return true; + }; break; } default: