# 同步引擎设计 > 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(2026-10-05 作者裁决)**:① 本阶段**不考虑 Cloudflare Workers,也不为 CFW 做准备**;② 引擎**独立于 HTTP 服务进程**(`apps/engine`),避免后台 CPU 与 API 抢事件循环;③ 调度用**手写 `setTimeout` 循环**(不引入 cron 库),周期**可配置**。字段与去重的权威仍在各篇表字段设计;本文定「引擎怎么转」。 | 项目 | 内容 | |------|------| | 状态 | 已实现(定稿 2026-10-05) | | 关联 | 《api_keys表字段设计》§4 写入时机 · 《usage_logs表字段设计》§3.1 去重 · 《qpu_history表字段设计》§4 采样 · 《API契约-管理面》§3 `engine` 端点 · 《技术选型》§4 monorepo(本文增补 `apps/engine`)· `docs/references/NewAPI-接口范围.md` | | 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 | --- ## 1. 进程形态(与后端分离) 同步引擎是**独立入口进程**(`apps/engine`),与 `apps/server`(Hono)**分进程、可同机部署**: ```text apps/server ──► HTTP(/api/admin · /api/member)──► packages/db apps/engine ──► 定时 syncRound() ──► packages/db │ └────────► packages/core/upstream(NewAPI) ``` | 约定 | 说明 | |------|------| | 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** | | 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** | | 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` | | 运行形态 | 开发:两个命令(`dev:server` / `dev:engine`);生产两个进程(均连同一 PostgreSQL) | **为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。 ## 2. 调度(方案 A:手写 `setTimeout`) 不引入 `croner` / `node-cron` 等库;用标准定时器实现「一轮结束 → 约下一枪」。 ```text loop(): if inFlight: log warn; schedule(); return inFlight = true try: await syncRound() finally: inFlight = false schedule() // 本轮结束后再等 interval schedule(): setTimeout(loop, SYNC_INTERVAL_MS) ``` | 项 | 约定 | |----|------| | 周期 | **`SYNC_INTERVAL_MS`**,默认 `300000`(5 分钟);由 env 配置,非法值启动失败 | | 语义 | **结束后 + interval**(周期 = 间隔 + 上轮耗时);不追求整点 cron | | 防堆积 | 上一轮未结束则跳过 / 不叠加(`inFlight`);**禁止**裸 `setInterval(async …)` | | 启动 | `apps/engine` 进程起来后立即跑一轮,再进循环 | | 停止 | 收到退出信号:不强制中断本轮(可等完);不保证优雅排空 | | 错过 | 不追补「错过的时刻」;下一轮全量拉最近 1000 条即补偿(上游窗口语义) | > 与交互文案「约 5 分钟 / ≤5 分钟」对齐:实际最坏接近 `interval + 单轮耗时`;若单轮通常远小于 interval,观感仍约为 5 分钟。需要「整点对齐」时再改为 `nextSlot = ceil(now / interval) * interval`(非本阶段)。 ## 3. 一轮 `syncRound()` 做什么 ```text 1. 选 key:api_keys.status = active 全量(id 升序,串行) 2. 对每个 key: a. 解密 key_ciphertext → sk 明文 b. GET /api/usage/token 成功 → UPDATE 上游字段全组 + usage_synced_at 失败 → 记入本轮 failed,不写 api_keys c. GET /api/log/token 成功 → 逐行 dedupe_key → INSERT ON CONFLICT DO NOTHING → UPDATE log_synced_at 失败 → 记入本轮 failed,不写 log_synced_at d. (建议)每 key 之间 setImmediate/短 sleep 让出,降低单轮 CPU 连续占用 3. QPU 采样(每轮一次,与 key 无关): GET /api/status → quota_per_unit 与 qpu_history 最新值相同 → 不写;变化 → INSERT(effective_from = 本轮时刻) 4. 收束 last_round(§5),打一行轮次日志 ``` ### 3.1 与表设计的对齐(勿另立标准) | 行为 | 权威出处 | |------|----------| | 仅写成功结果;失败不写行 | 《api_keys表字段设计》§4 | | `dedupe_key` 规则(type=2 / 非 2 / 空 request_id) | 《usage_logs表字段设计》§3.1 | | `ON CONFLICT (dedupe_key) DO NOTHING` | 同文写入时机 | | QPU **值变化才追加** | 《qpu_history表字段设计》§4 | | 登记后**异步补一轮**,不阻塞 201 | 《api_keys表字段设计》§4 | ### 3.2 幂等与重试 - 重复拉取 → `dedupe_key` 合并,零副作用。 - 单 key 失败不影响其他 key;不写「半行」已成功的 key 会保留。 - 不做单 key 积极重试;**周期即重试**。 ### 3.3 登记后补跑(已裁决:方案 A) - Key 登记(`POST /api/admin/keys`)成功后,**不**在 HTTP 请求里同步拉日志,**也不**引入秒级唤醒/优先队列。 - **最迟一个 `SYNC_INTERVAL_MS` 内**由下一轮自然覆盖(独立进程下避免 IPC;与交互文案「约 5 分钟内完成」一致)。 - 与《api_keys表字段设计》§4「首轮日志同步不阻塞登记响应」不冲突:登记 201 立即返回;首轮日志异步、且上限为一个调度周期。 ## 4. 失败语义(R2:记录、不决策) | 情况 | 引擎行为 | 可见 | |------|----------|------| | 401 / 403 / 5xx / 超时 | 该 key 本轮失败;不写 api_keys / log_synced_at | `log_synced_at` 停滞;`last_round.failed_api_key_ids` | | 登记阶段 422/502 | 不属引擎 | 登记接口已处理 | **不做**:自动停用、自动摘除、自动告警(R4 延后)。 运行日志(stdout)建议**中文描述**(与平台文案基调一致),例如: ```text 同步轮次完成:key=18 成功=16 失败=2 耗时=812ms 同步失败:key_id=3 原因=401 ``` 访问日志、启动日志与错误日志同样使用中文表述(实现已统一)。 ## 5. 引擎状态与 `/api/admin/engine` 衔接 契约响应形态不变。**存储:`engine_status` 单行快照表**(已裁决);engine 每轮写入,server 的 `/api/admin/engine` 只读该表(不 import engine 包、不走 IPC)。 ```text engine_status(单行) running: boolean sync_interval_ms: number // 只读观测:当前生效周期 last_round: { started_at, keys_total, succeeded, failed, failed_api_key_ids } | null updated_at ``` - 与《API契约-管理面》§3 字段一一对应(`last_round` 为 `null` 合法);`sync_interval_ms` 为**只读观测**,便于界面展示,**不提供修改接口**。 - `GET /api/admin/keys?recent_failed=true` 读 `failed_api_key_ids`。 - **不做**完整 `engine_rounds` 历史表;表名/列名实现期可微调,语义不变。 ## 6. 代码落点 | 逻辑 | 位置 | 约束 | |------|------|------| | 上游 HTTP | `packages/core/upstream.ts`(补 `fetchLogs`) | Web 标准 API | | dedupe / 折算 | `packages/core` | 已有 | | SQL | `packages/db`(engine 写入用仓储) | 方言不外泄 | | `syncRound` + 定时循环 + 进程入口 | **`apps/engine`**(如 `src/loop.ts`、`src/sync-round.ts`、`src/index.ts`) | 独立 `package.json` | | `/api/admin/engine` 读状态 | `apps/server` 读 `engine_status` | 不 import engine 包 | Monorepo 增补: ```text apps/ server/ # HTTP engine/ # 同步循环(本设计) web/ ``` ## 7. 配置(env,由 `apps/engine` 读取) | 键 | 默认 | 含义 | |----|------|------| | `SYNC_INTERVAL_MS` | `300000` | 轮询间隔毫秒(**可配置**;默认 5 分钟)。**仅 env、仅启动时读**;管理面/API **不提供修改**(运维旋钮而非业务口径) | | 复用 | `DATABASE_URL` · `SK_KEY_ENCRYPTION_SECRET` · `NEWAPI_BASE` | 与 server 相同语义 | `server` **不读** `SYNC_*`。 ## 8. 非目标 1. **CFW / Cron Triggers / D1**——不预留; 2. **cron 库 / cron 表达式**——本阶段方案 A,只用毫秒间隔; 3. **经 server / API / 管理面修改同步周期**——interval 只走 env;状态快照中的 `sync_interval_ms` 仅只读观测; 4. **R3 备份 / R4 告警**; 5. **多 key 并行、分布式锁、多 engine 实例**——单 engine 实例部署约定(文档写明即可); 6. **轮次历史表、调用自家 HTTP API、进程间 IPC**——协作只经 PostgreSQL。 ## 9. 验收清单(实现后) - [ ] 只启 `dev:engine`(或只跑 engine + db):日志入库、`log_synced_at` 前进 - [ ] 只启 `dev:server`:HTTP 正常,无轮询日志 - [ ] 同一数据重复轮次不产生重复行 - [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之 - [ ] QPU 不变无新行,变化 +1 - [ ] `GET /api/admin/engine` 与契约一致(跨进程读到 engine 写的状态) - [ ] `SYNC_INTERVAL_MS` 可调;**不启动 `apps/engine` 即不轮询**(独立进程,无 `SYNC_ENABLED` 开关) - [ ] 无定时器叠加(人为拉长一轮耗时,验证 `inFlight`) ## 10. 裁决记录 ### 2026-10-05 · 进程与调度形态 - 独立 `apps/engine`,与 `apps/server` 分进程;不为 CFW 预留。 - 调度方案 A:手写 `setTimeout`,一轮结束再约下一枪;`SYNC_INTERVAL_MS` 可配置。 ### 2026-10-05 · 补跑与状态(作者认可推荐项) 1. **登记后补跑 = 方案 A**:最迟一个 interval 内由下一轮覆盖;无秒级唤醒/优先队列。 2. **`engine_status` 单行快照表**:engine 写、server 读;表名实现期可微调;可含只读 `sync_interval_ms`。 3. 周期文案:默认 5 分钟、可配置(`SYNC_INTERVAL_MS`)。 ### 2026-10-05 · 周期修改通道 **不通过 server / API 修改 interval**——仅 env、启动时读;管理面无改配。协作只经 PostgreSQL,无 IPC/内嵌 HTTP。 实现落点:`syncRound` + 调度循环在 `apps/engine`(见 §6)。