docs(asl): Upgrade Tool 3 architecture from Fan-out to Scatter+Aggregator (v2.0)
Architecture transformation: - Replace Fan-out (Manager->Child->Last Child Wins) with Scatter+Aggregator pattern - API layer directly dispatches N independent jobs (no Manager) - Worker only writes its own Result row, never touches Task table (zero row-lock) - Aggregator polls groupBy for completion + zombie cleanup (replaces Sweeper) - Reduce red lines from 13 to 9, eliminate distributed complexity Documents updated (10 files): - 08-Tool3 main architecture doc: v2.0 rewrite (schema, Task 2.3/2.4, red lines, risks) - 08d-Code patterns: rewrite sections 4.1-4.6 (API dispatch, SingleWorker, Aggregator) - 08a-M1 sprint: rewrite M1-3 core (Worker+Aggregator), red lines, acceptance criteria - 08b-M2 sprint: simplify SSE (NOTIFY/LISTEN downgraded to P2 optional) - 08c-M3 sprint: milestone table wording update - New: Scatter+Polling Aggregator pattern guide v1.1 (Level 2 cookbook) - New: V2.0 architecture deep review and gap-fix report - Updated: ASL module status, system status, capability layer index Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,93 +1,54 @@
|
||||
# 工具 3:全文智能提取工作台 V2.0 开发计划
|
||||
|
||||
> **版本:** v1.5(多 Pod SSE 通信 + PKB 数据一致性快照)
|
||||
> **版本:** v2.0(架构转型:Fan-out → 散装派发 + Aggregator 轮询收口)
|
||||
> **创建日期:** 2026-02-22
|
||||
> **更新日期:** 2026-02-23(v1.1 → v1.2 PKB → v1.3 排雷 → v1.4 终极 → v1.4.1 逻辑补丁 → v1.4.2 致命修复 → v1.5 跨实例+快照)
|
||||
> **架构审查:** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3架构审查与研发改进建议.md`
|
||||
> **深度排雷:** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3开发计划深度审查与排雷指南.md`
|
||||
> **终极审查:** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3终极架构审查与研发规范.md`
|
||||
> **更新日期:** 2026-02-24(v1.1→v1.5 见历史补丁 → **v2.0 架构转型**)
|
||||
> **异步任务指南:** `docs/02-通用能力层/散装派发与轮询收口任务模式指南.md`(Level 2,**本模块遵循**)
|
||||
> **Postgres-Only 指南:** `docs/02-通用能力层/Postgres-Only异步任务处理指南.md`(Level 1 底层规范)
|
||||
> **PKB 模块:** `docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md`
|
||||
> **Postgres-Only 指南:** `docs/02-通用能力层/Postgres-Only异步任务处理指南.md`
|
||||
> **产品原型:** `docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/工具3 全文提取产品原型图V2.html`
|
||||
> **数据字典:** `docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/ASL 工具 3 全文提取数据字典与规范.md`
|
||||
> **模板规范:** `docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/ASL 工具 3 提取模板管理规范.md`
|
||||
> **OSS 规范:** `docs/04-开发规范/11-OSS存储开发规范.md`
|
||||
> **OSS 规范:** `docs/04-开发规范/11-OSS存储开发规范.md`
|
||||
> **架构审查(历史):** `docs/03-业务模块/ASL-AI智能文献/06-技术文档/` 下 3 份审查文档(v1.1-v1.5 期间产出,部分结论已被 v2.0 架构转型替代)
|
||||
>
|
||||
> ### v1.5 补丁:多 Pod SSE 通信 + PKB 数据一致性快照
|
||||
> ### 🚀 v2.0 架构转型:Fan-out → 散装派发 + Aggregator 轮询收口
|
||||
>
|
||||
> | # | 问题 | 严重度 | 修正方案 | 影响章节 | 归属里程碑 |
|
||||
> |---|------|--------|---------|---------|-----------|
|
||||
> | 1 | SSE `sseEmitter.emit()` 基于内存 EventEmitter,用户连 Pod A 但 Worker 跑在 Pod B → 实时日志零推送 | **关键** | PostgreSQL `NOTIFY/LISTEN` 跨实例广播:Worker 端 `NOTIFY asl_sse_channel`,API 端每 Pod 启动时 `LISTEN` 常驻连接,收到后检查本机是否有该 taskId 的 SSE 客户端并推送 | Task 2.4 | M2 |
|
||||
> | 2 | 提取任务可能持续 50 分钟,期间用户在 PKB 删除/修改文档 → Child Worker 找不到 `storageKey` 崩溃 | **重要** | Manager 派发时一次性快照 `storageKey` + `filename` 到 `AslExtractionResult`,Child Worker 从自身记录读取而非运行时回查 PKB | Task 1.1, Task 2.3 | M1 |
|
||||
> **转型决策依据:** v1.1-v1.5 期间累计发现 12+ 个 Fan-out 分布式 Bug(Last Child Wins 终点丢失、
|
||||
> 乐观锁与重试绞杀、原子递增行锁争用、Sweeper 误杀、NOTIFY/LISTEN 复杂性等),
|
||||
> 经团队评估,Fan-out 的运维复杂度远超创业团队承受能力。
|
||||
> 详见 `散装派发与轮询收口任务模式指南.md` 的成本-收益分析。
|
||||
>
|
||||
> ### v1.4.2 补丁:3 项致命缺陷修复 + 队列命名合规
|
||||
> | 维度 | v1.5 Fan-out(已废弃) | v2.0 散装派发(当前) |
|
||||
> |------|----------------------|---------------------|
|
||||
> | **调度模型** | API → Manager Job → N Child Job → Last Child Wins | API 直接散装派发 N 个独立 Job → Aggregator 轮询收口 |
|
||||
> | **任务完成判定** | 最后一个 Child 原子递增后翻转 Task status | Aggregator 定时 `groupBy` 聚合,`pending=0 && extracting=0` → completed |
|
||||
> | **进度统计** | Task 表 `successCount`/`failedCount` 冗余字段 | 实时 `groupBy COUNT(Result.status)`,Task 表无计数字段 |
|
||||
> | **僵尸清理** | 独立 Sweeper 定时任务 | Aggregator 自带僵尸清理(一个组件两个职责) |
|
||||
> | **跨 Pod 通信** | NOTIFY/LISTEN 广播 SSE 日志 | 纯轮询,无需跨 Pod 通信(SSE 日志为可选增强) |
|
||||
> | **消灭的组件** | — | ~~ExtractionManagerWorker~~、~~Sweeper~~、~~NOTIFY/LISTEN Bridge~~ |
|
||||
> | **消灭的 Bug 类** | — | ~~Last Child Wins 终点丢失~~、~~原子递增行锁争用~~、~~三级嵌套子队列 OOM~~ |
|
||||
> | **设计模式数** | 12+ | 4 个核心模式 |
|
||||
> | **研发红线数** | 13 条 | 9 条 |
|
||||
>
|
||||
> | # | 致命缺陷 | 严重度 | 补丁方案 | 影响章节 |
|
||||
> |---|---------|--------|---------|---------|
|
||||
> | 1 | Fan-out 终点丢失:没有人把 `AslExtractionTask.status` 从 `processing` 更新为 `completed`,前端永远卡在 Step 2 | **致命** | "Last Child Wins" 模式:Child 原子递增后判断 `successCount + failedCount === totalCount`,最后一个 Child 负责翻转状态 | Task 2.3 |
|
||||
> | 2 | 伪幂等 Read-then-Write 反模式:`findUnique` 检查状态后再操作,并发 retry 穿透导致双倍 MinerU/LLM 调用 + 计数双递增 | **致命** | 替换为 Prisma `updateMany({ where: { status: 'pending' } })` 乐观锁,原子抢占 `extracting` 状态 | Task 2.3 |
|
||||
> | 3 | 队列名称使用点号(`asl.extraction.child`)违反《Postgres-Only 指南》§4.1 红线 | **严重** | 全局替换为下划线格式:`asl_extraction_manager`、`asl_extraction_child`、`asl_mineru_extract`、`asl_llm_extract` | 全局 |
|
||||
> | — | `executionLogs` 注释歧义 | 低 | Schema 注释清理:v1.1 已决定不存日志,注释改为更明确的措辞 | Task 1.1 |
|
||||
> **保留的 v1.x 成果(不受架构转型影响):**
|
||||
> - v1.2 PKB 对接方案(ACL 防腐层)
|
||||
> - v1.3 XML 隔离 Prompt、fuzzyQuoteMatch 搜索池扩容、签名 URL 懒加载
|
||||
> - v1.4 双轨制通信(React Query 主驱 + SSE 日志增强)、MinerU 缓存、HITL 解锁、错误分级
|
||||
> - v1.5 PKB 数据一致性快照(移至 API 层执行)
|
||||
>
|
||||
> ### v1.4.1 补丁:3 项隐蔽逻辑漏洞修复
|
||||
> <details>
|
||||
> <summary>📜 v1.1 - v1.5 历史补丁记录(已被 v2.0 架构转型部分替代,点击展开)</summary>
|
||||
>
|
||||
> | # | 漏洞 | 严重度 | 补丁方案 | 影响章节 |
|
||||
> |---|------|--------|---------|---------|
|
||||
> | 1 | fuzzyQuoteMatch 仅搜索 pymupdf4llm 文本,遗漏 MinerU 表格来源的 Quote → 满屏误报红色警告 | **致命** | 搜索池扩容:剥离 MinerU HTML 标签后与 Markdown 拼接再匹配 | Task 2.3 |
|
||||
> | 2 | logBuffer 内存存储在多 Pod 下为空,SSE Hydration 首帧 recentLogs 为空数组 | 中等 | 降级方案:不存历史日志,重连时仅下发进度 + 打印"监控已重连"提示 | Task 2.4 |
|
||||
> | 3 | `asl_extraction_child` 无 teamConcurrency 限制,1000 篇文献 → 1000 个挂起闭包 → Node.js OOM | **严重** | Child 队列加 `teamConcurrency: 10`,其余在 PostgreSQL 中排队 | Task 2.3 |
|
||||
> v1.5: 多 Pod SSE NOTIFY/LISTEN + PKB 快照 → **SSE 部分已被 v2.0 纯轮询替代,PKB 快照保留但移至 API 层**
|
||||
> v1.4.2: Last Child Wins + 乐观锁 + 队列命名 → **Last Child Wins 和原子递增已被 Aggregator 替代,乐观锁(幽灵重试守卫)和队列命名保留**
|
||||
> v1.4.1: fuzzyQuoteMatch 搜索池 + logBuffer 降级 + teamConcurrency → **全部保留**
|
||||
> v1.4: P-Queue→teamConcurrency + 原子递增 + 错误分级 + MinerU 缓存 + HITL + 双轨制 + 计算卸载 → **原子递增已废弃,其余全部保留**
|
||||
> v1.3: PKB ACL + XML 隔离 + Fan-out 扇出 + 签名 URL + SSE Hydration + PKB 复用日志 → **Fan-out 扇出已被散装模式替代,其余全部保留**
|
||||
> v1.2: PKB 对接 → **全部保留**
|
||||
> v1.1: 10 项架构审查修正 → **全部保留**
|
||||
>
|
||||
> ### v1.4 变更:终极架构审查 7 项修正(v1.4 定稿)
|
||||
>
|
||||
> | # | 终极审查问题 | 修正措施 | 影响章节 |
|
||||
> |---|------------|---------|---------|
|
||||
> | 1 | P-Queue 单机限流在多 Pod 下失效 | 废弃 P-Queue,改用 pg-boss `teamConcurrency` 全局队列限流 | Task 2.3 |
|
||||
> | 2 | Fan-out 并发回写父任务 Race Condition | Prisma 原子递增 `{ increment: 1 }` + 事务保障 | Task 2.3 |
|
||||
> | 3 | 永久错误被 pg-boss 盲目重试 3 次 | 异常分级路由:致命错误 return success 停止重试 | Task 2.3 |
|
||||
> | 4 | MinerU 重复解析同一 PDF 浪费算力 | MinerU Clean Data OSS 缓存(Cache-Aside 模式) | Task 2.2 |
|
||||
> | 5 | Quote 标红后医生无路可走(HITL 死锁) | [强制认可] + [手动修改数值] 双按钮解锁 | Task 5.2 |
|
||||
> | 6 | SSE 断开导致主业务流中断 | 双轨制:React Query 轮询驱动主流 + SSE 仅驱动日志流 | Task 4.1 |
|
||||
> | 7 | 红线 2 计算卸载约束未显式标注 | Node.js 禁碰 pymupdf4llm/MinerU 解析,必须路由到外部服务 | Task 2.2 |
|
||||
>
|
||||
> ### v1.3 变更:深度排雷 6 项修正 + Postgres-Only 安全规范(v1.3)
|
||||
>
|
||||
> | # | 排雷问题 | 修正措施 | 影响章节 |
|
||||
> |---|---------|---------|---------|
|
||||
> | 1 | 跨 Schema 直查打破模块边界 | PKB 暴露 `PkbExportService`(ACL 防腐层),ASL 进程内调用获取 DTO | Task 3.3b, 七.4 |
|
||||
> | 2 | 双引擎上下文污染致 LLM 幻觉 | `<FULL_TEXT>` + `<HIGH_FIDELITY_TABLES>` XML 隔离 + 表格优先级指令 | Task 2.1, 2.2 |
|
||||
> | 3 | pg-boss 单 Job 粗粒度致崩溃重做 | Fan-out 扇出模式:父任务派发 N 个子任务(单篇粒度) | Task 2.3, 2.4 |
|
||||
> | 4 | 签名 URL 1 小时过期打断医生心流 | 懒签名(10 分钟有效期)+ 前端 403 自动刷新 | Task 5.3 |
|
||||
> | 5 | SSE 重连后进度条空白 | SSE Hydration on Connect:连接时下发 `sync` 初始事件 | Task 4.1, 2.4 |
|
||||
> | 6 | PKB 复用无用户感知 | Worker 日志高亮 `[System] Fast-path: Reused from PKB` | Task 2.3 |
|
||||
> | — | Postgres-Only 安全规范 | 幂等 upsert + Payload 轻量 + 合理过期时间(强制遵守) | Task 2.3, 全局 |
|
||||
>
|
||||
> ### v1.2 变更:对接 PKB 个人知识库(取消自建 PDF 上传)
|
||||
>
|
||||
> **决策依据:** PKB 模块已完整集成 OSS 存储(`storage.upload()` + `generatePkbStorageKey()`
|
||||
> + `getDocumentSignedUrl()`),且上传后自动通过 pymupdf4llm 提取 Markdown 全文
|
||||
> 存入 `extractedText` 字段。Tool 3 无需重复造轮子。
|
||||
>
|
||||
> | 删除项 | 原计划内容 | 替代方案 |
|
||||
> |--------|-----------|---------|
|
||||
> | ~~Task 2.6~~ | PDF 上传接入 OSS | PKB 已完成,直接读取 |
|
||||
> | ~~Task 3.3~~ | PdfUploadPanel 组件 | 改为 PkbKnowledgeBaseSelector |
|
||||
> | ~~前端 Upload.Dragger~~ | 拖拽上传 PDF | 选择 PKB 知识库 + 勾选文献 |
|
||||
> | ~~pymupdf4llm 运行时~~ | 每篇 PDF 提取全文 | 直接读 PKB `extractedText`(已有) |
|
||||
>
|
||||
> ### 架构审查修正记录(v1.1)
|
||||
>
|
||||
> | # | 审查问题 | 修正措施 | 影响章节 |
|
||||
> |---|---------|---------|---------|
|
||||
> | 1 | 任务进度管理违背 Postgres-Only 规范 | AslExtractionTask 仅存业务元数据,实时进度交 pg-boss/CheckpointService | Task 1.1 |
|
||||
> | 2 | Step 2 日志推送退回轮询 | 改用 SSE 流式推送,复用 `common/streaming/` | Task 4.1 |
|
||||
> | 3 | MinerU 批量并发无控制 | ~~P-Queue 漏斗~~ → v1.4 改 pg-boss `teamConcurrency` | Task 2.3 |
|
||||
> | 4 | Quote 子串匹配过于理想化 | fuzzyQuoteMatch:Unicode 标准化 + Levenshtein ≤5% | Task 2.3, 七.3 |
|
||||
> | 5 | JSONB 字段无节制膨胀 | MinerU HTML 表格压缩存 OSS,DB 仅存 ossKey | Task 1.1 |
|
||||
> | 6 | 自定义 Prompt 无注入防护 | 指令隔离护栏 BEGIN/END + 防逃逸声明 | Task 2.1 |
|
||||
> | 7 | 断点恢复缺失 | 状态驱动路由,首屏读 status 决定渲染步骤 | Task 4.1, 5.1 |
|
||||
> | 8 | Drawer 大表单渲染卡顿 | Collapse 折叠懒渲染 + Ant Design Form shouldUpdate | Task 5.2 |
|
||||
> | 9 | 排期过于乐观 | Sprint 1 跑通主干 → Sprint 2 叠加高级特性 | 六 |
|
||||
> | 10 | 缺乏 E2E 测试 | 新增 Playwright E2E 测试节点 | Task 6.3 |
|
||||
> </details>
|
||||
|
||||
---
|
||||
|
||||
@@ -224,26 +185,19 @@ model AslProjectTemplate {
|
||||
}
|
||||
|
||||
// 提取任务(业务元数据映射表)
|
||||
// ⚠️ 架构审查修正:本表仅存储业务元数据和任务完成后的聚合统计。
|
||||
// 实时进度由 pg-boss + CheckpointService 托管;日志流为 SSE 瞬时推送(阅后即焚),
|
||||
// 不存入任何数据库字段。Schema 中无 executionLogs 字段(v1.4.2 确认删除设计正确)。
|
||||
// 🆕 v1.2:新增 pkbKnowledgeBaseId,文献来源从 PKB 知识库获取
|
||||
// 🚀 v2.0 散装模式:Task 表无 successCount/failedCount,进度由 COUNT(Result) 实时聚合。
|
||||
// Worker 绝不写 Task 表,仅 Aggregator 在收口时更新 status。
|
||||
// 日志流为 SSE 瞬时推送(可选增强),不存入数据库。
|
||||
model AslExtractionTask {
|
||||
id String @id @default(uuid())
|
||||
projectId String
|
||||
templateId String
|
||||
pkbKnowledgeBaseId String? // 🆕 PKB 知识库 ID(文献 PDF 来源)
|
||||
jobId String? @unique // pg-boss job ID,用于关联实时进度
|
||||
pkbKnowledgeBaseId String? // PKB 知识库 ID(文献 PDF 来源)
|
||||
idempotencyKey String? @unique // 🚀 v2.0 DB 级幂等(@unique + P2002 捕获,防并发重复创建)
|
||||
modelName String @default("deepseek-v3")
|
||||
totalCount Int @default(0) // 待提取文献总数(创建时写入)
|
||||
|
||||
// 以下字段仅在任务完成后由 Worker 回写
|
||||
status String @default("pending") // pending | processing | completed | failed
|
||||
successCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
totalTokens Int @default(0)
|
||||
totalCost Decimal @default(0)
|
||||
startedAt DateTime?
|
||||
totalCount Int @default(0) // 待提取文献总数(创建时写入,不再变更)
|
||||
status String @default("processing") // processing | completed | failed(仅 Aggregator 变更)
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
errorMessage String?
|
||||
|
||||
@@ -255,21 +209,21 @@ model AslExtractionTask {
|
||||
}
|
||||
|
||||
// 单篇文献的提取结果
|
||||
// 🆕 v1.2:新增 pkbDocumentId,支持从 PKB 文档直接读取 extractedText 和 storageKey
|
||||
// 🚀 v2.0:Worker 只写自己的 Result 行,绝不碰 Task 表。
|
||||
// Aggregator 通过 groupBy 聚合 Result.status 来判定 Task 是否完成。
|
||||
model AslExtractionResult {
|
||||
id String @id @default(uuid())
|
||||
taskId String
|
||||
projectId String
|
||||
literatureId String? // 来自工具 1/2 的 AslLiterature(可选)
|
||||
pkbDocumentId String? // 🆕 来自 PKB 的文档 ID(与 literatureId 二选一)
|
||||
pkbDocumentId String? // 来自 PKB 的文档 ID(与 literatureId 二选一)
|
||||
|
||||
// 🆕 v1.5:PKB 数据一致性快照
|
||||
// Manager 派发时冻结,Child Worker 直接使用,无需运行时回查 PKB
|
||||
// 防止提取进行中 PKB 侧删除/修改文档导致任务崩溃
|
||||
snapshotStorageKey String? // 快照:OSS 存储路径(派发时从 PKB 冻结)
|
||||
snapshotFilename String? // 快照:文件名(派发时从 PKB 冻结)
|
||||
// PKB 数据一致性快照(v1.5 设计,v2.0 移至 API 层执行)
|
||||
// API 创建任务时一次性冻结,Worker 直接使用,无需运行时回查 PKB
|
||||
snapshotStorageKey String? // 快照:OSS 存储路径
|
||||
snapshotFilename String? // 快照:文件名
|
||||
|
||||
status String @default("pending") // pending | extracting | extracted | approved | rejected | error
|
||||
status String @default("pending") // pending | extracting | completed | error
|
||||
|
||||
// MinerU 表格提取
|
||||
// ⚠️ 架构审查修正:MinerU HTML 表格可能非常庞大(几 MB),
|
||||
@@ -303,6 +257,7 @@ model AslExtractionResult {
|
||||
literature AslLiterature? @relation(fields: [literatureId], references: [id])
|
||||
// pkbDocumentId 为跨 schema 引用,不建 Prisma relation,通过 PkbBridgeService 查询
|
||||
|
||||
@@index([taskId, status]) // 🚀 v2.0:Aggregator groupBy 聚合加速
|
||||
@@schema("asl_schema")
|
||||
}
|
||||
```
|
||||
@@ -483,88 +438,78 @@ model AslLiterature {
|
||||
|
||||
---
|
||||
|
||||
#### Task 2.3 — 智能提取服务(核心)— ⚠️ v1.3 Fan-out 扇出模式 + Postgres-Only 安全规范
|
||||
#### Task 2.3 — 智能提取服务(核心)— 🚀 v2.0 散装派发 + Aggregator 轮询收口
|
||||
|
||||
**文件:** `backend/src/modules/asl/extraction/services/ExtractionService.ts`
|
||||
**Worker:** `backend/src/modules/asl/extraction/services/ExtractionWorker.ts`
|
||||
**Worker:** `backend/src/modules/asl/extraction/services/ExtractionSingleWorker.ts`
|
||||
**Aggregator:** `backend/src/modules/asl/extraction/services/ExtractionAggregator.ts`
|
||||
|
||||
> **🚨 v1.3 排雷修正(隐患 3):** 原计划将 N 篇文献打包在一个 `pg-boss` Job 中处理,
|
||||
> 如果第 80 篇崩溃,前 79 篇的 `CheckpointService` 数据无法独立恢复(pg-boss 整体 retry 会从头跑)。
|
||||
> 改为 **Fan-out 扇出模式**:Manager Job → N 个 Child Job(单篇粒度),每个 Child 独立 retry。
|
||||
>
|
||||
> **同时强制遵守 `Postgres-Only异步任务处理指南` 的 6 项安全规范。**
|
||||
> **🚀 v2.0 架构转型:** 废弃 Fan-out 扇出模式(Manager → N Child → Last Child Wins),
|
||||
> 改用**散装派发 + Aggregator 轮询收口**模式。
|
||||
> API 层直接创建 N 个独立 Job,Worker 只管自己的 Result 行,Aggregator 定时收口。
|
||||
> 详见 `散装派发与轮询收口任务模式指南.md`。
|
||||
|
||||
**Fan-out 架构图:**
|
||||
**散装派发架构图:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ POST /api/v1/asl/extraction/tasks │
|
||||
│ → 创建 AslExtractionTask (DB) │
|
||||
│ → pg-boss.send('asl_extraction_manager', { taskId }) │
|
||||
│ ↓ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ Manager Job (asl_extraction_manager) │ │
|
||||
│ │ 1. 读取任务关联的 N 篇文献 │ │
|
||||
│ │ 2. ⚠️ v1.5 批量快照 PKB 元数据 → 冻结到 │ │
|
||||
│ │ AslExtractionResult.snapshotStorageKey/Filename │ │
|
||||
│ │ 3. 为每篇文献 dispatch Child Job │ │
|
||||
│ │ pg-boss.send('asl_extraction_child', { │ │
|
||||
│ │ taskId, resultId, pkbDocumentId │ │
|
||||
│ │ }) │ │
|
||||
│ │ 4. 派发完毕后 Manager 退出(Fire-and-forget) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ ↓ (N 个) │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ Child Job (asl_extraction_child) × N │ │
|
||||
│ │ 1. ⚠️ v1.4.2 乐观锁抢占 pending → extracting │ │
|
||||
│ │ 2. 读 PKB extractedText(零成本)+ 用快照字段 │ │
|
||||
│ │ snapshotStorageKey 访问 OSS(防 PKB 删除) │ │
|
||||
│ │ 3. 派发 asl_mineru_extract 子队列(teamConc: 2) │ │
|
||||
│ │ 4. 组装 Prompt → LLM 调用 → fuzzyQuoteMatch │ │
|
||||
│ │ 5. 事务: upsert Result + 原子递增父任务计数 │ │
|
||||
│ │ 6. ⚠️ v1.4.2 "Last Child Wins":判断完成数 = │ │
|
||||
│ │ totalCount → 翻转 Task status = completed │ │
|
||||
│ │ 7. SSE 推送进度日志 │ │
|
||||
│ │ ⚡ 致命错误 return success 停止重试 │ │
|
||||
│ │ ⚡ 临时错误 throw → pg-boss 指数退避自动 retry │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
┌─ API 层 ──────────────────────────────────────┐
|
||||
│ POST /api/v1/asl/extraction/tasks │
|
||||
│ 1. DB 级幂等(idempotencyKey @unique + P2002)│
|
||||
│ 2. 创建 Task(status: processing) │
|
||||
│ 3. 批量创建 N 个 Result(status: pending) │
|
||||
│ 4. PKB 快照冻结(snapshotStorageKey/Filename)│
|
||||
│ 5. 散装派发 N 个 pgBoss Job(一次 insert) │
|
||||
│ 6. 立即返回 taskId(< 0.1 秒) │
|
||||
└────────────────────────────────────────────────┘
|
||||
↓ (N 个独立 Job)
|
||||
┌─ Worker 层(多 Pod 各自抢单)─────────────────┐
|
||||
│ 每个 Worker 只管自己的 1 篇文献: │
|
||||
│ 1. 幽灵重试守卫(updateMany where: pending) │
|
||||
│ 2. 读 PKB extractedText + snapshotStorageKey │
|
||||
│ 3. MinerU 表格提取(含 OSS 缓存) │
|
||||
│ 4. 组装 Prompt → LLM 调用 → fuzzyQuoteMatch │
|
||||
│ 5. 只更新自己的 Result 行 │
|
||||
│ 6. 绝不触碰父任务 Task 表! │
|
||||
│ 7. 错误分级:致命 return / 临时回退 + throw │
|
||||
└────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Aggregator(全局唯一,每 10 秒)─────────────┐
|
||||
│ 1. 清理僵尸:extracting > 30min → error │
|
||||
│ 2. 聚合统计:groupBy Result.status │
|
||||
│ 3. 收口判定:pending=0 且 extracting=0 │
|
||||
│ → 标记 Task completed │
|
||||
└────────────────────────────────────────────────┘
|
||||
|
||||
┌─ 前端(React Query 轮询)─────────────────────┐
|
||||
│ GET /tasks/:taskId/status │
|
||||
│ → 实时 groupBy 聚合进度 │
|
||||
│ → 无需 successCount/failedCount 冗余字段 │
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> 📖 代码模式见 08d-工具3-代码模式与技术规范.md §4.1
|
||||
|
||||
> 📖 Manager/Child Worker 代码见 08d §4.2 + §4.3
|
||||
> 📖 代码模式见 08d-工具3-代码模式与技术规范.md §4
|
||||
|
||||
**⚠️ 强制遵守:Postgres-Only 异步任务处理指南安全规范**
|
||||
|
||||
| 规范 | 要求 | 本模块实现 |
|
||||
|------|------|-----------|
|
||||
| **幂等性** | Worker 必须容忍 pg-boss 重投(at-least-once) | ⚠️ v1.4.2 改为 `updateMany({ where: { status: 'pending' } })` 乐观锁原子抢占,替代 Read-then-Write 反模式 |
|
||||
| **Payload 轻量** | Job data 不超过数 KB,禁止塞 PDF 正文 | 仅传 `{ taskId, resultId, pkbDocumentId }`,不超过 200 bytes。快照数据存在 `AslExtractionResult` 表中(v1.5),不塞 Job |
|
||||
| **过期时间** | 必须设置 `expireInMinutes`,防止僵尸 Job | Manager: 60min,Child: 30min |
|
||||
| **错误分级** | 区分"可重试"和"永久失败" | 429/5xx → retry(pg-boss 指数退避),4xx/解析错误 → 标记 error,不 retry |
|
||||
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` handler 标记该篇为 `error` |
|
||||
| **进度追踪** | 不在 Job data 中存大量进度 | 进度统一走 `CheckpointService`,Job data 仅含 ID 引用 |
|
||||
| **幂等性** | Worker 必须容忍 pg-boss 重投(at-least-once) | 幽灵重试守卫 `updateMany({ where: { status: 'pending' } })`,只允许 pending→extracting |
|
||||
| **API 幂等** | 防止网络重试创建重复 Task | `idempotencyKey @unique` + P2002 捕获(DB 物理级幂等) |
|
||||
| **Payload 轻量** | Job data 不超过数 KB,禁止塞 PDF 正文 | 仅传 `{ resultId, pkbDocumentId }`,不超过 200 bytes |
|
||||
| **过期时间** | 必须设置 `expireInMinutes`,防止僵尸 Job | `expireInMinutes: 30`(执行超时,非排队等待超时) |
|
||||
| **错误分级** | 区分"可重试"和"永久失败" | 致命错误 return(停止重试)/ 临时错误回退 pending + throw(指数退避) |
|
||||
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` + Aggregator 僵尸清理双保险 |
|
||||
| **Worker 独立性** | Worker 只写自己的行 | **绝不碰 Task 表**,进度由 Aggregator groupBy 聚合 |
|
||||
|
||||
**⚠️ v1.4 终极修正:废弃 P-Queue,改用 pg-boss `teamConcurrency` 全局限流**
|
||||
**并发控制:**
|
||||
|
||||
> **❌ 旧方案(已废弃):** `ExtractionService` 内部使用 `P-Queue({ concurrency: 2 })` 控制 MinerU 并发。
|
||||
> 在多实例(K8s Pods)部署下,每个 Pod 各自有一个 P-Queue,实际全局并发 = 2 × Pod 数,
|
||||
> MinerU API 瞬间 429 熔断 + 重试风暴。
|
||||
>
|
||||
> **✅ 新方案:** 把并发控制权交还给数据库。将 MinerU 解析拆为独立 pg-boss 子队列,
|
||||
> 配置 `teamConcurrency`——这是 PostgreSQL 级全局锁,跨所有 Node.js 实例生效。
|
||||
```
|
||||
asl_extract_single (teamConcurrency: 10) ← 全局限流,防 OOM
|
||||
```
|
||||
|
||||
> 📖 Worker 注册与三级限流见 08d-工具3-代码模式与技术规范.md §4.4
|
||||
|
||||
> **v1.4.2 三级限流架构(队列名已合规):**
|
||||
> ```
|
||||
> asl_extraction_child (teamConcurrency: 10) ← 背压阀门,防 OOM
|
||||
> └─ asl_mineru_extract (teamConcurrency: 2) ← 昂贵 API 保护
|
||||
> └─ asl_llm_extract (teamConcurrency: 5) ← LLM 并发保护
|
||||
> ```
|
||||
> 全部基于 PostgreSQL 行锁实现全局并发控制,跨所有 Node.js 实例生效。
|
||||
> P-Queue 是进程内信号量,多实例下形同虚设——已废弃。
|
||||
> 🚀 v2.0 简化:废弃三级嵌套子队列(`asl_mineru_extract`、`asl_llm_extract`),
|
||||
> 统一为单一队列 `asl_extract_single`,MinerU 和 LLM 调用在 Worker 内部串行执行。
|
||||
> `teamConcurrency: 10` 已足够控制全局并发(10 个 Worker × 1 MinerU/LLM = 最多 10 并发外部调用)。
|
||||
|
||||
**LLM 调用策略:**
|
||||
- 主模型:DeepSeek-V3(128K 上下文,成本低,JSON 输出能力强)
|
||||
@@ -649,28 +594,18 @@ SSE 端点在客户端首次连接时,立即下发一个 `sync` 事件,包
|
||||
|
||||
> 📖 前端 sync 降级处理见 08d-工具3-代码模式与技术规范.md §7.4
|
||||
|
||||
Worker 中通过 `CheckpointService` 更新进度,SSE 端点监听 checkpoint 变化并推送给前端。
|
||||
**🚀 v2.0 SSE 简化:纯轮询为主,SSE 日志流为可选增强**
|
||||
|
||||
**🆕 v1.5:SSE 跨实例实时日志通信 — PostgreSQL NOTIFY/LISTEN**
|
||||
|
||||
> **物理限制:** `sseEmitter.emit()` 基于内存 EventEmitter,用户连 Pod A,但 Worker 跑在 Pod B。
|
||||
> Pod B 触发 emit,Pod A 的 SSE 连接收不到任何实时日志。v1.4.1 仅解决了历史日志(logBuffer 降级),
|
||||
> **实时日志流本身** 在多 Pod 下仍然是断裂的。
|
||||
> v1.5 设计了 PostgreSQL NOTIFY/LISTEN 跨 Pod 广播方案。
|
||||
> v2.0 散装模式以**纯轮询**为核心(React Query 3s 轮询 + groupBy 进度查询),
|
||||
> 无需跨 Pod 实时通信即可满足业务需求。
|
||||
>
|
||||
> **解决方案:** 使用 PostgreSQL `NOTIFY/LISTEN` 实现跨实例广播(Postgres-Only 合规,不引入 Redis)。
|
||||
> **SSE 日志流定位降级为"可选增强":**
|
||||
> - M1:不做 SSE,纯轮询即可
|
||||
> - M2:如果团队有余力,可接入 NOTIFY/LISTEN 做终端日志流(锦上添花)
|
||||
> - 即使 SSE 完全不做,前端进度条和步骤跳转也不受任何影响(双轨制 v1.4 已保障)
|
||||
|
||||
| 角色 | 职责 |
|
||||
|------|------|
|
||||
| **Worker 发送端(Pod B)** | Child Worker 在生成日志时执行 `NOTIFY asl_sse_channel, '{taskId, type, data}'` |
|
||||
| **API 接收端(所有 Pod)** | Pod 启动时建立一条**独立的长连接**(不从连接池借),执行 `LISTEN asl_sse_channel`,收到消息后检查本机是否有该 `taskId` 的 SSE 客户端,有则推送 |
|
||||
|
||||
**关键约束:**
|
||||
- NOTIFY payload 上限 **8000 bytes**(日志消息绰绰有余)
|
||||
- LISTEN 连接必须**独立于连接池**(归还连接后 LISTEN 失效)
|
||||
- NOTIFY 是 fire-and-forget(无持久化、无重放),与 v1.4 双轨制定位完全吻合——日志流本身就是"阅后即焚"的视觉增强
|
||||
- **归属 M2**(M1 不做 SSE 日志流)
|
||||
|
||||
> 📖 NOTIFY/LISTEN 代码模式见 08d-工具3-代码模式与技术规范.md §7.6
|
||||
> 📖 如需实现 SSE 跨 Pod 广播,参见 08d-工具3-代码模式与技术规范.md §7.6(保留为可选方案)
|
||||
|
||||
---
|
||||
|
||||
@@ -1073,11 +1008,11 @@ backend/src/modules/asl/
|
||||
│ │ ├── DynamicPromptBuilder.ts # 动态 Prompt 组装(XML 隔离 + 防注入护栏)
|
||||
│ │ ├── PdfProcessingPipeline.ts # MinerU 表格增强 + PKB 文本复用
|
||||
│ │ ├── PkbBridgeService.ts # PKB 桥接服务(调用 PkbExportService ACL)
|
||||
│ │ ├── ExtractionService.ts # 核心提取服务(teamConcurrency 全局限流)
|
||||
│ │ ├── ExtractionService.ts # 核心提取服务
|
||||
│ │ ├── ExtractionValidator.ts # 提取结果验证(fuzzyQuoteMatch)
|
||||
│ │ ├── ExtractionExcelExporter.ts # Excel 宽表导出
|
||||
│ │ ├── ExtractionManagerWorker.ts # 🆕 v1.3 Fan-out Manager(派发 N 个 Child)
|
||||
│ │ └── ExtractionChildWorker.ts # 🆕 v1.3 Fan-out Child(单篇粒度提取)
|
||||
│ │ ├── ExtractionSingleWorker.ts # 🚀 v2.0 独立 Worker(单篇粒度,只写自己的 Result)
|
||||
│ │ └── ExtractionAggregator.ts # 🚀 v2.0 轮询收口(僵尸清理 + 完成判定)
|
||||
│ ├── prompts/
|
||||
│ │ ├── system_prompt_v2.md # V2 系统提示词
|
||||
│ │ └── extraction_schema/ # 各模板的 JSON Schema
|
||||
@@ -1150,7 +1085,7 @@ frontend-v2/src/modules/asl/
|
||||
|
||||
| 里程碑 | 文档 | 时间 | 核心交付 |
|
||||
|--------|------|------|---------|
|
||||
| **M1 骨架管线** | `08a-工具3-M1-骨架管线冲刺清单.md` | Week 1(5-6 天) | Fan-out 全链路 + PKB ACL + 纯文本盲提 + 极简前端 |
|
||||
| **M1 骨架管线** | `08a-工具3-M1-骨架管线冲刺清单.md` | Week 1(5-6 天) | 散装派发 + Aggregator 全链路 + PKB ACL + 纯文本盲提 + 极简前端 |
|
||||
| **M2 HITL 工作台** | `08b-工具3-M2-HITL工作台冲刺清单.md` | Week 2-3(8-9 天) | MinerU + 审核抽屉 + SSE 日志 + Excel 导出 |
|
||||
| **M3 动态模板引擎** | `08c-工具3-M3-动态模板引擎冲刺清单.md` | Week 4(5-6 天) | 自定义字段 + 安全护栏 + E2E 封版 |
|
||||
| **合计** | | **~22 天** | 每周五可独立 Demo |
|
||||
@@ -1251,22 +1186,15 @@ fileUrl = await storage.upload(storageKey, file); // ✅ 已集成 storage.uplo
|
||||
| 🆕 v1.3 跨模块耦合蔓延 | 未来 PKB 改表导致 ASL 崩溃 | ACL 防腐层:PkbExportService 返回 DTO,ASL 不 import PKB 内部类型 |
|
||||
| 🆕 v1.3 签名 URL 过期 403 | 医生审核中途无法查看 PDF | 懒签名(10 分钟)+ 前端 403 自动刷新 |
|
||||
| 🆕 v1.3 SSE 重连后进度空白 | 用户刷新页面看到空白进度条 | SSE Hydration on Connect:首帧 `sync` 事件下发当前快照 |
|
||||
| 🆕 v1.3 pg-boss 单 Job 崩溃全重做 | N 篇文献因 1 篇崩溃全部重来 | Fan-out 扇出模式:每篇独立 Child Job,独立 retry |
|
||||
| 🆕 v1.3 pg-boss retry 产生重复数据 | 同一篇文献被提取多次 | ⚠️ v1.4.2 改为 `updateMany` 乐观锁抢占 + singletonKey 防重复派发 |
|
||||
| 🆕 v1.4 P-Queue 多实例并发翻倍 | K8s 3 Pod → 实际并发 6 导致 429 | 废弃 P-Queue,改 pg-boss `teamConcurrency`(DB 级全局锁) |
|
||||
| 🆕 v1.4 Fan-out 并发回写计数丢失 | 100 子任务并发 +1 产生 Race Condition | Prisma 原子递增 `{ increment: 1 }` + 事务保障 |
|
||||
| 🆕 v1.4 永久错误被盲目重试 3 次 | PKB 文件删除 → pg-boss 白重试 | 错误分级路由:致命错误 return success 停止重试 |
|
||||
| 🆕 v1.4 MinerU 重复解析浪费算力 | retry / 跨任务同 PDF 重复调用 | MinerU Clean Data OSS 缓存(Cache-Aside) |
|
||||
| 🆕 v1.4 Quote 标红后 HITL 死锁 | 医生无法放行正确但低置信度的提取 | [强制认可] + [手动修改数值] 双按钮解锁 |
|
||||
| 🆕 v1.4 SSE 断开中断主业务流 | 进度条停滞、步骤跳转失效 | 双轨制:React Query 轮询驱动主流 + SSE 仅驱动日志 |
|
||||
| 🚨 v1.4.1 Quote 验证搜索范围遗漏 MinerU | LLM 引用 MinerU 表格文本但验证仅搜索 pymupdf4llm → 满屏红色误报 | `buildQuoteSearchScope()` 拼接 MinerU 纯文本后再匹配 |
|
||||
| 🚨 v1.4.1 logBuffer 多 Pod 内存不共享 | SSE 重连被路由到其他 Pod → 日志区空白 | 降级方案:不存历史日志,重连时打印"监控已重连"提示 |
|
||||
| 🚨 v1.4.1 Child Worker 无并发限制 OOM | 1000 篇文献 → 1000 个挂起闭包 → V8 堆溢出 | `teamConcurrency: 10` 背压限流,其余在 PostgreSQL 排队 |
|
||||
| 🚨 v1.4.2 Fan-out 终点丢失 | Manager 退出后无人翻转 Task status → 前端永远卡在 Step 2 processing | "Last Child Wins":Child 原子递增后判断 `successCount + failedCount >= totalCount` 则翻转 `completed` |
|
||||
| 🚨 v1.4.2 伪幂等 Read-then-Write 穿透 | 并发 retry 同时 `findUnique` 读到 pending → 双倍 MinerU/LLM 调用 + 计数双递增 | `updateMany({ where: { status: 'pending' } })` 乐观锁原子抢占,`count=0` 直接退出 |
|
||||
| 🚨 v1.4.2 队列名称点号违规 | pg-boss 底层将点号识别为 Schema 分隔符,可能路由截断 | 全局替换为下划线格式:`asl_extraction_manager`、`asl_extraction_child`、`asl_mineru_extract`、`asl_llm_extract` |
|
||||
| 🆕 v1.5 SSE 实时日志跨 Pod 断裂 | 用户连 Pod A,Worker 跑 Pod B,`sseEmitter.emit()` 基于内存 EventEmitter → Pod A 零日志 | PostgreSQL `NOTIFY/LISTEN` 跨实例广播,Pod 启动时建立独立长连接 `LISTEN`,Worker 端 `NOTIFY`(归属 M2) |
|
||||
| 🆕 v1.5 PKB 数据提取中被删改 | 50 分钟提取窗口内用户在 PKB 删除 PDF → Child Worker 找不到 `storageKey` 批量崩溃 | Manager 派发时快照 `storageKey` + `filename` 到 `AslExtractionResult`,Child 从自身记录读取(归属 M1) |
|
||||
| 🚀 v2.0 pg-boss retry 产生重复处理 | 同一篇文献被提取多次,浪费 LLM 费用 | 幽灵重试守卫 `updateMany({ where: { status: 'pending' } })`,幽灵 retry 直接跳过 |
|
||||
| 🚀 v2.0 Worker OOM 崩溃后 Result 卡 extracting | Aggregator 前该 Result 永远不收口 | Aggregator 每 10 秒扫描 `extracting > 30min` → 强制标记 error |
|
||||
| 🚀 v2.0 API 重试创建重复 Task | 浏览器重试 / 网络超时重发 | `idempotencyKey @unique` + P2002 捕获(DB 物理级幂等) |
|
||||
| v1.4 永久错误被盲目重试 3 次 | PKB 文件删除 → pg-boss 白重试 | 错误分级路由:致命错误 return 停止重试 |
|
||||
| v1.4 MinerU 重复解析浪费算力 | retry / 跨任务同 PDF 重复调用 | MinerU Clean Data OSS 缓存(Cache-Aside) |
|
||||
| v1.4 Quote 标红后 HITL 死锁 | 医生无法放行正确但低置信度的提取 | [强制认可] + [手动修改数值] 双按钮解锁 |
|
||||
| v1.4 SSE 断开中断主业务流 | 进度条停滞、步骤跳转失效 | 双轨制:React Query 轮询驱动主流 + SSE 仅驱动日志 |
|
||||
| v1.4.1 Quote 验证搜索范围遗漏 MinerU | LLM 引用 MinerU 表格文本但验证仅搜索 pymupdf4llm → 满屏红色误报 | `buildQuoteSearchScope()` 拼接 MinerU 纯文本后再匹配 |
|
||||
| v1.5 PKB 数据提取中被删改 | 50 分钟提取窗口内用户在 PKB 删除 PDF → Worker 找不到 `storageKey` 批量崩溃 | API 创建任务时快照 `storageKey` + `filename` 到 `AslExtractionResult`,Worker 从自身记录读取 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1286,7 +1214,8 @@ fileUrl = await storage.upload(storageKey, file); // ✅ 已集成 storage.uplo
|
||||
| **架构审查与改进建议** | `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3架构审查与研发改进建议.md` |
|
||||
| **🆕 深度审查与排雷指南** | `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3开发计划深度审查与排雷指南.md` |
|
||||
| **🆕 终极架构审查与研发规范** | `docs/03-业务模块/ASL-AI智能文献/06-技术文档/工具3终极架构审查与研发规范.md` |
|
||||
| **🆕 Postgres-Only 异步任务处理指南** | `docs/02-通用能力层/Postgres-Only异步任务处理指南.md` |
|
||||
| **🚀 散装派发与轮询收口指南** | `docs/02-通用能力层/散装派发与轮询收口任务模式指南.md`(**v2.0 核心参考**) |
|
||||
| **Postgres-Only 异步任务处理指南** | `docs/02-通用能力层/Postgres-Only异步任务处理指南.md` |
|
||||
| **🆕 PKB 个人知识库状态** | `docs/03-业务模块/PKB-个人知识库/00-模块当前状态与开发指南.md` |
|
||||
| **🆕 M1 骨架管线冲刺清单** | `docs/03-业务模块/ASL-AI智能文献/04-开发计划/08a-工具3-M1-骨架管线冲刺清单.md` |
|
||||
| **🆕 M2 HITL 工作台冲刺清单** | `docs/03-业务模块/ASL-AI智能文献/04-开发计划/08b-工具3-M2-HITL工作台冲刺清单.md` |
|
||||
@@ -1296,19 +1225,15 @@ fileUrl = await storage.upload(storageKey, file); // ✅ 已集成 storage.uplo
|
||||
|
||||
---
|
||||
|
||||
*本文档为架构总纲(v1.5)。排期与任务分配已拆分为 M1/M2/M3 三个独立冲刺清单(见第六章)。*
|
||||
*本文档为架构总纲(v2.0 散装派发 + Aggregator 轮询收口)。排期与任务分配已拆分为 M1/M2/M3 三个独立冲刺清单(见第六章)。*
|
||||
|
||||
*M1 启动前全员必须确认以下研发红线:*
|
||||
*1. **Payload 轻量**:pg-boss Job Data 仅传 ID,禁止塞 PDF 正文或 extractedText*
|
||||
*2. **计算卸载**:Node.js 禁碰 pymupdf4llm/MinerU 解析,必须路由到 Python 微服务或 Cloud API*
|
||||
*3. **过期时间**:Child Job `expireInMinutes: 30`,Manager Job `expireInMinutes: 60`*
|
||||
*4. **三级限流**:Child `teamConcurrency: 10` → MinerU `teamConcurrency: 2` → LLM `teamConcurrency: 5`(全部 pg-boss 全局锁)*
|
||||
*5. **原子递增**:Fan-out Child 完成时必须用 Prisma `{ increment: 1 }` 原子更新父任务计数*
|
||||
*6. **错误分级**:致命错误 return success 停止重试,临时错误 throw 让 pg-boss 自动退避*
|
||||
*7. **ACL 防腐层**:ASL 绝不直接 import PKB 内部类型,统一通过 `PkbExportService` 获取 DTO*
|
||||
*8. **Quote 搜索池**:`fuzzyQuoteMatch` 必须在 pymupdf4llm 全文 **+ MinerU 纯文本** 中匹配,禁止仅搜索 Markdown*
|
||||
*9. **Last Child Wins**:Fan-out 模式中最后一个完成的 Child(无论成功或失败)必须翻转父任务 `status = completed`,否则前端永远卡在 Step 2*
|
||||
*10. **乐观锁抢占**:Child Worker 禁止使用 `findUnique → if check` 模式(Read-then-Write),必须用 `updateMany({ where: { status: 'pending' } })` 原子锁*
|
||||
*11. **队列命名下划线**:所有 pg-boss 队列名使用下划线(`asl_extraction_child`),禁止点号(遵守《Postgres-Only 指南》§4.1)*
|
||||
*12. **PKB 快照冻结**:Manager 派发 Child Job 前必须将 `storageKey` + `filename` 快照到 `AslExtractionResult`,Child Worker 从自身记录读取,禁止运行时回查 PKB(v1.5)*
|
||||
*13. **SSE 跨 Pod 广播**:Worker 日志推送必须经 PostgreSQL `NOTIFY`,禁止依赖内存 EventEmitter 跨实例通信(v1.5,M2 实施)*
|
||||
*1. **Worker 独立性**:Worker 只更新自己的 Result 行,**绝不碰 Task 表**,进度由 Aggregator groupBy 聚合*
|
||||
*2. **幽灵重试守卫**:Worker 禁止 `findUnique → if check`(Read-then-Write),必须用 `updateMany({ where: { status: 'pending' } })` 原子锁*
|
||||
*3. **临时错误回退**:临时错误 throw 前必须 `update({ status: 'pending' })` 释放幽灵守卫*
|
||||
*4. **API DB 级幂等**:`idempotencyKey @unique` + P2002 捕获,禁止 Read-then-Write 幂等*
|
||||
*5. **Payload 轻量**:pg-boss Job Data 仅传 `{ resultId, pkbDocumentId }`,禁止塞 PDF 正文*
|
||||
*6. **计算卸载**:Node.js 禁碰 pymupdf4llm/MinerU 解析,必须路由到 Python 微服务或 Cloud API*
|
||||
*7. **错误分级**:致命错误 return 停止重试,临时错误回退 pending + throw 让 pg-boss 自动退避*
|
||||
*8. **ACL 防腐层**:ASL 绝不直接 import PKB 内部类型,统一通过 `PkbExportService` 获取 DTO*
|
||||
*9. **PKB 快照冻结**:API 创建任务时必须将 `storageKey` + `filename` 快照到 `AslExtractionResult`,Worker 从自身记录读取,禁止运行时回查 PKB*
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
> **所属:** 工具 3 全文智能提取工作台 V2.0
|
||||
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
|
||||
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
|
||||
> **建议时间:** Week 1(5.5-6.5 天,含 v1.6 Sweeper 清道夫 0.5 天)
|
||||
> **核心目标:** 证明 "PKB 拿数据 → Fan-out 分发 → LLM 盲提 → 数据落库 → 前端看到 completed" 这条管线是通的。
|
||||
> **建议时间:** Week 1(5-6 天)
|
||||
> **核心目标:** 证明 "API 散装派发 → Worker 单兵提取 → Aggregator 收口 → 前端轮询到 completed" 这条管线是通的。
|
||||
> **异步模式指南:** `散装派发与轮询收口任务模式指南.md`(Level 2 Cookbook)
|
||||
|
||||
---
|
||||
|
||||
@@ -28,9 +29,10 @@
|
||||
**验收标准:**
|
||||
- [ ] `npx prisma migrate deploy` 成功
|
||||
- [ ] `npx prisma db seed` 后数据库有 3 套模板记录
|
||||
- [ ] `AslExtractionTask` 含 `pkbKnowledgeBaseId` 字段
|
||||
- [ ] `AslExtractionResult` 含 `snapshotStorageKey` + `snapshotFilename` 快照字段(v1.5)
|
||||
- [ ] `AslExtractionResult` 含 `pkbDocumentId` 字段、`status` 含 `extracting` 状态值
|
||||
- [ ] `AslExtractionTask` 含 `pkbKnowledgeBaseId` + `idempotencyKey @unique` 字段(无 `successCount/failedCount`)
|
||||
- [ ] `AslExtractionResult` 含 `snapshotStorageKey` + `snapshotFilename` 快照字段
|
||||
- [ ] `AslExtractionResult` 含 `pkbDocumentId` 字段、`status` 含 `pending | extracting | completed | error`
|
||||
- [ ] `AslExtractionResult` 含 `@@index([taskId, status])` 复合索引(Aggregator 性能保障)
|
||||
|
||||
> 📖 Schema 详情见架构总纲 Task 1.1
|
||||
|
||||
@@ -40,8 +42,11 @@
|
||||
|
||||
**做什么:**
|
||||
- `TemplateController.ts`:GET 模板列表、GET 模板详情、POST 克隆到项目
|
||||
- `ExtractionController.ts`:POST 创建任务、GET 任务状态(**React Query 轮询用**)、GET 结果列表
|
||||
- 创建任务时:锁定模板 → 批量创建 `AslExtractionResult`(status=pending)→ `pgBoss.send('asl_extraction_manager', { taskId })`
|
||||
- `ExtractionController.ts`:
|
||||
- POST 创建任务(含 DB 幂等 `idempotencyKey @unique` + P2002)
|
||||
- GET 任务状态(`groupBy` 聚合 Result 状态,驱动 React Query 轮询)
|
||||
- GET 结果列表
|
||||
- 🚀 **创建任务 = API 层散装派发(无 Manager)**:锁定模板 → PKB 快照冻结 → `createMany` Result → `jobQueue.insert` N 个 `asl_extract_single` Job
|
||||
|
||||
**不做什么:**
|
||||
- 不做自定义字段 CRUD API(M3)
|
||||
@@ -49,15 +54,17 @@
|
||||
- 不做 Excel 导出(M2)
|
||||
|
||||
**验收标准:**
|
||||
- [ ] `POST /api/v1/asl/extraction/tasks` 能创建任务并入队
|
||||
- [ ] `GET /api/v1/asl/extraction/tasks/:taskId` 返回 `status`、`successCount`、`totalCount`
|
||||
- [ ] `POST /api/v1/asl/extraction/tasks` 创建任务并散装派发 N 个 Job
|
||||
- [ ] 重复 `idempotencyKey` 请求返回已有 taskId(幂等验证)
|
||||
- [ ] `GET /api/v1/asl/extraction/tasks/:taskId` 返回 `groupBy` 聚合进度(completedCount / errorCount / pendingCount / extractingCount)
|
||||
- [ ] `GET /api/v1/asl/extraction/tasks/:taskId/results` 返回提取结果列表
|
||||
|
||||
> 📖 端点完整列表见架构总纲 Task 1.3 + Task 2.4
|
||||
> 📖 端点完整列表见架构总纲 Task 1.3 + Task 2.4
|
||||
> 📖 API 散装派发代码见 08d §4.2
|
||||
|
||||
---
|
||||
|
||||
### M1-3:PKB ACL 防腐层 + Fan-out 调度核心(2 天)⚠️ 本里程碑最关键
|
||||
### M1-3:PKB ACL 防腐层 + 散装 Worker + Aggregator(2 天)⚠️ 本里程碑最关键
|
||||
|
||||
**做什么(按顺序):**
|
||||
|
||||
@@ -68,44 +75,46 @@
|
||||
**Step B — ASL 侧桥接(0.5 天):**
|
||||
- `PkbBridgeService.ts`:调用 `PkbExportService`,代理所有 PKB 数据访问
|
||||
|
||||
**Step C — Fan-out Manager + Child Worker(1 天)⚠️ 核心战役:**
|
||||
- `ExtractionManagerWorker.ts`:读取任务 → 🆕 **v1.6 空集合守卫**(`results.length === 0` → 直接 completed) → ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出(Fire-and-forget)
|
||||
- `ExtractionChildWorker.ts` 完整逻辑:
|
||||
1. **乐观锁抢占**:`updateMany({ where: { status: 'pending' }, data: { status: 'extracting' } })`
|
||||
**Step C — ExtractionSingleWorker(0.5 天)⚠️ 核心战役:**
|
||||
- `ExtractionSingleWorker.ts` 完整逻辑:
|
||||
1. **幽灵重试守卫**:`updateMany({ where: { status: 'pending' }, data: { status: 'extracting' } })`
|
||||
2. **纯文本降级提取**:从 PKB 读 `extractedText` + 写死 RCT Schema → 调用 DeepSeek
|
||||
3. **原子递增**:事务内 `update Result + increment Task counts`
|
||||
4. **Last Child Wins**:`successCount + failedCount >= totalCount` → 翻转 `status = completed`
|
||||
5. **错误分级路由**:致命错误 return / 🆕 **v1.6 临时错误 throw 前释放乐观锁(回退 status → pending)**
|
||||
3. **只更新自己的 Result**:`prisma.aslExtractionResult.update({ status: 'completed', extractedData })`
|
||||
4. **绝不碰 Task 表**(无 `$transaction`、无 `increment`、无 Last Child Wins)
|
||||
5. **错误分级路由**:致命错误 → 标 error + return;临时错误 → 回退 `pending` + throw
|
||||
|
||||
**Step D — 🆕 Sweeper 清道夫注册(0.5 天)(v1.6 新增):**
|
||||
- `asl_extraction_sweeper`:pg-boss 定时任务,每 10 分钟扫描 `processing` 且 `updatedAt > 2h` 的任务,强制标记 `failed`
|
||||
- 使用 `updatedAt`(最后活跃时间)判断卡死,禁止用 `startedAt`(防误杀健康的超大批量任务)
|
||||
**Step D — ExtractionAggregator(0.5 天):**
|
||||
- `ExtractionAggregator.ts`:pg-boss schedule 每 2 分钟执行
|
||||
- 一人兼两职:**僵尸清理**(`extracting > 30min` → error)+ **收口**(`pending === 0 && extracting === 0` → completed)
|
||||
- 使用 `groupBy` 一次查询统计所有状态
|
||||
|
||||
**Worker 注册(遵守队列命名规范):**
|
||||
**Worker + Aggregator 注册(遵守队列命名规范):**
|
||||
```
|
||||
jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
|
||||
jobQueue.work('asl_extract_single', { teamConcurrency: 10 }, handler)
|
||||
await jobQueue.schedule('asl_extraction_aggregator', '*/2 * * * *')
|
||||
await jobQueue.work('asl_extraction_aggregator', aggregatorHandler)
|
||||
```
|
||||
|
||||
**M1 阶段简化:不注册 `asl_mineru_extract` 子队列(M2 才接 MinerU)。**
|
||||
**M1 阶段简化:Worker 内部串行调 LLM(不接 MinerU,M2 再接)。**
|
||||
|
||||
**验收标准:**
|
||||
- [ ] PkbExportService 能返回知识库列表和文档详情(DTO)
|
||||
- [ ] Manager 派发后 `AslExtractionResult.snapshotStorageKey` 和 `snapshotFilename` 已填充(v1.5 快照验证)
|
||||
- [ ] 手动删除 PKB 文档记录后,Child Worker 仍能通过 `snapshotStorageKey` 从 OSS 获取 PDF(v1.5 一致性验证)
|
||||
- [ ] Manager 能为 N 篇文献派发 N 个 Child Job
|
||||
- [ ] Child Worker 乐观锁正确:并发重试不会双倍处理
|
||||
- [ ] Child Worker 原子递增:10 篇并发提取后 `successCount = 10`
|
||||
- [ ] Last Child Wins:最后一个 Child 翻转 Task status = completed
|
||||
- [ ] API 创建任务后 `AslExtractionResult.snapshotStorageKey` 和 `snapshotFilename` 已填充(PKB 快照验证)
|
||||
- [ ] 手动删除 PKB 文档记录后,Worker 仍能通过 `snapshotStorageKey` 从 OSS 获取 PDF(一致性验证)
|
||||
- [ ] API 为 N 篇文献散装派发 N 个 `asl_extract_single` Job
|
||||
- [ ] Worker 幽灵守卫正确:并发重试不会双倍处理(`lock.count === 0` 跳过)
|
||||
- [ ] Worker 只写 Result 行,Task 表零更新(确认无行锁争用)
|
||||
- [ ] Aggregator 每 2 分钟轮询:`pending === 0 && extracting === 0` → Task `completed`
|
||||
- [ ] Aggregator 僵尸清理:手动将 Result 卡在 `extracting` 超 30 分钟 → 被标为 `error`
|
||||
- [ ] 致命错误(PKB 文档不存在)→ 该篇标 error + 不重试 + 不阻塞其他篇
|
||||
- [ ] 临时错误(429)→ pg-boss 指数退避重试
|
||||
- [ ] 🆕 临时错误 throw 前回退 `status → pending`:模拟 429 重试后乐观锁仍能抢占成功(v1.6 乐观锁释放验证)
|
||||
- [ ] 🆕 Manager 空集合守卫:`results.length === 0` 时 Task 直接标记 `completed`(v1.6 边界验证)
|
||||
- [ ] 🆕 Sweeper 清道夫已注册:`asl_extraction_sweeper` 定时任务在 pg-boss 中可查到(v1.6)
|
||||
- [ ] 🆕 Sweeper 判定条件为 `updatedAt > 2h`,而非 `startedAt`(v1.6 防误杀验证)
|
||||
- [ ] 临时错误(429)→ 回退 `pending` + pg-boss 指数退避重试
|
||||
- [ ] 临时错误回退后重试成功:模拟 429 → 重试 → 幽灵守卫通过 → 提取成功
|
||||
- [ ] 空文献边界:`documentIds = []` → API 直接拒绝(400 Bad Request)
|
||||
|
||||
> 📖 Fan-out 架构图、Worker 代码模式、研发红线见架构总纲 Task 2.3
|
||||
> 📖 散装架构图、Worker 代码模式见架构总纲 Task 2.3
|
||||
> 📖 ACL 防腐层设计见架构总纲 Task 3.3b
|
||||
> 📖 Sweeper、乐观锁释放、空集合守卫代码见 08d §4.2 / §4.3 / §4.6
|
||||
> 📖 Worker / Aggregator 代码见 08d §4.3 / §4.6
|
||||
> 📖 散装模式指南见 `散装派发与轮询收口任务模式指南.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -152,29 +161,27 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
|
||||
|
||||
| # | 红线 | 违反后果 |
|
||||
|---|------|---------|
|
||||
| 1 | 队列名用下划线(`asl_extraction_child`),禁止点号 | pg-boss 路由截断 |
|
||||
| 2 | Child Worker 用 `updateMany` 乐观锁,禁止 `findUnique → if` | 并发穿透,算力翻倍 |
|
||||
| 3 | Last Child Wins 终止器,成功和失败路径都要检查 | Task 永远卡在 processing |
|
||||
| 4 | `teamConcurrency: 10`,禁止无限拉取 Child Job | Node.js OOM |
|
||||
| 5 | Job Payload 仅传 ID(< 200 bytes),禁止塞 PDF 正文 | pg-boss 阻塞 |
|
||||
| 6 | ACL 防腐层:ASL 不 import PKB 内部类型 | 模块耦合蔓延 |
|
||||
| 7 | Manager 必须快照 `snapshotStorageKey` + `snapshotFilename`,Child 禁止运行时回查 PKB 获取 storageKey(v1.5) | 提取中 PKB 删文档 → 批量崩溃 |
|
||||
| 8 | 🆕 临时错误 `throw` 前必须 `update({ status: 'pending' })` 释放乐观锁(v1.6) | 重试时被"幂等跳过",计数永远缺一票,Task 永久卡死 |
|
||||
| 9 | 🆕 Manager 必须检查 `results.length === 0` 并直接 completed(v1.6) | 空文献 → 无 Child → Last Child Wins 死锁 |
|
||||
| 10 | 🆕 必须注册 `asl_extraction_sweeper` 清道夫(`updatedAt > 2h`,禁止用 `startedAt`)(v1.6) | 进程 OOM/SIGKILL 后 Task 永久挂起 |
|
||||
| 1 | 队列名用下划线(`asl_extract_single`),禁止点号 | pg-boss 路由截断 |
|
||||
| 2 | **Worker 绝不碰 Task 表**,只写自己的 Result 行 | 行锁争用 / 死锁 |
|
||||
| 3 | Worker 用 `updateMany({ status: 'pending' })` 幽灵守卫,禁止 `findUnique → if` | 并发穿透,LLM 费用白烧 |
|
||||
| 4 | 临时错误 `throw` 前必须 `update({ status: 'pending' })` 回退 | 重试被幽灵守卫误跳过 |
|
||||
| 5 | `teamConcurrency: 10`,禁止无限拉取 Job | Node.js OOM |
|
||||
| 6 | Job Payload 仅传 ID(< 200 bytes),禁止塞 PDF 正文 | pg-boss 阻塞 |
|
||||
| 7 | ACL 防腐层:ASL 不 import PKB 内部类型 | 模块耦合蔓延 |
|
||||
| 8 | API 层快照 `snapshotStorageKey` + `snapshotFilename`,Worker 禁止运行时回查 PKB | PKB 删文档 → 批量崩溃 |
|
||||
| 9 | API 创建任务用 `idempotencyKey @unique` + P2002,禁止 Read-then-Write | 并发穿透创建重复任务 |
|
||||
|
||||
---
|
||||
|
||||
## M1 结束时的状态
|
||||
|
||||
```
|
||||
✅ Prisma 表 + 3 套 Seed 模板
|
||||
✅ Prisma 表 + 3 套 Seed 模板(含 idempotencyKey @unique)
|
||||
✅ PKB ACL 防腐层 → PkbExportService + PkbBridgeService
|
||||
✅ Fan-out 全链路:Manager → N × Child → Last Child Wins → completed
|
||||
✅ 乐观锁 + 原子递增 + 错误分级路由 — 所有并发 Bug 已验证
|
||||
✅ 🆕 Sweeper 清道夫注册(v1.6 防 OOM/SIGKILL 卡死)
|
||||
✅ 🆕 乐观锁释放 + 空集合守卫 + pg_notify 参数化(v1.6 全量代码级同步)
|
||||
✅ 前端三步走:选模板/选文献 → 轮询进度 → 极简结果列表
|
||||
✅ 散装派发全链路:API 散装 → N × Worker → Aggregator 收口 → completed
|
||||
✅ 幽灵守卫 + 错误分级路由 + Aggregator 僵尸清理 — 所有并发 Bug 已验证
|
||||
✅ API 层 DB 幂等 + PKB 快照冻结
|
||||
✅ 前端三步走:选模板/选文献 → 轮询进度(groupBy 聚合)→ 极简结果列表
|
||||
❌ 无 MinerU(纯文本降级)
|
||||
❌ 无 SSE 日志流
|
||||
❌ 无审核抽屉
|
||||
@@ -182,4 +189,4 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
|
||||
❌ 无 Excel 导出
|
||||
```
|
||||
|
||||
> **M1 的核心价值:** 所有分布式 Bug(并发死锁、幂等穿透、终点丢失、背压 OOM)在第一周就被逼出来。M2 加特性时地基是稳的。
|
||||
> **M1 的核心价值:** 散装架构天然消除了 Fan-out 的行锁争用、Last Child Wins 终点丢失等分布式 Bug。Worker 逻辑极简(只写自己),Aggregator 定时收口,第一周就能稳定跑通全链路。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **所属:** 工具 3 全文智能提取工作台 V2.0
|
||||
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
|
||||
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
|
||||
> **前置依赖:** M1 全部完成(Fan-out 管线已验证、PKB ACL 已通、纯文本提取可跑通)
|
||||
> **前置依赖:** M1 全部完成(散装管线 + Aggregator 已验证、PKB ACL 已通、纯文本提取可跑通)
|
||||
> **建议时间:** Week 2-3(8-9 天)
|
||||
> **核心目标:** 接入 MinerU 视觉大模型提升表格准确率,完成前端最复杂的 HITL 审核抽屉,交付一个"完全可用"的 V1 产品。
|
||||
|
||||
@@ -21,19 +21,18 @@
|
||||
|
||||
**做什么:**
|
||||
- `PdfProcessingPipeline.ts` 升级:M1 的纯文本降级 → 完整双引擎流水线
|
||||
- 从 PKB `storageKey` 下载 PDF Buffer → 调用 MinerU Cloud API → 返回结构化 HTML 表格
|
||||
- 从 PKB `snapshotStorageKey` 下载 PDF Buffer → 调用 MinerU Cloud API → 返回结构化 HTML 表格
|
||||
- **MinerU Clean Data OSS 缓存**(Cache-Aside):调用前先检查 `pkb/{kbId}/{docId}_mineru_clean.html`,命中则 <1 秒返回
|
||||
- 注册 `asl_mineru_extract` 子队列(`teamConcurrency: 2`)
|
||||
- Child Worker 内部通过 `pgBoss.send('asl_mineru_extract', ...)` 派发 MinerU 子任务
|
||||
- Worker 内部串行 `await mineruClient.extractTables()`(无需独立子队列,`teamConcurrency: 10` 即为 MinerU 隐式并发上限)
|
||||
|
||||
**不做什么:**
|
||||
- 不改 Fan-out 架构(M1 已稳定)
|
||||
- 不改散装架构(M1 已稳定)
|
||||
- 不做动态 Prompt(M3),继续用写死的 RCT Schema
|
||||
|
||||
**验收标准:**
|
||||
- [ ] MinerU 返回 HTML 表格,含 `<table>` + `colspan/rowspan`
|
||||
- [ ] OSS 缓存命中时跳过 MinerU 调用(日志可见 "Cache hit")
|
||||
- [ ] `asl_mineru_extract` 队列 `teamConcurrency: 2` 生效(3 Pod 环境下全局最多 2 个并行)
|
||||
- [ ] `teamConcurrency: 10` 全局并发控制生效(MinerU 调用受此限制)
|
||||
- [ ] MinerU 超时(>3min)自动降级到纯文本
|
||||
|
||||
> 📖 缓存代码模式见架构总纲 Task 2.2
|
||||
@@ -68,30 +67,30 @@
|
||||
|
||||
**做什么:**
|
||||
- `ExtractionController.ts` 新增 SSE 端点 `GET /tasks/:taskId/stream`
|
||||
- SSE 事件类型:`sync`(首帧)、`progress`、`log`、`complete`、`error`
|
||||
- SSE 事件类型:`sync`(首帧)、`log`、`error`
|
||||
- **首帧 sync 降级方案**:`recentLogs: []`(不依赖内存 logBuffer),前端检测到空日志时打印 "--- 监控已重新连接 ---"
|
||||
- `ProcessingTerminal.tsx` 组件:深色终端风格,来源颜色区分(MinerU 蓝 / DeepSeek 紫 / System 绿)
|
||||
- `useExtractionLogs.ts` Hook:仅驱动日志区,不影响主业务流
|
||||
- SSE 使用**本 Pod 内存事件**即可(Worker 和 API 在同一 Pod 时日志实时可达)
|
||||
|
||||
**M1 已完成的不动:**
|
||||
- `useTaskStatus.ts`(React Query 轮询)继续驱动进度条和步骤跳转
|
||||
- `useTaskStatus.ts`(React Query 轮询 + groupBy 进度)继续驱动进度条和步骤跳转
|
||||
- `complete` 检测完全由 React Query 轮询到 `status === 'completed'` 触发,不依赖 SSE
|
||||
|
||||
**[P2 可选] SSE 跨 Pod 广播 — NOTIFY/LISTEN:**
|
||||
> 散装架构下进度条由 React Query 轮询驱动,SSE 仅为日志增强。
|
||||
> 多 Pod 部署后若日志体验不佳,可后续实施 `SseNotifyBridge.ts`(代码见 08d §7.6)。
|
||||
> M2 阶段不强制实施。
|
||||
|
||||
**验收标准:**
|
||||
- [ ] SSE 连接后立即收到 `sync` 首帧
|
||||
- [ ] 日志实时打字机效果(`[MinerU]`、`[DeepSeek]`、`[System]` 分色)
|
||||
- [ ] SSE 断开后进度条不受影响(React Query 继续轮询)
|
||||
- [ ] 多 Pod 环境下 SSE 重连到其他 Pod → 显示 "监控已重新连接" 提示
|
||||
- [ ] **🆕 v1.5 NOTIFY/LISTEN 跨 Pod 实时日志:** Worker 在 Pod B 提取 → Pod A 的 SSE 客户端能实时收到日志
|
||||
|
||||
**🆕 v1.5 额外任务:SSE 跨 Pod 广播 — NOTIFY/LISTEN(含在 M2-3 工期内):**
|
||||
- `SseNotifyBridge.ts`:Pod 启动时创建独立 PgClient 长连接(不从连接池借),执行 `LISTEN asl_sse_channel`
|
||||
- 收到 NOTIFY 后检查本机是否有该 `taskId` 的 SSE 客户端,有则推送,无则静默忽略
|
||||
- `ExtractionChildWorker` 中替代 `sseEmitter.emit()`:改用 `prisma.$executeRawUnsafe('NOTIFY asl_sse_channel, ...')`
|
||||
- `complete` 事件同样走 NOTIFY 广播,确保"Last Child Wins"翻转后所有 Pod 收到
|
||||
- [ ] 多 Pod 环境下 SSE 重连到其他 Pod → 显示 "监控已重新连接" 提示(本 Pod 无历史日志时)
|
||||
|
||||
> 📖 双轨制架构见架构总纲 Task 4.1
|
||||
> 📖 SSE Hydration 降级见架构总纲 Task 2.4 补丁 2
|
||||
> 📖 NOTIFY/LISTEN 代码模式见 08d §7.6
|
||||
> 📖 SSE 降级方案见架构总纲 Task 2.4
|
||||
> 📖 [P2 可选] NOTIFY/LISTEN 代码模式见 08d §7.6
|
||||
|
||||
---
|
||||
|
||||
@@ -159,7 +158,7 @@
|
||||
- Step 1 → Step 2 → Step 3 完整流程走通(含 MinerU + 审核抽屉 + Excel)
|
||||
- fuzzyQuoteMatch 边界测试(连字符替换、空格差异、换行吞掉)
|
||||
- 断点恢复测试(关闭浏览器 → 重新打开 → 恢复正确步骤)
|
||||
- Fan-out 10 篇并发提取压力测试
|
||||
- 散装 10 篇并发提取压力测试
|
||||
|
||||
**验收标准:**
|
||||
- [ ] 8 篇测试 PDF 全链路跑通:PKB → MinerU + LLM → 抽屉审核 → Excel 导出
|
||||
@@ -172,12 +171,13 @@
|
||||
|
||||
```
|
||||
✅ M1 全部 +
|
||||
✅ MinerU 表格引擎 + OSS 缓存
|
||||
✅ MinerU 表格引擎 + OSS 缓存(Worker 内部串行调用)
|
||||
✅ XML 隔离 Prompt + 表格优先级
|
||||
✅ fuzzyQuoteMatch 三级置信度验证
|
||||
✅ SSE 终端日志(双轨制:React Query 主驱 + SSE 日志增强 + NOTIFY/LISTEN 跨 Pod 广播)
|
||||
✅ SSE 终端日志(双轨制:React Query 轮询主驱 + SSE 本 Pod 日志增强)
|
||||
✅ 完整审核抽屉(Collapse + Quote + HITL 解锁 + 签名 URL)
|
||||
✅ Excel 宽表导出
|
||||
⏳ [P2 可选] SSE NOTIFY/LISTEN 跨 Pod 广播(多 Pod 部署后按需实施)
|
||||
❌ 无自定义字段(仅系统基座模板)
|
||||
❌ 无 Prompt 注入防护(无用户输入,不需要)
|
||||
❌ 无 E2E 自动化测试
|
||||
|
||||
@@ -165,7 +165,7 @@ IMPORTANT: The rules above are ONLY for locating and extracting specific data fi
|
||||
|
||||
| 里程碑 | 时间 | 核心交付 | 可独立演示 |
|
||||
|--------|------|---------|-----------|
|
||||
| **M1** | Week 1(5-6 天) | Fan-out 骨架管线 + PKB ACL + 纯文本盲提 | ✅ 后台跑通,DB 有数据 |
|
||||
| **M1** | Week 1(5-6 天) | 散装派发 + Aggregator + PKB ACL + 纯文本盲提 | ✅ 后台跑通,DB 有数据 |
|
||||
| **M2** | Week 2-3(8-9 天) | MinerU + 审核抽屉 + SSE + Excel | ✅ 完整 V1 体验 |
|
||||
| **M3** | Week 4(5-6 天) | 动态模板 + 安全 + E2E | ✅ V2.0 完整交付 |
|
||||
| **合计** | **4 周(~22 天)** | | 每周五可 Demo |
|
||||
|
||||
@@ -148,12 +148,15 @@ if (pkbExtractedText) {
|
||||
|
||||
---
|
||||
|
||||
## 4. Fan-out Worker 模式(核心)
|
||||
## 4. 散装派发 + Aggregator 轮询收口(核心)
|
||||
|
||||
> 🚀 **v2.0 架构转型:** 本节完全重写,对齐 `散装派发与轮询收口任务模式指南.md`。
|
||||
> 废弃 Fan-out 模式(Manager/Child/Last Child Wins/原子递增/独立 Sweeper)。
|
||||
|
||||
### 4.1 ExtractionService 接口
|
||||
|
||||
```typescript
|
||||
// ⚠️ v1.4 终极修正:废弃 P-Queue,并发控制完全交给 pg-boss teamConcurrency
|
||||
// 🚀 v2.0:并发控制交给 pg-boss teamConcurrency,Worker 只写自己的 Result
|
||||
class ExtractionService {
|
||||
constructor(
|
||||
private promptBuilder: DynamicPromptBuilder,
|
||||
@@ -163,295 +166,258 @@ class ExtractionService {
|
||||
private pkbBridge: PkbBridgeService,
|
||||
) {}
|
||||
|
||||
// 单篇文献提取(Child Job 调用)
|
||||
async extractOne(resultId: string, taskId: string): Promise<void>;
|
||||
// 单篇文献提取(Worker 调用)
|
||||
async extractOne(resultId: string, taskId: string): Promise<ExtractionOutput>;
|
||||
|
||||
// 内部流程(单篇粒度):
|
||||
// 1. 加载项目模板 → 组装 Schema
|
||||
// 2. 从 PKB 读取 extractedText(零成本);用 snapshotStorageKey 访问 OSS(防 PKB 删除,v1.5)
|
||||
// 3. ⚠️ v1.4: 通过 snapshotStorageKey → OSS 缓存检查 → MinerU 子队列(teamConcurrency 全局限流)
|
||||
// 2. 从 PKB 读取 extractedText(零成本);用 snapshotStorageKey 访问 OSS(防 PKB 删除)
|
||||
// 3. MinerU 表格提取(含 OSS 缓存 Cache-Aside)
|
||||
// 4. 组装 Prompt(XML 隔离区 + 防注入护栏)→ LLM 调用
|
||||
// 5. 解析 JSON → fuzzyQuoteMatch 验证
|
||||
// 6. ⚠️ 事务内 upsert Result + 原子递增父任务计数(防 Race Condition)
|
||||
// 7. SSE 推送进度日志
|
||||
// 6. 返回 ExtractionOutput(Worker 负责写 Result,本方法不写 DB)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 ExtractionManagerWorker(Fire-and-forget)
|
||||
### 4.2 API 层散装派发(ExtractionController)
|
||||
|
||||
> 🚀 v2.0:**删除 ExtractionManagerWorker**。API 层直接创建 Result + 快照 PKB 元数据 + 散装派发 Job。
|
||||
|
||||
```typescript
|
||||
// Manager Worker — Fire-and-forget,派发后立即退出
|
||||
// ⚠️ v1.5:派发前一次性快照 PKB 元数据,防止提取中 PKB 侧删改导致崩溃
|
||||
class ExtractionManagerWorker {
|
||||
async handle(job: { data: { taskId: string } }) {
|
||||
const task = await prisma.aslExtractionTask.findUnique({ where: { id: job.data.taskId } });
|
||||
const results = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🚨 v1.6 空集合边界守卫
|
||||
// 如果文献被全部删除或过滤后 results 为空,无 Child 被派发,
|
||||
// Last Child Wins 永远不触发,Task 永远卡在 processing。
|
||||
// Manager 必须自己充当"收口人"直接完成任务。
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (results.length === 0) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
await broadcastLog(task.id, { source: 'system', message: '⚠️ No documents to extract, task auto-completed.' });
|
||||
return;
|
||||
// ExtractionController.ts — POST /api/v1/asl/extraction/tasks
|
||||
async function createTask(req, reply) {
|
||||
const { projectId, templateId, documentIds, idempotencyKey } = req.body;
|
||||
|
||||
if (documentIds.length === 0) throw new Error('No documents selected');
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// DB 级幂等:@unique 索引 + P2002 冲突捕获
|
||||
// ═══════════════════════════════════════════════════════
|
||||
let task;
|
||||
try {
|
||||
task = await prisma.aslExtractionTask.create({
|
||||
data: { projectId, templateId, totalCount: documentIds.length, status: 'processing', idempotencyKey },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'P2002' && idempotencyKey) {
|
||||
const existing = await prisma.aslExtractionTask.findFirst({ where: { idempotencyKey } });
|
||||
return reply.send({ success: true, taskId: existing.id, note: 'Idempotent return' });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⚠️ v1.5 PKB 数据一致性快照
|
||||
// 提取任务可能持续 50 分钟,期间用户可能在 PKB 删除/修改文档。
|
||||
// 一次性批量读取 PKB 元数据并冻结到 AslExtractionResult,
|
||||
// Child Worker 从自身记录读取 snapshotStorageKey/snapshotFilename,
|
||||
// 不再运行时回查 PKB,即使 PKB 删了记录,OSS 文件通常仍在。
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const pkbDocIds = results.map(r => r.pkbDocumentId).filter(Boolean);
|
||||
const pkbDocs = await Promise.all(
|
||||
pkbDocIds.map(id => this.pkbBridge.getDocumentDetail(id))
|
||||
);
|
||||
const pkbDocMap = new Map(pkbDocs.map(d => [d.documentId, d]));
|
||||
|
||||
// 批量快照写入
|
||||
await prisma.$transaction(
|
||||
results.map(result => {
|
||||
const doc = pkbDocMap.get(result.pkbDocumentId);
|
||||
return prisma.aslExtractionResult.update({
|
||||
where: { id: result.id },
|
||||
data: {
|
||||
snapshotStorageKey: doc?.storageKey ?? null,
|
||||
snapshotFilename: doc?.filename ?? null,
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Fan-out:为每篇文献派发 Child Job
|
||||
for (const result of results) {
|
||||
await pgBoss.send('asl_extraction_child', {
|
||||
taskId: task.id,
|
||||
resultId: result.id,
|
||||
pkbDocumentId: result.pkbDocumentId,
|
||||
}, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 10, // 10 秒后重试
|
||||
retryBackoff: true, // 指数退避
|
||||
expireInMinutes: 30,
|
||||
singletonKey: `extract-${result.id}`, // 幂等键,防止重复派发
|
||||
});
|
||||
}
|
||||
// Manager 派发完毕后直接退出,不等待 Child 完成
|
||||
// 任务状态翻转由 "Last Child Wins" 机制在 Child Worker 中完成
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// PKB 快照冻结(从旧 Manager 移至 API 层)
|
||||
// 提取可能持续 50 分钟,期间用户可能在 PKB 删除/修改文档
|
||||
// ═══════════════════════════════════════════════════════
|
||||
const pkbDocs = await Promise.all(
|
||||
documentIds.map(id => pkbBridge.getDocumentDetail(id))
|
||||
);
|
||||
const resultsData = pkbDocs.map(doc => ({
|
||||
taskId: task.id,
|
||||
projectId,
|
||||
pkbDocumentId: doc.documentId,
|
||||
snapshotStorageKey: doc.storageKey,
|
||||
snapshotFilename: doc.filename,
|
||||
status: 'pending',
|
||||
}));
|
||||
await prisma.aslExtractionResult.createMany({ data: resultsData });
|
||||
|
||||
const createdResults = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 散装派发:N 个独立 Job 一次入队(无 Manager 中转)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
const jobs = createdResults.map(result => ({
|
||||
name: 'asl_extract_single',
|
||||
data: { resultId: result.id, taskId: task.id, pkbDocumentId: result.pkbDocumentId },
|
||||
options: {
|
||||
retryLimit: 3,
|
||||
retryBackoff: true,
|
||||
expireInMinutes: 30, // 执行超时,非排队等待超时
|
||||
singletonKey: `extract-${result.id}`,
|
||||
},
|
||||
}));
|
||||
await jobQueue.insert(jobs);
|
||||
|
||||
return reply.send({ success: true, taskId: task.id });
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 ExtractionChildWorker(乐观锁 + Last Child Wins + 错误分级)
|
||||
**要点:**
|
||||
- `idempotencyKey @unique` + P2002 = DB 物理级幂等
|
||||
- PKB 快照冻结在 API 层完成(无需 Manager)
|
||||
- `singletonKey: extract-${result.id}` 防重复派发
|
||||
- `jobQueue.insert(jobs)` 批量入队,100 篇 < 0.1 秒
|
||||
|
||||
### 4.3 ExtractionSingleWorker(幽灵守卫 + 错误分级)
|
||||
|
||||
> 🚀 v2.0:**删除 $transaction、删除 increment、删除 Last Child Wins**。
|
||||
> Worker 只管更新自己的 Result 行,绝不碰 Task 表。收口由 Aggregator(§4.6)负责。
|
||||
|
||||
```typescript
|
||||
// Child Worker — ⚠️ v1.4.2 终极修正:乐观锁 + 原子递增 + Last Child Wins + 错误分级路由
|
||||
class ExtractionChildWorker {
|
||||
async handle(job: { data: { taskId: string; resultId: string; pkbDocumentId: string } }) {
|
||||
const { taskId, resultId, pkbDocumentId } = job.data;
|
||||
|
||||
// 🚀 v2.0 散装 Worker — 只管自己的 Result,绝不碰 Task 表
|
||||
class ExtractionSingleWorker {
|
||||
async handle(job: { data: { resultId: string; taskId: string; pkbDocumentId: string } }) {
|
||||
const { resultId, taskId, pkbDocumentId } = job.data;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 幽灵重试守卫:只允许 pending → extracting
|
||||
// OOM 崩溃 → status 卡在 extracting → 重试被跳过
|
||||
// → Aggregator 30 分钟后将 extracting 标为 error 兜底
|
||||
// ═══════════════════════════════════════════════════════
|
||||
const lock = await prisma.aslExtractionResult.updateMany({
|
||||
where: { id: resultId, status: 'pending' },
|
||||
data: { status: 'extracting' },
|
||||
});
|
||||
if (lock.count === 0) {
|
||||
return { success: true, note: 'Phantom retry skipped' };
|
||||
}
|
||||
|
||||
try {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⚠️ v1.4.2 补丁 2:乐观锁抢占(替代 Read-then-Write 反模式)
|
||||
// 利用 updateMany 的 WHERE 条件充当原子锁:
|
||||
// 只有 status='pending' 的行才允许被更新为 'extracting'
|
||||
// 并发重试时第二个 Worker 会得到 count=0,直接退出
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const lock = await prisma.aslExtractionResult.updateMany({
|
||||
where: { id: resultId, status: 'pending' },
|
||||
data: { status: 'extracting' },
|
||||
const data = await this.extractionService.extractOne(resultId, taskId);
|
||||
|
||||
// ✅ 只更新自己的 Result 行,绝不碰 Task 表!
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId },
|
||||
data: { status: 'completed', extractedData: data, processedAt: new Date() },
|
||||
});
|
||||
|
||||
if (lock.count === 0) {
|
||||
// 已被其他 Worker 抢占或已完成,幂等跳过
|
||||
return { success: true, note: 'Idempotent skip: already processing or completed' };
|
||||
}
|
||||
|
||||
// 执行提取(此时该行已被本 Worker 独占为 'extracting')
|
||||
const extractResult = await this.extractionService.extractOne(resultId, taskId);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⚠️ v1.4.2 补丁 1 + v1.4 原子递增:
|
||||
// 事务内更新 Result 状态 + 原子递增父任务计数
|
||||
// 返回更新后的 Task,用于 "Last Child Wins" 判断
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
const [_resultUpdate, taskAfterUpdate] = await prisma.$transaction([
|
||||
prisma.aslExtractionResult.update({
|
||||
where: { id: resultId },
|
||||
data: { status: 'completed', extractedData: extractResult.data, processedAt: new Date() }
|
||||
}),
|
||||
prisma.aslExtractionTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
successCount: { increment: 1 },
|
||||
totalTokens: { increment: extractResult.tokens },
|
||||
totalCost: { increment: extractResult.cost },
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
// 🚨 v1.6:SSE 推送日志(跨 Pod 广播,替代原 sseEmitter.emit)
|
||||
await broadcastLog(taskId, { source: 'system', message: `✅ ${extractResult.filename} extracted` });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⚠️ v1.4.2 补丁 1:"Last Child Wins" 终止器
|
||||
// 最后一个完成(成功或失败)的 Child 负责将父任务翻转为 completed
|
||||
// 这是 Fan-out 模式的关键收口逻辑——没有它,Task 永远卡在 processing
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (taskAfterUpdate.successCount + taskAfterUpdate.failedCount >= taskAfterUpdate.totalCount) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// ⚠️ v1.4 错误分级路由:区分"致命错误"和"临时错误"
|
||||
if (error instanceof PkbDocumentNotFoundError || error.name === 'PdfCorruptedError') {
|
||||
// 致命错误:标记业务状态为 error + 原子递增 failedCount
|
||||
const taskAfterFail = await prisma.$transaction(async (tx) => {
|
||||
await tx.aslExtractionResult.update({
|
||||
where: { id: resultId },
|
||||
data: { status: 'error', errorMessage: error.message }
|
||||
});
|
||||
return tx.aslExtractionTask.update({
|
||||
where: { id: taskId },
|
||||
data: { failedCount: { increment: 1 } }
|
||||
});
|
||||
if (isPermanentError(error)) {
|
||||
// 致命错误:标记 error,不重试
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId },
|
||||
data: { status: 'error', errorMessage: error.message },
|
||||
});
|
||||
|
||||
// ⚠️ v1.4.2 "Last Child Wins":失败的 Child 也要检查是否是最后一个
|
||||
if (taskAfterFail.successCount + taskAfterFail.failedCount >= taskAfterFail.totalCount) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
|
||||
}
|
||||
|
||||
return { success: false, reason: 'Permanent failure, aborted retry.' };
|
||||
return { success: false, note: 'Permanent error' };
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🚨 v1.6 补丁:临时错误 throw 前必须释放乐观锁!
|
||||
// 原因:上方 updateMany 已将 status 改为 'extracting'。
|
||||
// 如果裸 throw,pg-boss 重试时乐观锁 where: { status: 'pending' }
|
||||
// 返回 count=0 → 误判"幂等跳过" → 计数永远少一票 → Last Child Wins 永远不触发。
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// 临时错误:回退 status → pending,让下次重试能通过幽灵守卫
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
throw error; // pg-boss 指数退避重试
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 临时错误 (429/网络抖动):throw → pg-boss 自动指数退避重试
|
||||
throw error;
|
||||
function isPermanentError(error: any): boolean {
|
||||
return error instanceof PkbDocumentNotFoundError
|
||||
|| error.name === 'PdfCorruptedError'
|
||||
|| (error.status && error.status >= 400 && error.status < 500 && error.status !== 429);
|
||||
}
|
||||
```
|
||||
|
||||
**与旧版 Fan-out Child Worker 的关键区别:**
|
||||
- ❌ ~~`prisma.$transaction([...Result, ...Task])`~~ → ✅ 只 `prisma.aslExtractionResult.update()`
|
||||
- ❌ ~~`successCount: { increment: 1 }`~~ → ✅ Worker 不碰 Task 表
|
||||
- ❌ ~~Last Child Wins 终止器~~ → ✅ Aggregator 定时收口(§4.6)
|
||||
- ✅ 幽灵守卫 + 临时错误回退 pending 保留不变
|
||||
|
||||
### 4.4 Worker + Aggregator 注册(扁平单队列)
|
||||
|
||||
> 🚀 v2.0:废弃三级嵌套队列,改为**单一扁平队列** `asl_extract_single`。
|
||||
> MinerU / LLM 调用由 Worker 内部 `await` 串行完成,`teamConcurrency` 即为总并发上限。
|
||||
|
||||
```typescript
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 单一 Worker 队列:全局最多 10 篇文献并行提取
|
||||
// ═══════════════════════════════════════════════════════
|
||||
jobQueue.work('asl_extract_single', { teamConcurrency: 10 }, async (job) => {
|
||||
await extractionSingleWorker.handle(job);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Aggregator 定时收口:每 2 分钟扫描(代码见 §4.6)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
await jobQueue.schedule('asl_extraction_aggregator', '*/2 * * * *');
|
||||
await jobQueue.work('asl_extraction_aggregator', aggregatorHandler);
|
||||
```
|
||||
|
||||
> **扁平架构对比:**
|
||||
> ```
|
||||
> 旧 Fan-out(三级嵌套,已废弃):
|
||||
> asl_extraction_manager → asl_extraction_child(10) → asl_mineru_extract(2) + asl_llm_extract(5)
|
||||
>
|
||||
> 新散装(扁平一级):
|
||||
> asl_extract_single (teamConcurrency: 10) ← Worker 内部串行调 MinerU + LLM
|
||||
> asl_extraction_aggregator (schedule: */2 * * * *) ← 定时收口 + 僵尸清理
|
||||
> ```
|
||||
> `teamConcurrency: 10` 即为 MinerU + LLM 的隐式总并发上限,无需额外子队列。
|
||||
|
||||
### 4.5 Postgres-Only 安全规范速查
|
||||
|
||||
| 规范 | 要求 | v2.0 散装模式实现 |
|
||||
|------|------|-----------|
|
||||
| **API 幂等** | 防止重复创建任务 | `idempotencyKey @unique` + P2002 冲突捕获 |
|
||||
| **Worker 幂等** | 容忍 pg-boss 重投 | `updateMany({ where: { status: 'pending' } })` 幽灵守卫 |
|
||||
| **Worker 独立** | Worker 绝不碰 Task 表 | 只写自己的 Result 行,Aggregator 负责收口 |
|
||||
| **Payload 轻量** | Job data 不超过数 KB | 仅传 `{ resultId, taskId, pkbDocumentId }`(< 200 bytes) |
|
||||
| **过期时间** | 必须设置 `expireInMinutes` | `expireInMinutes: 30`(执行超时,非排队等待超时) |
|
||||
| **错误分级** | 区分"可重试"和"永久失败" | 临时错误 → 回退 pending + throw;致命错误 → 标 error + return |
|
||||
| **死信处理** | 超过 retryLimit 的 Job | Aggregator 兼职 Sweeper:`extracting` 超 30min → error |
|
||||
| **进度查询** | 不在 Task 存冗余计数 | 前端 `groupBy` 实时聚合 Result 状态 |
|
||||
|
||||
### 4.6 ExtractionAggregator — 轮询收口 + 僵尸清理
|
||||
|
||||
> 🚀 v2.0:**Aggregator 兼职 Sweeper,一个组件两个职责。**
|
||||
> 取代旧版独立的 `asl_extraction_sweeper` + Fan-out `Last Child Wins` 收口逻辑。
|
||||
|
||||
```typescript
|
||||
// ExtractionAggregator.ts — pg-boss schedule 每 2 分钟执行一次
|
||||
async function aggregatorHandler() {
|
||||
const tasks = await prisma.aslExtractionTask.findMany({
|
||||
where: { status: 'processing' },
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 职责 1:僵尸清理(Sweeper 兼职)
|
||||
// extracting 超过 30 分钟 → 大概率 Worker 进程崩溃(OOM/SIGKILL)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
await prisma.aslExtractionResult.updateMany({
|
||||
where: {
|
||||
taskId: task.id,
|
||||
status: 'extracting',
|
||||
updatedAt: { lt: new Date(Date.now() - 30 * 60 * 1000) },
|
||||
},
|
||||
data: { status: 'error', errorMessage: '[Aggregator] Timeout, likely worker crash.' },
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 职责 2:聚合统计 — groupBy 一次查询
|
||||
// ═══════════════════════════════════════════════════════
|
||||
const stats = await prisma.aslExtractionResult.groupBy({
|
||||
by: ['status'],
|
||||
where: { taskId: task.id },
|
||||
_count: true,
|
||||
});
|
||||
const pending = stats.find(s => s.status === 'pending')?._count ?? 0;
|
||||
const extracting = stats.find(s => s.status === 'extracting')?._count ?? 0;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 职责 3:收口 — pending 和 extracting 都清零即完成
|
||||
// ═══════════════════════════════════════════════════════
|
||||
if (pending === 0 && extracting === 0) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
logger.info(`[Aggregator] Task ${task.id} completed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Worker 注册(三级限流 + 队列命名合规)
|
||||
**Aggregator 与旧 Sweeper / Last Child Wins 的对比:**
|
||||
|
||||
```typescript
|
||||
// ⚠️ v1.4.2 补丁 3:队列名称全部使用下划线(遵守《Postgres-Only 指南》§4.1 红线)
|
||||
// 点号(.)在 pg-boss 底层解析中可能被识别为 Schema 分隔符,导致路由截断异常
|
||||
|
||||
jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, async (job) => {
|
||||
// 全局最多 10 个文献同时在 Node.js 内存中处理
|
||||
// 其余在 PostgreSQL 中排队(零内存占用)
|
||||
await extractionChildWorker.handle(job);
|
||||
});
|
||||
|
||||
// MinerU 子队列:全局仅允许 2 个并行(跨所有 Pod)
|
||||
jobQueue.work('asl_mineru_extract', { teamConcurrency: 2 }, async (job) => {
|
||||
const { storageKey, kbId, docId } = job.data;
|
||||
return await pdfPipeline.extractTables(storageKey, kbId, docId); // 含 OSS 缓存
|
||||
});
|
||||
|
||||
// LLM 子队列:全局仅允许 5 个并行
|
||||
jobQueue.work('asl_llm_extract', { teamConcurrency: 5 }, async (job) => {
|
||||
const { resultId, taskId, prompt } = job.data;
|
||||
return await llmGateway.call(prompt);
|
||||
});
|
||||
|
||||
// Child Worker 内部调用方式(不再使用 P-Queue)
|
||||
class ExtractionChildWorker {
|
||||
async extractWithMinerU(storageKey: string, kbId: string, docId: string) {
|
||||
const jobId = await pgBoss.send('asl_mineru_extract', { storageKey, kbId, docId });
|
||||
return await pgBoss.getJobResult(jobId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **三级限流架构:**
|
||||
> ```
|
||||
> asl_extraction_child (teamConcurrency: 10) ← 背压阀门,防 OOM
|
||||
> └─ asl_mineru_extract (teamConcurrency: 2) ← 昂贵 API 保护
|
||||
> └─ asl_llm_extract (teamConcurrency: 5) ← LLM 并发保护
|
||||
> ```
|
||||
> 全部基于 PostgreSQL 行锁实现全局并发控制,跨所有 Node.js 实例生效。
|
||||
|
||||
### 4.5 Postgres-Only 安全规范速查
|
||||
|
||||
| 规范 | 要求 | 本模块实现 |
|
||||
|------|------|-----------|
|
||||
| **幂等性** | Worker 必须容忍 pg-boss 重投(at-least-once) | ⚠️ v1.4.2 `updateMany({ where: { status: 'pending' } })` 乐观锁原子抢占 |
|
||||
| **Payload 轻量** | Job data 不超过数 KB,禁止塞 PDF 正文 | 仅传 `{ taskId, resultId, pkbDocumentId }`,不超过 200 bytes |
|
||||
| **过期时间** | 必须设置 `expireInMinutes`,防止僵尸 Job | Manager: 60min,Child: 30min |
|
||||
| **错误分级** | 区分"可重试"和"永久失败" | 429/5xx → retry(pg-boss 指数退避),4xx/解析错误 → 标记 error,不 retry |
|
||||
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` handler 标记该篇为 `error` |
|
||||
| **进度追踪** | 不在 Job data 中存大量进度 | 进度统一走 `CheckpointService`,Job data 仅含 ID 引用 |
|
||||
|
||||
### 🆕 4.6 Sweeper 清道夫 — 进程硬崩溃兜底(v1.6)
|
||||
|
||||
> **Fan-out 指南 v1.2 强制要求:** 单兵 Worker 无法处理自身猝死(OOM/SIGKILL),
|
||||
> 必须有系统级外部定时任务兜底。否则父任务可能永远卡在 `processing`。
|
||||
|
||||
```typescript
|
||||
// ===== 工具 3 专属清道夫(模块启动时注册) =====
|
||||
async function aslExtractionSweeper() {
|
||||
const stuckTasks = await prisma.aslExtractionTask.findMany({
|
||||
where: {
|
||||
status: 'processing',
|
||||
// 🚨 使用 updatedAt(最后活跃时间),而非 startedAt!
|
||||
// 500 篇文献正常排队可能需要 3+ 小时,用 startedAt 会误杀健康任务。
|
||||
// 只要 Child 还在完成并递增计数,updatedAt 就会持续刷新。
|
||||
updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) },
|
||||
},
|
||||
});
|
||||
|
||||
for (const task of stuckTasks) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: '[Sweeper] No progress for 2h — likely Child Worker OOM/SIGKILL. Force-closed.',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// 广播失败事件,确保前端 SSE 能感知
|
||||
await broadcastLog(task.id, {
|
||||
source: 'system',
|
||||
type: 'complete',
|
||||
message: '❌ [Sweeper] Task force-closed after 2h inactivity.',
|
||||
});
|
||||
logger.warn(`[Sweeper] Force-closed stuck task ${task.id} (no progress for 2h)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 注册为 pg-boss 定时任务(每 10 分钟扫描一次)
|
||||
await jobQueue.schedule('asl_extraction_sweeper', '*/10 * * * *');
|
||||
await jobQueue.work('asl_extraction_sweeper', aslExtractionSweeper);
|
||||
```
|
||||
|
||||
> **关键:** Sweeper 判断"卡死"基于 `updatedAt` 而非 `startedAt`,避免误杀正在排队的超大批量任务。
|
||||
| 旧方案(已废弃) | 新方案 Aggregator |
|
||||
|---|---|
|
||||
| Last Child Wins 收口 + 独立 Sweeper 清道夫 | Aggregator 一人兼两职 |
|
||||
| Worker 必须 `$transaction` 递增 Task 计数 | Worker 只写 Result,零行锁争用 |
|
||||
| Sweeper 用 `updatedAt > 2h` 判断卡死 | Aggregator 用 `extracting > 30min` 清僵尸 |
|
||||
| 两个独立组件需分别注册 | 一个 `pg-boss schedule` 搞定 |
|
||||
|
||||
---
|
||||
|
||||
@@ -658,34 +624,38 @@ function useExtractionLogs(taskId: string) {
|
||||
```typescript
|
||||
// Step 2 页面组件:双轨制组合
|
||||
function ExtractionProgress({ taskId }: { taskId: string }) {
|
||||
const { data: task } = useTaskStatus(taskId); // 主驱动:轮询
|
||||
const { logs } = useExtractionLogs(taskId); // 增强:SSE 日志
|
||||
|
||||
// 进度条由 React Query 驱动(稳健)
|
||||
const percent = task ? Math.round((task.successCount + task.failedCount) / task.totalCount * 100) : 0;
|
||||
|
||||
// 完成检测由 React Query 驱动(不依赖 SSE complete 事件)
|
||||
// 🚀 v2.0:进度 API 返回 groupBy 聚合结果(无 successCount/failedCount 冗余字段)
|
||||
const { data: progress } = useTaskStatus(taskId); // 主驱动:轮询
|
||||
const { logs } = useExtractionLogs(taskId); // 增强:SSE 日志
|
||||
|
||||
const processed = (progress?.completedCount ?? 0) + (progress?.errorCount ?? 0);
|
||||
const percent = progress ? Math.round(processed / progress.totalCount * 100) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (task?.status === 'completed' || task?.status === 'failed') {
|
||||
if (progress?.status === 'completed' || progress?.status === 'failed') {
|
||||
navigate(`/asl/extraction/workbench/${taskId}`);
|
||||
}
|
||||
}, [task?.status]);
|
||||
|
||||
}, [progress?.status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Progress percent={percent} />
|
||||
<ProcessingTerminal logs={logs} /> {/* SSE 驱动,纯视觉 */}
|
||||
<ProcessingTerminal logs={logs} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **双轨制分工:** React Query 轮询驱动进度条和步骤跳转(稳健可靠),SSE 仅灌日志流给 ProcessingTerminal(视觉增强,断开无影响)。
|
||||
> **v2.0 变化:** 进度 API 返回 `groupBy` 聚合的 `completedCount` / `errorCount` / `extractingCount` / `pendingCount`,不再依赖 Task 表冗余计数字段。
|
||||
|
||||
### 7.6 SSE 跨 Pod 广播 — PostgreSQL NOTIFY/LISTEN(v1.5,M2 实施)
|
||||
### 7.6 [P2 可选] SSE 跨 Pod 广播 — PostgreSQL NOTIFY/LISTEN
|
||||
|
||||
> ⚠️ **v2.0 降级说明:** 散装架构下,进度条和步骤跳转完全由 React Query 轮询驱动(§7.5),
|
||||
> SSE 日志流仅为视觉增强。跨 Pod 广播为 **P2 可选增强**,M1/M2 前期可用本 Pod 内存事件替代。
|
||||
>
|
||||
> **物理限制:** `sseEmitter.emit()` 基于内存 EventEmitter,用户连 Pod A、Worker 跑 Pod B → Pod A 零日志。
|
||||
> 使用 PostgreSQL `NOTIFY/LISTEN` 实现 Postgres-Only 合规的跨实例广播(不引入 Redis)。
|
||||
> 如需实现跨 Pod 日志广播,可使用以下 PostgreSQL `NOTIFY/LISTEN` 方案。
|
||||
|
||||
```typescript
|
||||
// ===== Worker 发送端(ExtractionChildWorker 内部) =====
|
||||
@@ -757,8 +727,8 @@ class SseNotifyBridge {
|
||||
- NOTIFY payload 物理上限 **~8000 bytes** → 发送前必须截断至 **7000 bytes**(v1.6 强制规范)
|
||||
- **禁止 `$executeRawUnsafe` + 字符串拼接!** 必须使用 `$executeRaw` Tagged Template + `pg_notify()`(v1.6 强制规范)
|
||||
- LISTEN 连接必须**独立于 Prisma 连接池**(PgClient 单独创建)
|
||||
- NOTIFY 是 fire-and-forget(无持久化),完美匹配 v1.4 双轨制定位
|
||||
- `complete` 事件仍走 NOTIFY 广播,确保"Last Child Wins"翻转状态后所有 Pod 的 SSE 客户端都能收到
|
||||
- NOTIFY 是 fire-and-forget(无持久化),完美匹配双轨制定位(SSE 断开不影响业务)
|
||||
- v2.0 下 `complete` 事件由前端轮询到 `status === 'completed'` 触发,不再强依赖 SSE 广播
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user