204 lines
10 KiB
Markdown
204 lines
10 KiB
Markdown
# 同步引擎设计
|
||
|
||
> 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(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)。
|