feat(api): add cache probing (#21300)

* feat(api): add cache probing

* fix(tests): correct query parameter from 'path' to 'requestPath' in status tests

* feat(cache): add has to workers kv
This commit is contained in:
Tony
2026-03-05 02:36:53 +08:00
committed by GitHub
parent 24406677ca
commit c09dacac5b
16 changed files with 252 additions and 7 deletions
+2 -1
View File
@@ -45,6 +45,7 @@ const QuerySchema = z.object({
const route = createRoute({
method: 'get',
path: '/category/{category}',
description: 'Namespace list filtered by category',
tags: ['Category'],
request: {
query: QuerySchema,
@@ -52,7 +53,7 @@ const route = createRoute({
},
responses: {
200: {
description: 'Namespace list by categories and language',
description: 'Namespaces matching the requested category',
},
},
});
+2 -1
View File
@@ -7,10 +7,11 @@ import { gitDate, gitHash } from '@/utils/git-hash';
const route = createRoute({
method: 'get',
path: '/follow/config',
description: 'Follow configuration for the current instance',
tags: ['Follow'],
responses: {
200: {
description: 'Follow config',
description: 'Follow configuration for the current instance',
},
},
});
+31 -1
View File
@@ -8,6 +8,7 @@ import { handler as namespaceAllHandler, route as namespaceAllRoute } from '@/ap
import { handler as namespaceOneHandler, route as namespaceOneRoute } from '@/api/namespace/one';
import { handler as radarRulesAllHandler, route as radarRulesAllRoute } from '@/api/radar/rules/all';
import { handler as radarRulesOneHandler, route as radarRulesOneRoute } from '@/api/radar/rules/one';
import { handler as routeStatusHandler, route as routeStatusRoute } from '@/api/route/status';
const app = new OpenAPIHono();
@@ -16,6 +17,7 @@ app.openapi(namespaceOneRoute, namespaceOneHandler);
app.openapi(radarRulesAllRoute, radarRulesAllHandler);
app.openapi(radarRulesOneRoute, radarRulesOneHandler);
app.openapi(categoryOneRoute, categoryOneHandler);
app.openapi(routeStatusRoute, routeStatusHandler);
app.openapi(followConfigRoute, followConfigHandler);
const docs = app.getOpenAPI31Document({
@@ -30,6 +32,34 @@ for (const path in docs.paths) {
delete docs.paths[path];
}
app.get('/openapi.json', (ctx) => ctx.json(docs));
app.get('/reference', Scalar({ content: docs }));
app.get(
'/reference',
Scalar({
content: docs,
hiddenClients: {
c: true,
clojure: true,
csharp: true,
dart: true,
fsharp: true,
go: false,
http: true,
java: true,
js: true,
kotlin: true,
node: ['axios'], // allow fetch, ofetch, undici
objc: true,
ocaml: true,
php: false,
powershell: true,
python: false,
r: true,
ruby: true,
rust: true,
shell: ['httpie', 'wget'], // allow curl
swift: true,
},
})
);
export default app;
+2 -1
View File
@@ -6,10 +6,11 @@ import { namespaces } from '@/registry';
const route = createRoute({
method: 'get',
path: '/namespace',
description: 'Information about all namespaces',
tags: ['Namespace'],
responses: {
200: {
description: 'Information about all namespaces',
description: 'Namespace registry data for all namespaces',
},
},
});
+2 -1
View File
@@ -16,13 +16,14 @@ const ParamsSchema = z.object({
const route = createRoute({
method: 'get',
path: '/namespace/{namespace}',
description: 'Information about a namespace',
tags: ['Namespace'],
request: {
params: ParamsSchema,
},
responses: {
200: {
description: 'Information about a namespace',
description: 'Namespace registry data for a namespace',
},
},
});
+2 -1
View File
@@ -45,10 +45,11 @@ for (const namespace in namespaces) {
const route = createRoute({
method: 'get',
path: '/radar/rules',
description: 'All Radar rules grouped by domain',
tags: ['Radar'],
responses: {
200: {
description: 'All Radar rules',
description: 'Radar rules grouped by domain',
},
},
});
+2 -1
View File
@@ -55,13 +55,14 @@ const ParamsSchema = z.object({
const route = createRoute({
method: 'get',
path: '/radar/rules/{domain}',
description: 'Radar rules for a domain name',
tags: ['Radar'],
request: {
params: ParamsSchema,
},
responses: {
200: {
description: 'Radar rules for a domain name (does not support subdomains)',
description: 'Radar rules for a domain name (no subdomains)',
},
},
});
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import api from '@/api';
const mockHas = vi.hoisted(() => vi.fn());
const mockGet = vi.hoisted(() => vi.fn());
vi.mock('@/utils/cache/index', () => ({
default: {
status: { available: true },
globalCache: {
has: mockHas,
get: mockGet,
set: vi.fn(),
},
tryGet: vi.fn(),
},
}));
describe('GET /api/route/status', () => {
beforeEach(() => {
mockHas.mockReset();
mockGet.mockReset();
});
it('returns 404 when cache is cold', async () => {
mockHas.mockResolvedValue(false);
const response = await api.request('/route/status?requestPath=/github/comments/DIYgod/RSSHub/20768');
expect(response.status).toBe(404);
const data = await response.json();
expect(data.cached).toBe(false);
expect(data.lastBuildDate).toBeNull();
});
it('returns cached: true with lastBuildDate when cache is warm', async () => {
const mockBuildDate = 'Mon, 1 Jan 2026 10:00:00 GMT';
mockHas.mockResolvedValue(true);
mockGet.mockResolvedValue(
JSON.stringify({
lastBuildDate: mockBuildDate,
items: [],
})
);
const response = await api.request('/route/status?requestPath=/github/comments/DIYgod/RSSHub/20768');
expect(response.status).toBe(200);
const data = await response.json();
expect(data.cached).toBe(true);
expect(data.lastBuildDate).toBe(mockBuildDate);
});
it('returns 503 when cache is unavailable', async () => {
const { default: cacheModule } = await import('@/utils/cache/index');
(cacheModule.status as { available: boolean }).available = false;
try {
const response = await api.request('/route/status?requestPath=/github/comments/DIYgod/RSSHub/20768');
expect(response.status).toBe(503);
const data = await response.json();
expect(data.cached).toBe(false);
} finally {
(cacheModule.status as { available: boolean }).available = true;
}
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { RouteHandler } from '@hono/zod-openapi';
import { createRoute, z } from '@hono/zod-openapi';
import xxhash from 'xxhash-wasm';
import cacheModule from '@/utils/cache/index';
const QuerySchema = z.object({
requestPath: z.string().openapi({
param: {
name: 'requestPath',
in: 'query',
},
example: '/github/comments/DIYgod/RSSHub/20768',
description: 'The route path to check cache status for',
}),
});
const ResponseSchema = z.object({
cached: z.boolean(),
lastBuildDate: z.string().nullable(),
});
const route = createRoute({
method: 'get',
path: '/route/status',
description: 'Check if a route path is cached',
tags: ['Route'],
request: {
query: QuerySchema,
},
responses: {
200: {
content: {
'application/json': {
schema: ResponseSchema,
},
},
description: 'Cache found',
},
404: {
content: {
'application/json': {
schema: ResponseSchema,
},
},
description: 'Cache not found',
},
503: {
content: {
'application/json': {
schema: ResponseSchema,
},
},
description: 'Cache module unavailable',
},
},
});
const handler: RouteHandler<typeof route> = async (ctx) => {
if (!cacheModule.status.available) {
return ctx.json({ cached: false, lastBuildDate: null }, 503);
}
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);
if (!cached) {
return ctx.json({ cached: false, lastBuildDate: null }, 404);
}
let lastBuildDate: string | null = null;
try {
const cachedData = await cacheModule.globalCache.get(key);
if (cachedData) {
const parsed = JSON.parse(cachedData);
lastBuildDate = parsed.lastBuildDate || null;
}
} catch {
//
}
return ctx.json({ cached, lastBuildDate }, 200);
};
export { handler, route };
+8
View File
@@ -25,9 +25,11 @@ describe('cache', () => {
}
await cache.set('mock', undefined);
expect(await cache.get('mock')).toBe('');
expect(await cache.has('mock')).toBe(true);
await cache.globalCache.set('mock', undefined);
expect(await cache.globalCache.get('mock')).toBe('');
expect(await cache.globalCache.has('mock')).toBe(true);
await cache.globalCache.set('mock', {
mock: 1,
});
@@ -37,6 +39,7 @@ describe('cache', () => {
it('memory get returns null before init', async () => {
const memory = (await import('@/utils/cache/memory')).default;
expect(await memory.get('missing')).toBeNull();
expect(await memory.has('missing')).toBe(false);
});
it('redis', async () => {
@@ -51,6 +54,7 @@ describe('cache', () => {
await cache.set('mock2', '2');
await cache.set('mock2', '2');
expect(await cache.get('mock2')).toBe('2');
expect(await cache.has('mock2')).toBe(true);
await cache.clients.redisClient?.quit();
}, 10000);
@@ -64,6 +68,7 @@ describe('cache', () => {
}
await cache.set('mock2', '2');
expect(await cache.get('mock2')).toBe(null);
expect(await cache.has('mock2')).toBe(false);
});
it('redis with error', async () => {
@@ -72,6 +77,7 @@ describe('cache', () => {
const cache = (await import('@/utils/cache')).default;
await cache.set('mock2', '2');
expect(await cache.get('mock2')).toBe(null);
expect(await cache.has('mock2')).toBe(false);
cache.clients.redisClient?.disconnect();
});
@@ -81,7 +87,9 @@ describe('cache', () => {
await cache.init();
await cache.set('mock2', '2');
expect(await cache.get('mock2')).toBe(null);
expect(await cache.has('mock2')).toBe(false);
expect(await cache.globalCache.get('mock2')).toBeNull();
expect(await cache.globalCache.has('mock2')).toBe(false);
expect(cache.globalCache.set('mock2', '2')).toBeNull();
});
+1
View File
@@ -4,6 +4,7 @@ import type { LRUCache } from 'lru-cache';
type CacheModule = {
init: () => void;
get: (key: string, refresh?: boolean) => Promise<string | null> | string | null;
has: (key: string) => Promise<boolean> | boolean;
set: (key: string, value?: string | Record<string, any>, maxAge?: number) => any;
status: {
available: boolean;
+17
View File
@@ -8,9 +8,11 @@ import redis from './redis';
const globalCache: {
get: (key: string) => Promise<string | null | undefined> | string | null | undefined;
has: (key: string) => Promise<boolean> | boolean;
set: (key: string, value?: string | Record<string, any>, maxAge?: number) => any;
} = {
get: () => null,
has: () => false,
set: () => null,
};
@@ -21,6 +23,7 @@ if (isWorker) {
cacheModule = {
init: () => null,
get: () => null,
has: () => false,
set: () => null,
status: {
available: false,
@@ -37,6 +40,13 @@ if (isWorker) {
return value;
}
};
globalCache.has = async (key) => {
if (key && cacheModule.status.available && redisClient) {
const result = await redisClient.exists(key);
return result > 0;
}
return false;
};
globalCache.set = cacheModule.set;
} else if (config.cache.type === 'memory') {
cacheModule = memory;
@@ -47,6 +57,12 @@ if (isWorker) {
return memoryCache.get(key, { updateAgeOnGet: false }) as string | undefined;
}
};
globalCache.has = (key) => {
if (key && cacheModule.status.available && memoryCache) {
return memoryCache.has(key);
}
return false;
};
globalCache.set = (key, value, maxAge = config.cache.routeExpire) => {
if (!value || value === 'undefined') {
value = '';
@@ -62,6 +78,7 @@ if (isWorker) {
cacheModule = {
init: () => null,
get: () => null,
has: () => false,
set: () => null,
status: {
available: false,
+7
View File
@@ -45,6 +45,13 @@ export default {
return null;
}
},
has: async (key: string) => {
if (key && status.available && kvNamespace) {
const value = await kvNamespace.get(key);
return value !== null;
}
return false;
},
set: async (key: string, value?: string | Record<string, any>, maxAge = config.cache.contentExpire) => {
if (!status.available || !kvNamespace) {
return;
+6
View File
@@ -28,6 +28,12 @@ export default {
return null;
}
},
has: (key: string) => {
if (key && status.available && clients.memoryCache) {
return clients.memoryCache.has(key);
}
return false;
},
set: (key, value, maxAge = config.cache.contentExpire) => {
if (!value || value === 'undefined') {
value = '';
+6
View File
@@ -13,6 +13,7 @@ vi.mock('@/utils/logger', () => ({
class RedisMock extends EventTarget {
mget = vi.fn();
expire = vi.fn();
exists = vi.fn();
set = vi.fn();
on(event: string, listener: (...args: any[]) => void) {
@@ -47,6 +48,7 @@ describe('redis cache module', () => {
const redisCache = (await import('@/utils/cache/redis')).default;
const client = new RedisMock() as any;
client.mget.mockResolvedValue(['value', '30']);
client.exists.mockResolvedValue(true);
redisCache.status.available = true;
redisCache.clients.redisClient = client;
@@ -54,6 +56,10 @@ describe('redis cache module', () => {
expect(value).toBe('value');
expect(client.expire).toHaveBeenCalledWith('rsshub:cacheTtl:mock', '30');
expect(client.expire).toHaveBeenCalledWith('mock', '30');
await expect(redisCache.has('mock')).resolves.toBe(true);
client.exists.mockResolvedValue(false);
await expect(redisCache.has('missing')).resolves.toBe(false);
});
it('marks redis unavailable on error', async () => {
+7
View File
@@ -54,6 +54,13 @@ export default {
return null;
}
},
has: async (key: string) => {
if (key && status.available && clients.redisClient) {
const result = await clients.redisClient.exists(key);
return result > 0;
}
return false;
},
set: (key: string, value?: string | Record<string, any>, maxAge = config.cache.contentExpire) => {
if (!status.available || !clients.redisClient) {
return;