10 KiB
同步引擎设计
母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。范围声明(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)分进程、可同机部署:
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 等库;用标准定时器实现「一轮结束 → 约下一枪」。
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() 做什么
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)建议中文描述(与平台文案基调一致),例如:
同步轮次完成:key=18 成功=16 失败=2 耗时=812ms
同步失败:key_id=3 原因=401
访问日志、启动日志与错误日志同样使用中文表述(实现已统一)。
5. 引擎状态与 /api/admin/engine 衔接
契约响应形态不变。存储:engine_status 单行快照表(已裁决);engine 每轮写入,server 的 /api/admin/engine 只读该表(不 import engine 包、不走 IPC)。
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 增补:
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. 非目标
- CFW / Cron Triggers / D1——不预留;
- cron 库 / cron 表达式——本阶段方案 A,只用毫秒间隔;
- 经 server / API / 管理面修改同步周期——interval 只走 env;状态快照中的
sync_interval_ms仅只读观测; - R3 备份 / R4 告警;
- 多 key 并行、分布式锁、多 engine 实例——单 engine 实例部署约定(文档写明即可);
- 轮次历史表、调用自家 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 · 补跑与状态(作者认可推荐项)
- 登记后补跑 = 方案 A:最迟一个 interval 内由下一轮覆盖;无秒级唤醒/优先队列。
engine_status单行快照表:engine 写、server 读;表名实现期可微调;可含只读sync_interval_ms。- 周期文案:默认 5 分钟、可配置(
SYNC_INTERVAL_MS)。
2026-10-05 · 周期修改通道
不通过 server / API 修改 interval——仅 env、启动时读;管理面无改配。协作只经 PostgreSQL,无 IPC/内嵌 HTTP。
实现落点:syncRound + 调度循环在 apps/engine(见 §6)。