Files
UHD_Media_Tool/packages/db/src/cache.ts
T

184 lines
7.5 KiB
TypeScript

import { type Kysely, sql } from "kysely";
import type { DB } from "./types.ts";
/**
* PG 缓存后端:移植 MP app/runtime/cache.py 的内存后端(MemoryBackend + _MemoryTLRUCache)与 @cached 装饰器语义,
* 只把存储从进程内存换成 PG UNLOGGED 表 kv_cache(迁移 0042),三进程共享。行为对照(docs/porting/moviepilot-tmdb-cache.md):
* - region 分区;set 时每个 key 独立 TTL(秒),ttl ≤ 0 = 删除该 key;未给 ttl 用后端缺省(DEFAULT_CACHE_TTL 365 天);
* - maxsize 按 region 计条数,超出时先删已过期、再删最久未用(TLRU);get 命中刷新"最近使用";
* - get 未命中 / 已过期返回 null;clear(region) 清一个区;items(region) 列出未过期条目;
* - cached():skipNone / skipEmpty / skipIf 不写缓存,emptyTtl + emptyIf 空结果短 TTL,fresh 跳过读缓存。
* 与 MP 的差异只在存储介质:值用 JSON(jsonb)而不是 pickle;过期行由定时任务 sweep() 物理删除。
*/
/** MP DEFAULT_CACHE_TTL(秒) */
export const DEFAULT_CACHE_TTL = 365 * 24 * 60 * 60;
/** MP DEFAULT_CACHE_SIZE */
export const DEFAULT_CACHE_SIZE = 1024;
// biome-ignore lint/suspicious/noExplicitAny: 测试会传入隔离 schema 的实例
type AnyDb = Kysely<DB> | Kysely<any>;
export interface SetOptions {
/** 秒;缺省 DEFAULT_CACHE_TTL;≤ 0 删除该 key */
ttl?: number;
/** region 条数上限;缺省 DEFAULT_CACHE_SIZE */
maxsize?: number;
}
export class PgCache {
constructor(private readonly db: AnyDb) {}
/** MemoryBackend.set */
async set(region: string, key: string, value: unknown, opts: SetOptions = {}): Promise<void> {
const ttl = opts.ttl ?? DEFAULT_CACHE_TTL;
if (ttl <= 0) {
await this.delete(region, key);
return;
}
await sql`insert into kv_cache (region, key, value, expires_at, updated_at, accessed_at)
values (${region}, ${key}, ${JSON.stringify(value ?? null)}::jsonb, now() + make_interval(secs => ${ttl}), now(), now())
on conflict (region, key) do update set value = excluded.value, expires_at = excluded.expires_at,
updated_at = excluded.updated_at, accessed_at = excluded.accessed_at`.execute(this.db);
await this.evict(region, opts.maxsize ?? DEFAULT_CACHE_SIZE);
}
/** TLRUCache 写入时的淘汰:先删已过期,再按最近使用从旧到新删到 maxsize */
private async evict(region: string, maxsize: number): Promise<void> {
await sql`delete from kv_cache where region = ${region} and expires_at <= now()`.execute(this.db);
await sql`delete from kv_cache where region = ${region} and key in (
select key from kv_cache where region = ${region}
order by accessed_at desc, updated_at desc offset ${Math.max(1, Math.floor(maxsize))})`.execute(this.db);
}
/** MemoryBackend.get:未命中 / 过期返回 null;命中刷新最近使用时间 */
async get<T = unknown>(region: string, key: string): Promise<T | null> {
const r = await sql<{ value: T }>`update kv_cache set accessed_at = now()
where region = ${region} and key = ${key} and expires_at > now() returning value`.execute(this.db);
return r.rows.length ? (r.rows[0]!.value as T) : null;
}
/** 续期:未过期的条目把 expires_at 推到 now + ttl(只延长不缩短);返回是否有行被续期 */
async renew(region: string, key: string, ttl: number): Promise<boolean> {
if (ttl <= 0) return false;
const r = await sql`update kv_cache set expires_at = greatest(expires_at, now() + make_interval(secs => ${ttl}))
where region = ${region} and key = ${key} and expires_at > now()`.execute(this.db);
return Number(r.numAffectedRows ?? 0) > 0;
}
async exists(region: string, key: string): Promise<boolean> {
const r =
await sql`select 1 from kv_cache where region = ${region} and key = ${key} and expires_at > now()`.execute(
this.db,
);
return r.rows.length > 0;
}
async delete(region: string, key: string): Promise<void> {
await sql`delete from kv_cache where region = ${region} and key = ${key}`.execute(this.db);
}
/** 清一个区,返回删除行数 */
async clear(region: string): Promise<number> {
const r = await sql`delete from kv_cache where region = ${region}`.execute(this.db);
return Number(r.numAffectedRows ?? 0);
}
/** MemoryBackend.items:未过期条目(附过期时间与大小,管理界面用),按 key 排序 */
async items<T = unknown>(
region: string,
limit = 5000,
): Promise<{ key: string; value: T; expiresAt: Date; updatedAt: Date; bytes: number }[]> {
const r = await sql<{ key: string; value: T; expires_at: Date; updated_at: Date; bytes: number }>`
select key, value, expires_at, updated_at, pg_column_size(value)::int as bytes
from kv_cache where region = ${region} and expires_at > now() order by key limit ${limit}`.execute(this.db);
return r.rows.map((x) => ({
key: x.key,
value: x.value,
expiresAt: x.expires_at,
updatedAt: x.updated_at,
bytes: Number(x.bytes),
}));
}
/** 未过期条目数(len(region_cache)) */
async count(region: string): Promise<number> {
const r = await sql<{ n: number }>`select count(*)::int as n from kv_cache
where region = ${region} and expires_at > now()`.execute(this.db);
return Number(r.rows[0]?.n ?? 0);
}
/** 删除全部过期行(存储换成 PG 后需要的物理清理;cache.sweep 定时任务) */
async sweep(): Promise<number> {
const r = await sql`delete from kv_cache where expires_at <= now()`.execute(this.db);
return Number(r.numAffectedRows ?? 0);
}
}
/** MP @cached 的参数 */
export interface CachedOptions<T> {
region: string;
maxsize?: number;
/** 秒 */
ttl?: number;
/** 缺省 true:结果为 null / undefined 不缓存 */
skipNone?: boolean;
/** 缺省 false:结果为假值(空数组 / 空对象 / 空串…)不缓存 */
skipEmpty?: boolean;
/** 返回真值时不缓存(如业务失败的响应) */
skipIf?: (v: T) => boolean;
/** 空结果的独立 TTL(秒) */
emptyTtl?: number;
/** 判断空结果;缺省按假值 */
emptyIf?: (v: T) => boolean;
/** MP fresh():true 时不读缓存(仍写) */
fresh?: boolean;
}
const isFalsy = (v: unknown) =>
!v || (Array.isArray(v) && v.length === 0) || (typeof v === "object" && Object.keys(v as object).length === 0);
/** MP @cached 的调用体:命中直接返回;否则执行 fn,按 should_cache / get_cache_ttl 写入 */
export async function cachedCall<T>(
cache: PgCache,
key: string,
o: CachedOptions<T>,
fn: () => Promise<T>,
): Promise<T> {
const should = (v: T | null | undefined) => {
if ((o.skipNone ?? true) && (v === null || v === undefined)) return false;
if (o.skipEmpty && isFalsy(v)) return false;
if (o.skipIf && v !== null && v !== undefined && o.skipIf(v)) return false;
return true;
};
if (!o.fresh) {
const hit = await cache.get<T>(o.region, key);
if (should(hit)) return hit as T;
}
const result = await fn();
if (!should(result)) return result;
let ttl = o.ttl;
if (o.emptyTtl !== undefined) {
if (result === null || result === undefined) ttl = o.emptyTtl;
else if (o.emptyIf) ttl = o.emptyIf(result) ? o.emptyTtl : o.ttl;
else ttl = isFalsy(result) ? o.emptyTtl : o.ttl;
}
await cache.set(o.region, key, result, {
...(ttl !== undefined ? { ttl } : {}),
...(o.maxsize ? { maxsize: o.maxsize } : {}),
});
return result;
}
const caches = new WeakMap<object, PgCache>();
/** 同一个 Kysely 实例共用一个 PgCache */
export function pgCacheFor(db: AnyDb): PgCache {
let c = caches.get(db);
if (!c) {
c = new PgCache(db);
caches.set(db, c);
}
return c;
}