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:
2026-02-24 22:11:09 +08:00
parent 85fda830c2
commit 371fa53956
13 changed files with 1163 additions and 597 deletions

View File

@@ -3,8 +3,9 @@
> **所属:** 工具 3 全文智能提取工作台 V2.0
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
> **建议时间:** Week 15.5-6.5 天,含 v1.6 Sweeper 清道夫 0.5 天)
> **核心目标:** 证明 "PKB 拿数据 → Fan-out 分发 → LLM 盲提 → 数据落库 → 前端到 completed" 这条管线是通的。
> **建议时间:** Week 15-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 APIM3
@@ -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-3PKB ACL 防腐层 + Fan-out 调度核心2 天)⚠️ 本里程碑最关键
### M1-3PKB ACL 防腐层 + 散装 Worker + Aggregator2 天)⚠️ 本里程碑最关键
**做什么(按顺序):**
@@ -68,44 +75,46 @@
**Step B — ASL 侧桥接0.5 天):**
- `PkbBridgeService.ts`:调用 `PkbExportService`,代理所有 PKB 数据访问
**Step C — Fan-out Manager + Child Worker1 天)⚠️ 核心战役:**
- `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 — ExtractionSingleWorker0.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 — ExtractionAggregator0.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接 MinerUM2 再接)。**
**验收标准:**
- [ ] PkbExportService 能返回知识库列表和文档详情DTO
- [ ] Manager 派发`AslExtractionResult.snapshotStorageKey``snapshotFilename` 已填充(v1.5 快照验证)
- [ ] 手动删除 PKB 文档记录后,Child Worker 仍能通过 `snapshotStorageKey` 从 OSS 获取 PDFv1.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 获取 storageKeyv1.5 | 提取中 PKB 删文档 → 批量崩溃 |
| 8 | 🆕 临时错误 `throw` 前必须 `update({ status: 'pending' })` 释放乐观锁v1.6 | 重试时被"幂等跳过"计数永远缺一票Task 永久卡死 |
| 9 | 🆕 Manager 必须检查 `results.length === 0` 并直接 completedv1.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 定时收口,第一周就能稳定跑通全链路