Files
new-api-key-usage/docs/design/同步引擎设计.md
T
skycurtain 3e12db1706 docs: 同步引擎设计定稿,API 时刻改为 Unix 秒
独立 apps/engine、setTimeout 轮次、engine_status 快照、不为 CFW 预留;
公共约定 §4 响应时刻统一 Unix 秒;契约 engine 增 sync_interval_ms。
2026-10-07 12:53:37 +08:00

10 KiB
Raw Blame History

同步引擎设计

母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。范围声明(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);生产 compose 两个 service(均连同一 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. 非目标

  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(或 compose 仅 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)。