// telemetry JSONL 的保留策略与磁盘预算(作业单 O-5)。 // // 这个文件为什么存在:可观测三通道(生成链指标 / 失败样本 / 渲染信标)都用同一套 // 「append-only JSONL 按天落盘」的纪律,好处是零依赖、tail 即排障;代价是**只增不删**。 // 一个跑了一年的部署会在 runtime/ 下攒出成百上千个文件,最终把磁盘写满—— // 而磁盘写满会同时打死生成链、声明仓库和宿主进程。可观测性绝不能变成新的故障源, // 所以「保留多久 / 最多占多少」必须与「怎么写」写在一起,而不是留给运维配 crontab。 // // 三条设计纪律: // 1. **惰性触发,不引入定时器**:清理挂在写入路径上(写完顺带看一眼),进程不常驻也能生效; // 定时器会让库擅自持有一个永不释放的 handle,宿主进程从此退不干净。 // 同一目录用节流窗口兜住高频写入的开销(缺省 5 分钟内只真扫一次)。 // 2. **故障全静默**:清理失败绝不抛,绝不打日志——它自己就是可观测通道的一部分, // 在这里报错等于让观测通道互相拖垮。 // 3. **只动自己的文件**:按目录 + 文件名前缀 + `.jsonl` 后缀三重限定, // 绝不因为"目录里有别的东西"就误删——落盘目录可被环境变量指到任何地方。 // // 三道预算,依次执行(前一道清完仍超才走后一道): // ① 保留天数:按天文件名里的日期判龄(拿不到日期回落 mtime),过期即删; // ② 单文件上限:超限的文件截头保尾(丢最旧的行)——渲染信标是按页面名而非按天分文件的, // 单个热门页面能把一个文件写到很大,只有截头能兜住它; // ③ 目录总量上限:仍超限就按"最旧优先"整文件删,直到落回预算内。 import fs from 'node:fs'; import path from 'node:path'; /** 缺省保留天数(可用 SOMA_TELEMETRY_RETENTION_DAYS 覆盖) */ const DEFAULT_RETENTION_DAYS = 30; /** 缺省单文件上限:32MB(可用 SOMA_TELEMETRY_MAX_FILE_MB 覆盖) */ const DEFAULT_MAX_FILE_MB = 32; /** 缺省目录总量上限:256MB(可用 SOMA_TELEMETRY_MAX_DIR_MB 覆盖) */ const DEFAULT_MAX_DIR_MB = 256; /** 惰性清理的节流窗口:同一目录 5 分钟内最多真扫一次 */ const THROTTLE_MS = 5 * 60 * 1000; const MB = 1024 * 1024; export interface JsonlRetentionPolicy { /** 落盘目录(绝对路径;不存在直接返回,不当错误) */ dir: string; /** * 文件名前缀(如 `generation-`)。 * 缺省 undefined = 目录内全部 `.jsonl` 都归本策略管—— * 只有目录专属于一条通道时才可省略(信标目录就是这种)。 */ prefix?: string; /** 覆盖保留天数(优先级低于环境变量:环境变量是运维的最终裁量) */ retentionDays?: number; maxFileBytes?: number; maxDirBytes?: number; /** 当前时刻注入点(测试用确定性时钟) */ now?: () => number; } export interface JsonlPruneResult { /** 是否真的执行了扫描(节流窗口内被跳过时为 false) */ scanned: boolean; /** 因过期被删的文件名 */ expired: string[]; /** 因目录总量超限被删的文件名(最旧优先) */ evicted: string[]; /** 因单文件超限被截头的文件名 */ truncated: string[]; /** 清理后目录内本策略文件的总字节 */ totalBytes: number; } const EMPTY_RESULT: JsonlPruneResult = { scanned: false, expired: [], evicted: [], truncated: [], totalBytes: 0, }; /** 总开关:SOMA_TELEMETRY_RETENTION ∈ {off,0,false} 时完全不清理(取证场景要留全量) */ function retentionEnabled(): boolean { const flag = (process.env.SOMA_TELEMETRY_RETENTION ?? '').trim().toLowerCase(); return flag !== 'off' && flag !== '0' && flag !== 'false'; } /** 环境变量数值读取:非法值(空/非数/负数)一律按未配置处理,宽进严出 */ function envNumber(name: string): number | undefined { const raw = process.env[name]; if (raw === undefined || raw.trim() === '') return undefined; const value = Number(raw); return Number.isFinite(value) && value >= 0 ? value : undefined; } function resolveRetentionDays(policy: JsonlRetentionPolicy): number { return envNumber('SOMA_TELEMETRY_RETENTION_DAYS') ?? policy.retentionDays ?? DEFAULT_RETENTION_DAYS; } function resolveMaxFileBytes(policy: JsonlRetentionPolicy): number { const fromEnv = envNumber('SOMA_TELEMETRY_MAX_FILE_MB'); if (fromEnv !== undefined) return fromEnv * MB; return policy.maxFileBytes ?? DEFAULT_MAX_FILE_MB * MB; } function resolveMaxDirBytes(policy: JsonlRetentionPolicy): number { const fromEnv = envNumber('SOMA_TELEMETRY_MAX_DIR_MB'); if (fromEnv !== undefined) return fromEnv * MB; return policy.maxDirBytes ?? DEFAULT_MAX_DIR_MB * MB; } /** * 文件的"年龄时刻":按天文件名(`xxx-YYYY-MM-DD.jsonl`)里的日期优先。 * * 为什么不直接用 mtime:按天文件写完当天就不再变,mtime 与文件名日期一致; * 但文件被复制/恢复/rsync 过来时 mtime 会被刷新成"今天",保留期就白设了。 * 文件名里的日期是事实本身,拿得到就用它。 */ function timestampOf(fileName: string, stat: fs.Stats): number { const matched = /(\d{4})-(\d{2})-(\d{2})/.exec(fileName); if (matched) { const parsed = Date.parse(`${matched[1]}-${matched[2]}-${matched[3]}T00:00:00Z`); if (Number.isFinite(parsed)) { return parsed; } } return stat.mtimeMs; } /** * 单文件截头保尾:保留最新的 targetBytes 字节,并对齐到行首(不留半行 JSON)。 * 用整读整写而不是流式:JSONL 是行文本,targetBytes 已被上限约束在几十 MB 量级, * 一次性处理换来的是"要么全成功要么原样不动"的原子性——半截文件比超限文件更糟。 */ function truncateHead(filePath: string, targetBytes: number): boolean { const buffer = fs.readFileSync(filePath); if (buffer.length <= targetBytes) { return false; } const tail = buffer.subarray(buffer.length - targetBytes); const firstNewline = tail.indexOf(0x0a); // 找不到换行说明整段尾巴是一行超长记录,那就整体丢弃重新开始(极端情况,保住上限优先) const aligned = firstNewline < 0 ? Buffer.alloc(0) : tail.subarray(firstNewline + 1); fs.writeFileSync(filePath, aligned); return true; } /** * 立即执行一次清理(不走节流)。任何异常都被吞掉,返回已完成的部分结果。 * 供测试与"运维手动清一次"使用;生产路径请用 pruneJsonlDirLazily。 */ export function pruneJsonlDir(policy: JsonlRetentionPolicy): JsonlPruneResult { const result: JsonlPruneResult = { scanned: true, expired: [], evicted: [], truncated: [], totalBytes: 0, }; if (!retentionEnabled()) { return { ...EMPTY_RESULT }; } try { let entries: string[]; try { entries = fs.readdirSync(policy.dir); } catch { // 目录不存在 = 一条都还没落盘,不是错误 return { ...EMPTY_RESULT, scanned: true }; } const prefix = policy.prefix ?? ''; const now = (policy.now ?? Date.now)(); const retentionMs = resolveRetentionDays(policy) * 24 * 60 * 60 * 1000; const maxFileBytes = resolveMaxFileBytes(policy); const maxDirBytes = resolveMaxDirBytes(policy); type Entry = { name: string; file: string; at: number; size: number }; let files: Entry[] = []; for (const name of entries) { if (!name.endsWith('.jsonl') || !name.startsWith(prefix)) continue; const file = path.join(policy.dir, name); let stat: fs.Stats; try { stat = fs.statSync(file); } catch { continue; // 并发下文件可能刚被别人删掉 } if (!stat.isFile()) continue; files.push({ name, file, at: timestampOf(name, stat), size: stat.size }); } // ① 保留天数:retentionMs 为 0 表示"不按时间清"(只留容量兜底) if (retentionMs > 0) { const deadline = now - retentionMs; const kept: Entry[] = []; for (const entry of files) { if (entry.at < deadline) { try { fs.rmSync(entry.file, { force: true }); result.expired.push(entry.name); continue; } catch { // 删不掉(权限/占用)就当它还在,继续参与容量核算 } } kept.push(entry); } files = kept; } // ② 单文件上限:截头保尾 if (maxFileBytes > 0) { for (const entry of files) { if (entry.size <= maxFileBytes) continue; try { if (truncateHead(entry.file, maxFileBytes)) { result.truncated.push(entry.name); entry.size = fs.statSync(entry.file).size; } } catch { // 截不动就留给第 ③ 步整文件淘汰 } } } // ③ 目录总量上限:最旧优先整文件删,直到落回预算 files.sort((left, right) => left.at - right.at || left.name.localeCompare(right.name)); let total = files.reduce((sum, entry) => sum + entry.size, 0); if (maxDirBytes > 0) { for (const entry of files) { if (total <= maxDirBytes) break; // 最后一个文件通常正被追加写:删了下一次写入会自动重建,不会丢当前进程的句柄语义 try { fs.rmSync(entry.file, { force: true }); result.evicted.push(entry.name); total -= entry.size; } catch { // 删不掉就跳过,下次写入时再试 } } } result.totalBytes = total; return result; } catch { // 清理是尽力而为:任何未预期的异常都不许冒泡到写入路径 return result; } } /** 目录 → 上次真正扫描的时刻(进程内节流表;不是缓存,丢了最多多扫一次) */ const lastRunAt = new Map(); /** * 惰性清理:挂在写入路径上调用,不引入定时器。 * 同一目录在节流窗口内的调用直接返回(scanned=false),避免高频写入把 readdir 打成热点。 */ export function pruneJsonlDirLazily(policy: JsonlRetentionPolicy): JsonlPruneResult { try { if (!retentionEnabled()) { return { ...EMPTY_RESULT }; } const now = (policy.now ?? Date.now)(); const key = `${policy.dir}${policy.prefix ?? ''}`; const previous = lastRunAt.get(key); if (previous !== undefined && now - previous < THROTTLE_MS) { return { ...EMPTY_RESULT }; } lastRunAt.set(key, now); return pruneJsonlDir(policy); } catch { return { ...EMPTY_RESULT }; } } /** 清空节流表(仅供测试隔离;运行时无需调用) */ export function resetJsonlRetentionThrottle(): void { lastRunAt.clear(); }