Backend fixes: - Fix PgBoss task infinite loop on SAE (root cause: missing queue table constraints) - Add singletonKey to prevent duplicate job enqueueing - Add idempotency check in reviewWorker (skip completed tasks) - Add optimistic locking in reviewService (atomic status update) Frontend fixes: - Add isSubmitting state to prevent duplicate submissions in RVW Dashboard - Fix API baseURL in knowledgeBaseApi (relative path) Cleanup (removed): - Old frontend/ directory (migrated to frontend-v2) - python-microservice/ (unused, replaced by extraction_service) - Root package.json and node_modules (accidentally created) - redcap-docker-dev/ (external dependency) - Various temporary files and outdated docs in root New documentation: - docs/07-运维文档/01-PgBoss队列监控与维护.md - docs/07-运维文档/02-故障预防检查清单.md - docs/07-运维文档/03-数据库迁移注意事项.md Database fix applied to RDS: - Added PRIMARY KEY to platform_schema.queue - Added 3 missing foreign key constraints Tested: Local build passed, RDS constraints verified
148 lines
7.6 KiB
Markdown
148 lines
7.6 KiB
Markdown
# **PG Boss 任务重复故障分析与修复方案**
|
||
|
||
## **1\. 故障核心分析 (Root Cause Analysis) \- 修正版**
|
||
|
||
针对 "同一 TaskID 被创建 7 次" 且 "创建时间在同一毫秒" 的现象,在确认 **仅有 1 个 SAE 实例** 运行的情况下,我们排除了多实例并发的可能性。
|
||
|
||
结合已清理的 "rvw\_review\_task 有 7 个重复条目" 这一关键证据,我们得出了确切的结论:
|
||
|
||
### **核心根因:持久化配置重复 (Persisted Configuration Duplication)**
|
||
|
||
**问题不在于有多少个实例在跑,而在于数据库里存了多少份重复的指令。**
|
||
|
||
#### **💡 深度解析:为什么会有 7 个?(7 个闹钟的比喻)**
|
||
|
||
你的疑问是:*"每次处理应该是生成不同的任务ID,不可能是重复的对吗?"*
|
||
|
||
**答案:是的,pg-boss 生成了 7 个完全不同的 Job ID,但它们都在做同一件事。**
|
||
|
||
这就好比你为了早上 7 点起床,设置了 **7 个闹钟**:
|
||
|
||
1. **指令 (Schedules/Definitions)**:数据库里那些被清理的 "7 个重复条目",就像是 7 个闹钟配置。它们都设定在同一个触发条件下(比如 Cron 表达式,或系统启动时)。
|
||
2. **触发 (Trigger)**:当时间到了,或者系统启动扫描时,这 **7 个闹钟同时响了**。
|
||
3. **执行 (Jobs)**:系统听到第 1 个闹钟,创建了 Job A;听到第 2 个闹钟,创建了 Job B... 直到 Job G。
|
||
* **结果**:你在一毫秒内,被叫醒了 7 次。
|
||
* **数据**:这 7 个 Job 都有**不同的 UUID**(符合数据库约束),但它们的\*\*内容(Payload)\*\*全是 "处理 Task bd19c3d3"。
|
||
|
||
这就是为什么你在数据库里看到 created\_on 完全一致,但 Job ID 不同。因为那 1 个 SAE 实例在极短的时间内,忠实地执行了数据库里残留的 7 条指令。
|
||
|
||
* **机制解析**:pg-boss 是一个基于数据库的任务队列。它的调度(Schedules)和某些队列配置是**持久化**在 PostgreSQL 数据库中的(通常在 pgboss.schedule 表中)。
|
||
* **故障复盘**:
|
||
1. **积累阶段**:在过去的历史部署或重启中,代码可能在启动时调用了 boss.schedule('queue', 'cron')。由于没有加去重逻辑,每次部署都在数据库里**新增**了一条调度记录,而不是更新旧的。日积月累,数据库里就有了 7 条完全一样的调度记录。
|
||
2. **爆发阶段**:当你当前的 **1 个 SAE 实例** 运行时,pg-boss 内部的轮询器扫描数据库,读取到了这 7 条重复的记录。
|
||
3. **瞬间执行**:当触发条件满足,这单个实例在极短的 CPU 周期内,为这 7 条记录分别生成了一个 Job。
|
||
* **证据链闭环**:
|
||
* **7 次重复** 对应 **7 个重复的 Schedule/配置记录**。
|
||
* **同一毫秒创建** 对应 **单实例在一次事件循环中连续处理了这 7 条指令**。
|
||
|
||
**结论**:你执行的 "清理了 32 个重复的队列定义" 操作,实际上就是**关掉了多余的 6 个闹钟**,这已经移除了问题的根源。
|
||
|
||
### **为什么 SingletonKey 之前没生效?**
|
||
|
||
虽然这是单实例产生的重复,但如果代码使用的是 insert 或者是没有严格 unique constraint 保护的 send,在极快的循环中(Event Loop),数据库可能仍未完成第一条的提交,第二条就来了。
|
||
|
||
但最可能的原因是:**生成 Key 的逻辑有问题**,或者根本没有在产生任务的那段特定逻辑中加上 singletonKey。
|
||
|
||
## **2\. 解决方案:三层防御体系**
|
||
|
||
虽然根因(重复配置)已被你清理,但为了防止未来代码逻辑再次意外引入重复配置,或者防止前端意外的连击,我们依然强烈建议保留以下防御措施。
|
||
|
||
### **第一层:入队时防御 (生产者层面 \- 强制去重)**
|
||
|
||
这是最关键的一步。无论是因为配置重复导致被调用 7 次,还是前端点了 7 次,这里都能拦住。
|
||
|
||
**修改代码建议 (Producer/Service):**
|
||
|
||
// reviewTaskProducer.ts
|
||
|
||
import { PgBoss } from 'pg-boss';
|
||
|
||
// 假设这是你的入队逻辑
|
||
export async function createReviewTask(boss: PgBoss, taskId: string, payload: any) {
|
||
const queueName \= 'rvw\_review\_task';
|
||
|
||
// ✅ 核心修复:构造确定性的 singletonKey
|
||
// 不要包含时间戳等变量,只包含业务唯一标识 (如 taskId)
|
||
const singletonKey \= \`review\_task\_${taskId}\`;
|
||
|
||
// 发送任务
|
||
const jobId \= await boss.send(queueName, payload, {
|
||
// ✅ 启用单例模式
|
||
singletonKey: singletonKey,
|
||
// ✅ 节流/防抖:如果任务已存在且活跃,300秒内不再创建
|
||
singletonSeconds: 300,
|
||
// ✅ 即使旧任务完成了,保留Key一段时间以防重复触发
|
||
singletonNextSlot: false
|
||
});
|
||
|
||
if (\!jobId) {
|
||
console.warn(\`\[Duplicate Prevented\] Task ${taskId} already exists in queue.\`);
|
||
return null;
|
||
}
|
||
|
||
return jobId;
|
||
}
|
||
|
||
### **第二层:处理时防御 (Worker 层面 \- 幂等性检查)**
|
||
|
||
你已经添加了状态检查,这很好。为了处理潜在的竞争(虽然单实例下竞争少,但为了健壮性),建议保持乐观锁逻辑。
|
||
|
||
**修改代码建议 (Worker):**
|
||
|
||
// reviewWorker.ts
|
||
|
||
export async function processReviewTask(job: Job) {
|
||
const { taskId } \= job.data;
|
||
|
||
// 1\. 业务状态检查 (你已经做了)
|
||
const task \= await db.task.findUnique({ where: { id: taskId } });
|
||
|
||
// ✅ 状态检查:如果已经是处理中或完成,直接跳过
|
||
if (task.status \=== 'COMPLETED' || task.status \=== 'PROCESSING') {
|
||
console.log(\`\[Skipped\] Task ${taskId} is already ${task.status}\`);
|
||
return;
|
||
}
|
||
|
||
// 2\. 乐观锁更新 (Database Atomic Update)
|
||
const updateResult \= await db.task.updateMany({
|
||
where: {
|
||
id: taskId,
|
||
status: 'PENDING' // 👈 关键:只有当前状态是 PENDING 时才更新
|
||
},
|
||
data: {
|
||
status: 'PROCESSING',
|
||
startedAt: new Date()
|
||
}
|
||
});
|
||
|
||
if (updateResult.count \=== 0\) {
|
||
console.log(\`\[Concurrency Control\] Task ${taskId} claimed by another worker or logic.\`);
|
||
return;
|
||
}
|
||
|
||
// 3\. 执行逻辑
|
||
try {
|
||
await performReviewLogic(taskId);
|
||
await db.task.update({ where: { id: taskId }, data: { status: 'COMPLETED' }});
|
||
} catch (error) {
|
||
await db.task.update({ where: { id: taskId }, data: { status: 'FAILED' }});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
### **第三层:初始化代码审查 (防止复发)**
|
||
|
||
针对 **"32 个重复队列定义"** 的来源,需要检查你的启动代码。
|
||
|
||
1. **检查 schedule 调用**:
|
||
如果你在代码里使用了 boss.schedule('queue', cron, ...),请确保不要在每次应用启动时都无脑调用。
|
||
* **错误做法**:在 main.ts 直接调用 boss.schedule(...)。每次部署都会尝试再加一个(取决于 pg-boss 版本行为)。
|
||
* **正确做法**:通常 pg-boss 会处理去重,但如果参数稍有不同(比如 cron 表达式或数据),它可能会视为新 Schedules。建议检查 pg-boss 的 schedules 表,确保没有垃圾数据。
|
||
2. **清理脚本**:
|
||
保留一个数据库迁移脚本或运维 SQL,定期检查 pg-boss 的 job 表中是否有异常激增的 created 状态的任务。
|
||
|
||
## **3\. 总结**
|
||
|
||
* **问题原因**:数据库中残留的历史重复配置(7个重复条目)导致单实例在循环中瞬间创建了 7 个任务。
|
||
* **当前状态**:你清理了重复条目,这已经解决了根源。
|
||
* **未来保障**:部署带有 singletonKey 的代码,这将是永远的防线,即使数据库里有 100 个重复配置,pg-boss 也会拒绝创建第 2 到 第 100 个任务。 |