feat(ssa): Complete Phase V-A editable analysis plan variables

Features:
- Add editable variable selection in workflow plan (SingleVarSelect + MultiVarTags)
- Implement 3-layer flexible interception (warning bar + icon + blocking dialog)
- Add tool_param_constraints.json for 12 statistical tools parameter validation
- Add PATCH /workflow/:id/params API with Zod structural validation
- Implement synchronous parameter sync before execution (Promise chaining)
- Fix LLM hallucination by strict system prompt constraints
- Fix DynamicReport object-based rows compatibility (R baseline_table)
- Fix Word export row.map error with same normalization logic
- Restore inferGroupingVar for smart default variable selection
- Add ReactMarkdown rendering in SSAChatPane
- Update SSA module status document to v3.5

Modified files:
- backend: workflow.routes, ChatHandlerService, SystemPromptService, FlowTemplateService
- frontend: WorkflowTimeline, SSAWorkspacePane, DynamicReport, SSAChatPane, ssaStore, ssa.css
- config: tool_param_constraints.json (new)
- docs: SSA status doc, team review reports

Tested: Cohort study end-to-end execution + report export verified
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 13:08:29 +08:00
parent dc6b292308
commit 85fda830c2
27 changed files with 2732 additions and 154 deletions

View File

@@ -1,6 +1,6 @@
# 分布式 Fan-out 任务模式开发指南
> **版本:** v1.0基于 ASL 工具 3 架构设计经验,尚未经生产验证
> **版本:** v1.2逐行级审查修正乐观锁释放 + Sweeper updatedAt + 空集合守卫 + pg_notify 参数化
> **创建日期:** 2026-02-23
> **定位:** 实战 Cookbook开发时按需查阅
> **互补文档:** `系统级异步架构风险剖析与演进技术蓝图.md`Why→ 本文How
@@ -33,6 +33,7 @@
┌─ Manager Job ─────────────────────────────┐
│ 1. 读取 N 个子项 │
│ 1.5 🆕 if N=0 → 直接 completed + return │
│ 2. 快照外部依赖数据(防源头失踪) │
│ 3. for each → pgBoss.send(child_queue) │
│ 4. 派发完毕 → 退出Fire-and-forget
@@ -51,7 +52,7 @@
---
## 三、7 项关键设计模式
## 三、8 项关键设计模式
### 模式 1原子递增禁止 Read-then-Write
@@ -71,6 +72,17 @@ const taskAfterUpdate = await prisma.task.update({
Prisma 的 `{ increment: 1 }` 编译为 SQL `SET success_count = success_count + 1`,数据库行锁保证原子性。
**🚨 v1.1 补充:短事务原则(防行锁争用)**
> **场景推演:** 100 个极轻量 Child Job缓存命中瞬间完成在不同 Pod 中几乎同时走到 `prisma.$transaction`。
> 这 100 个事务都需要对同一父任务行执行 `{ increment: 1 }`PostgreSQL 在这一行上加排他行锁Row-Level Lock
> 100 个并发请求排队等一把锁,极易触发 Lock wait timeout大量本已成功的任务在最后一步报数据库错误。
**强制规范:**
- **绝不允许**在更新父任务的 `$transaction` 内发起任何网络请求或耗时操作
- 事务必须极度纯粹:更新子项(Result) + 递增父亲(Task),确保事务在 **< 1ms** 内提交并释放行锁
- 高并发下若仍出现行锁超时,可在 Prisma 连接串中适当调大 `pool_timeout`
### 模式 2Last Child Wins终止器
**问题:** Manager 派发完就退出,没有人负责把父任务从 `processing` 翻转为 `completed`
@@ -89,6 +101,55 @@ if (taskAfterUpdate.successCount + taskAfterUpdate.failedCount >= taskAfterUpdat
**关键:** 成功路径和失败路径都必须有这段检查。漏掉任何一条路径,任务就可能永远卡在 `processing`
**🚨 v1.1 补充Sweeper 清道夫 — 应对进程硬崩溃(最危险场景)**
> **场景推演:** N=100第 99 个 Child Worker 解析超大 PDF 触发 Node.js V8 OOM或容器被云平台 SIGKILL。
> 进程瞬间蒸发,代码根本没有机会走到 `catch` 块。`failedCount` 永远不会 +1。
> pg-boss 虽然会在 `expireInMinutes` 后标记该 Job 为 failed但业务表里
> `successCount + failedCount = 99`,永远达不到 100。父任务永远卡在 `processing`。
>
> **本质:** 单兵 Worker 无法处理自身猝死,必须有系统级外部兜底。
**解法:注册全局定时清道夫 `FanOut_Task_Sweeper`(每 10 分钟运行一次):**
```typescript
// 全局 Cron Job — 清道夫(建议用 pg-boss 的 schedule 功能注册)
async function fanOutTaskSweeper() {
const stuckTasks = await prisma.task.findMany({
where: {
status: 'processing',
// 🚨 v1.2 修正:使用 updatedAt最后活跃时间而非 startedAt
// 原因500 篇文献正常排队可能需要 3+ 小时,用 startedAt 会误杀健康任务。
// 只要子任务还在完成原子递增updatedAt 就会持续刷新。
// 超过 2 小时没有任何进度更新的,才是真正卡死。
updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) },
},
});
for (const task of stuckTasks) {
await prisma.task.update({
where: { id: task.id },
data: {
status: 'failed',
errorMessage: '[Sweeper] No progress update for 2h. Likely Child Worker hard crash (OOM/SIGKILL). Force-closed.',
completedAt: new Date(),
},
});
logger.warn(`[Sweeper] Force-closed stuck task ${task.id} (no progress for 2h)`);
}
}
// 注册为 pg-boss 定时任务
await pgBoss.schedule('fanout_task_sweeper', '*/10 * * * *'); // 每 10 分钟
await pgBoss.work('fanout_task_sweeper', fanOutTaskSweeper);
```
**Sweeper 是 Fan-out 模式的终极保险丝。** 即使所有 Last Child Wins 逻辑都正确进程硬崩溃仍然是不可避免的物理级异常。Sweeper 确保任何"卡死"的任务最终都会被收口。
> **⚠️ v1.2 关键修正:** 判断"卡死"的依据是 `updatedAt`(最后活跃时间),而非 `startedAt`(任务创建时间)。
> 只要 Child 还在完成并递增计数Prisma 的 `update` 会自动刷新 `updatedAt`。
> 超大批量任务500+ 文献)正常排队执行可能需要数小时,用 `startedAt` 会导致 Sweeper 误杀正在健康运行的任务("友军之火")。
### 模式 3乐观锁抢占Optimistic Locking
**问题:** pg-boss 的 at-least-once 语义意味着同一 Child Job 可能被投递多次。如果用 `findUnique → if (status !== 'pending') return` 做幂等检查,两个 Worker 可能同时读到 `pending` 然后同时处理。
@@ -108,6 +169,29 @@ if (lock.count === 0) return { success: true, note: 'Idempotent skip' };
`updateMany` 的 WHERE 条件充当乐观锁,数据库保证只有一个 Worker 能成功更新。
**🚨 v1.1 补充:子任务派发防重 — singletonKey 的真正意图**
> **场景推演:** Manager Job 在派发了 50 个 Child Job 后进程崩溃。pg-boss 的 at-least-once 语义会
> 重新投递 Manager Job。重试时 Manager 重新查出 100 个子项,再次循环派发 100 个 Child Job。
> 前 50 个任务被重复派发,队列瞬间塞满垃圾数据,并导致 Child Worker 重复处理。
**强制规范Manager 必须为每个 Child 赋予基于业务 ID 的 `singletonKey`**
```typescript
// Manager 内循环派发
for (const item of items) {
await pgBoss.send('module_task_child', { taskId, itemId: item.id }, {
singletonKey: `child-${item.id}`, // ← 基于业务 ID 去重!
retryLimit: 3,
retryBackoff: true,
expireInMinutes: 30,
});
}
```
**`singletonKey` 是保证 Manager 自身崩溃重试时不会导致子任务指数级爆炸的唯一防线。**
pg-boss 在收到重复 `singletonKey` 时自动去重(忽略重复插入),无需手动判断。新手开发**绝不可省略**此字段。
### 模式 4错误分级路由
**问题:** pg-boss 默认对所有失败 Job 进行指数退避重试。但"PDF 损坏"这类永久错误重试 3 次也不会好。
@@ -122,6 +206,15 @@ try {
// ⚠️ 别忘了 Last Child Wins 检查!
return { success: false }; // return 而非 throw → pg-boss 视为"成功消费",停止重试
}
// 🚨 v1.2 补丁:临时错误 throw 前必须释放乐观锁!
// 否则 pg-boss 重试时 updateMany({ where: { status: 'pending' } }) 返回 0
// 被误判为"幂等跳过"计数永远少一票Last Child Wins 永远无法触发。
await prisma.result.update({
where: { id: resultId },
data: { status: 'pending' },
});
// 临时错误 (429/5xx/网络抖动)throw → pg-boss 指数退避自动重试
throw error;
}
@@ -157,10 +250,15 @@ jobQueue.work('module_llm_call', { teamConcurrency: 5 }, handler);
**问题:** `sseEmitter.emit()` 基于内存 EventEmitter用户连 Pod A、Worker 跑 Pod B → Pod A 收不到日志。
```typescript
// Worker 端(发送)
await prisma.$executeRawUnsafe(
`NOTIFY sse_channel, '${JSON.stringify({ taskId, type: 'log', data: logEntry }).replace(/'/g, "''")}'`
);
// Worker 端(发送)— v1.2 使用 pg_notify + 参数化查询(免疫 SQL 注入)
const payloadStr = JSON.stringify({ taskId, type: 'log', data: logEntry });
const safePayload = payloadStr.length > 7000
? payloadStr.substring(0, 7000) + '..."}'
: payloadStr;
// 🚨 v1.2 修正:抛弃 $executeRawUnsafe + 字符串拼接!
// 使用 PostgreSQL 内置 pg_notify() 函数 + Prisma Tagged Template参数化绑定
await prisma.$executeRaw`SELECT pg_notify('sse_channel', ${safePayload})`;
// API 端(接收)— Pod 启动时初始化
const pgClient = new Client({ connectionString: DATABASE_URL });
@@ -179,7 +277,12 @@ pgClient.on('notification', (msg) => {
**约束:**
- LISTEN 连接必须独立于连接池(归还后 LISTEN 失效)
- NOTIFY payload 上限 8000 bytes
- **NOTIFY payload 物理上限 ~8000 bytes**(超出直接报错,阻断业务流程!)
- **强制规范v1.1** 发送前必须安全截断至 7000 bytes 以内(预留 JSON 结构和转义开销)
- LLM 错误堆栈、超长乱码是最常见的超限来源
- **🚨 v1.2 强制规范:禁止 `$executeRawUnsafe` + 字符串拼接发送 NOTIFY**
- 必须使用 `$executeRaw` Tagged Template + `pg_notify()` 函数(参数化绑定,彻底免疫 SQL 注入)
- 不同编码、特殊换行符、反斜杠均可能绕过手动 `.replace` 转义
- fire-and-forget无持久化适合日志流这类"丢了不影响业务"的场景
### 模式 7数据一致性快照
@@ -211,6 +314,26 @@ await prisma.$transaction(
**原则:** 快照轻量元数据storageKey、filename 等 < 1KB到数据库。大文件内容不快照通过错误分级路由兜底。
### 🆕 模式 8Manager 空集合边界守卫v1.2
**问题:** 如果源数据被过滤后 `items.length === 0`空列表、数据异常等极端情况Manager 的 `for` 循环不执行,没有任何 Child 被派发Last Child Wins 永远不会触发,父任务永远卡在 `processing`
```typescript
// Manager Worker — 派发前必须检查空集合
if (items.length === 0) {
await prisma.task.update({
where: { id: taskId },
data: { status: 'completed', completedAt: new Date() },
});
logger.info(`[Manager] Task ${taskId}: 0 items, auto-completed`);
return;
}
// 正常路径:继续快照 + 派发 Child Job...
```
**这是 Last Child Wins 的唯一盲区。** 当 N=0 时Manager 必须自己充当"收口人"直接完成任务。
---
## 四、反模式速查表
@@ -227,6 +350,14 @@ await prisma.$transaction(
| 不设 `expireInMinutes` | 僵尸 Job 占据队列名额 | Manager: 60min, Child: 30min |
| 成功路径漏检 Last Child Wins | 任务永远卡在 processing | 成功 + 失败路径都检查 |
| Child 运行时回查外部模块数据 | 源头删改导致批量崩溃 | Manager 快照元数据到子项记录 |
| 🆕 无 Sweeper 清道夫 | 进程 OOM/SIGKILL 后任务永远卡死 | 全局 Cron 扫描 processing > 2h 强制收口 |
| 🆕 事务内做网络请求 | 父表行锁长时间持有 → Lock timeout | 短事务:仅更新 Result + 递增 Task |
| 🆕 Child 派发漏 singletonKey | Manager 重试导致子任务指数级爆炸 | `singletonKey: child-${itemId}` |
| 🆕 NOTIFY payload 不截断 | 超 8000 bytes 直接报错阻断流程 | 发送前截断至 7000 bytes |
| 🆕 临时错误 throw 前不释放乐观锁 | 重试时被误判"幂等跳过",计数永远缺一票 | throw 前 `update({ status: 'pending' })` |
| 🆕 Sweeper 用 `startedAt` 判断卡死 | 误杀正在排队的健康超大批量任务 | 用 `updatedAt`(最后活跃时间) |
| 🆕 Manager 不检查空集合 | N=0 时无 Child → Last Child Wins 死锁 | `if (items.length === 0)` 直接 completed |
| 🆕 NOTIFY 用 `$executeRawUnsafe` 拼接 | SQL 注入高危 | `$executeRaw` + `pg_notify()` 参数化 |
---
@@ -246,7 +377,7 @@ await pgBoss.send('module_task_child', { taskId, itemId }, {
retryDelay: 10, // 10 秒后重试
retryBackoff: true, // 指数退避10s, 20s, 40s
expireInMinutes: 30,
singletonKey: `child-${itemId}`,
singletonKey: `child-${itemId}`, // ← 派发防重Manager 崩溃重试时 pg-boss 自动去重
});
// Worker 注册(队列名必须用下划线!)
@@ -270,6 +401,14 @@ jobQueue.work('module_task_child', { teamConcurrency: 10 }, handler);
- [ ] **数据快照**Manager 是否在派发前快照了外部依赖数据?
- [ ] **NOTIFY 广播**SSE 日志推送是否经过 PostgreSQL NOTIFY如需跨 Pod
- [ ] **事务保障**:子项状态更新 + 父任务原子递增是否在同一事务中?
- [ ] 🆕 **Sweeper 清道夫**:是否注册了全局定时任务扫描 processing > 2h 的父任务并强制收口?
- [ ] 🆕 **短事务原则**`$transaction` 内是否仅包含纯 DB 操作(无网络请求/无耗时计算)?
- [ ] 🆕 **派发防重**Manager 循环派发 Child 时是否设置了 `singletonKey: child-${itemId}`
- [ ] 🆕 **NOTIFY 截断**NOTIFY payload 发送前是否截断至 7000 bytes 以内?
- [ ] 🆕 **乐观锁释放**:临时错误 `throw` 前是否将子项状态回退为 `pending`(防重试时被幂等跳过)?
- [ ] 🆕 **Sweeper 活跃判定**:清道夫是否基于 `updatedAt`(而非 `startedAt`)判断任务卡死?
- [ ] 🆕 **空集合守卫**Manager 是否在 `items.length === 0` 时直接将任务标记为 `completed`
- [ ] 🆕 **NOTIFY 参数化**:是否使用 `$executeRaw` + `pg_notify()` 而非 `$executeRawUnsafe` + 字符串拼接?
---
@@ -287,4 +426,6 @@ jobQueue.work('module_task_child', { teamConcurrency: 10 }, handler);
---
*本文档基于 ASL 工具 3 全文智能提取工作台开发计划v1.5,经 6 轮架构审查)的设计经验总结。*
*v1.1 补充 4 项生产级防御策略Sweeper 清道夫、短事务原则、singletonKey 派发防重、NOTIFY 安全截断。*
*v1.2 逐行级审查修正 4 项致命漏洞乐观锁与重试绞杀、Sweeper 友军之火、空集合死锁、SQL 注入隐患。*
*待 M1/M2 实战后升级为 v2.0,届时补充真实踩坑记录和性能数据。*