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>
6.6 KiB
🔬 分布式 Fan-out 指南 (v1.2) 破壁级审查与修订报告
审查人: 资深架构师 & Node.js 底层专家
审查对象: 《分布式 Fan-out 任务模式开发指南 v1.2》
审查深度: V8 引擎内存栈、Postgres 底层驱动、pg-boss 命名空间
核心结论: 业务逻辑已彻底闭环!但在JSON 字符串强截断、长期闲置连接保活、全局唯一键防重这三个底层物理机制上,存在 3 个会直接导致进程崩溃或静默失效的高危隐患。
🚨 破壁级漏洞 1:NOTIFY 强截断导致的 JSON.parse 爆栈崩溃
❌ 逐行审查发现的问题(位于 模式 6:SSE 跨实例广播)
指南中为了防止超过 PostgreSQL 的 8000 bytes 限制,写了如下安全截断代码:
const payloadStr = JSON.stringify({ taskId, type: 'log', data: logEntry });
const safePayload = payloadStr.length > 7000
? payloadStr.substring(0, 7000) + '..."}' // 👈 致命漏洞
: payloadStr;
🔥 灾难时序爆发:
- 假设 logEntry 的内容包含大量报错栈,导致 payloadStr 长达 9000 字节。
- 代码直接在第 7000 个字符处一刀切,然后粗暴地拼上 ..."}。
- 如果第 7000 个字符刚好切在一个中文字符的中间(导致 Unicode 乱码),或者切在了 JSON 的某个 key 名字中间(如 {"ms),拼接后的字符串将变成绝对非法的 JSON。
- 爆炸点: 在 API 接收端,代码是这样写的:const { taskId, type, data } = JSON.parse(msg.payload);
- 当非法的 JSON 被 JSON.parse 解析时,Node.js 会抛出 SyntaxError: Unexpected token。由于 pgClient.on('notification') 内部没有写 try-catch,这个异常会直接击穿 Event Loop,导致整个 API Pod 进程崩溃重启 (Crash)!
✅ 架构师修正方案 (内部字段安全截断)
绝对不能对 JSON.stringify 后的字符串进行切片!必须切片原始对象内的长文本字段:
// 发送端:在 Stringify 之前,截断真正导致超长的 message 字段
if (logEntry.message && logEntry.message.length > 3000) {
logEntry.message = logEntry.message.substring(0, 3000) + '...[Truncated]';
}
const payloadStr = JSON.stringify({ taskId, type: 'log', data: logEntry });
await prisma.$executeRaw`SELECT pg_notify('sse_channel', ${payloadStr})`;
// 接收端:必须加上防御性 try-catch,防止毒数据炸毁整个实例
pgClient.on('notification', (msg) => {
try {
const { taskId, type, data } = JSON.parse(msg.payload);
// ... 推送逻辑
} catch (error) {
logger.error('Failed to parse SSE notification payload', { payload: msg.payload });
}
});
🚨 破壁级漏洞 2:LISTEN 监听器的“静默死亡” (Silent Connection Drop)
❌ 逐行审查发现的问题(位于 模式 6:SSE 跨实例广播)
指南中的 API 接收端初始化代码如下:
const pgClient = new Client({ connectionString: DATABASE_URL });
await pgClient.connect();
await pgClient.query('LISTEN sse_channel');
🔥 灾难时序爆发:
- 这是一个一直挂在后台的长期长连接(Long-lived Connection)。
- 在云端环境,由于底层网络波动、PgBouncer 代理的闲置超时掐断、或者数据库主备切换,这根 TCP 连接一定会在几天内断开一次。
- pg 原生库的设计是:Client 连接断开后,不会自动重连!
- 后果: 没有任何报错,服务依然在跑,但这个 Pod 永远也收不到任何 NOTIFY 消息了。前端 SSE 终端彻底变成一潭死水。
✅ 架构师修正方案 (加入心跳重连与错误监听)
必须为这个裸 Client 加上底层的生命周期守护:
// 封装为健壮的监听器启动函数
async function setupSSEListener() {
const pgClient = new Client({ connectionString: DATABASE_URL });
// 核心补丁:监听错误与断开,强制重启监听!
pgClient.on('error', (err) => {
logger.error('PG Listen Client Error, reconnecting...', err);
pgClient.end().catch(console.error);
setTimeout(setupSSEListener, 5000); // 5 秒后自动重连
});
pgClient.on('end', () => {
logger.warn('PG Listen Client Ended, reconnecting...');
setTimeout(setupSSEListener, 5000);
});
await pgClient.connect();
await pgClient.query('LISTEN sse_channel');
pgClient.on('notification', (msg) => { /* ... */ });
}
setupSSEListener();
🚨 破壁级漏洞 3:singletonKey 作用域跨界的“狸猫换太子”
❌ 逐行审查发现的问题(位于 五、pg-boss 配置速查)
指南中建议这样写防重复 Key:
await pgBoss.send('module_task_child', { taskId, itemId }, {
singletonKey: `child-${itemId}`, // ← 派发防重
});
🔥 灾难时序爆发:
- 假设 itemId 指的是源数据的 ID(例如 PKB里的 Document A)。
- 医生张三在“项目 1”里提取了 Document A。pg-boss 生成了 singletonKey: child-DocA,正在缓慢处理。
- 同时,医生李四在“项目 2”里,也碰巧勾选了同一个 Document A 进行提取任务。
- pg-boss 会发现 singletonKey: child-DocA 已经在队列里了。根据幂等性去重规则,pg-boss 会直接丢弃(Ignored)李四的这个子任务!
- 后果: 李四的这个任务永远缺少了这一篇文献的进度,父任务 Last Child Wins 机制卡死,系统挂起!
✅ 架构师修正方案 (引入绝对隔离域)
singletonKey 在 pg-boss 中是全局数据库唯一的!它绝不能仅仅绑定业务实体 ID,必须绑定**“实体 + 本次任务实例”**的联合主键。
如果 itemId 对应的是 AslExtractionResult.id(专属于某一次提取任务的单行 ID),那是安全的。但为了规范,指南必须明确指出:
// 核心补丁:singletonKey 必须携带父任务 ID 形成绝对隔离!
await pgBoss.send('module_task_child', { taskId, itemId }, {
singletonKey: `task-${taskId}-item-${itemId}`, // 必须绑定 Task 实例级别!
});
🏁 最终判词与交付建议
经过这次“贴脸”级别的底层审查,我们不仅防住了高并发的业务死锁,还防住了网络断线、V8 JSON解析、全局哈希碰撞这三个底层基建级别的灭顶之灾。
请将这 3 个补丁打入《分布式 Fan-out 任务模式开发指南 v1.2》中(升级为 v1.3 定稿版)。有了这套带底层防线的文档,您的团队在面对任何高并发、高可用挑战时,都能写出真正的“大厂级”工业代码!