diff --git a/backend/app.ts b/backend/app.ts index 3e3da9e..e3ebec1 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -210,7 +210,7 @@ async function main() { console.log("[config] admin upload:", !!adminApiKey); /** 初始化子集化并发队列(含字体分组调度) */ - initMemoryGate(0, subsetConcurrency); + initMemoryGate(subsetConcurrency); /** 启动临时字体定时清理器 */ startTempCleaner(); diff --git a/backend/font_util/font_meta.ts b/backend/font_util/font_meta.ts index facf898..ea1f70e 100644 --- a/backend/font_util/font_meta.ts +++ b/backend/font_util/font_meta.ts @@ -460,6 +460,14 @@ export interface FontMeta { config?: FontUserConfig; } +/** + * 元数据版本指纹 + * + * extractCodePoints / calcCoverage 的算法变更后手动 bump, + * 让路由层的 .meta.json 磁盘缓存自动失效(旧文件的 metaVersion 不匹配即重算)。 + */ +export const META_VERSION = 3; + /** * 提取字体元数据:codepoint 总数、覆盖率、支持的区间、字体基本信息。 * 注意:config 字段由路由层从 font-config.json 填充,此处不包含。 @@ -467,6 +475,7 @@ export interface FontMeta { export function extractFontMeta(fontBuffer: ArrayBuffer | Uint8Array): FontMeta { const cps = extractCodePoints(fontBuffer); return { + metaVersion: META_VERSION, totalCodePoints: cps.size, coverage: calcCoverage(cps), ranges: codePointsToRanges(cps), diff --git a/backend/routes/font_meta.ts b/backend/routes/font_meta.ts index ac0ab1e..7ef7c7a 100644 --- a/backend/routes/font_meta.ts +++ b/backend/routes/font_meta.ts @@ -1,6 +1,8 @@ -import { extractFontMeta, type FontUserConfig } from "../font_util/font_meta.js"; +import { extractFontMeta, META_VERSION, type FontUserConfig } from "../font_util/font_meta.js"; import { parseUrl, jsonResponse, findFontPath, readFontBuffer } from "../shared"; import { readFile, writeFile, stat } from "../interface"; +import { withMemoryGate } from "../subset_queue"; +import { subsetQueueTimeoutSeconds } from "../config"; /** * 字体元数据路由 —— 分为两层: @@ -99,17 +101,41 @@ export async function handleFontMeta(req: Request, _res: Response) { /** 1. 进程内存命中 */ let meta = metaCache.get(fontPath); if (!meta) { - /** 2. 磁盘 .meta.json 命中 */ + /** 2. 磁盘 .meta.json 命中(版本指纹不匹配则视为 miss,重新计算) */ meta = await loadMetaFromDisk(fontPath); - if (meta) { + if (meta && meta.metaVersion === META_VERSION) { metaCache.set(fontPath, meta); + } else { + meta = undefined; } } - /** 3. 首次请求 —— 解析 cmap + name 表计算元数据 */ + /** 3. 首次请求 —— 解析 cmap + name 表计算元数据(CPU/内存密集,复用子集化并发闸门) */ if (!meta) { - const fontBuffer = await readFontBuffer(fontPath); - meta = extractFontMeta(fontBuffer); + /** 排队超时,返回 503 让客户端重试 */ + const result = await withMemoryGate( + async () => { + const fontBuffer = await readFontBuffer(fontPath); + return extractFontMeta(fontBuffer); + }, + subsetQueueTimeoutSeconds * 1000, + fontPath, + ); + + if (result === null) { + return { + req, + res: new Response("Server busy, please retry", { + status: 503, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Retry-After": "1", + }, + }), + }; + } + + meta = result; metaCache.set(fontPath, meta); /** 异步写盘,不阻塞响应 */ saveMetaToDisk(fontPath, meta); diff --git a/backend/routes/subset.ts b/backend/routes/subset.ts index ba46a38..3e27092 100644 --- a/backend/routes/subset.ts +++ b/backend/routes/subset.ts @@ -116,7 +116,7 @@ export async function handleFontSubset(req: Request, res: Response) { * 缓存未命中的请求才进入闸门;RSS 超 softLimit 时排队等待, * 前面请求完成 + GC 释放内存后 RSS 回落才执行。避免 OOM 崩溃。 */ - const subsetResult = await withMemoryGate(subsetConcurrency, async () => { + const subsetResult = await withMemoryGate(async () => { return fontSubset(oldFontBuffer, text, { outType: outType, sourceType: fontType, diff --git a/backend/subset_queue.ts b/backend/subset_queue.ts index 88a826e..fbbfe35 100644 --- a/backend/subset_queue.ts +++ b/backend/subset_queue.ts @@ -37,16 +37,12 @@ let lastGroupKey = ""; * 分组优化:同 groupKey(同一字体)的排队请求优先连续唤醒, * 使字体 buffer / 解析对象在缓存窗口内被下一个请求复用,降低内存峰值。 * - * 函数名保留 withMemoryGate 以减少调用方改动(subset.ts 等)。 - * - * @param _softLimitMB 废弃保留(兼容签名),不再使用 - * @param task 实际的子集化异步任务 + * @param task 实际的异步任务 * @param queueTimeoutMs 排队超时(毫秒) * @param groupKey 分组键(通常为 fontPath),同组连续处理以复用缓存 * @returns 任务结果,或 null 表示排队超时 */ export async function withMemoryGate( - _softLimitMB: number, task: () => Promise, queueTimeoutMs: number, groupKey = "", @@ -121,10 +117,9 @@ function release(): void { * 初始化并发参数(由 config 设置) * * 必须在第一次 withMemoryGate 调用前执行。 - * @param _softLimitMB 废弃(兼容签名) * @param concurrency 最大并发数 */ -export function initMemoryGate(_softLimitMB: number, concurrency?: number): void { +export function initMemoryGate(concurrency?: number): void { if (concurrency && concurrency > 0) { maxConcurrency = concurrency; }