// services/sync/src/main.ts —— sync 进程入口(P2 Task 3 发送器 + Task 4 对账 // 调度 + Task 9 统计回传)。构建产物 dist/main.js 由根 dev:sync / 生产启动 // 命令运行;env 一律来自进程环境(根 dotenv-cli 注入),此处不读文件。 // // 三个独立循环: // · 发送循环:每 SYNC_PUSH_INTERVAL_MS 轮询 outbox(SYNC_BATCH_SIZE 一批, // high 优先——见 sender.ts 排序说明;满批立即追下一轮,积压排空不被轮询 // 间隔拖慢)。失败退避:连续失败按间隔 ×2^n 指数递增、60s 封顶,成功归零。 // · 对账循环(Task 4):每 SYNC_RECONCILE_INTERVAL_MS 一轮 SyncReconciler. // runOnce(当前态清扫重放 + digest 比对触发 bootstrap,见 reconciler.ts)。 // 失败仅记日志、下轮再试(分钟级节奏,无需退避——幂等安全)。 // · 统计回传循环(Task 9):每 STAT_RETURN_INTERVAL_MS 一轮 StatReturnSender. // runReturnOnce(ov outbox_stat_events 聚合 → cn stat-snapshots 版本替换, // 见 stat-return.ts)。失败语义同对账:留待下轮重试(cn 侧版本比较保证 // 幂等),分钟级节奏不另设退避。 // // 双库连接:cn 库(outbox 取数)+ ov 库(副本计数直读/回传队列取数)。 // 优雅退出:SIGINT/SIGTERM 停全部循环(等待在飞一轮,fetch 超时已封顶 30s) // 后断开两连接。 import { pino } from 'pino'; import { PrismaClient } from '@photo/db-cn'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; import { STAT_RETURN_INTERVAL_MS, SYNC_BATCH_SIZE, SYNC_INTERNAL_KEY_ENV, SYNC_PUSH_INTERVAL_MS, SYNC_RECONCILE_INTERVAL_MS, } from '@photo/contracts'; import { OutboxSender } from './sender'; import { SyncReconciler } from './reconciler'; import { StatReturnSender } from './stat-return'; /** 失败退避封顶(对齐 SYNC_RECONCILE_INTERVAL_MS 量级:分钟级封顶足够)。 */ const MAX_BACKOFF_MS = 60_000; /** 单次 HTTP 请求超时。 */ const REQUEST_TIMEOUT_MS = 30_000; /** 读正整数 env(可覆盖契约默认值);非法值回退默认并告警。 */ function readIntEnv( logger: { warn: (obj: object, msg: string) => void }, name: string, fallback: number, ): number { const raw = process.env[name]; if (raw === undefined || raw === '') { return fallback; } const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed) || parsed <= 0) { logger.warn({ name, raw }, 'env 非正整数,回退契约默认值'); return fallback; } return parsed; } /** * 可被停机唤醒的 sleep:对账循环节拍是分钟级,若让 SIGTERM 干等一个完整 * interval 才退出不可接受——shutdown 置 stopping 后调用 wake 提前放行, * 循环条件 !stopping 随即退出(在飞一轮的各步均有界,见 shutdown 注释)。 */ let wakeSleeper: (() => void) | null = null; const sleepStoppable = (ms: number): Promise => new Promise((resolve) => { const timer = setTimeout(() => { wakeSleeper = null; resolve(); }, ms); wakeSleeper = () => { clearTimeout(timer); wakeSleeper = null; resolve(); }; }); async function main(): Promise { const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' }); const intervalMs = readIntEnv(logger, 'SYNC_PUSH_INTERVAL_MS', SYNC_PUSH_INTERVAL_MS); const batchSize = readIntEnv(logger, 'SYNC_BATCH_SIZE', SYNC_BATCH_SIZE); const reconcileIntervalMs = readIntEnv( logger, 'SYNC_RECONCILE_INTERVAL_MS', SYNC_RECONCILE_INTERVAL_MS, ); const statReturnIntervalMs = readIntEnv( logger, 'STAT_RETURN_INTERVAL_MS', STAT_RETURN_INTERVAL_MS, ); const cnApiUrl = process.env.CN_API_URL ?? 'http://127.0.0.1:8080'; const ovApiUrl = process.env.OV_API_URL ?? 'http://127.0.0.1:8081'; const internalKey = process.env[SYNC_INTERNAL_KEY_ENV] ?? ''; if (internalKey === '') { // 与接收端 guard 同一取舍:密钥缺失宁可不可用,绝不裸推。 logger.error({ env: SYNC_INTERNAL_KEY_ENV }, '缺少内部同步密钥,发送器退出'); process.exit(1); } const prismaCn = new PrismaClient(); const prismaOv = new OvPrismaClient(); const sender = new OutboxSender({ prisma: prismaCn, ovApiUrl, internalKey, batchSize, timeoutMs: REQUEST_TIMEOUT_MS, logger, }); const reconciler = new SyncReconciler({ prismaOv, cnApiUrl, ovApiUrl, internalKey, timeoutMs: REQUEST_TIMEOUT_MS, logger, }); const statReturn = new StatReturnSender({ prismaOv, cnApiUrl, internalKey, timeoutMs: REQUEST_TIMEOUT_MS, logger, }); logger.info( { cnApiUrl, ovApiUrl, intervalMs, batchSize, reconcileIntervalMs, statReturnIntervalMs, }, 'sync sender + reconciler + stat-return started', ); let stopping = false; const senderLoop = async (): Promise => { let failureStreak = 0; while (!stopping) { try { const stats = await sender.runOnce(); failureStreak = 0; if (stats.picked > 0) { logger.info(stats, 'outbox batch pushed'); } if (stats.picked >= batchSize) { continue; // 满批:积压未清,立即追下一轮 } } catch (err) { failureStreak += 1; const backoffMs = Math.min( intervalMs * 2 ** (failureStreak - 1), MAX_BACKOFF_MS, ); logger.error( { err: err instanceof Error ? err.message : String(err), failureStreak, backoffMs, }, 'outbox push failed(整批未标记,下轮重推)', ); await sleepStoppable(backoffMs); continue; } await sleepStoppable(intervalMs); } }; const reconcileLoop = async (): Promise => { while (!stopping) { try { await reconciler.runOnce(); } catch (err) { logger.error( { err: err instanceof Error ? err.message : String(err) }, 'reconcile cycle failed(幂等安全,下轮再试)', ); } await sleepStoppable(reconcileIntervalMs); } }; const statReturnLoop = async (): Promise => { while (!stopping) { try { const stats = await statReturn.runReturnOnce(); if (stats.picked > 0) { logger.info(stats, 'stat snapshots returned'); } } catch (err) { logger.error( { err: err instanceof Error ? err.message : String(err) }, 'stat return cycle failed(未确认行留待下轮重发,幂等安全)', ); } await sleepStoppable(statReturnIntervalMs); } }; const inFlight: Promise[] = [ senderLoop(), reconcileLoop(), statReturnLoop(), ]; const shutdown = (signal: string): void => { if (stopping) return; stopping = true; wakeSleeper?.(); logger.info({ signal }, 'sync sender shutting down'); void (async () => { try { // 等在飞一轮收尾(runOnce 各步均有界:HTTP 30s 超时 / DB 查询即回)。 await Promise.all(inFlight); await prismaCn.$disconnect(); await prismaOv.$disconnect(); } catch (err) { logger.error( { err: err instanceof Error ? err.message : String(err) }, 'sync sender 关闭异常', ); } finally { process.exit(0); } })(); }; process.once('SIGINT', () => shutdown('SIGINT')); process.once('SIGTERM', () => shutdown('SIGTERM')); } void main();