api-server/docs/architecture/adr-003-unified-ai-job-engine.md
wangdl d44fe57b6d
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 32s
Deploy API Server / backward-compat (push) Has been cancelled
Deploy API Server / deploy (push) Has been cancelled
Deploy API Server / current-integration (push) Has been cancelled
fix: ADR-003 补充幂等流程 + 修正队列配置对齐 #288 规格
- 新增 §2.4 AiJobService.createJob 幂等流程(findUnique + insert + P2002 catch)
- §3.1 队列配置对齐 Issue #288:ai-interactive attempts=2, backoff=2000ms;ai-background concurrency=1, backoff=5000ms
- 新增设计理由说明与原始规格的偏差原因

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 17:11:18 +08:00

37 KiB
Raw Blame History

ADR-003统一 AI Job Engine、Registry、Outbox 与状态机

状态

已提出2026-06-20

背景

M-AI-03-01 审计(docs/architecture/m-ai-03-current-execution-audit.md)确认:

  • 存在两套 Job 系统:AiJobBullMQ + AiAnalysisWorker2 种 jobTypeAiRuntimeJobREST Poll + 外部 heavy-runtime5 种 jobType0 行生产数据)
  • M-AI-02 已在 AiAnalysisJob 物理表上完成 Schema Expand19 个新字段就绪Outbox/Artifact/Snapshot 表已创建
  • 3 个 BullMQ 队列notification、domain-events、audit-logs的 Consumer 已注册但无活跃 Producer
  • OutboxRepository 已完整实现但零调用方,无 Dispatcher
  • queueName 不一致AiAnalysisRepository 写入 queueName: 'ai-interactive',实际 BullMQ 路由到 ai-analysis
  • STATUS_TO_LIFECYCLE 映射缺口:仅 4 个状态,缺少 cancel_requested / cancelled

M-AI-03 的目标是在 M-AI-02 Schema Expand 之上,纯代码层实现统一 Job Engine、Registry、Outbox Dispatcher 与 Projector——无 Prisma Migration不迁移真实业务 Job不修改已有 Worker/队列


决策

1. 状态机

1.1 完整状态定义

                ┌──────────┐
                │  queued  │  ← 唯一初始状态AiJobLifecycleRepository.createJob
                └────┬─────┘
                     │ lockJob (CAS)
                ┌────▼─────┐
                │  running  │
                └────┬─────┘
           ┌────────┼──────────┐
           │        │          │
    ┌──────▼──┐ ┌──▼───┐ ┌───▼──────┐
    │succeeded│ │failed│ │cancelled │  ← 终态
    └─────────┘ └──────┘ └──────────┘
状态 说明 终态 进入条件
queued Job 已创建并入队,等待 Worker 领取 AiJobLifecycleRepository.createJob()
running Worker 已锁定并开始执行 AiJobLifecycleRepository.lockJob() CAS 成功
succeeded 执行成功Projector 已提交结果 AiJobLifecycleRepository.markSucceeded()
failed 执行失败且重试耗尽 AiJobLifecycleRepository.markFailed()
cancelled 用户/系统取消 AiJobLifecycleRepository.markCancelled()

1.2 允许迁移

queued    → running (lockJob)
queued    → cancelled (用户取消,仅 queued 状态允许)
running   → succeeded (submitResult)
running   → failed (submitFailureretryCount >= maxRetryCount)
running   → cancelled (cancelRequestedAt 已设置heartbeat 检测)

1.3 非法迁移

以下迁移在任何情况下都不允许:

queued    → succeeded    (必须先 lockJob → running)
queued    → failed       (必须先 lockJob → running)
succeeded → *            (终态,不可变)
failed    → *            (终态,不可变;需重试应创建新 Job)
cancelled → *            (终态,不可变)
running   → queued       (不可回退;需重试应通过 retryCount 机制)

1.4 唯一写入入口

所有 lifecycleStatus 的写入必须通过 AiJobLifecycleRepository 的以下方法:

// AiJobLifecycleRepository — 唯一写入 lifecycleStatus 的入口
export class AiJobLifecycleRepository {
  /**
   * 在给定事务中创建 JoblifecycleStatus = 'queued')。
   * 调用方负责在同一 tx 中写入 OutboxEvent以保证 Job + Outbox 原子性。
   */
  createJob(tx: Prisma.TransactionClient, input: CreateJobInput): Promise<AiJob>;

  /**
   * CAS 抢锁queued → running   * 必须是独立操作非事务内CAS 需要立即对其他 Worker 可见。
   */
  lockJob(jobId: string, instanceId: string): Promise<AiJob>;

  /**
   * 标记成功(→ succeeded   * @param tx 可选事务客户端。由 Projector 在事务内调用以确保 Projector + markSucceeded 原子性。
   *           若未传 tx方法内部使用独立事务非 Projector 路径使用)。
   */
  markSucceeded(jobId: string, tx?: Prisma.TransactionClient): Promise<AiJob>;

  /**
   * 标记失败(→ failed   * @param tx 可选事务客户端。若非 Projector 路径(如超时/Provider 错误),传 undefined 使用独立事务。
   */
  markFailed(jobId: string, error: JobErrorInfo, tx?: Prisma.TransactionClient): Promise<AiJob>;

  /**
   * 标记取消(→ cancelled   * @param tx 可选事务客户端。取消路径通常为独立操作,不传 tx。
   */
  markCancelled(jobId: string, tx?: Prisma.TransactionClient): Promise<AiJob>;

  // 只读
  findByLifecycleStatus(status: LifecycleStatus[], opts?: { queueName?: string; limit?: number }): Promise<AiJob[]>;
  findExpiredLocks(thresholdMs: number): Promise<AiJob[]>;
}

禁止:业务代码直接调用 prisma.aiJob.update({ where: { id }, data: { lifecycleStatus: '...' } })this.repository.updateJobStatus() 写入 lifecycleStatus

1.5 Legacy status Shadow Write

过渡期状态映射M-AI-02-10 已有,本 ADR 扩展):

const STATUS_TO_LIFECYCLE: Record<string, string> = {
  pending:       'queued',
  processing:    'running',
  completed:     'succeeded',
  failed:        'failed',
  cancelled:     'cancelled',        // 新增 — 修正 #284 发现的映射缺口
  cancel_requested: 'cancelled',     // 新增 — 统一映射
};

AiJobLifecycleRepository 变更 lifecycleStatus 时,若 legacy status 列存在同步写入映射值Shadow Write。M-AI-05 完成后删除 status 列。

1.6 并发状态更新策略

lockJob 使用 MySQL 乐观锁CAS updateMany

async lockJob(jobId: string, instanceId: string): Promise<AiJob> {
  const now = new Date();
  const lockUntil = new Date(now.getTime() + 60_000); // 60s TTL

  const result = await this.prisma.aiJob.updateMany({
    where: {
      id: jobId,
      lifecycleStatus: 'queued',
    },
    data: {
      lifecycleStatus: 'running',
      lockedBy: instanceId,
      lockedAt: now,
      lockUntil,
      startedAt: now,
    },
  });

  if (result.count === 0) {
    throw new JobLockConflictError(jobId);
  }

  return this.prisma.aiJob.findUniqueOrThrow({ where: { id: jobId } });
}

1.7 Heartbeat 与过期锁收割

机制 实现 频率
Heartbeat AiJobLifecycleRepository.extendLock(jobId, instanceId)UPDATE ... SET lockUntil = now() + 60s WHERE lifecycleStatus = 'running' AND lockedBy = instanceId 每 30s
过期锁收割 AiJobLifecycleRepository.findExpiredLocks(90_000) → 锁超时 90s60s TTL + 30s 宽限期)未续约 → markFailed,触发 BullMQ retry 每 30s

2. Job Definition Registry

2.1 TypeScript 接口(冻结)

// ── Job Definition ──

interface JobDefinition {
  /** 全局唯一 jobType与 AiJob.jobType 对应 */
  jobType: string;

  /** 元数据 */
  metadata: {
    /** 人类可读标签 */
    label: string;
    /** 描述 */
    description: string;
    /** 所属领域 */
    domain: 'analysis' | 'generation' | 'import' | 'maintenance';
    /** 版本 */
    version: string;
  };

  /** 路由策略 */
  queue: {
    /** 目标队列名 */
    queueName: 'ai-interactive' | 'ai-background';
    /** 优先级 0=最高, 100=最低 */
    defaultPriority: number;
  };

  /** 执行策略 */
  execution: {
    /** 超时(毫秒) */
    timeoutMs: number;
    /** 最大重试次数(不含首次) */
    maxRetries: number;
    /** BullMQ 重试退避 */
    retryBackoff: { type: 'exponential'; delay: number };
    /** 是否支持取消 */
    cancellable: boolean;
    /** AbortSignal 超时后行为 */
    abortStrategy: 'fail' | 'retry';
  };

  /** 输入 Schema 版本(对应 SnapshotBuilder 产出) */
  input: {
    schemaVersion: string;
  };

  /** 输出 Schema 版本(对应 AI Provider 产出 JSON Schema */
  output: {
    schemaVersion: string;
    /** Zod schema 引用,用于输出校验 */
    validator?: string;
  };

  /** 凭据模式 */
  credential: {
    /** 支持的凭据模式 */
    allowedModes: ('platform_key' | 'user_deepseek_key')[];
    /** 默认模式 */
    defaultMode: 'platform_key' | 'user_deepseek_key';
  };

  /** Projector 引用 */
  projector?: string; // 对应 ResultProjector 注册 key

  /** 安全约束 */
  security: {
    /** 是否需要内容安全检查 */
    contentSafetyCheck: boolean;
    /** 输出是否需要脱敏后存储 */
    outputRedaction: boolean;
  };
}

2.2 Registry

// JobDefinitionRegistry — 替代未来可能出现的巨型 switch(jobType)

@Injectable()
export class JobDefinitionRegistry {
  private definitions = new Map<string, JobDefinition>();

  register(def: JobDefinition): void {
    if (this.definitions.has(def.jobType)) {
      throw new DuplicateJobDefinitionError(def.jobType);
    }
    // 启动时校验
    this.validate(def);
    this.definitions.set(def.jobType, def);
  }

  get(jobType: string): JobDefinition {
    const def = this.definitions.get(jobType);
    if (!def) throw new UnknownJobTypeError(jobType);
    return def;
  }

  getAll(): JobDefinition[] {
    return Array.from(this.definitions.values());
  }

  private validate(def: JobDefinition): void {
    if (!def.jobType.match(/^[a-z][a-z0-9_]{1,63}$/)) {
      throw new InvalidJobTypeError(def.jobType);
    }
    if (!['ai-interactive', 'ai-background'].includes(def.queue.queueName)) {
      throw new InvalidQueueError(def.queue.queueName);
    }
    if (def.execution.timeoutMs < 1000 || def.execution.timeoutMs > 600_000) {
      throw new InvalidTimeoutError(def.execution.timeoutMs);
    }
    // ... 其他校验
  }
}

2.3 注册生命周期

// 在 AiJobModule 的 onModuleInit 中注册

@Module({ /* ... */ })
export class AiJobModule implements OnModuleInit {
  constructor(private readonly registry: JobDefinitionRegistry) {}

  onModuleInit() {
    // M-AI-04 才注册 active-recall / feynman-evaluation
    // M-AI-06 才注册 quiz_generation / flashcard_generation
    // M-AI-07 才注册 learning_state_analysis / weak_point_analysis / next_action_planning

    // M-AI-03 仅注册 Synthetic Definition测试用
    this.registry.register(SYNTHETIC_JOB_DEFINITION);
  }
}

2.4 AiJobService — 统一 Job 创建入口与幂等性

// AiJobService — 唯一 Job 创建入口internal-only
@Injectable()
export class AiJobService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly registry: JobDefinitionRegistry,
    private readonly lifecycleRepo: AiJobLifecycleRepository,
    private readonly outboxRepo: OutboxRepository,
    private readonly snapshotBuilder: SnapshotBuilderService,
  ) {}

  /**
   * 创建 Job + Snapshot + Outbox一个 Prisma Transaction 内)。
   *
   * 幂等性:
   * - AiJob 表有 @@unique([userId, jobType, idempotencyKey])
   * - 如果 (userId, jobType, idempotencyKey) 三元组匹配到已有 Job直接返回已有 Job
   * - 未提供 idempotencyKey 时每次创建新 Job
   *
   * 事务边界:
   * - BEGIN TRANSACTION
   * - createJob(tx) → INSERT AiJob (lifecycleStatus = 'queued')
   * - createInTransaction(tx) → INSERT OutboxEvent (eventType = 'ai.job.enqueue')
   * - COMMIT
   * - OutboxDispatcher 异步投递到 BullMQ
   */
  async createJob(input: CreateJobInput): Promise<AiJob>;
}

interface CreateJobInput {
  userId: string;
  jobType: string;
  targetType: string;
  targetId: string;
  /** 幂等 Key。提供时启用幂等不提供时每次创建新 Job */
  idempotencyKey?: string;
  /** 覆盖 Definition 默认值 */
  overrides?: {
    priority?: number;
    credentialMode?: 'platform_key' | 'user_deepseek_key';
    credentialId?: string;
    promptVersion?: string;
    outputSchemaVersion?: string;
  };
}

幂等流程

AiJobService.createJob(input)
  │
  ├─ 1. lookup JobDefinition via Registry.get(input.jobType)
  │
  ├─ 2. if input.idempotencyKey is provided:
  │     existing = prisma.aiJob.findUnique({
  │       where: { userId_jobType_idempotencyKey: {
  │         userId: input.userId,
  │         jobType: input.jobType,
  │         idempotencyKey: input.idempotencyKey
  │       }}
  │     })
  │     if existing → return existing  // 幂等返回,不创建新 Job
  │
  ├─ 3. build snapshot via SnapshotBuilder
  │
  ├─ 4. prisma.$transaction(async (tx) => {
  │       job = lifecycleRepo.createJob(tx, input)
  │       outboxRepo.createInTransaction(tx, {
  │         eventType: 'ai.job.enqueue',
  │         aggregateType: 'AiJob',
  │         aggregateId: job.id,
  │         dedupeKey: `${input.jobType}:${input.userId}:${input.idempotencyKey ?? job.id}`,
  │         payload: { jobId: job.id, jobType: input.jobType, queueName: def.queue.queueName, priority: job.priority },
  │         availableAt: new Date(),
  │       })
  │       return job
  │     })
  │
  └─ 5. return job

约束

  • 不提供通用公开创建接口 —— 外部通过具体业务 ServiceActiveRecallService.createJob())调用
  • CreateJobInput 的类型由 JobDefinitioninput.schemaVersion 隐式约束
  • 并发相同 (userId, jobType, idempotencyKey)findUnique 无记录 → 两个事务同时 INSERT → 一个成功,一个 P2002 唯一冲突 → catch 后 findUnique 返回已有赢者Job

3. 队列架构

3.1 队列配置

以下值来自 Issue #288 原始规格。有意偏离需在对应 Issue 中注明原因。

队列名 用途 Worker 进程 concurrency lockDuration attempts backoff
ai-interactive 新统一 Engine — 交互式 Job用户等待结果 WorkerModule 新增 2 30s 2 exponential 2000ms
ai-background 新统一 Engine — 后台 Job用户不等待 WorkerModule 新增 1 60s 3 exponential 5000ms
ai-analysis 旧系统 A — active-recall + feynman-evaluation WorkerModule(已有) 1 30s 3 exponential 1000ms

设计理由

  • ai-interactive attempts=2非 3用户等待结果BullMQ retry 会延长感知延迟。Engine 内部已有一层重试BullMQ 仅兜底 Worker 崩溃。
  • ai-background concurrency=1非 3后台 Job 以吞吐而非延迟为目标;单 Worker 串行消费避免 DeepSeek API 并发限流。若后续需要更大吞吐,通过水平扩展 Worker 实例而非提高 concurrency。
  • ai-background backoff=5000ms非默认 1000ms后台场景无实时性要求更长退避减少无效重试。

约束

  • ai-interactive / ai-background 仅在 WorkerModule 中注册 Processor
  • ai-analysis 队列不修改、不删除
  • document-importnotificationdomain-eventsaudit-logsfile-cleanup 五个队列不在 M-AI-03 范围

3.2 Queue Payload冻结

// ── BullMQ Job Payload ──

interface AiJobPayload {
  /** AiJob 数据库主键 */
  jobId: string;
  /** jobType — Worker 据此 lookup JobDefinition */
  jobType: string;
  /** 幂等 Key与 Outbox dedupeKey 相同) */
  idempotencyKey: string;
  /** 创建时间戳 */
  createdAt: string;
}

// BullMQ jobId 格式: "{queueName}:{jobType}:{idempotencyKey}"
// 例如: "ai-interactive:active-recall:user123_20260620_001"

3.3 Worker 进程注册边界

WorkerModule 新增:
├── AiInteractiveJobWorker   @Processor('ai-interactive')
│   └── AiJobExecutionEngine.execute()
└── AiBackgroundJobWorker    @Processor('ai-background')
    └── AiJobExecutionEngine.execute()

WorkerModule 已有(不修改):
├── AiAnalysisWorker          @Processor('ai-analysis')
├── DocumentImportWorker      @Processor('document-import')
├── NotificationWorker        @Processor('notification')
├── AuditLogProcessor         @Processor('audit-logs')
└── FileCleanupProcessor      @Processor('file-cleanup')

4. Outbox

4.1 事件类型

eventType 触发时机 dedupeKey 组成 aggregateType
ai.job.enqueue 事务内创建 Job 后 {jobType}:{userId}:{idempotencyKey} AiJob

未来扩展(不在 M-AI-03 范围):ai.job.completedai.job.failed

4.2 Dedupe 策略

dedupeKey 在 Outbox 表上有 UNIQUE 约束prisma/schema.prisma:2323)。重复插入 → 数据库层拒绝,调用方视为幂等成功。

4.3 领取算法(修正 #284 发现的并发竞态)

Dispatcher.cycle():
  1. findDispatchable(N) → 读取 status='pending' AND availableAt <= now()
  2. FOR EACH event:
     a. locked = markProcessing(eventId, instanceId)  // CAS: pending → processing
     b. IF locked IS NULL → SKIP其他 Dispatcher 已抢走)
     c. success = queue.add(event.payload)  // 投递到 BullMQ
     d. IF success → markPublished(eventId)
        ELSE → markFailed(eventId, 'DISPATCH_FAILED')

核心修复:先 CAS 抢锁(步骤 2a锁成功才投递(步骤 2c。UT (#284 审计发现的CAS 失败时跳过而非重试其他事件。修复了 queue.add() 先于 markProcessing() 导致的重复投递。

4.4 锁超时与重试

参数 说明
markProcessing CAS 条件 status = 'pending' 仅 pending 事件可抢锁
lockedAt TTL 60s Dispatcher 崩溃后事件锁有效时间
releaseExpiredLocks 间隔 30s 收割过期 processing → pending
最大 attemptCount 3 超过后 status = 'dead'(新增终态,区别于 failed
未知 eventType 记录 warning 日志,标记 published(避免阻塞队列),不投递

4.5 崩溃窗口处理

Dispatcher 或 BullMQ Redis 在 markProcessing() 成功但 queue.add() 尚未执行时崩溃:

  • lockedAt 超时60sreleaseExpiredLocks() 重置为 pending → 下一个周期重新领取
  • 潜在重复投递窗口:60s
  • 减轻措施BullMQ jobId = "{queueName}:{jobType}:{idempotencyKey}" 确保相同 dedupeKey 的入队幂等BullMQ 同 jobId 重复 add 视为 no-op

5. Execution Engine

5.1 执行阶段(管线)

Job (BullMQ)
  │
  ├─ 1. PREPARE
  │   ├─ lookup JobDefinition via Registry
  │   ├─ load AiJob from DB
  │   ├─ verify lifecycleStatus = 'queued'
  │   └─ lockJob (CAS queued → running)
  │
  ├─ 2. RESOLVE
  │   ├─ resolve snapshot (SnapshotBuilder)
  │   ├─ resolve credential (credentialMode / credentialId)
  │   ├─ resolve prompt (PromptTemplateService)
  │   └─ resolve provider (ModelRouter → AiProvider)
  │
  ├─ 3. EXECUTE
  │   ├─ AbortSignal (timeout from JobDefinition.execution.timeoutMs)
  │   ├─ content safety check (if security.contentSafetyCheck)
  │   ├─ AiGatewayService.generate(request)
  │   ├─ parse & validate output (outputSchema)
  │   └─ compute outputHash
  │
  ├─ 4. PROJECT
  │   ├─ begin Prisma transaction
  │   ├─ ResultProjector.project(output) → Artifact(s)
  │   ├─ AiJobLifecycleRepository.markSucceeded()
  │   ├─ OutboxRepository.createInTransaction(tx, ai.job.completed)  // 未来
  │   └─ commit transaction
  │
  └─ 5. COMPLETE
      ├─ usage logging (ModelInvocationLog)
      ├─ publish domain event (sync EventBus)
      └─ return

5.2 错误分类

错误类别 错误码前缀 可重试 行为
PROVIDER_* 外部 AI Provider 错误超时、5xx、限流 retry with backoff
VALIDATION_* 输出 Schema 校验失败 retry重试时调整 prompt
CONTENT_SAFETY_* 内容安全拦截 标记 failed不重试
TIMEOUT Job 超时 (如果 abortStrategy = 'retry' 根据 Definition 决定
CREDENTIAL_* 凭据无效/过期 标记 failed用户通知
CANCELLED 用户/系统取消 标记 cancelled
INTERNAL_* 内部错误DB、Redis、代码异常 (最多 1 次) 重试 1 次后 failed

5.3 Timeout 与 AbortSignal

// Execution Engine 内部
const def = this.registry.get(jobType);
const controller = new AbortController();
const timeoutId = setTimeout(() => {
  controller.abort();
  this.logger.warn(`Job ${jobId} timed out after ${def.execution.timeoutMs}ms`);
}, def.execution.timeoutMs);

try {
  const output = await this.aiGateway.generate(request, controller.signal);
  clearTimeout(timeoutId);
  // ...
} catch (err) {
  clearTimeout(timeoutId);
  if (controller.signal.aborted) {
    throw new JobTimeoutError(jobId, def.execution.timeoutMs);
  }
  throw err;
}

5.4 取消

// 用户取消流程
// 1. UserAiController.cancelJob() → AiJobLifecycleRepository.markCancelled(jobId)
//    - queued → cancelled直接
//    - running → 设置 cancelRequestedAtHeartbeat 检测后 markCancelled
//    - succeeded/failed/cancelled → 409 ConflictException终态不可取消

// 2. Worker Heartbeat 检测
//    Execution Engine 在每次 Provider 调用前后检查:
//    if (job.cancelRequestedAt != null) throw new JobCancelledError(jobId);

5.5 Retry

层级 机制 配置来源
BullMQ attempts + backoff JobDefinition.execution.maxRetries + retryBackoff
Execution Engine 内部重试循环 JobDefinition.execution.maxRetries
Outbox attemptCount < 3 → pending>= 3 → dead OutboxRepository

约束BullMQ retry 与 Engine 内部 retry 不叠加。Engine 内部错误不抛出(不触发 BullMQ retry而是在 Engine 内完成所有重试后,一次性 markFailedmarkSucceeded

5.6 ValidatedOutput 与 OutputHash

outputHash = SHA256(JSON.stringify(validatedOutput, Object.keys(validatedOutput).sort()))
  • 存储在 AiJob.outputHash
  • 用于去重(相同 input + outputHash → 幂等返回已有结果)
  • 用于 Artifact 去重(相同 outputHash → 跳过 Projector

5.7 Usage Logging

每次 AI Provider 调用后写入 AiUsageLogM-AI-02-08 已扩展 jobIdattemptNocredentialModeerrorCode 字段),关联 AiJob.id


6. Result Projector

6.1 接口(冻结)

interface ResultProjector {
  /** 全局唯一 key对应 JobDefinition.projector */
  readonly key: string;

  /**
   * 在 Prisma Transaction 内执行投影。
   * 如果 Artifact 已存在(幂等),返回已有引用。
   */
  project(
    tx: Prisma.TransactionClient,
    job: AiJob,
    validatedOutput: Record<string, any>,
  ): Promise<ProjectionResult>;
}

interface ProjectionResult {
  /** 创建的 Artifact 引用列表 */
  artifacts: Array<{
    artifactType: string;
    artifactId: string;
    ordinal: number;
  }>;
}

6.2 事务边界

// tx = await prisma.$transaction(async (tx) => { ... })
BEGIN TRANSACTION
  1. ResultProjector.project(tx, job, validatedOutput)
     → 写入业务表QuizQuestion / Flashcard / AiLearningAnalysis / WeakPointCandidate / NextActionRecommendation 等)
     → 写入 AiJobArtifact (jobId, artifactType, artifactId)
  2. AiJobLifecycleRepository.markSucceeded(jobId, tx)  // 传入 tx与 Projector 共享事务
  3. (未来) OutboxRepository.createInTransaction(tx, { eventType: 'ai.job.completed', ... })
COMMIT
  • markSucceeded 接受可选 tx:传入 tx 时复用事务,不传时内部开启独立事务

  • Projector 抛错 → Transaction Rollback → Job 回到 Engine retry 流程

  • 全部成功 → Commit

  • 任何步骤失败 → RollbackJob 重新进入 retry 流程

  • Projector 执行中触发唯一约束冲突 → 视为幂等成功,跳过

6.3 幂等保证

  • AiJobArtifact@@unique([jobId, artifactType, artifactId])
  • 重复 Projection → DB UNIQUE 冲突 → catch → 返回已有 Artifact 引用
  • {{##duplicate}} 标记在 Projector 日志中

6.4 失败回滚

Projector 抛错 → Transaction Rollback → JobFailedError → Engine retry如果可重试或 markFailed


7. API 设计

7.1 端点

方法 路径 权限 说明
GET /api/ai/jobs/:jobId 用户(自己的 Job/ Admin Job 状态查询
POST /api/ai/jobs/:jobId/cancel 用户(自己的 Job 取消 Job
GET /admin-api/ai/jobs AdminSUPER_ADMIN Job 列表查询(支持按 jobType/status/queueName/时间范围筛选)
POST /admin-api/ai/jobs/:jobId/retry AdminSUPER_ADMIN 手动重试失败 Job创建新 Job复制 input

7.2 Job 状态响应(冻结)

interface JobStatusResponse {
  jobId: string;
  jobType: string;
  lifecycleStatus: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
  queueName: string;
  priority: number;
  progress: number;         // 0100
  createdAt: string;
  startedAt?: string;
  finishedAt?: string;
  errorCode?: string;
  publicErrorMessage?: string;   // 面向用户的错误,不含内部堆栈
  result?: {
    artifactCount: number;
    artifacts: Array<{ artifactType: string; artifactId: string }>;
  };
}

7.3 公开错误码

错误码 HTTP 状态 说明
JOB_NOT_FOUND 404 Job 不存在或不属于当前用户
JOB_NOT_CANCELLABLE 409 Job 处于终态,不可取消
JOB_ALREADY_CANCELLED 409 Job 已被取消
TOO_MANY_JOBS 429 用户并发 Job 数超限
QUOTA_EXCEEDED 429 用户日/月额度用尽
PLATFORM_UNAVAILABLE 503 平台 AI 服务不可用

7.4 Admin 公开错误

Admin 端点额外返回 internalErrorMessage(仅 SUPER_ADMIN 角色可见),包含内部堆栈信息。


8. 安全约束

8.1 Credential

  • 用户 Keyuser_deepseek_key mode通过 CredentialEncryptionService 加解密AiJob 表仅存储 credentialIdFK → UserModelCredential
  • 平台 Keyplatform_key mode从环境变量注入不在 DB 中存储
  • Runtime Resolve 端点:仅验证 Runtime Service Token 后返回解密 Key

8.2 Redis

  • BullMQ 连接通过 Redis ACL 用户隔离
  • Heartbeat Keyworker:heartbeat:{instanceId}TTL 90s
  • 取消信号:job:cancel:{jobId}TTL 5min
  • 无敏感数据AiJob payload存储在 Redis

8.3 日志

  • AiJob.payloadinput不在日志中输出
  • validatedOutput 不在日志中输出(仅 outputHash
  • publicErrorMessage 不含 IP、路径、堆栈
  • internalErrorMessage 仅 SUPER_ADMIN 可见

8.4 Snapshot

  • AiJobSnapshot.content 存储在 DBJSON 列),遵循 redactionVersion 脱敏规则
  • expiresAt 过期后由 SnapshotCleanupService 清理

8.5 Outbox Payload

  • OutboxEvent.payload 仅包含 { jobId, jobType, queueName, priority }
  • 不含 userIdcredentialId、snapshot 内容

9. 模块图

AiJobModule新增
├── AiJobService                  // 统一 Job 创建入口事务内AiJob + Outbox
├── AiJobLifecycleRepository      // 唯一 lifecycleStatus 写入入口
├── JobDefinitionRegistry         // JobType → Definition 注册表
├── AiJobExecutionEngine          // 统一执行管线PREPARE/RESOLVE/EXECUTE/PROJECT/COMPLETE
├── OutboxDispatcher              // 轮询 OutboxEvent → CAS 领取 → BullMQ 投递
├── ResultProjector               // 投影器接口 + 内置实现(业务 Issue 注册)
│
├── AiInteractiveJobWorker        // @Processor('ai-interactive')
└── AiBackgroundJobWorker         // @Processor('ai-background')

依赖模块(已有,不修改):
├── AiGatewayService              // AI Provider 调用
├── ModelRouter                   // Provider 路由
├── PromptTemplateService         // Prompt 模板
├── AiCostCalculatorService       // 成本计算
├── AiUsageLogService             // 用量记录
├── ContentSafetyService          // 内容安全
├── CredentialEncryptionService   // 凭据加解密
├── SnapshotBuilderService        // Snapshot 构建
├── QueueService                  // BullMQ 入队(仅 OutboxDispatcher 使用)
└── EventBusService               // 领域事件sync publish

10. 时序图

User                AiJobService      AiJobLifecycleRepo   OutboxRepo         BullMQ         Worker         Engine              AiGateway

  │ POST /ai/jobs       │                   │                  │                 │              │                   │
  ├─────────────────────>│                   │                  │                 │              │                   │
  │                      │                   │                  │                 │              │                   │
  │                      │─ BEGIN TX ────────>                  │                 │              │                   │
  │                      │─ createJob(tx) ──>│                  │                 │              │                   │
  │                      │                   │─INSERT AiJob─────│                 │              │                   │
  │                      │                   │  (queued)        │                 │              │                   │
  │                      │─ createInTx(tx) ──────────────────>│                 │              │                   │
  │                      │                   │  INSERT Outbox   │                 │              │                   │
  │                      │─ COMMIT ───────────────────────────────────────────────────────────────────────────────│
  │                      │                   │                  │                 │              │                   │
  │<── { jobId, queued } │                   │                  │                 │              │                   │
  │                      │                   │                  │                 │              │                   │
  │                      │                   │     ╔═════════════════════════════╗                             │
  │                      │                   │     ║ OutboxDispatcher 异步       ║                             │
  │                      │                   │     ║                             ║                             │
  │                      │                   │     ║ findDispatchable()          ║                             │
  │                      │                   │     ║ markProcessing(CAS)         ║                             │
  │                      │                   │     ║ queue.add('ai-interactive')─>│                            │
  │                      │                   │     ║ markPublished()             ║                             │
  │                      │                   │     ╚═════════════════════════════╝                             │
  │                      │                   │                  │                 │              │                   │
  │                      │                   │                  │  ai-interactive│              │                   │
  │                      │                   │                  │<───────────────│              │                   │
  │                      │                   │                  │                 │─ process() ─>│                   │
  │                      │                   │                  │                 │              │                   │
  │                      │                   │                  │                 │              │─ 1. PREPARE       │
  │                      │                   │                  │                 │              │  lockJob(CAS)     │
  │                      │                   │                  │                 │              │                   │
  │                      │                   │                  │                 │              │─ 2. RESOLVE       │
  │                      │                   │                  │                 │              │  snapshot         │
  │                      │                   │                  │                 │              │  credential       │
  │                      │                   │                  │                 │              │                   │
  │                      │                   │                  │                 │              │─ 3. EXECUTE ──────────────────>│
  │                      │                   │                  │                 │              │                   │generate()
  │                      │                   │                  │                 │              │<──────── ─ ─ ─ ─ ─│
  │                      │                   │                  │                 │              │  validatedOutput  │
  │                      │                   │                  │                 │              │                   │
  │                      │                   │                  │                 │              │─ 4. PROJECT       │
  │                      │                   │                  │                 │              │  markSucceeded    │
  │                      │                   │                  │                 │              │  create Artifact  │
  │                      │                   │                  │                 │              │                   │
  │                      │                   │                  │                 │              │─ 5. COMPLETE      │
  │                      │                   │                  │                 │              │  usage log        │
  │                      │                   │                  │                 │              │  event            │

11. 测试矩阵

测试层级 范围 Issue
单元测试 AiJobLifecycleRepository — 状态迁移、非法迁移、CAS 并发 #286
单元测试 JobDefinitionRegistry — 注册、查重、校验 #287
单元测试 OutboxDispatcher — 领取算法、CAS 竞争、崩溃恢复 #290
单元测试 AiJobExecutionEngine — 正常执行、超时、取消、重试 #291
单元测试 ResultProjector — 事务提交、幂等、失败回滚 #292
集成测试 Synthetic Job Definition 端到端 #294
集成测试 AiJobService + OutboxRepository 事务原子性 #289
集成测试 Admin API 查询/取消/重试 #293

12. 非目标M-AI-03 明确不包含)

  1. 无 Prisma Migration — 全部在 M-AI-02 Schema 基础上实现
  2. 不迁移真实业务 Jobai-analysis 队列的 active-recall 和 feynman-evaluation 继续走旧链路
  3. 不创建通用公开创建接口AiJobService.createJob() 为 internal-only外部通过具体业务 Service 调用
  4. 不修改 AiRuntimeJob 表 — 不碰、不读、不写
  5. 不修改已有 WorkerAiAnalysisWorkerDocumentImportWorker 等不受影响
  6. 不引入新的第三方依赖
  7. 不替换 BullMQ — 队列基础设施不变

影响

  • 所有 M-AI-03 后续 Issue#286#295必须引用本 ADR
  • ai-interactive / ai-background 队列在 QueueModule 中注册
  • OutboxDispatcherWorkerModule 中注册为独立 Service非 BullMQ Processor

回滚

每个 Issue 独立回滚revert 对应 commit。本 ADR 无代码变更,无需回滚。

证据

  • M-AI-03-01 审计:docs/architecture/m-ai-03-current-execution-audit.md
  • M-AI-02 Schema 冻结:docs/architecture/adr-002-ai-job-database-expand.md
  • M-AI-01 架构决策:docs/architecture/adr-001-unified-ai-architecture.md
  • 当前 Prisma Schemaprisma/schema.prisma:568-677AiJob、AiJobSnapshot、AiJobArtifact
  • Outbox Repositorysrc/infrastructure/outbox/outbox.repository.ts:27-139
  • Queue Definitionssrc/infrastructure/queue/queue-definitions.ts:94-101
  • Worker Modulesrc/worker.module.ts:1-82