All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
接口侧: - cache.ts 改为 stale-while-revalidate:每个 key 自调度,TTL 到期前 5s 后台刷新,用户永远命中热缓存 - 闲置 10 分钟后停止调度,避免空跑 - loader 失败保留旧值 + 10s 后退避重试 - 所有 4 个端点支持 ?force=1 强制绕过缓存 前端 HydrogenOverview: - 顶部加 RefreshCw 按钮(强刷绕过缓存),带旋转动画 - 显示"更新于 X 秒前"相对时间 - 刷新中:顶部 0.5px 流光进度条,不替换内容、不闪烁 - 60s 静默自动刷新(命中后端热缓存) 实测:cold 6.1s → 命中 13ms(470× 提速) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.1 KiB
TypeScript
136 lines
4.1 KiB
TypeScript
/**
|
||
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||
*
|
||
* 工作机制:
|
||
* - 首次请求:阻塞等待 loader(cold start,3-4s 不可避免)
|
||
* - 之后:每个 key 自调度刷新(TTL 到期前 5s),用户永远命中热缓存
|
||
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
|
||
* - 同一 key 并发请求只触发一次 loader
|
||
* - force=true:手动强制刷新,绕过缓存(但仍参与 inflight 复用)
|
||
*/
|
||
|
||
interface Entry<T> {
|
||
value: T;
|
||
freshAt: number;
|
||
expiresAt: number;
|
||
loader: () => Promise<T>;
|
||
lastAccess: number;
|
||
timer?: NodeJS.Timeout;
|
||
}
|
||
|
||
const TTL_MS = 60 * 1000;
|
||
const REFRESH_LEAD_MS = 5 * 1000; // TTL 到期前多久触发刷新
|
||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟无访问则停止调度
|
||
const RETRY_BACKOFF_MS = 10 * 1000; // loader 失败时重试间隔
|
||
|
||
const cache = new Map<string, Entry<unknown>>();
|
||
const inflight = new Map<string, Promise<unknown>>();
|
||
|
||
function scheduleRefresh<T>(key: string, entry: Entry<T>) {
|
||
if (entry.timer) clearTimeout(entry.timer);
|
||
const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
|
||
entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
|
||
entry.timer.unref?.();
|
||
}
|
||
|
||
async function runRefresh(key: string) {
|
||
const entry = cache.get(key) as Entry<unknown> | undefined;
|
||
if (!entry) return;
|
||
// 闲置超时:停止调度
|
||
if (Date.now() - entry.lastAccess > IDLE_TIMEOUT_MS) {
|
||
if (entry.timer) clearTimeout(entry.timer);
|
||
return;
|
||
}
|
||
if (inflight.has(key)) return;
|
||
const p = entry.loader()
|
||
.then(value => {
|
||
const now = Date.now();
|
||
const next: Entry<unknown> = {
|
||
value,
|
||
freshAt: now,
|
||
expiresAt: now + TTL_MS,
|
||
loader: entry.loader,
|
||
lastAccess: entry.lastAccess,
|
||
};
|
||
cache.set(key, next);
|
||
scheduleRefresh(key, next);
|
||
return value;
|
||
})
|
||
.catch(e => {
|
||
console.error(`[energy/cache] refresh failed for "${key}":`, e instanceof Error ? e.message : e);
|
||
// 保留旧值,10s 后重试
|
||
const retry: Entry<unknown> = { ...entry };
|
||
retry.timer = setTimeout(() => { void runRefresh(key); }, RETRY_BACKOFF_MS);
|
||
retry.timer.unref?.();
|
||
cache.set(key, retry);
|
||
})
|
||
.finally(() => inflight.delete(key));
|
||
inflight.set(key, p);
|
||
}
|
||
|
||
export interface CachedOpts {
|
||
force?: boolean;
|
||
}
|
||
|
||
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
|
||
const now = Date.now();
|
||
const hit = cache.get(key) as Entry<T> | undefined;
|
||
if (hit) {
|
||
hit.lastAccess = now;
|
||
hit.loader = loader;
|
||
}
|
||
|
||
// 强制刷新:等待 loader 完成
|
||
if (opts.force) {
|
||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||
if (ongoing) return ongoing;
|
||
const p = loader()
|
||
.then(value => {
|
||
const t = Date.now();
|
||
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||
cache.set(key, next);
|
||
scheduleRefresh(key, next);
|
||
return value;
|
||
})
|
||
.finally(() => inflight.delete(key));
|
||
inflight.set(key, p as Promise<unknown>);
|
||
return p;
|
||
}
|
||
|
||
// 命中且未过期 → 立即返回
|
||
if (hit && hit.expiresAt > now) {
|
||
return hit.value;
|
||
}
|
||
|
||
// 命中但过期 → 返回 stale,后台刷新
|
||
if (hit) {
|
||
if (!inflight.has(key)) void runRefresh(key);
|
||
return hit.value;
|
||
}
|
||
|
||
// 完全未命中 → 阻塞等待
|
||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||
if (ongoing) return ongoing;
|
||
|
||
const p = loader()
|
||
.then(value => {
|
||
const t = Date.now();
|
||
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||
cache.set(key, entry);
|
||
scheduleRefresh(key, entry);
|
||
return value;
|
||
})
|
||
.finally(() => inflight.delete(key));
|
||
inflight.set(key, p as Promise<unknown>);
|
||
return p;
|
||
}
|
||
|
||
/** 仅用于测试或调试:清空所有缓存与定时器 */
|
||
export function _clearEnergyCache() {
|
||
for (const e of cache.values()) {
|
||
if (e.timer) clearTimeout(e.timer);
|
||
}
|
||
cache.clear();
|
||
inflight.clear();
|
||
}
|