docs: 修正审计文档 — 6 项 Non-blocking 问题
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 31s
Deploy API Server / current-integration (push) Successful in 29s
Deploy API Server / backward-compat (push) Successful in 1s
Deploy API Server / deploy (push) Successful in 1m0s

问题 5:新增 queueName 与实际队列不一致(ai-interactive vs ai-analysis)及风险标注
问题 6:移除 QueueService.add() 独立 Producer 身份,降级为基础设施注释
问题 7:细化孤儿队列描述 — 每个队列的生产者状态、WorkerModule 注册、闲置含义
问题 8:新增 STATUS_TO_LIFECYCLE 映射缺口分析(缺少 cancel_requested / cancelled)
问题 9:补充 RuntimeInternalService.notifyJobComplete() 的直接通知路径
问题 10:新增 Outbox 并发重复投递场景的时序分析

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-20 16:59:34 +08:00
parent ed783967e8
commit 64659825ae

View File

@ -56,12 +56,16 @@
|---|---------------|--------|---------|----------|-------|---------|----------|----------| |---|---------------|--------|---------|----------|-------|---------|----------|----------|
| P1 | `AiAnalysisService` | `analyze()` | User API | `AiJob` | `ai-analysis` | `{jobId, userId, type:'active-recall', questionText, knowledgeItemContent, userAnswer}` | `status:pending`, `lifecycleStatus:queued` | `ai-analysis.service.ts:19-31` | | P1 | `AiAnalysisService` | `analyze()` | User API | `AiJob` | `ai-analysis` | `{jobId, userId, type:'active-recall', questionText, knowledgeItemContent, userAnswer}` | `status:pending`, `lifecycleStatus:queued` | `ai-analysis.service.ts:19-31` |
| P2 | `AiAnalysisService` | `evaluateFeynman()` | User API | `AiJob` | `ai-analysis` | `{jobId, userId, type:'feynman-evaluation', knowledgeItemTitle, knowledgeItemContent, userExplanation}` | `status:pending`, `lifecycleStatus:queued` | `ai-analysis.service.ts:40-51` | | P2 | `AiAnalysisService` | `evaluateFeynman()` | User API | `AiJob` | `ai-analysis` | `{jobId, userId, type:'feynman-evaluation', knowledgeItemTitle, knowledgeItemContent, userExplanation}` | `status:pending`, `lifecycleStatus:queued` | `ai-analysis.service.ts:40-51` |
> ⚠️ **queueName 与实际队列不一致**`AiAnalysisRepository.createJob()` 写入 `queueName: 'ai-interactive'``ai-analysis.repository.ts:28`),但 `AiAnalysisService` 实际将 BullMQ Job 发送到 `ai-analysis` 队列(`ai-analysis.service.ts:21,42`)。数据库记录的 `queueName` 字段与真实 BullMQ 队列路由存在偏差。M-AI-03 构建统一 Engine 将依赖 `queueName` 字段进行路由决策,此为关键风险。
| P3 | `DocumentImportService` | `createImport()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, rawText, fileName}` | `status:QUEUED`(via Repository) | `document-import.service.ts:43-49` | | P3 | `DocumentImportService` | `createImport()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, rawText, fileName}` | `status:QUEUED`(via Repository) | `document-import.service.ts:43-49` |
| P4 | `KnowledgeSourceService` | `addSource()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, sourceId, fileName}` | `status:QUEUED`(via Repository) | `knowledge-source.service.ts:43-50` | | P4 | `KnowledgeSourceService` | `addSource()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, sourceId, fileName}` | `status:QUEUED`(via Repository) | `knowledge-source.service.ts:43-50` |
| P5 | `KnowledgeSourceService` | `triggerParse()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, sourceId, fileName}` | `status:QUEUED`(via Repository) | `knowledge-source.service.ts:90-97` | | P5 | `KnowledgeSourceService` | `triggerParse()` | User API | `DocumentImport` | `document-import` | `{importId, userId, knowledgeBaseId, sourceId, fileName}` | `status:QUEUED`(via Repository) | `knowledge-source.service.ts:90-97` |
| P6 | `UserAiService` | `requestJob()` | User API | `AiRuntimeJob` | **无 BullMQ** | REST Poll不适用 | `status:pending` | `user-ai.service.ts:282-299` | | P6 | `UserAiService` | `requestJob()` | User API | `AiRuntimeJob` | **无 BullMQ** | REST Poll不适用 | `status:pending` | `user-ai.service.ts:282-299` |
| P7 | `AdminFilesController` | COS cleanup | Admin API | 无 | `file-cleanup` | `{objectKey, bucket, region}` | 无(仅入队) | `admin-files.controller.ts:41` | | P7 | `AdminFilesController` | COS cleanup | Admin API | 无 | `file-cleanup` | `{objectKey, bucket, region}` | 无(仅入队) | `admin-files.controller.ts:41` |
> **注**`QueueService.add()``queue.service.ts:41-58`)是上述所有 BullMQ Producer 的公共抽象层,不作为独立 Producer 列出。其副作用:写入 `TaskLog` 记录(`status:enqueued`)和同步发布 `task.enqueued` 事件。
### 死代码(定义为入队方法但零调用方) ### 死代码(定义为入队方法但零调用方)
| 方法 | 文件 | 入队目标 | 说明 | | 方法 | 文件 | 入队目标 | 说明 |
@ -70,11 +74,13 @@
### 孤儿队列Consumer 已注册但生产者未找到) ### 孤儿队列Consumer 已注册但生产者未找到)
| 队列 | Consumer | 状态 | | 队列 | Consumer | WorkerModule 注册 | 生产者状态 |
|------|----------|------| |------|----------|-------------------|-----------|
| `notification` | `NotificationWorker` (`workers/notification.worker.ts:8`) | ⚠️ 无 `queue.add('notification', ...)` 调用方;`NotificationsService.send()` 仅写 DB + sync eventBus.publish(),不入队 | | `notification` | `NotificationWorker` (`workers/notification.worker.ts:8`) | `worker.module.ts:76` | ❌ 无生产者 — 全文搜索确认零 `queue.add('notification', ...)``NotificationsService.send()` 仅写 DB + sync eventBus不入队 |
| `domain-events` | 无专用 Consumer`EventBusService.publishAsync` 是唯一入队路径但为零调用方) | ⚠️ 完全闲置 | | `domain-events` | 无专用 Consumer只有 `EventBusService.publishAsync` 可入队) | N/A | ❌ 无生产者 — `publishAsync()` 为零调用方死代码;队列完全闲置 |
| `audit-logs` | `AuditLogProcessor` (`modules/admin-audit-log/audit-log.processor.ts:7`) | ⚠️ 无 `queue.add('audit-logs', ...)` 调用方;审计日志走 `prisma.adminAuditLog.create()` 直接写 DB | | `audit-logs` | `AuditLogProcessor` (`modules/admin-audit-log/audit-log.processor.ts:7`) | `worker.module.ts:77` | ❌ 无生产者 — 全文搜索确认零 `queue.add('audit-logs', ...)`;所有审计日志通过 `prisma.adminAuditLog.create()` 直接写 DB |
> **含义**`NotificationWorker``AuditLogProcessor` 两个 Worker 进程注册在 `WorkerModule` 中,每 30s 从各自队列 poll但这两个队列从未有消息进入——实为永久空转的闲置进程。
> **注意**`AdminEventsController` 仅**读取**队列状态(`getWaitingCount`/`getActiveCount`/`getFailed` 等)和**重试**已有失败 Job`job.retry()`**从不创建新 Job**。因此不列为 Producer。 > **注意**`AdminEventsController` 仅**读取**队列状态(`getWaitingCount`/`getActiveCount`/`getFailed` 等)和**重试**已有失败 Job`job.retry()`**从不创建新 Job**。因此不列为 Producer。
@ -289,6 +295,24 @@ AiGatewayService
- EventBus 当前仅作为 In-Process Event Emitter 使用其异步域事件能力BullMQ `domain-events` 队列)处于闲置状态 - EventBus 当前仅作为 In-Process Event Emitter 使用其异步域事件能力BullMQ `domain-events` 队列)处于闲置状态
- `QueueService` 中同步发布的 `task.enqueued` 事件无 Subscriber 消费 - `QueueService` 中同步发布的 `task.enqueued` 事件无 Subscriber 消费
### 补充:不在 EventBus 中的直接通知路径
`RuntimeInternalService.notifyJobComplete()``runtime-internal.service.ts:629-646`**绕过 EventBus 直接写入** `prisma.notification.create()`——在提交结果或 Job 彻底失败时触发。此路径不在上述事件矩阵中,是独立的 Job 完成通知机制:
```typescript
private async notifyJobComplete(userId, jobId, jobType, status) {
await this.prisma.notification.create({
data: {
userId,
type: status === 'succeeded' ? 'ai_job_succeeded' : 'ai_job_failed',
title: ..., content: ..., data: { jobId, jobType, status },
},
});
}
```
调用时机:`submitResult()` 成功后(`runtime-internal.service.ts:278`)和 `submitFailure()` 重试耗尽后(`runtime-internal.service.ts:618`)。
--- ---
## 8. Outbox 现状评估 ## 8. Outbox 现状评估
@ -307,9 +331,28 @@ AiGatewayService
| `releaseExpiredLocks(thresholdMs)` | ✅ 已实现 | 重置超时 processing → pending | | `releaseExpiredLocks(thresholdMs)` | ✅ 已实现 | 重置超时 processing → pending |
| **是否有生产写入** | ❌ 无 | **所有搜索返回零调用方** | | **是否有生产写入** | ❌ 无 | **所有搜索返回零调用方** |
| **是否有 Dispatcher** | ❌ 无 | 没有 Dispatcher Service/Worker | | **是否有 Dispatcher** | ❌ 无 | 没有 Dispatcher Service/Worker |
| **是否支持并发领取** | ⚠️ 部分 | CAS 保证了唯一领取,但 `findDispatchable` 可能多 Worker 读到相同事件CAS 后有竞态窗口) | | **是否支持并发领取** | ❌ 否 | `findDispatchable` 仅做无锁 `findMany``markProcessing` 使用应用层 CAS`updateMany WHERE status='pending'`)后补救。若 CAS 失败,调用方静默丢弃该事件,不重试也不回退 |
| **是否使用 SKIP LOCKED** | ❌ 否 | 使用应用层 CASupdateMany + status check非数据库层 SKIP LOCKED | | **是否使用 SKIP LOCKED** | ❌ 否 | 使用应用层 CASupdateMany + status check非数据库层 SKIP LOCKED |
| **重复发布风险** | ⚠️ 存在 | `findDispatchable` 读取后 `markProcessing` 若 CAS 失败不重试;若 Dispatcher 崩溃后重启,`releaseExpiredLocks` 可恢复但非实时 | | **并发重复发布风险** | ⚠️ 存在 | **具体场景**:两个 Dispatcher 实例同时调用 `findDispatchable(50)` → 读到同一批 `[E1, E2, ...]` → 各自投递到 BullMQ → 同一事件可能被发布两次。`markProcessing` 的 CAS 仅防止数据库状态被双重更新但不阻止网络层重复投递BullMQ `queue.add()` 一旦调用就无法回滚) |
**重复发布场景分析**Issue #290 相关):
```
Dispatcher-A Dispatcher-B
│ │
├─ findDispatchable() → [E1,E2] │
│ ├─ findDispatchable() → [E1,E2]
├─ queue.add(E1) ← 第一次投递 │
│ ├─ queue.add(E1) ← 重复投递!
├─ markProcessing(E1) ← CAS 成功 │
│ ├─ markProcessing(E1) ← CAS 失败,静默丢弃
├─ queue.add(E2) │
│ ├─ queue.add(E2) ← 重复投递!
├─ markProcessing(E2) ← CAS 成功 │
│ ├─ markProcessing(E2) ← CAS 失败,静默丢弃
```
**根因**`queue.add()``markProcessing()` 不是原子操作。BullMQ 投递成功后若 CAS 失败,没有补偿路径。修复方向:先抢锁(`markProcessing` CAS锁定成功后再投递或使用 DB SKIP LOCKED 在 `findDispatchable` 阶段就排他抢占。
### 表结构 (Prisma) ### 表结构 (Prisma)
@ -335,6 +378,21 @@ pending → processing → completed / failed
- **无取消支持** - **无取消支持**
- **无 retry/reaper** — 完全依赖 BullMQ 的 attempts/backoff - **无 retry/reaper** — 完全依赖 BullMQ 的 attempts/backoff
#### ⚠️ STATUS_TO_LIFECYCLE 映射缺口
`ai-analysis.repository.ts:10-15` 仅映射 4 个状态:
```typescript
private static readonly STATUS_TO_LIFECYCLE: Record<string, string> = {
pending: 'queued',
processing: 'running',
completed: 'succeeded',
failed: 'failed',
};
```
**缺失**`cancel_requested``cancelled`Issue #286 验收标准要求这两个状态也在映射范围内)。当前 AiJob 系统完全不支持取消操作,但如果 #286 引入统一状态机后 `cancel_requested` / `cancelled` 成为通用状态,此映射表将产生缺口。
### AiRuntimeJob ### AiRuntimeJob
`src/modules/ai-runtime/job-reaper.service.ts` + `runtime-internal.service.ts` `src/modules/ai-runtime/job-reaper.service.ts` + `runtime-internal.service.ts`
@ -433,14 +491,18 @@ pending → locked → running → succeeded / failed / cancelled
### 关键风险 ### 关键风险
| 风险 | 严重度 | 说明 | | 风险 | 严重度 | 说明 |
|------|--------|------| |------|--------|------|
| queueName 与实际路由不一致 | 🔴 高 | DB 写入 `queueName: 'ai-interactive'``ai-analysis.repository.ts:28`),实际入队 `ai-analysis``ai-analysis.service.ts:21`M-AI-03 依赖 `queueName` 字段进行路由 |
| STATUS_TO_LIFECYCLE 映射缺口 | 🟡 中 | 仅映射 4 个状态,缺 `cancel_requested` / `cancelled`#286 引入统一状态机后将暴露此缺口 |
| 两套 Job 状态不同步 | 🔴 高 | `AiJob.status`(legacy enum) 与 `AiJob.lifecycleStatus`(M-AI-02 新字段) 存在映射但不完整 | | 两套 Job 状态不同步 | 🔴 高 | `AiJob.status`(legacy enum) 与 `AiJob.lifecycleStatus`(M-AI-02 新字段) 存在映射但不完整 |
| Outbox 无 Dispatcher | 🟡 中 | 需新建但独立 | | Outbox 无 Dispatcher 且并发发布不安全 | 🔴 高 | `queue.add()``markProcessing()` 非原子,两 Dispatcher 并发时可重复投递(详见第 8 节) |
| `forwardRef` EventBus↔Queue | 🟢 低 | 已有解决方案,不扩大 | | `forwardRef` EventBus↔Queue | 🟢 低 | 已有解决方案,不扩大 |
| DocumentImport 使用独立 model | 🟡 中 | 不在 M-AI-03 范围,但未来统一需注意 | | DocumentImport 使用独立 model | 🟡 中 | 不在 M-AI-03 范围,但未来统一需注意 |
| RuntimeInternal 无事务保证 | 🔴 高 | result + job update 分两步,非原子 | | RuntimeInternal 无事务保证 | 🔴 高 | result + job update 分两步,非原子 |
| 孤儿队列notification/domain-events/audit-logs | 🟡 中 | Consumer 已注册但无活跃 Producer若从未有消息入队`NotificationWorker``AuditLogProcessor` 实为闲置进程 | 孤儿队列notification/domain-events/audit-logs | 🟡 中 | Consumer 已注册但无活跃 Producer`NotificationWorker``AuditLogProcessor` 实为永久空转 |
--- ---