Files
ttstd-infinite-canvas/web/src/services/resource-blob-cache.ts

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import localforage from "localforage";
import { getActiveUserScope } from "@/lib/user-scope";
import { getResourceBlob, resourceIdFromStorageKey } from "@/services/api/resources";
type ResourceCacheMeta = {
key: string;
userScope: string;
resourceId: string;
version: string;
size: number;
mimeType: string;
lastAccessedAt: number;
};
const blobStore = localforage.createInstance({ name: "infinite-canvas", storeName: "resource_blobs" });
const metaStore = localforage.createInstance({ name: "infinite-canvas", storeName: "resource_blob_meta" });
const objectUrls = new Map<string, string>();
const sessionBlobs = new Map<string, Blob>();
const inFlight = new Map<string, Promise<string>>();
const cacheMetaTouchWarnings = new Set<string>();
const metaTouchedAt = new Map<string, number>();
const downloadQueue: Array<() => void> = [];
let activeDownloads = 0;
let persistQueue: Promise<void> = Promise.resolve();
let cacheGeneration = 0;
const MAX_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
const MAX_CACHE_ENTRIES = 500;
const TOUCH_INTERVAL_MS = 10 * 60 * 1000;
const BUDGET_REFRESH_MS = 5 * 60 * 1000;
// 现代浏览器对同源 HTTP/2 连接多路复用;上限给到 16 让大画布冷启动在 1~2 轮内完成并发拉取。
const MAX_CONCURRENT_DOWNLOADS = 16;
/** Release session-only byte resources during account/logout transitions. */
export function clearResourceBlobCache() {
cacheGeneration += 1;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls.clear();
sessionBlobs.clear();
inFlight.clear();
metaTouchedAt.clear();
cacheMetaTouchWarnings.clear();
cacheStats = null;
}
export async function getCachedResourceObjectUrl(storageKey: string) {
const target = await cacheTarget(storageKey);
if (!target) return "";
return readCachedObjectUrl(target);
}
/** 同步读取本会话内存中的 Blob URL,命中时节点首帧即可上屏,不必等 IndexedDB 异步读取。 */
export function peekCachedResourceObjectUrl(storageKey: string) {
const resourceId = resourceIdFromStorageKey(storageKey);
if (!resourceId) return "";
const userScope = getActiveUserScope();
if (userScope === "guest") return "";
return objectUrls.get(`${userScope}:${resourceId}:file`) || "";
}
export async function cacheResourceObjectUrl(storageKey: string) {
const target = await cacheTarget(storageKey);
if (!target) return "";
const cached = await readCachedObjectUrl(target);
if (cached) return cached;
const pending = inFlight.get(target.key);
if (pending) return pending;
const generation = cacheGeneration;
let task: Promise<string>;
task = withDownloadSlot(() => downloadAndCacheResource(storageKey, target, generation)).finally(() => {
if (inFlight.get(target.key) === task) inFlight.delete(target.key);
});
inFlight.set(target.key, task);
return task;
}
function withDownloadSlot<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
downloadQueue.push(() => {
activeDownloads += 1;
task()
.then(resolve, reject)
.finally(() => {
activeDownloads -= 1;
runDownloadQueue();
});
});
runDownloadQueue();
});
}
function runDownloadQueue() {
while (activeDownloads < MAX_CONCURRENT_DOWNLOADS && downloadQueue.length) downloadQueue.shift()?.();
}
export async function primeResourceBlobCache(storageKey: string, blob: Blob) {
const target = await cacheTarget(storageKey);
if (!target) return "";
sessionBlobs.set(target.key, blob);
const url = objectUrl(target.key, blob);
if (blob.size <= MAX_CACHE_BYTES) void enqueuePersist(target, blob);
return url;
}
export async function getCachedResourceBlob(storageKey: string) {
const target = await cacheTarget(storageKey);
if (!target) return null;
const cached = await blobStore.getItem<Blob>(target.key);
if (cached) {
touchCacheMetaSafely(target);
return cached;
}
const sessionBlob = sessionBlobs.get(target.key);
if (sessionBlob) return sessionBlob;
const pending = inFlight.get(target.key);
if (pending) {
await pending.catch(() => "");
const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem<Blob>(target.key));
if (downloaded) return downloaded;
return loadAndPersistResource(storageKey);
}
await cacheResourceObjectUrl(storageKey).catch(() => "");
const downloaded = sessionBlobs.get(target.key) || (await blobStore.getItem<Blob>(target.key));
if (downloaded) return downloaded;
return loadAndPersistResource(storageKey);
}
async function loadAndPersistResource(storageKey: string) {
const blob = await getResourceBlob(storageKey);
if (blob) await primeResourceBlobCache(storageKey, blob).catch(() => "");
return blob;
}
async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta, generation: number) {
const blob = await downloadResourceBlob(storageKey, target);
if (!blob || generation !== cacheGeneration) return "";
return objectUrl(target.key, blob);
}
async function downloadResourceBlob(storageKey: string, target: ResourceCacheMeta) {
const blob = await getResourceBlob(storageKey);
if (!blob) return null;
sessionBlobs.set(target.key, blob);
if (blob.size <= MAX_CACHE_BYTES) await enqueuePersist(target, blob);
return blob;
}
function enqueuePersist(target: ResourceCacheMeta, blob: Blob) {
const task = persistQueue.then(() => persistBlob(target, blob));
// IndexedDB 缓存是读性能优化,不得反向判定服务端资源上传失败;但失败必须可观测,
// 并把队列恢复为 fulfilled,避免一个坏条目永久阻断后续缓存写入。
const observed = task.catch((error) => {
console.warn("媒体缓存持久化失败,当前会话仍可继续读取", { resourceId: target.resourceId, version: target.version, error });
});
persistQueue = observed;
return observed;
}
async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
// 不尝试写入超过当前缓存预算的单个媒体,避免触发浏览器配额异常和无效的全量淘汰。
if (blob.size > (await cacheBudget())) return;
await evictFor(blob.size, target.key);
const write = async () => {
await blobStore.setItem(target.key, blob);
await metaStore.setItem(target.key, {
...target,
size: blob.size,
mimeType: blob.type || target.mimeType,
lastAccessedAt: Date.now(),
});
};
try {
await write();
invalidateCacheStats();
return;
} catch (firstError) {
// 第一次失败通常意味着浏览器配额不足;激进淘汰后只允许再尝试一次,
// 避免缓存层无限重试拖慢资源读取,也不把缓存失败误报成服务端资源失败。
await evictFor(blob.size, target.key, true);
try {
await write();
invalidateCacheStats();
return;
} catch (retryError) {
const cleanupResults = await Promise.allSettled([blobStore.removeItem(target.key), metaStore.removeItem(target.key)]);
const cleanupError = cleanupResults.find((result): result is PromiseRejectedResult => result.status === "rejected")?.reason;
if (cleanupError) {
console.error("媒体缓存写入失败后的清理也失败", {
resourceId: target.resourceId,
version: target.version,
firstError,
retryError,
cleanupError,
});
}
throw retryError;
}
}
}
async function readCachedObjectUrl(target: ResourceCacheMeta) {
const existing = objectUrls.get(target.key);
if (existing) {
touchCacheMetaSafely(target);
return existing;
}
const blob = await blobStore.getItem<Blob>(target.key);
if (!blob) return "";
touchCacheMetaSafely(target);
return objectUrl(target.key, blob);
}
async function cacheTarget(storageKey: string): Promise<ResourceCacheMeta | null> {
const resourceId = resourceIdFromStorageKey(storageKey);
if (!resourceId) return null;
const userScope = getActiveUserScope();
if (userScope === "guest") throw new Error("游客不能读取远程媒体缓存");
// resource ID 是不可变资源的稳定标识,文件接口本身负责鉴权。
// 缓存初始化不能先对每个资源读取一遍元数据,否则首屏会重新形成 N+1 请求。
const version = "file";
return {
key: `${userScope}:${resourceId}:${version}`,
userScope,
resourceId,
version,
size: 0,
mimeType: "application/octet-stream",
lastAccessedAt: Date.now(),
};
}
async function touchCacheMeta(target: ResourceCacheMeta) {
const now = Date.now();
// 同一会话内重复访问不落 IndexedDB:淘汰扫描在写入时才需要元数据,
// 内存时间戳足够让高频读取跳过跨进程 I/O。
const lastTouchedAt = metaTouchedAt.get(target.key);
if (lastTouchedAt && now - lastTouchedAt < TOUCH_INTERVAL_MS) return;
metaTouchedAt.set(target.key, now);
const current = await metaStore.getItem<ResourceCacheMeta>(target.key);
if (!current || now - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
await metaStore.setItem(target.key, { ...current, lastAccessedAt: now });
}
function touchCacheMetaSafely(target: ResourceCacheMeta) {
void touchCacheMeta(target).catch((error) => {
// 访问时间只服务于缓存淘汰,不影响资源读取;失败可降级,但不能无痕吞掉。
if (cacheMetaTouchWarnings.has(target.key)) return;
cacheMetaTouchWarnings.add(target.key);
console.warn("更新资源缓存访问时间失败", { storageKey: target.key, error });
});
}
// 条目数/字节数与存储配额在内存中跟踪,常规写入只做 O(1) 检查,
// 只有接近预算才执行全量 LRU 扫描,避免每张图持久化都 iterate 全部元数据。
let cacheStats: { count: number; bytes: number } | null = null;
let cachedBudget: { value: number; at: number } | null = null;
async function evictFor(incomingBytes: number, protectedKey: string, aggressive = false) {
const entryCount = await metaStore.length().catch(() => 0);
const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget();
// 常规写入只在统计可信或明显未接近阈值时跳过全表扫描;写入会使内存统计失效,
// 因此不会用可能过时的字节数做永久放行。
if (!aggressive) {
if (cacheStats && cacheStats.count < MAX_CACHE_ENTRIES && cacheStats.bytes + incomingBytes <= budget) return;
if (!cacheStats && entryCount < MAX_CACHE_ENTRIES - 10 && incomingBytes < budget / 10) return;
}
const metas: ResourceCacheMeta[] = [];
await metaStore.iterate<ResourceCacheMeta, void>((value) => {
if (value?.key) metas.push(value);
});
let total = metas.reduce((sum, item) => sum + Math.max(0, item.size || 0), 0);
let count = metas.length;
// 当前页面正在使用的 Blob URL 不能在 LRU 清理时撤销,否则已渲染节点会立即变成失效资源。
const candidates = metas.filter((item) => item.key !== protectedKey && !objectUrls.has(item.key) && !sessionBlobs.has(item.key)).sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);
for (const candidate of candidates) {
if (total + incomingBytes <= budget && count < MAX_CACHE_ENTRIES) break;
await removeCacheEntry(candidate);
total -= Math.max(0, candidate.size || 0);
count -= 1;
}
cacheStats = { count, bytes: total };
}
function invalidateCacheStats() {
cacheStats = null;
}
async function removeCacheEntry(meta: ResourceCacheMeta) {
const url = objectUrls.get(meta.key);
if (url) URL.revokeObjectURL(url);
objectUrls.delete(meta.key);
sessionBlobs.delete(meta.key);
await Promise.all([blobStore.removeItem(meta.key), metaStore.removeItem(meta.key)]);
}
async function cacheBudget() {
// 配额估计是跨进程调用且会话内基本稳定,短暂缓存避免每张图一次 IPC 往返。
if (cachedBudget && Date.now() - cachedBudget.at < BUDGET_REFRESH_MS) return cachedBudget.value;
let value = FALLBACK_CACHE_BYTES;
if (navigator.storage?.estimate) {
const estimate = await navigator.storage.estimate().catch(() => null);
if (estimate?.quota) value = Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
}
cachedBudget = { value, at: Date.now() };
return value;
}
function objectUrl(key: string, blob: Blob) {
const existing = objectUrls.get(key);
if (existing) return existing;
const url = URL.createObjectURL(blob);
objectUrls.set(key, url);
return url;
}
if (typeof window !== "undefined") {
window.addEventListener("pagehide", (event) => {
if (event.persisted) return;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
objectUrls.clear();
sessionBlobs.clear();
});
}