Files
photo-live/services/sync/src/main.ts
T
浅念 955bea49b5 feat(sync): stat snapshot return path with version replacement
P2 Task 9 统计回传(ov→cn):
- cn 迁移:albums 增 viewCountReplica/statsVersion/statsSyncedAt、
  photos 增 statsVersion(like_count 已有副本位;无 VARCHAR 新列不涉校对手决)
- cn 接收端 POST /v1/internal/sync/stat-snapshots(InternalSyncGuard):
  整批单事务,逐快照 updateMany WHERE statsVersion < statVersion —— 仅更高
  statVersion 以权威 total 整体替换副本(绝不相加),重复/乱序幂等忽略;
  accepted 只数实际应用条数;views 应用同时推进 statsSyncedAt(A27 展示)
- ov 回传发送器 services/sync/stat-return.ts:聚合 outbox_stat_events 未发送
  行按(kind, photoId|albumId)取 MAX(statVersion) 折叠突发 → HMAC 推送 →
  200 后按计数器标记截至该版本全部源行 sentAt;失败留待下轮重发;main.ts
  第三循环 STAT_RETURN_INTERVAL_MS(默认 60s,env 可调)
- 契约 AlbumSummary + statsSyncedAt(string|null readOnly,+gen);listAlbums/
  getAlbum 经 toSummaryResponse 暴露(管理 UI 列留 P4a)
- e2e(services/sync stat-return.e2e-spec,7 例):逐次点赞回传替换式收敛
  (2→5→10 非累加)、浏览窗口去重回传 + statsSyncedAt、重复回传 accepted=0
  值不动(A27 不重复累计)、乱序高版本替换(total 可下调)+ 低版本忽略、
  发送端折叠只发最高版本且被折叠行随批确认、管理列表 likeCount/statsSyncedAt
  展示、新端点 401/400 守卫

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 18:45:43 +08:00

230 lines
7.5 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.
// 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<void> =>
new Promise((resolve) => {
const timer = setTimeout(() => {
wakeSleeper = null;
resolve();
}, ms);
wakeSleeper = () => {
clearTimeout(timer);
wakeSleeper = null;
resolve();
};
});
async function main(): Promise<void> {
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<void> => {
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<void> => {
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<void> => {
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<void>[] = [
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();