api-server/src/modules/ai-job/projection-executor.service.ts
wangdl e89c0fbd6b
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 28s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
fix: B1 — 无 Projector 路径调用 markSucceeded 防止 Job 卡在 running
projectorKey 为 undefined 或 Projector 未注册时,
ProjectionExecutor 调用 markSucceeded 后再返回空 Artifact 列表。
避免 Engine 的 AI 调用成功后 Job 永远停留在 running 状态。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 09:51:10 +08:00

119 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger, Inject, Optional } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
import { AiJobStateMachine } from './ai-job-state-machine';
import { ResultProjector, ProjectionContext, ArtifactReference, RESULT_PROJECTORS } from './result-projector.interface';
import { JobAlreadyTerminalError } from './ai-job.errors';
/**
* M-AI-03-09: Projection Executor
*
* 在 Prisma Transaction 内原子执行:
* 1. ResultProjector.project(tx, context) → 业务实体
* 2. AiJobArtifact 写入project 内部已做,此处验证)
* 3. AiJobLifecycleRepository.markSucceeded(tx) → 原子标记成功
*
* 幂等Job 已 succeeded → 直接返回(不重复执行 Projector
*/
@Injectable()
export class ProjectionExecutor {
private readonly logger = new Logger(ProjectionExecutor.name);
private readonly projectorMap = new Map<string, ResultProjector>();
constructor(
private readonly prisma: PrismaService,
private readonly lifecycleRepo: AiJobLifecycleRepository,
private readonly stateMachine: AiJobStateMachine,
@Optional()
@Inject(RESULT_PROJECTORS)
projectors?: ResultProjector[],
) {
if (projectors) {
for (const p of projectors) {
if (this.projectorMap.has(p.key)) {
this.logger.warn(`Duplicate projector key "${p.key}" — overwriting`);
}
this.projectorMap.set(p.key, p);
}
}
this.logger.log(
`ProjectionExecutor initialized with ${this.projectorMap.size} projector(s): ` +
`[${Array.from(this.projectorMap.keys()).join(', ') || 'none'}]`,
);
}
/**
* 执行 Projector。
*
* @param projectorKey JobDefinition.projectorKey
* @param context Projection 上下文
* @returns Artifact 引用列表
*/
async execute(
projectorKey: string | undefined,
context: ProjectionContext,
): Promise<ArtifactReference[]> {
if (!projectorKey) {
this.logger.log(`No projectorKey for jobType=${context.job.jobType} — marking succeeded without projection`);
await this.lifecycleRepo.markSucceeded(context.job.id);
return [];
}
const projector = this.projectorMap.get(projectorKey);
if (!projector) {
// Projector 未注册 — 仍应标记 Job 成功Engine 的 AI 调用已完成)
this.logger.warn(`Projector "${projectorKey}" not registered — marking succeeded without projection for job=${context.job.id}`);
await this.lifecycleRepo.markSucceeded(context.job.id);
return [];
}
// 幂等检查Job 已终态则跳过
const currentJob = await this.prisma.aiJob.findUnique({
where: { id: context.job.id },
select: { lifecycleStatus: true },
});
if (currentJob && this.stateMachine.isTerminal(currentJob.lifecycleStatus as any)) {
this.logger.warn(
`Job ${context.job.id} already terminal (${currentJob.lifecycleStatus}) — skipping projector`,
);
// 返回已有 Artifact 引用
const existing = await this.prisma.aiJobArtifact.findMany({
where: { jobId: context.job.id },
orderBy: { ordinal: 'asc' },
});
return existing.map((a) => ({
artifactType: a.artifactType,
artifactId: a.artifactId,
ordinal: a.ordinal,
}));
}
// 事务内Projector + markSucceeded
const artifacts = await this.prisma.$transaction(async (tx) => {
const result = await projector.project(tx, context);
// 验证 Artifact 引用的完整性
if (result.length > 0) {
const dbArtifacts = await tx.aiJobArtifact.findMany({
where: { jobId: context.job.id },
});
if (dbArtifacts.length < result.length) {
throw new Error(
`Artifact mismatch: projector returned ${result.length} but only ${dbArtifacts.length} in DB`,
);
}
}
// 标记成功(事务内,与 Projector 原子提交)
await this.lifecycleRepo.markSucceeded(context.job.id, tx);
return result;
});
this.logger.log(
`Projection complete: job=${context.job.id} projector=${projectorKey} artifacts=${artifacts.length}`,
);
return artifacts;
}
}