Files
DIYgod 021f1807d3 fix(cache): isolate ordering and item filter candidate selections
Affected files:
- lib/middleware/cache.test.ts
- lib/middleware/cache.ts
2026-10-14 15:07:45 +08:00

557 lines
24 KiB
TypeScript

import { Context } from 'hono';
import Parser from 'rss-parser';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type CacheModule from '@/utils/cache';
import type { getRouteLimit as RouteLimit } from '@/utils/route-limit';
import wait from '@/utils/wait';
import type CacheMiddleware from './cache';
import type ParameterMiddleware from './parameter';
process.env.CACHE_EXPIRE = '1';
process.env.CACHE_CONTENT_EXPIRE = '2';
const parser = new Parser();
afterEach(() => {
vi.resetModules();
});
const noCacheTestFunc = async () => {
const app = (await import('@/app')).default;
const response1 = await app.request('/test/cache');
const response2 = await app.request('/test/cache');
const parsed1 = await parser.parseString(await response1.text());
const parsed2 = await parser.parseString(await response2.text());
expect(response2.status).toBe(200);
expect(response2.headers).not.toHaveProperty('rsshub-cache-status');
expect(parsed1.items[0].content).toBe('Cache1');
expect(parsed2.items[0].content).toBe('Cache2');
expect(parsed1.ttl).toEqual('1');
};
describe('cache', () => {
it('memory', async () => {
process.env.CACHE_TYPE = 'memory';
const app = (await import('@/app')).default;
const response1 = await app.request('/test/cache');
const response2 = await app.request('/test/cache');
const parsed1 = await parser.parseString(await response1.text());
const parsed2 = await parser.parseString(await response2.text());
delete parsed1.lastBuildDate;
delete parsed2.lastBuildDate;
delete parsed1.feedUrl;
delete parsed2.feedUrl;
delete parsed1.paginationLinks;
delete parsed2.paginationLinks;
expect(parsed2).toMatchObject(parsed1);
expect(response2.status).toBe(200);
expect(response2.headers.get('rsshub-cache-status')).toBe('HIT');
expect(parsed1.ttl).toEqual('1');
await wait(1 * 1000 + 100);
const response3 = await app.request('/test/cache');
expect(response3.headers).not.toHaveProperty('rsshub-cache-status');
const parsed3 = await parser.parseString(await response3.text());
await wait(2 * 1000 + 100);
const response4 = await app.request('/test/cache');
const parsed4 = await parser.parseString(await response4.text());
expect(parsed1.items[0].content).toBe('Cache1');
expect(parsed2.items[0].content).toBe('Cache1');
expect(parsed3.items[0].content).toBe('Cache1');
expect(parsed4.items[0].content).toBe('Cache2');
await app.request('/test/refreshCache');
await wait(1 * 1000 + 100);
const response5 = await app.request('/test/refreshCache');
const parsed5 = await parser.parseString(await response5.text());
await wait(1 * 1000 + 100);
const response6 = await app.request('/test/refreshCache');
const parsed6 = await parser.parseString(await response6.text());
expect(parsed5.items[0].content).toBe('1 1');
expect(parsed6.items[0].content).toBe('1 0');
}, 10000);
it('redis', async () => {
process.env.CACHE_TYPE = 'redis';
const app = (await import('@/app')).default;
await wait(500);
const response1 = await app.request('/test/cache');
const response2 = await app.request('/test/cache');
const parsed1 = await parser.parseString(await response1.text());
const parsed2 = await parser.parseString(await response2.text());
delete parsed1.lastBuildDate;
delete parsed2.lastBuildDate;
delete parsed1.feedUrl;
delete parsed2.feedUrl;
delete parsed1.paginationLinks;
delete parsed2.paginationLinks;
expect(parsed2).toMatchObject(parsed1);
expect(response2.status).toBe(200);
expect(response2.headers.get('rsshub-cache-status')).toBe('HIT');
expect(parsed1.ttl).toEqual('1');
await wait(1 * 1000 + 100);
const response3 = await app.request('/test/cache');
expect(response3.headers).not.toHaveProperty('rsshub-cache-status');
const parsed3 = await parser.parseString(await response3.text());
await wait(2 * 1000 + 100);
const response4 = await app.request('/test/cache');
const parsed4 = await parser.parseString(await response4.text());
expect(parsed1.items[0].content).toBe('Cache1');
expect(parsed2.items[0].content).toBe('Cache1');
expect(parsed3.items[0].content).toBe('Cache1');
expect(parsed4.items[0].content).toBe('Cache2');
await app.request('/test/refreshCache');
await wait(1 * 1000 + 100);
const response5 = await app.request('/test/refreshCache');
const parsed5 = await parser.parseString(await response5.text());
await wait(1 * 1000 + 100);
const response6 = await app.request('/test/refreshCache');
const parsed6 = await parser.parseString(await response6.text());
expect(parsed5.items[0].content).toBe('1 1');
expect(parsed6.items[0].content).toBe('1 0');
const cache = (await import('@/utils/cache')).default;
await cache.clients.redisClient!.quit();
}, 10000);
it('redis with quit', async () => {
process.env.CACHE_TYPE = 'redis';
const cache = (await import('@/utils/cache')).default;
await cache.clients.redisClient!.quit();
await noCacheTestFunc();
});
it('redis with error', async () => {
process.env.CACHE_TYPE = 'redis';
process.env.REDIS_URL = 'redis://wrongpath:6379';
await noCacheTestFunc();
const cache = (await import('@/utils/cache')).default;
cache.clients.redisClient?.disconnect();
}, 20000);
it('no cache', async () => {
process.env.CACHE_TYPE = 'NO';
await noCacheTestFunc();
});
it('no cache (empty string)', async () => {
process.env.CACHE_TYPE = '';
await noCacheTestFunc();
});
it('throws URL key', async () => {
process.env.CACHE_TYPE = 'memory';
const app = (await import('@/app')).default;
try {
const response = await app.request('/test/cacheUrlKey');
expect(response).toThrow(Error);
} catch (error: any) {
expect(error.message).toContain('Cache key must be a string');
}
});
it('RSS TTL (no cache)', async () => {
process.env.CACHE_TYPE = '';
process.env.CACHE_EXPIRE = '600';
const app = (await import('@/app')).default;
const response = await app.request('/test/cache');
const parsed = await parser.parseString(await response.text());
expect(parsed.ttl).toEqual('1');
});
it('RSS TTL (w/ cache)', async () => {
process.env.CACHE_TYPE = 'memory';
process.env.CACHE_EXPIRE = '600';
const app = (await import('@/app')).default;
const response = await app.request('/test/cache');
const parsed = await parser.parseString(await response.text());
expect(parsed.ttl).toEqual('10');
});
});
describe('cache middleware error handling', () => {
it('clears control key when downstream throws', async () => {
process.env.CACHE_TYPE = 'memory';
const cache = (await import('@/utils/cache')).default;
const setSpy = vi.spyOn(cache.globalCache, 'set');
const { default: cacheMiddleware } = await import('@/middleware/cache');
const ctx = new Context(new Request('http://localhost/test'), { env: {}, path: '/test' });
await expect(
cacheMiddleware(ctx, () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(setSpy.mock.calls.some(([key, value]) => key.startsWith('rsshub:path-requested:') && value === '0')).toBe(true);
setSpy.mockRestore();
});
});
describe('cache request variants', () => {
it('keeps source-order selections separate from date-order selections', async () => {
vi.stubEnv('CACHE_TYPE', 'memory');
vi.stubEnv('CACHE_EXPIRE', '300');
vi.resetModules();
const { default: middleware } = await import('./cache');
const { default: cacheModule } = await import('@/utils/cache/index');
cacheModule.clients.memoryCache?.clear();
const fetcher = vi.fn((ctx: Context) => {
if (!ctx.get('data')) {
const entry = ctx.req.query('sorted') === 'false' ? 'source-first' : 'date-first';
ctx.set('data', { title: 'Feed', item: [{ title: entry, link: `https://example.com/${entry}` }] });
}
return Promise.resolve();
});
const request = async (query = '') => {
const ctx = new Context(new Request(`https://rsshub.example/ordering?limit=1${query}`), { env: {}, path: '/ordering' });
await middleware(ctx, () => fetcher(ctx));
return ctx;
};
try {
const dateOrder = await request();
const sourceOrder = await request('&sorted=false');
const explicitDateOrder = await request('&sorted=true');
const defaultDateOrderHit = await request();
const dateOrderHit = await request('&sorted=true');
const sourceOrderHit = await request('&sorted=false');
expect(dateOrder.get('data').item[0].title).toBe('date-first');
expect(sourceOrder.get('data').item[0].title).toBe('source-first');
expect(explicitDateOrder.get('data').item[0].title).toBe('date-first');
expect(defaultDateOrderHit.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
expect(dateOrderHit.get('data').item[0].title).toBe('date-first');
expect(sourceOrderHit.get('data').item[0].title).toBe('source-first');
expect(dateOrderHit.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
expect(sourceOrderHit.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
expect(sourceOrder.res.headers.get('RSSHub-Cache-Status')).toBeNull();
expect(explicitDateOrder.res.headers.get('RSSHub-Cache-Status')).toBeNull();
} finally {
vi.unstubAllEnvs();
}
});
});
const coordinationContext = () => new Context(new Request('https://rsshub.example/test/cache'), { env: {}, path: '/test/cache' });
const isControlKey = (key: string) => key.startsWith('rsshub:path-requested:');
const loadCoordination = async () => {
// Pin the config values asserted below before the middleware loads the config.
vi.stubEnv('CACHE_TYPE', 'memory');
vi.stubEnv('FORMAT', 'rss');
vi.stubEnv('CACHE_REQUEST_TIMEOUT', '60');
vi.stubEnv('CACHE_EXPIRE', '300');
vi.resetModules();
const { default: middleware } = await import('./cache');
const { default: cacheModule } = await import('@/utils/cache/index');
const { globalCache } = cacheModule;
return {
middleware,
cacheModule,
globalCache,
cache: {
get: vi.spyOn(globalCache, 'get'),
set: vi.spyOn(globalCache, 'set'),
claim: vi.spyOn(globalCache, 'claim'),
},
};
};
type Loaded = Awaited<ReturnType<typeof loadCoordination>>;
describe('cache coordination capabilities', () => {
let middleware: Loaded['middleware'];
let cacheModule: Loaded['cacheModule'];
let globalCache: Loaded['globalCache'];
let cache: Loaded['cache'];
let originalSupportsAtomicClaims: boolean;
const feed = { title: 'Feed', link: 'https://example.com', item: [{ title: 'Entry', link: 'https://example.com/entry' }] };
beforeAll(async () => {
({ middleware, cacheModule, globalCache, cache } = await loadCoordination());
originalSupportsAtomicClaims = globalCache.supportsAtomicClaims;
});
beforeEach(() => {
vi.resetAllMocks();
cache.set.mockReturnValue(undefined);
vi.useFakeTimers();
globalCache.supportsAtomicClaims = false;
// Model a stale remote control key, including after the prior writer finished.
cache.get.mockImplementation((key: string) => (isControlKey(key) ? '1' : null));
cache.claim.mockResolvedValue(false);
});
afterEach(() => {
vi.useRealTimers();
});
afterAll(() => {
globalCache.supportsAtomicClaims = originalSupportsAtomicClaims;
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it('serves an HTTP/KV feed hit without touching a stale remote lock', async () => {
cache.get.mockImplementation((key: string) => (isControlKey(key) ? '1' : JSON.stringify(feed)));
const ctx = coordinationContext();
await middleware(ctx, vi.fn());
expect(ctx.get('data')).toEqual(feed);
expect(ctx.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
expect(cache.get).toHaveBeenCalledTimes(1);
expect(cache.claim).not.toHaveBeenCalled();
expect(cache.set).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it('fetches and caches HTTP/KV misses without waiting on or writing control keys', async () => {
const ctx = coordinationContext();
const next = vi.fn(() => {
ctx.set('data', structuredClone(feed));
return Promise.resolve();
});
await middleware(ctx, next);
expect(next).toHaveBeenCalledOnce();
expect(cache.get.mock.calls.every(([key]) => !isControlKey(key))).toBe(true);
expect(cache.claim).not.toHaveBeenCalled();
expect(cache.set).toHaveBeenCalledExactlyOnceWith(expect.stringMatching(/^rsshub:koa-redis-cache:/), expect.any(String), 300);
expect(JSON.parse(String(cache.set.mock.calls[0][1]))).toMatchObject(feed);
expect(ctx.get('cacheControlKey')).toBeUndefined();
expect(vi.getTimerCount()).toBe(0);
});
it('allows concurrent HTTP/KV cold misses even when remote reads remain stale', async () => {
const contexts = [coordinationContext(), coordinationContext()];
const fetcher = vi.fn((ctx: Context) => {
ctx.set('data', structuredClone(feed));
return Promise.resolve();
});
await Promise.all(contexts.map((ctx) => middleware(ctx, () => fetcher(ctx))));
expect(fetcher).toHaveBeenCalledTimes(2);
expect(cache.claim).not.toHaveBeenCalled();
expect(cache.get.mock.calls.every(([key]) => !isControlKey(key))).toBe(true);
expect(cache.set.mock.calls.every(([key]) => !isControlKey(key))).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
it('does not release a remote control key when an HTTP/KV route fails', async () => {
await expect(middleware(coordinationContext(), () => Promise.reject(new Error('route failed')))).rejects.toThrow('route failed');
expect(cache.set).not.toHaveBeenCalled();
expect(cache.claim).not.toHaveBeenCalled();
});
it('keeps atomic backends single-flight and serves the waiting request from the completed feed', async () => {
// Use the real memory backend, which claims atomically.
globalCache.supportsAtomicClaims = true;
cacheModule.clients.memoryCache?.clear();
cache.get.mockReset();
cache.set.mockReset();
cache.claim.mockReset();
const { promise: producerReady, resolve: markReady } = Promise.withResolvers<void>();
const { promise: releaseProducer, resolve: release } = Promise.withResolvers<void>();
const first = coordinationContext();
const second = coordinationContext();
const fetcher = vi.fn(async (ctx: Context) => {
if (ctx.get('data')) {
return;
}
markReady();
await releaseProducer;
ctx.set('data', structuredClone(feed));
});
const producer = middleware(first, () => fetcher(first));
await producerReady;
const waiter = middleware(second, () => fetcher(second));
await vi.advanceTimersByTimeAsync(0);
expect(cache.claim).toHaveBeenCalledTimes(2);
release();
await producer;
await vi.advanceTimersByTimeAsync(6000);
await waiter;
expect(second.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
expect(second.get('data')).toMatchObject(feed);
expect(cache.set.mock.calls.filter(([key]) => !isControlKey(key))).toHaveLength(1);
expect(cache.set.mock.calls.filter(([key, value]) => isControlKey(key) && value === '0')).toHaveLength(1);
});
it.each(['route', 'cache write'])('releases an owned atomic claim when the %s fails', async (failure) => {
globalCache.supportsAtomicClaims = true;
cache.claim.mockResolvedValue(true);
cache.set.mockImplementation((key: string) => {
if (!isControlKey(key)) {
throw new Error('cache write failed');
}
});
const ctx = coordinationContext();
const next = () => {
if (failure === 'route') {
return Promise.reject(new Error('route failed'));
}
ctx.set('data', structuredClone(feed));
return Promise.resolve();
};
await expect(middleware(ctx, next)).rejects.toThrow(`${failure} failed`);
expect(cache.set).toHaveBeenCalledWith(expect.stringMatching(/^rsshub:path-requested:/), '0', 60);
});
it('does not overwrite or release a competing atomic claim during takeover', async () => {
globalCache.supportsAtomicClaims = true;
cache.get.mockResolvedValue(null);
cache.claim.mockResolvedValue(false);
const next = vi.fn();
const result = expect(middleware(coordinationContext(), next)).rejects.toThrow('This path is currently fetching');
await vi.advanceTimersByTimeAsync(6000);
await result;
expect(cache.claim).toHaveBeenCalledTimes(2);
expect(cache.set).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
});
const candidateEntries = [
{ title: 'skip', link: 'https://example.com/skip', pubDate: '2026-10-09T12:00:00Z' },
{ title: 'match older', link: 'https://example.com/older', pubDate: '2026-10-08T12:00:00Z' },
{ title: 'match newer', link: 'https://example.com/newer', pubDate: '2026-10-09T10:00:00Z' },
{ title: 'match fourth', link: 'https://example.com/fourth', pubDate: '2026-10-09T09:00:00Z' },
];
describe('filtered candidate cache variants', () => {
let parameter: typeof ParameterMiddleware;
let cache: typeof CacheMiddleware;
let cacheModule: typeof CacheModule;
let getRouteLimit: typeof RouteLimit;
beforeEach(async () => {
vi.stubEnv('CACHE_TYPE', 'memory');
vi.stubEnv('CACHE_EXPIRE', '300');
vi.resetModules();
({ default: parameter } = await import('./parameter'));
({ default: cache } = await import('./cache'));
({ default: cacheModule } = await import('@/utils/cache'));
({ getRouteLimit } = await import('@/utils/route-limit'));
cacheModule.clients.memoryCache?.clear();
});
afterEach(() => vi.unstubAllEnvs());
it.each([false, true])('isolates filtered and unfiltered source pools with filteredFirst=%s', async (filteredFirst) => {
let fetches = 0;
const request = async (query: string) => {
const ctx = new Context(new Request(`https://rsshub.example/candidates?${query}`), { env: {}, path: '/candidates' });
await parameter(ctx, async () => {
await cache(ctx, () => {
if (ctx.get('data')) {
return Promise.resolve();
}
fetches++;
const candidates = candidateEntries.slice(0, getRouteLimit(ctx, 3));
ctx.set('data', { title: 'Feed', item: structuredClone(candidates) });
return Promise.resolve();
});
});
return ctx;
};
const filteredQuery = 'limit=1&filter_title=match';
const limitedQuery = 'limit=1';
await request(filteredFirst ? filteredQuery : limitedQuery);
await request(filteredFirst ? limitedQuery : filteredQuery);
const limitedHit = await request(limitedQuery);
const filteredHit = await request(filteredQuery);
const differentFilterHit = await request('limit=1&filter_title=older');
const expanded = await request('limit=4&filter_title=match');
const sourceOrder = await request(`${filteredQuery}&sorted=false`);
const sourceOrderHit = await request(`${filteredQuery}&sorted=false`);
expect(limitedHit.get('data').item.map((item) => item.title)).toEqual(['skip']);
expect(filteredHit.get('data').item.map((item) => item.title)).toEqual(['match newer']);
expect(differentFilterHit.get('data').item.map((item) => item.title)).toEqual(['match older']);
expect(expanded.get('data').item).toHaveLength(3);
expect(sourceOrder.get('data').item.map((item) => item.title)).toEqual(['match older']);
expect(sourceOrderHit.get('data').item.map((item) => item.title)).toEqual(['match older']);
for (const ctx of [limitedHit, filteredHit, differentFilterHit, sourceOrderHit]) {
expect(ctx.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
}
expect(fetches).toBe(4);
});
it.each([false, true])('preserves ranked defaults and explicit date ordering with dateFirst=%s', async (dateFirst) => {
let fetches = 0;
const rankedEntries = [
{ title: 'Top ranked', link: 'https://example.com/top', pubDate: '2026-10-07T12:00:00Z' },
{ title: 'Second ranked', link: 'https://example.com/second', pubDate: '2026-10-08T12:00:00Z' },
{ title: 'Newest', link: 'https://example.com/newest', pubDate: '2026-10-09T12:00:00Z' },
];
const request = async (query = '') => {
const ctx = new Context(new Request(`https://rsshub.example/ranking?limit=1${query}`), { env: {}, path: '/ranking' });
await parameter(ctx, async () => {
await cache(ctx, () => {
if (ctx.get('data')) {
return Promise.resolve();
}
fetches++;
const sorted = ctx.req.query('sorted');
const candidates = sorted !== undefined && sorted !== 'false' ? rankedEntries : rankedEntries.slice(0, getRouteLimit(ctx, 3));
ctx.set('data', { title: 'Ranking', sortItems: false, item: structuredClone(candidates) });
return Promise.resolve();
});
});
return ctx;
};
await request(dateFirst ? '&sorted=true' : '');
await request(dateFirst ? '' : '&sorted=true');
const rankedHit = await request();
const datedHit = await request('&sorted=true');
const truthyHit = await request('&sorted=yes');
const explicitRank = await request('&sorted=false');
const explicitRankHit = await request('&sorted=false');
expect(rankedHit.get('data').item.map((item) => item.title)).toEqual(['Top ranked']);
expect(datedHit.get('data').item.map((item) => item.title)).toEqual(['Newest']);
expect(truthyHit.get('data').item.map((item) => item.title)).toEqual(['Newest']);
expect(explicitRank.get('data').item.map((item) => item.title)).toEqual(['Top ranked']);
for (const ctx of [rankedHit, datedHit, truthyHit, explicitRankHit]) {
expect(ctx.res.headers.get('RSSHub-Cache-Status')).toBe('HIT');
expect(ctx.get('data').sortItems).toBe(false);
expect(ctx.get('data').item[0].pubDate).toBeTruthy();
}
expect(fetches).toBe(3);
});
});