'use strict'; /** * 节点备份调度器(Node 内置定时器)—— 考试开始前 / 考试结束后 / 成绩发布后 * ============================================================================ * 需求来源(用户 2026-10-16提出):「系统在考试开始前,考试结束后,成绩发布后 * 这三个节点应该执行数据备份」。 * * ============================ 为什么不用巡检脚本 ============================ * 用户裁定:巡检脚本(cron 每分钟扫状态跃迁)会占用额外内存 ⇒ 改用**后端钩子**。 * 本模块就是该钩子的落点:**进程内 Node 定时器**,不新增常驻进程、不新增轮询。 * 额外内存开销 = 最多 8 个 setTimeout 句柄(4 组 × 2 节点),可忽略。 * * ============================ 三节点的时间口径 ============================ * ① before-start :考试开始前 30 分钟(缓冲,留出 dump 落盘时间) * ② after-end :考试结束后 10 分钟(缓冲,等最后一批提交落库) * ③ score-published:成绩发布**瞬间**触发(发布态本身不可逆,见下方「撤销」说明) * * ⚠️ 「撤销发布」不作为节点(用户裁定):撤销发生时备份到的是**已经发布过的数据**, * 对「回退到未发布态」这个诉求毫无意义 ⇒ 只在 publish === true 时触发。 * * ============================ 幂等:为什么用「计划键」而不是「组别」 ============================ * 定时器只活在内存里,进程重启即丢失 ⇒ 必须在 `didReady` 重新 hydrate。 * 若 hydrate 时无条件按组别重排,会出现「重启一次 = 重复备份一次」。 * 故幂等键 = `${group}:${node}:${fireAtISO}` —— **时间点本身就是身份**: * · 管理员把考试时间改成新值 ⇒ fireAt 变了 ⇒ 是新计划,理应再备一次 ✅ * · 进程重启 hydrate 出同一 fireAt ⇒ 键相同 ⇒ 覆盖同一计划,不重复 ❌→ ✅ 不触发 * 另有一层持久化去重:`system_configs` 的 `node_backup_fired_` * (哈希后落键),用于「重启后 fireAt 已在过去」的场景下仍不重复备份。 * * ============================ 🔴 生产隔离铁律 ============================ * 本模块**只在生产环境启用**(`config.isProduction === true`)。 * 非生产一律 `enabled = false` 且 `schedule()` 直接返回空 —— 因为备份目标是 * **生产库**(用户裁定②),本地/测试环境触发它等于让本机进程去 dump 生产库。 * 这与 config.default.js 里 `DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', ...]` * 是同一条纪律的延伸:非生产进程**不得持有生产连接凭据**。 * * ============================ 备份执行 ============================ * 不自己实现 dump ⇒ **复用** `scripts/backup_db.py`(同一份 mysqldump + gzip + * 完整性校验 + 保留期治理逻辑,避免两套备份实现的校验口径漂移)。 * 通过 `child_process.spawn` 异步执行,**绝不阻塞业务请求**: * · setTimes 写完库立即返回,备份在后台跑 * · publishScores 同理 * 失败只落日志 + 状态文件 + 告警日志,**绝不 throw 到调用方** —— * 备份失败不能把「设置考试时间」「发布成绩」这两个管理动作搞挂。 * * ============================ 🔴 多 worker 必须处理 ============================ * 🔴 R168 修正(生产取证):生产 `EGG_WORKERS=2`(deploy/.env.production:77)⇒ Egg 起 * **2 个 app worker 进程**,而 `didReady` 在**每个 worker 里各跑一次** ⇒ 原实现每个 * worker 各建一个调度器、各自 hydrate 出同一批计划 ⇒ **到点两个进程各 spawn 一次 * mysqldump**(重复备份 + 双倍 IO + 竞态写同一目录)。 * ⚠️ 本地 dev 单 worker(egg-bin 强制)⇒ 测试完全看不出来 —— 又一个 * 「测试环境掩盖生产缺陷」的实例,故判据必须显式模拟多 worker。 * 同理 `setTimes` 只落到「处理该请求的那一个 worker」⇒ 另一个 worker 的旧计划 * **不会被清除** ⇒ 改期后残留定时器仍会在旧时刻误触发一次。 * * 两道防线(缺一不可): * ① **只在 master 进程排程**(`isMaster` 门禁)—— 从根上消除重复源头; * ② **跨进程文件锁**(`_fire` 内)—— 兜底:worker 数配置漂移 / 时序异常时 * 也不会并发 dump。 * ============================================================================ */ const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const crypto = require('crypto'); // ---- 三节点常量 ---- const NODE_BEFORE_START = 'before-start'; const NODE_AFTER_END = 'after-end'; const NODE_SCORE_PUBLISHED = 'score-published'; /** * 计划键:节点身份 = 组别 + 节点 + 触发时刻 * @param {string} group 组别 * @param {string} node 节点常量 * @param {Date} fireAt 触发时刻 * @returns {string} */ function planKey(group, node, fireAt) { return `${group}:${node}:${new Date(fireAt).toISOString()}`; } // =========================================================================== // 结果台账(P1「生产备份静默失败防护」的落地) // =========================================================================== // 🔴 为什么必须有台账,而不是「只写 logger」: // 台账 P1 的原始形态就是「失败只落日志,没人看 ⇒ 直到需要恢复时才发现备份 // 根本不存在」。logger 是**运维异步消费**的通道,管理后台读不到它。 // 故这里额外维护一份 **append-only JSONL**(`node-backup-history.jsonl`), // 管理端状态接口直接读它 ⇒ 失败在设置页立刻可见,不必 ssh 翻日志。 // // 为什么用 JSONL 而不是一个 status.json: // · 生产 EGG_WORKERS=2,多进程并发写同一状态文件必定互相覆盖(读-改-写竞态); // · 单行 append + O_APPEND 是原子写(< PIPE_BUF),多进程安全; // · 天然保留**历史**,才能判「漏备」「连续失败」这类需要时间序列的结论。 // 体量估算:4 组 × 2 节点 + 少量即时节点 ≈ 每天数十行(<10KB/天), // 90 天后仍 <1MB ⇒ 暂不做轮转(轮转需 mv 归档,等真的有量再说)。 const HISTORY_FILE_NAME = 'node-backup-history.jsonl'; /** 台账文件路径 */ function historyPath(logDir) { return path.join(logDir, HISTORY_FILE_NAME); } /** * 读取台账尾部 N 条(**最新在前**) * * ⚠️ 单行损坏(进程在 append 中途被 kill)只跳过该行,绝不让整个读取失败 —— * 台账读不出来等于把可见性做回到「只能翻日志」。 * @param {string} logDir * @param {number} limit * @returns {Array} */ function readHistory(logDir, limit) { const n = Math.max(1, Math.min(Number(limit) || 50, 500)); let text = ''; try { text = fs.readFileSync(historyPath(logDir), 'utf8'); } catch (e) { return []; // 文件不存在 = 从未触发过,是正常状态而非错误 } const lines = String(text).split('\n'); const out = []; for (let i = lines.length - 1; i >= 0 && out.length < n; i--) { const s = lines[i].trim(); if (!s) continue; try { out.push(JSON.parse(s)); } catch (e) { /* 半行/损坏行跳过 */ } } return out; } /** * 列出备份产物(按 mtime 倒序) * @param {string} outDir * @param {number} limit */ function listArtifacts(outDir, limit) { const n = Math.max(1, Math.min(Number(limit) || 10, 100)); const items = []; try { for (const name of fs.readdirSync(outDir)) { if (!/\.(sql|gz|tgz|zip)$/i.test(name)) continue; try { const st = fs.statSync(path.join(outDir, name)); if (!st.isFile()) continue; items.push({ name, sizeMB: Math.round((st.size / 1048576) * 100) / 100, mtime: new Date(st.mtimeMs).toISOString(), }); } catch (e) { /* 并发被清理 ⇒ 跳过 */ } } } catch (e) { return []; } items.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : 0)); return items.slice(0, n); } /** * 健康判定(**纯函数**:不读磁盘、不看时钟以外的外部状态 ⇒ 可直接单测) * * 判据设计原则:每条都对应一种「备份实际没做,但界面上看不出来」的真实故障形态, * 而不是泛泛的「有错就红」: * H1 PROD_DISABLED 生产环境调度器没启用 ⇒ 三节点备份永远不会执行 * H2 LAST_RUN_FAILED 最近一次有结果执行是失败(含连续失败)⇒ 需人工介入 * H3 MISSED_RUN 计划已到点超宽限期,却既无成功也无失败也无跳过记录 * ⇒ 定时器丢了(进程重启/hydrate 漏了/kill -9),最危险 * H4 NO_ARTIFACT 台账说成功,但备份目录里一个产物都没有 ⇒ 假成功 * H5 NO_PLAN 已启用却没有任何待触发计划 ⇒ 提示(可能是善意状态) * * ⚠️ 非生产环境不报 H1:本地/测试本来就不该排生产备份,报红会造成告警疲劳 * (与 backup_daily.sh「不比对比报警」同一条纪律)。 * * @param {object} input * @param {boolean} input.enabled * @param {boolean} input.isProduction * @param {Array} input.plans [{ group, node, fireAt, key }] * @param {Array} input.history readHistory() 的结果(最新在前) * @param {number} input.artifactCount * @param {number} [input.now] * @returns {{level:string,reasons:Array,stats:object}} */ function evaluateHealth(input) { const now = Number.isFinite(Number(input && input.now)) ? Number(input.now) : Date.now(); const GRACE_MS = 10 * 60 * 1000; // 到点后 10 分钟内不判漏(dump 可能正在跑) const enabled = !!(input && input.enabled === true); const isProduction = !!(input && input.isProduction === true); const plans = (input && Array.isArray(input.plans)) ? input.plans : []; const history = (input && Array.isArray(input.history)) ? input.history : []; const artifactCount = Number(input && input.artifactCount) || 0; const reasons = []; const add = (level, code, message) => reasons.push({ level, code, message }); // ---- H1 生产未启用 ---- if (isProduction && !enabled) { add('error', 'PROD_DISABLED', '生产环境节点备份调度器未启用:考试前/考试后/发布后三节点均不会执行'); } // ---- H2 最近有结果的执行是失败 ---- let consecutiveFails = 0; let lastFailMsg = ''; for (const h of history) { if (!h || typeof h !== 'object') continue; if (h.event === 'fail') { consecutiveFails++; if (!lastFailMsg) lastFailMsg = h.message || h.stage || ''; } else if (h.event === 'success') { break; // 有成功兜底 ⇒ 连续失败链断裂 } // start / plan / lock-skip / invalid-skip 不参与连续失败计数 } if (consecutiveFails > 0) { add('error', 'LAST_RUN_FAILED', `最近 ${consecutiveFails} 次节点备份执行失败${lastFailMsg ? '(' + lastFailMsg + ')' : ''} ⇒ 需人工介入`); } // ---- H3 已到点却无任何结果(漏备)---- const settled = new Set(); for (const h of history) { if (!h || !h.key) continue; if (h.event === 'success' || h.event === 'fail' || h.event === 'lock-skip' || h.event === 'invalid-skip') { settled.add(h.key); } } const seenPlan = new Set(); const missed = []; for (const h of history) { if (!h || h.event !== 'plan' || !h.key) continue; if (seenPlan.has(h.key)) continue; seenPlan.add(h.key); const t = new Date(h.fireAt).getTime(); if (!Number.isFinite(t)) continue; if (t + GRACE_MS > now) continue; // 还没到点 / 刚过点,仍在宽限期内 if (settled.has(h.key)) continue; missed.push(h.key); } if (missed.length > 0) { add('error', 'MISSED_RUN', `有 ${missed.length} 个已到点计划既无成功也无失败记录(疑似漏备):${missed.slice(0, 3).join(' / ')}`); } // ---- H4 假成功 ---- if (history.some(h => h && h.event === 'success') && artifactCount === 0) { add('error', 'NO_ARTIFACT', '台账记录过备份成功,但备份目录中没有任何产物(疑似假成功或产物被清理)'); } // ---- H5 无待触发计划 ---- if (enabled && plans.length === 0) { add('warn', 'NO_PLAN', '当前没有任何待触发计划:四组均未设置考试时间,或时间节点均已过期(重启后不会补备)'); } const level = reasons.some(r => r.level === 'error') ? 'error' : reasons.some(r => r.level === 'warn') ? 'warn' : 'ok'; return { level, reasons, stats: { consecutiveFails, missedCount: missed.length, artifactCount, planCount: plans.length, historyCount: history.length, }, }; } /** * 计算某组某节点的触发时刻 * * ⚠️ 口径纪律: * · before-start = start_time − 30min,**不得**早于「现在」(管理员可能把开始 * 时间设成过去时间 ⇒ 减完仍在过去 ⇒ 必须钳到 now,否则会立刻触发一次 * 「已经开考」的备份,语义错误且浪费 IO)。 * · after-end = end_time + 10min,同样钳到 now 之后一点点(+1s), * 保证 setTimeout 拿到的是**未来**时刻而不是恰好相等(相等时行为依赖实现)。 * * 🔴 opts.clamp = false 时**不钳制**,返回数学上的真实触发时刻。 * 这不是可选优化,而是 hydrate 判据所必需:hydrate 必须能分辨 * 「这个节点本来就还没到」vs「这个节点的时间设置在過去、早就该跳过了」。 * 若 hydrate 也走钳制路径,过期节点会被伪造成 now+1s ⇒ 进程一重启就立刻 * 误触发一次备份(2026-10-16 由 C28 判据 J4.2 实测抓到,非理论风险)。 * * @param {string} group * @param {string} node * @param {Date|null} baseTime start_time 或 end_time * @param {object} opts * @param {boolean} [opts.clamp=true] 是否把过去时刻钳到 now+1s * @returns {Date|null} 触发时刻;不可调度时返回 null */ function computeFireAt(group, node, baseTime, opts) { if (!baseTime) return null; const t = new Date(baseTime).getTime(); if (!Number.isFinite(t)) return null; const leadMs = node === NODE_BEFORE_START ? (opts.beforeStartBufferMin || 30) * 60 * 1000 : (opts.afterEndDelayMin || 10) * 60 * 1000; const fire = node === NODE_BEFORE_START ? t - leadMs : t + leadMs; // ⛔ clamp=false ⇒ 返回数学时刻,交由调用方(hydrate)自行判断过期 if (opts.clamp === false) return new Date(fire); // 钳制:绝不让计划落在过去(否则 setTimeout 立刻 fire,语义错误) const now = Date.now(); if (fire <= now) return new Date(now + 1000); return new Date(fire); } /** * 节点备份调度器(挂在 app 上,全进程单例) */ class NodeBackupScheduler { /** * @param {object} app Egg app */ constructor(app) { this.app = app; this.logger = app.logger; this.config = (app.config && app.config.nodeBackup) || {}; // key → timeout 句柄 this.timers = new Map(); // ⛔ 非生产直接封死:不排任何计划、不spawn 任何进程 // ⚠️ fail-safe(不是 fail-open):配置段缺失 / 非对象 / enabled 未显式为 true // 时一律判为**关闭**。曾写成 `enabled !== false` ⇒ 配置段整个缺失时算出 // true(fail-open),由 C28 判据 J3.6 实测抓到 —— 生产隔离绝不能靠默认值。 this.enabled = !!( app.config && app.config.isProduction && this.config && typeof this.config === 'object' && this.config.enabled === true ); // 🔴 多 worker:这里**不能**按 isMaster 门禁关停 —— Egg 的 `didReady` 运行在 // **app worker 进程**里,master 根本不执行 app.js 生命周期钩子 // (曾一度想用 `EGG_SERVER_ENV === 'master'` 门禁,实测会��整个调度器 // 永远关闭 ⇒ 永不备份)。故改为「每个 worker 都排程」+ 触发时刻两道去重: // ① `_acquireLock` 跨进程文件锁(原子 mkdir)⇒ 并发到点只有一个真正 dump; // ② `_stillValid` 触发前回库核对当前时间配置 ⇒ 改期后其它 worker 的 // **残留旧定时器**在触发时发现计划已作废 ⇒ 直接放弃(不误备份)。 // 这样既不会重复备份,也不会漏备份,且对 worker 数配置漂移免疫。 if (!this.enabled) { this.logger.info( '[NodeBackup] 未启用(enabled=%s · isProduction=%s)—— 非生产环境禁止触发生产库备份', String(this.config.enabled !== false), String(!!(app.config && app.config.isProduction)) ); } } /** * 排一个节点备份计划(幂等:同key 覆盖旧句柄) * * @param {object} p * @param {string} p.group * @param {string} p.node * @param {Date|null} p.baseTime start_time / end_time * @param {string} p.reason 人类可读原因(落日志) * @returns {string|null} 计划键;未启用/不可调度返回 null */ schedule(p) { if (!this.enabled) return null; const { group, node, baseTime, reason } = p; const fireAt = computeFireAt(group, node, baseTime, this.config); if (!fireAt) { this.logger.warn( '[NodeBackup] 跳过排程(缺少有效时间):group=%s node=%s baseTime=%s', group, node, String(baseTime) ); return null; } const key = planKey(group, node, fireAt); // 同 key 已在册 ⇒ 说明是 hydrate 撞车,不重复排 if (this.timers.has(key)) { this.logger.info('[NodeBackup] 计划已存在,跳过重复排程:%s', key); return key; } const delay = Math.max(0, fireAt.getTime() - Date.now()); // setTimeout 上限 2^31-1 ms(约 24.8 天);超出则分段等待(考试时间被设到 // 很远以后时会真实触发此分支 —— 不处理会让定时器立即 fire ⇒ 立即误备份) const MAX_DELAY = 2147483647; const arm = ms => { const handle = setTimeout(async () => { this.timers.delete(key); if (ms <= MAX_DELAY) { await this._fire({ group, node, fireAt, key, reason }); } else { // 分段续等 const rest = ms - MAX_DELAY; this._rearm(key, { group, node, fireAt, reason }, rest); } }, ms); // ⛔ 定时器不应阻止进程退出(正常退出时无需等备份跑完) if (handle.unref) handle.unref(); this.timers.set(key, handle); }; arm(delay); // 台账:记下「曾经排过这个计划」—— 这是 H3「漏备」判定的唯一依据。 // 若不记,重启后计划丢了就再也无从发现「该备却没备」。 this._record('plan', { key, group, node, fireAt: fireAt.toISOString(), reason: reason || '-', }); this.logger.info( '[NodeBackup] 已排程:key=%s 触发=%s(%s 分钟后)原因=%s', key, fireAt.toISOString(), Math.round(delay / 60000), reason || '-' ); return key; } /** * 追加一条台账事件(管理后台可见性的数据源) * * ⛔ 台账写失败**绝不能**影响备份本身:备份是保命的,台账是观测。 * 故这里吞掉所有异常,只回落到 logger.warn 留痕。 * * @param {string} event plan | start | success | fail | lock-skip | invalid-skip * @param {object} meta 计划/执行元信息 * @param {object} [extra] 附加字段(rc / logFile / message / stage) */ _record(event, meta, extra) { try { const logDir = this.config && this.config.logDir; if (!logDir) return; fs.mkdirSync(logDir, { recursive: true }); const row = Object.assign({ ts: new Date().toISOString(), event, pid: process.pid, }, meta || {}, extra || {}); fs.appendFileSync(historyPath(logDir), JSON.stringify(row) + '\n'); } catch (e) { this.logger.warn('[NodeBackup] 台账写入失败(不影响备份):event=%s err=%s', event, e.message); } } /** * 管理端状态快照(供 GET /api/admin/node-backup/status) * * ⛔ 返回值**不含任何凭据**:只有路径、时间、计数、判定结论。 * envFile 只给路径不给内容(内容里有 DB 口令)。 * * @param {number} [limit] 台账取多少条 * @returns {object} */ status(limit) { const cfg = this.config || {}; const n = Math.max(1, Math.min(Number(limit) || 50, 200)); const isProduction = !!(this.app && this.app.config && this.app.config.isProduction); const history = cfg.logDir ? readHistory(cfg.logDir, n) : []; const plans = this.list() .map(p => { const t = new Date(p.fireAt).getTime(); return Object.assign({}, p, { remainingSec: Number.isFinite(t) ? Math.max(0, Math.round((t - Date.now()) / 1000)) : null, }); }) .sort((a, b) => (a.fireAt < b.fireAt ? -1 : a.fireAt > b.fireAt ? 1 : 0)); const artifacts = cfg.outDir ? listArtifacts(cfg.outDir, 10) : []; const health = evaluateHealth({ enabled: this.enabled, isProduction, plans, history, artifactCount: artifacts.length, }); return { enabled: this.enabled, isProduction, buffer: { beforeStartMin: cfg.beforeStartBufferMin || 30, afterEndMin: cfg.afterEndDelayMin || 10, }, keepDays: cfg.keepDays || 90, plans, history, artifacts, health, paths: { outDir: cfg.outDir || null, logDir: cfg.logDir || null, historyFile: cfg.logDir ? historyPath(cfg.logDir) : null, script: cfg.scriptPath || null, envFile: cfg.envFile || null, }, }; } /** * 分段续等(>24.8 天的远期计划) */ _rearm(key, meta, restMs) { const handle = setTimeout(() => { this.timers.delete(key); if (restMs <= 2147483647) { this._fire(meta); } else { this._rearm(key, meta, restMs - 2147483647); } }, 2147483647); if (handle.unref) handle.unref(); this.timers.set(key, handle); } /** * 组别时间被修改/保存后重排该组的两个时间节点 * @param {Array<{group:string,start_time?:Date,end_time?:Date}>} rows */ rescheduleGroupTimes(rows) { if (!this.enabled) return; for (const r of rows || []) { this.schedule({ group: r.group, node: NODE_BEFORE_START, baseTime: r.start_time, reason: `考试开始前备份(管理员设置/修改了 ${r.group} 的开始时间)`, }); this.schedule({ group: r.group, node: NODE_AFTER_END, baseTime: r.end_time, reason: `考试结束后备份(管理员设置/修改了 ${r.group} 的结束时间)`, }); } } /** * 成绩发布节点:立即异步备份(不等待) * * @param {string|null} group 缺省 = 全部组 */ async triggerScorePublished(group) { if (!this.enabled) return; const key = `publish:${group || 'ALL'}:${new Date().toISOString()}`; this.logger.info('[NodeBackup] 触发节点备份:成绩已发布 group=%s key=%s', group || 'ALL', key); await this._fire({ group: group || 'ALL', node: NODE_SCORE_PUBLISHED, fireAt: new Date(), key, reason: '成绩发布后备份', }); } /** * 进程启动时从 competition_times 重建未到期的计划 * * ⛔ 只重建**尚未到点**的计划:已过点的 fireAt 若 hydrate 出来会立刻触发, * 等于「重启一次 = 补备一次」,与幂等键设计相悖。 * @param {Array} rows competition_times 行 */ async hydrate(rows) { if (!this.enabled) return; const now = Date.now(); let armed = 0; let skipped = 0; for (const r of rows || []) { for (const node of [ NODE_BEFORE_START, NODE_AFTER_END ]) { const base = node === NODE_BEFORE_START ? r.start_time : r.end_time; if (!base) continue; // 🔴 必须用 clamp=false 取**数学时刻**来判过期,不能用默认的钳制结果 —— // 钳制会把过期时刻伪造成 now+1s,于是过期节点也会被排上, // 结果是「进程一重启就立刻误备份一次」(C28 判据 J4.2 实测抓到)。 const raw = computeFireAt(r.group, node, base, Object.assign({}, this.config, { clamp: false })); if (!raw) continue; if (raw.getTime() <= now) { skipped++; continue; } this.schedule({ group: r.group, node, baseTime: base, reason: `进程启动重建计划(${r.group})`, }); armed++; } } this.logger.info( '[NodeBackup] hydrate 完成:已排 %d 个未来计划,跳过 %d 个已过期节点(避免重启即误备份)', armed, skipped ); } /** * 列出当前在册计划(供管理端排障接口读取) * * 🔴 R171 修既有缺陷:原实现用 `k.split(/:(.*):(.*)/)` 解析,正则是贪婪的 ⇒ * `zhongzhi:before-start:2026-11-01T08:30:00.000Z` 会被拆成 * ['zhongzhi', 'before-start:2026-11-01T08:30', '00.000Z'], * 即 node 串进时间、fireAt 只剩碎片。group/node 本身不含 ':', * 而 ISO 时刻含 ':' ⇒ 只能「按前两个冒号切、剩余全部归还 fireAt」。 */ list() { return Array.from(this.timers.keys()).map(k => { const parts = String(k).split(':'); const group = parts[0] || ''; const node = parts[1] || ''; const at = parts.slice(2).join(':'); return { key: k, group, node, fireAt: at }; }); } /** * 🔴 防线①:跨进程文件锁(原子 mkdir,与 scripts/backup_daily.sh 的锁同法) * * 为什么必须有:生产 EGG_WORKERS=2 ⇒ 两个 app worker 进程各自持有一份**相同**的 * 计划(Egg 的 app worker 是独立进程,内存定时器**不共享**)⇒ 到点时两个进程 * 会同时触发。文件锁保证同一时刻只有一个进程真正 dump。 * * ⚠️ 锁目录用 `mv` 归档而非 `rm`(⛔ 全项目禁 rm)。 * ⚠️ 残留锁(进程被 kill -9)会让后续备份永久拒绝执行 ⇒ 故设 TTL: * 超过 lockTtlSec(默认 2h,远大于一次 dump 的耗时)视为陈旧锁,可接管。 * @returns {string|null} 持有则返回锁路径,未持有返回 null */ _acquireLock(lockKey) { const lockDir = path.join(this.config.logDir, '.lock-' + lockKey.replace(/[^\w.-]/g, '_')); try { fs.mkdirSync(lockDir); // 原子:并发下只有一个成功 } catch (e) { if (e.code !== 'EEXIST') { this.logger.error('[NodeBackup] 创建锁失败(%s):%s', lockDir, e.message); return null; } // 已存在 ⇒ 判断是否陈旧(残留锁) const metaFile = path.join(lockDir, 'meta.json'); let ageMs = 0; try { const st = fs.statSync(lockDir); ageMs = Date.now() - st.mtimeMs; } catch (statErr) { return null; // 锁刚被别人释放/改名 ⇒ 本次保守放弃 } const ttlMs = (this.config.lockTtlSec || 7200) * 1000; if (ageMs < ttlMs) { this.logger.warn( '[NodeBackup] 已有进程持锁(age=%ds),本次跳过(多 worker 去重生效):%s', Math.round(ageMs / 1000), lockDir ); return null; } // 陈旧锁 ⇒ 改名归档后重试一次(⛔ 不用 rm) const stale = lockDir + '.stale-' + Date.now(); try { fs.renameSync(lockDir, stale); this.logger.warn('[NodeBackup] 清理陈旧锁(age=%ds)→ %s', Math.round(ageMs / 1000), stale); } catch (renErr) { return null; } try { fs.mkdirSync(lockDir); } catch (e2) { return null; // 又被别人抢先 ⇒ 让给它 } } try { fs.writeFileSync(path.join(lockDir, 'meta.json'), JSON.stringify({ pid: process.pid, started: new Date().toISOString(), key: lockKey })); } catch (e) { /* 锁本身已拿到,元信息写失败不阻塞 */ } return lockDir; } /** * 🔴 防线②:触发前回库核对计划是否仍然有效 * * 解决的问题:`setTimes` 请求只落到「处理它的那一个 worker」⇒ 其它 worker 里 * 的**旧定时器不会被清除**。若管理员把考试时间改了,别的 worker 仍会在旧时刻 * 触发一次备份(备份内容是新配置下的库,触发时刻却是错的)。 * 修法:触发时按「当前 DB 里的时间」重算计划键;与本进程的旧键不符 ⇒ 已作废,放弃。 * ⛔ 对 score-published 节点**不做**此核对:它是即时事件、无时间基线可比。 */ async _stillValid(meta) { if (meta.node === NODE_SCORE_PUBLISHED) return true; const col = meta.node === NODE_BEFORE_START ? 'start_time' : 'end_time'; try { const row = await this.app.model.CompetitionTime.findOne({ where: { group: meta.group }, attributes: [ 'start_time', 'end_time' ], raw: true, }); if (!row || !row[col]) return false; const current = computeFireAt(meta.group, meta.node, row[col], Object.assign({}, this.config, { clamp: false })); const currentKey = planKey(meta.group, meta.node, current); if (currentKey !== meta.key) { this.logger.info( '[NodeBackup] 计划已作废(时间配置已被修改),放弃触发:旧=%s 新=%s', meta.key, currentKey ); return false; } return true; } catch (e) { // 🔴 核对失败**不放行**:宁可漏一次备份,也不误备份(误备份会覆盖正确语义)。 // 漏备份可由下一节点/每日备份兜底。 this.logger.error('[NodeBackup] 计划有效性核对失败 ⇒ 放弃本次触发(防误备份):%s', e.message); return false; } } /** * 实际执行备份(异步、不抛错) */ async _fire(meta) { const { group, node, key, reason } = meta; // ---- 防线②:计划是否仍然有效 ---- if (!(await this._stillValid(meta))) { // 计划已作废(管理员改过时间)⇒ 必须记一笔:否则 H3 会把它误判成「漏备」 this._record('invalid-skip', { key, group, node, reason: '计划已作废(时间配置已被修改)' }); return; } const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const logDir = this.config.logDir; const outDir = this.config.outDir; try { fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true }); } catch (e) { this.logger.error('[NodeBackup] 建目录失败,放弃本次备份:%s', e.message); // 目录建不出来 ⇒ 台账也写不进去,只能靠 logger;管理端会由 H3「漏备」暴露 return; } // ---- 防线①:跨进程锁(多 worker 去重)---- const lockDir = this._acquireLock(node === NODE_SCORE_PUBLISHED ? `publish-${group}` : key); if (!lockDir) { // 另一个 worker 正在做同一件事 ⇒ 记 lock-skip,H3 才不判成漏备 this._record('lock-skip', { key, group, node, reason: '另一进程持锁(多 worker 去重)' }); return; } const releaseLock = () => { try { fs.renameSync(lockDir, lockDir + '.done-' + Date.now()); } catch (e) { /* ignore */ } }; const logFile = path.join(logDir, `node-backup-${stamp}.log`); const args = [ this.config.scriptPath, '--env-file', this.config.envFile, '--out', outDir, '--keep-days', String(this.config.keepDays || 90), ]; if (this.config.mysqldump) args.push('--mysqldump', this.config.mysqldump); const env = Object.assign({}, process.env); if (this.config.pythonPath) env.PYTHONPATH = this.config.pythonPath; if (this.config.python) env.PYTHON_BIN = this.config.python; const header = [ `===== 节点备份 ${stamp} =====`, `节点=${node} 组别=${group} 计划键=${key}`, `原因=${reason || '-'}`, `命令=python ${args.join(' ')}`, '', ].join('\n'); const out = fs.openSync(logFile, 'a'); try { fs.writeSync(out, header); } finally { fs.closeSync(out); } this.logger.info('[NodeBackup] 开始备份 → %s(日志:%s)', outDir, logFile); this._record('start', { key, group, node, logFile, reason: reason || '-', cmd: `python ${args.join(' ')}`, }); const child = spawn('python', args, { env, cwd: this.config.repoRoot }); const fd = fs.openSync(logFile, 'a'); child.stdout.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 日志失败不阻塞备份 */ } }); child.stderr.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 同上 */ } }); // ⚠️ spawn 的 'error' 与 'close' 可能**同时**触发(如 ENOENT)⇒ 用 settled 保证 // 只落一条结果,否则台账会出现「一次执行 = 一个 fail + 一个 success」的假象。 let settled = false; const finish = (event, extra) => { if (settled) return; settled = true; this._record(event, { key, group, node, logFile }, extra); }; child.on('error', err => { try { fs.closeSync(fd); } catch (e) { /* ignore */ } releaseLock(); // 🔴 备份失败必须**响亮**:这正是台账 P1「生产备份静默失败」的成因 —— // 旧实现失败只落文件没人看 ⇒ 直到需要恢复时才发现备份根本不存在。 // R171:除 logger 外再落台账 ⇒ 管理后台「时间设置」页可直接看到红灯。 this.logger.error( '[NodeBackup] 🔴 节点备份启动失败 group=%s node=%s key=%s err=%s(日志:%s)', group, node, key, err.message, logFile ); finish('fail', { stage: 'spawn', message: err.message }); }); child.on('close', code => { try { fs.closeSync(fd); } catch (e) { /* ignore */ } releaseLock(); // ⚠️ 必须在 close 后释放,否则同 key 的重排会被自己的锁挡住 if (code === 0) { this.logger.info( '[NodeBackup] ✅ 节点备份成功 group=%s node=%s key=%s 日志=%s', group, node, key, logFile ); finish('success', { rc: 0 }); } else { this.logger.error( '[NodeBackup] 🔴 节点备份失败(rc=%s)group=%s node=%s key=%s ⇒ 需人工介入 日志=%s', code, group, node, key, logFile ); finish('fail', { stage: 'exit', rc: code, message: `备份脚本退出码 ${code}` }); } }); } } module.exports = { NodeBackupScheduler, NODE_BEFORE_START, NODE_AFTER_END, NODE_SCORE_PUBLISHED, planKey, computeFireAt, // 台账 / 可见性(纯函数,便于 C28 判据独立验证) HISTORY_FILE_NAME, historyPath, readHistory, listArtifacts, evaluateHealth, };