Files

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