feat(cache): PG kv_cache ported from MP TTLCache, TMDB request and recognize caches
Migration 0042_cache adds UNLOGGED kv_cache. PgCache mirrors MP
runtime/cache.py (region, per-key TTL, TLRU maxsize eviction, @cached as
cachedCall); only the storage moves from memory to PG. TMDB request
responses (MP TMDb.request @cached) and recognize results (MP TmdbCache:
same key, {id:0} negative cache with EMPTY_RESULT_CACHE_TTL, type-conflict
checks, backfill after manual assignment) live in kv_cache, shared by all
processes. Settings cache.tmdbTtlHours / tmdbMaxSize / emptyResultTtlSec
map to META_CACHE_EXPIRE / CONF.tmdb / EMPTY_RESULT_CACHE_TTL. TMDB
details now also store posterPath / backdropPath.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
61f585a707
commit
946fca5e55
22 files changed
+743
-14
No files matched your search
@@ -59,6 +59,8 @@ export interface MetadataCandidate {
|
||||
overview: string | null;
|
||||
/** TMDB poster_path(如 `/abc.jpg`),拼图片 CDN 用;没有为 null */
|
||||
posterPath: string | null;
|
||||
/** TMDB backdrop_path;没有为 null(旧数据 / 测试构造可缺省) */
|
||||
backdropPath?: string | null;
|
||||
seasons?: CandidateSeason[];
|
||||
externalIds?: { imdb?: string; tvdb?: string };
|
||||
/** 是否已拉过详情(别名/季信息只在详情中完整) */
|
||||
@@ -98,12 +100,30 @@ export interface TmdbConfig {
|
||||
minIntervalMs?: number;
|
||||
/** 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL) */
|
||||
cache?: { max?: number; ttlMs?: number; emptyTtlMs?: number };
|
||||
/**
|
||||
* 跨进程共享的请求缓存(服务端传 PG 缓存);给了就不再用进程内 LRU。
|
||||
* 语义对照 MP @cached(skip_none、业务失败不缓存、results 为空按 emptyTtlMs)。
|
||||
*/
|
||||
sharedCache?: TmdbSharedCache;
|
||||
fetch?: typeof fetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 共享请求缓存端口(对照 MP TMDb.request 的 @cached;服务端用 PG 实现):key 为 `path?query`(不含凭证);
|
||||
* get 未命中返回 null;set 的 ttlMs 由客户端按 ttl / 空结果短 TTL 决定。
|
||||
*/
|
||||
export interface TmdbSharedCache {
|
||||
get(key: string): Promise<unknown | null>;
|
||||
set(key: string, value: unknown, ttlMs: number): Promise<void>;
|
||||
}
|
||||
|
||||
/** 空结果(搜索 0 条)用短 TTL(对照 MP EMPTY_RESULT_CACHE_TTL) */
|
||||
const isEmptyResult = (v: unknown) =>
|
||||
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const s = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
|
||||
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
@@ -130,6 +150,11 @@ export class TmdbClient {
|
||||
return redactUrl(text, [this.cfg.apiKey, this.cfg.accessToken]);
|
||||
}
|
||||
|
||||
/** 首选查询语言(识别缓存键里的语言段,对照 MP TMDB_LOCALE) */
|
||||
get language(): string {
|
||||
return this.langs[0]!;
|
||||
}
|
||||
|
||||
cacheStats() {
|
||||
return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced };
|
||||
}
|
||||
@@ -139,13 +164,21 @@ export class TmdbClient {
|
||||
for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!);
|
||||
const key = `${path}?${qs}`;
|
||||
const emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000;
|
||||
if (this.cfg.sharedCache) {
|
||||
const shared = this.cfg.sharedCache;
|
||||
const hit = await shared.get(key);
|
||||
if (hit !== null && hit !== undefined) return hit as T;
|
||||
// 网络错误 / 业务失败在 fetchWithRetry 抛出,不会走到写缓存(MP skip_none / skip_if)
|
||||
const v = await this.limiter.run(() => this.fetchWithRetry(path, qs));
|
||||
if (v !== null && v !== undefined) {
|
||||
await shared.set(key, v, isEmptyResult(v) ? emptyTtl : (this.cfg.cache?.ttlMs ?? 24 * 3600_000));
|
||||
}
|
||||
return v as T;
|
||||
}
|
||||
return this.cache.getOrLoad(
|
||||
key,
|
||||
() => this.limiter.run(() => this.fetchWithRetry(path, qs)),
|
||||
(v) =>
|
||||
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0
|
||||
? emptyTtl
|
||||
: undefined,
|
||||
(v) => (isEmptyResult(v) ? emptyTtl : undefined),
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -269,6 +302,7 @@ export class TmdbClient {
|
||||
popularity: n(r.popularity),
|
||||
overview: s(r.overview),
|
||||
posterPath: s(r.poster_path),
|
||||
backdropPath: s(r.backdrop_path),
|
||||
detailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +110,31 @@ describe("TmdbClient 缓存/限流/错误分类", () => {
|
||||
expect(mock.calls.length).toBe(n1 * 2);
|
||||
});
|
||||
|
||||
it("共享响应缓存:给了 sharedCache 就走它,key 不含凭证,空结果 30 分钟,其余按 ttlMs", async () => {
|
||||
const store = new Map<string, { v: unknown; ttl: number }>();
|
||||
const { mock, tmdb } = await setup({
|
||||
cache: { ttlMs: 7 * 3600_000 },
|
||||
sharedCache: {
|
||||
get: async (key) => store.get(key)?.v ?? null,
|
||||
set: async (key, v, ttl) => {
|
||||
store.set(key, { v, ttl });
|
||||
},
|
||||
},
|
||||
});
|
||||
await tmdb.details("movie", 872585);
|
||||
const n1 = mock.calls.length;
|
||||
await tmdb.details("movie", 872585);
|
||||
expect(mock.calls.length).toBe(n1);
|
||||
await tmdb.search("不存在的片名xyz", { type: "movie" });
|
||||
const keys = [...store.keys()];
|
||||
expect(keys.some((k) => k.includes("test-tmdb-key"))).toBe(false);
|
||||
const detailKey = keys.find((k) => k.startsWith("/movie/872585?"))!;
|
||||
expect(store.get(detailKey)!.ttl).toBe(7 * 3600_000);
|
||||
const emptyKey = keys.find((k) => k.startsWith("/search/movie?"))!;
|
||||
expect(store.get(emptyKey)!.ttl).toBe(30 * 60_000);
|
||||
expect(tmdb.cacheStats().size).toBe(0);
|
||||
});
|
||||
|
||||
it("并发合并:同一请求并发只发一次", async () => {
|
||||
const { mock, tmdb } = await setup();
|
||||
await Promise.all(Array.from({ length: 5 }, () => tmdb.season(100088, 1)));
|
||||
|
||||
Reference in new issue
Block a user