fix: #289 检修 — TOCTOU 幂等 + promptKey 传递 + require 清理
- 幂等保护从 findUnique 预检改为 P2002 catch(消除 TOCTOU 竞态窗口)
- AiJobLifecycleRepository: CreateJobInput 新增 promptKey,createJob 写入
- AiJobCreationService: 从 JobDefinition 传递 promptKey 到 Job
- computeHash: 内联 require('crypto') → 顶层 import * as crypto
- 测试更新: 幂等场景改为 P2002 冲突 → findUniqueOrThrow 返回已有 Job
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6668bba9b4
commit
471aa2b872
@ -138,58 +138,44 @@ describe('AiJobCreationService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('幂等性', () => {
|
describe('幂等性', () => {
|
||||||
it('相同 (userId, jobType, idempotencyKey) 返回已有 Job,不创建新记录', async () => {
|
it('并发重复创建相同 (userId, jobType, idempotencyKey) → P2002 → 返回已有 Job', async () => {
|
||||||
registry.get.mockReturnValue(validDef());
|
registry.get.mockReturnValue(validDef());
|
||||||
|
snapshotBuilder.buildSnapshot.mockResolvedValue({});
|
||||||
|
|
||||||
const existingJob = {
|
// First request creates successfully
|
||||||
id: 'existing-job',
|
lifecycleRepo.createJob.mockResolvedValueOnce({ id: 'job-001' });
|
||||||
lifecycleStatus: 'succeeded',
|
|
||||||
jobType: 'test_job',
|
await service.createJob({
|
||||||
};
|
userId: 'u1', jobType: 'test_job', triggerType: 'user_api',
|
||||||
prisma.aiJob.findUnique.mockResolvedValue(existingJob);
|
targetType: 'kb', targetId: 'kb-1', idempotencyKey: 'idem-001',
|
||||||
|
});
|
||||||
|
expect(lifecycleRepo.createJob).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Second request: transaction fails with P2002 (duplicate unique key)
|
||||||
|
const p2002Error = new Error('Unique constraint failed') as any;
|
||||||
|
p2002Error.code = 'P2002';
|
||||||
|
prisma.$transaction.mockRejectedValueOnce(p2002Error);
|
||||||
|
|
||||||
|
const existingJob = { id: 'job-001', lifecycleStatus: 'queued', jobType: 'test_job' };
|
||||||
|
prisma.aiJob.findUniqueOrThrow = jest.fn().mockResolvedValue(existingJob);
|
||||||
|
|
||||||
const result = await service.createJob({
|
const result = await service.createJob({
|
||||||
userId: 'u1',
|
userId: 'u1', jobType: 'test_job', triggerType: 'user_api',
|
||||||
jobType: 'test_job',
|
targetType: 'kb', targetId: 'kb-1', idempotencyKey: 'idem-001',
|
||||||
triggerType: 'user_api',
|
|
||||||
targetType: 'kb',
|
|
||||||
targetId: 'kb-1',
|
|
||||||
idempotencyKey: 'idem-001',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(existingJob);
|
expect(result).toBe(existingJob);
|
||||||
// 不应调用 snapshot、lifecycleRepo、transaction
|
// Snapshot was still built (pre-transaction), but no new Job created
|
||||||
expect(snapshotBuilder.buildSnapshot).not.toHaveBeenCalled();
|
|
||||||
expect(lifecycleRepo.createJob).not.toHaveBeenCalled();
|
|
||||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('未提供 idempotencyKey 时每次创建新 Job', async () => {
|
it('未提供 idempotencyKey 时每次创建新 Job(无幂等保护)', async () => {
|
||||||
registry.get.mockReturnValue(validDef());
|
registry.get.mockReturnValue(validDef());
|
||||||
snapshotBuilder.buildSnapshot.mockResolvedValue({});
|
snapshotBuilder.buildSnapshot.mockResolvedValue({});
|
||||||
const mockJob = { id: 'job-002' };
|
const mockJob = { id: 'job-002' };
|
||||||
lifecycleRepo.createJob.mockResolvedValue(mockJob);
|
lifecycleRepo.createJob.mockResolvedValue(mockJob);
|
||||||
|
|
||||||
// 第一次创建
|
await service.createJob({ userId: 'u1', jobType: 'test_job', triggerType: 'user_api', targetType: 'kb', targetId: 'kb-1' });
|
||||||
prisma.aiJob.findUnique.mockResolvedValue(null);
|
await service.createJob({ userId: 'u1', jobType: 'test_job', triggerType: 'user_api', targetType: 'kb', targetId: 'kb-1' });
|
||||||
await service.createJob({
|
|
||||||
userId: 'u1',
|
|
||||||
jobType: 'test_job',
|
|
||||||
triggerType: 'user_api',
|
|
||||||
targetType: 'kb',
|
|
||||||
targetId: 'kb-1',
|
|
||||||
// no idempotencyKey
|
|
||||||
});
|
|
||||||
|
|
||||||
// 第二次创建(无幂等键,应再次创建)
|
|
||||||
await service.createJob({
|
|
||||||
userId: 'u1',
|
|
||||||
jobType: 'test_job',
|
|
||||||
triggerType: 'user_api',
|
|
||||||
targetType: 'kb',
|
|
||||||
targetId: 'kb-1',
|
|
||||||
// no idempotencyKey
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lifecycleRepo.createJob).toHaveBeenCalledTimes(2);
|
expect(lifecycleRepo.createJob).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { OutboxRepository, CreateOutboxInput } from '../../infrastructure/outbox/outbox.repository';
|
import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository';
|
||||||
import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service';
|
import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service';
|
||||||
import { AiJobLifecycleRepository, CreateJobInput } from './ai-job-lifecycle.repository';
|
import { AiJobLifecycleRepository, CreateJobInput } from './ai-job-lifecycle.repository';
|
||||||
import { JobDefinitionRegistry } from './job-definition-registry';
|
import { JobDefinitionRegistry } from './job-definition-registry';
|
||||||
@ -63,32 +64,12 @@ export class AiJobCreationService {
|
|||||||
// 1. Resolve Definition(fail-fast:未知 JobType 在此拦截)
|
// 1. Resolve Definition(fail-fast:未知 JobType 在此拦截)
|
||||||
const def = this.registry.get(input.jobType);
|
const def = this.registry.get(input.jobType);
|
||||||
|
|
||||||
// 2. Validate input schema version consistency
|
// 2. Validate triggerType(TypeScript 编译期 + 运行时双重保护)
|
||||||
if (input.triggerType && !['user_api', 'admin_manual', 'system_scheduled', 'parent_job'].includes(input.triggerType)) {
|
if (input.triggerType && !['user_api', 'admin_manual', 'system_scheduled', 'parent_job'].includes(input.triggerType)) {
|
||||||
throw new BadRequestException(`Invalid triggerType: ${input.triggerType}`);
|
throw new BadRequestException(`Invalid triggerType: ${input.triggerType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Idempotency check(fast-return before snapshot build)
|
// 3. Build snapshot(事务外:依赖外部 DB 读取,不应占用事务时间)
|
||||||
if (input.idempotencyKey) {
|
|
||||||
const existing = await this.prisma.aiJob.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_jobType_idempotencyKey: {
|
|
||||||
userId: input.userId,
|
|
||||||
jobType: input.jobType,
|
|
||||||
idempotencyKey: input.idempotencyKey,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
this.logger.log(
|
|
||||||
`Idempotent return: jobId=${existing.id} ` +
|
|
||||||
`key=${input.idempotencyKey}`,
|
|
||||||
);
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Build snapshot(事务外:依赖外部 DB 读取,不应占用事务时间)
|
|
||||||
const snapshot = await this.snapshotBuilder.buildSnapshot(
|
const snapshot = await this.snapshotBuilder.buildSnapshot(
|
||||||
input.userId,
|
input.userId,
|
||||||
input.targetType,
|
input.targetType,
|
||||||
@ -97,9 +78,9 @@ export class AiJobCreationService {
|
|||||||
|
|
||||||
const contentHash = this.computeHash(JSON.stringify(snapshot));
|
const contentHash = this.computeHash(JSON.stringify(snapshot));
|
||||||
|
|
||||||
// 5. Transaction: Job + Snapshot + Outbox
|
// 4. Transaction: Job + Snapshot + Outbox(含幂等保护)
|
||||||
const job = await this.prisma.$transaction(async (tx) => {
|
const createFn = async (tx: Prisma.TransactionClient) => {
|
||||||
// 5a. Create AiJob
|
// 4a. Create AiJob
|
||||||
const jobInput: CreateJobInput = {
|
const jobInput: CreateJobInput = {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
jobType: input.jobType,
|
jobType: input.jobType,
|
||||||
@ -114,6 +95,7 @@ export class AiJobCreationService {
|
|||||||
credentialId: input.overrides?.credentialId,
|
credentialId: input.overrides?.credentialId,
|
||||||
modelProvider: def.model.modelProvider,
|
modelProvider: def.model.modelProvider,
|
||||||
modelName: def.model.modelName,
|
modelName: def.model.modelName,
|
||||||
|
promptKey: def.prompt.promptKey,
|
||||||
promptVersion: def.prompt.promptVersion,
|
promptVersion: def.prompt.promptVersion,
|
||||||
outputSchemaVersion: def.output.schemaVersion,
|
outputSchemaVersion: def.output.schemaVersion,
|
||||||
inputSchemaVersion: def.input.schemaVersion,
|
inputSchemaVersion: def.input.schemaVersion,
|
||||||
@ -123,7 +105,7 @@ export class AiJobCreationService {
|
|||||||
|
|
||||||
const job = await this.lifecycleRepo.createJob(tx, jobInput);
|
const job = await this.lifecycleRepo.createJob(tx, jobInput);
|
||||||
|
|
||||||
// 5b. Create AiJobSnapshot
|
// 4b. Create AiJobSnapshot
|
||||||
await tx.aiJobSnapshot.create({
|
await tx.aiJobSnapshot.create({
|
||||||
data: {
|
data: {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
@ -135,7 +117,7 @@ export class AiJobCreationService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5c. Create OutboxEvent
|
// 4c. Create OutboxEvent
|
||||||
const dedupeKey = `ai.job.enqueue:${job.id}`;
|
const dedupeKey = `ai.job.enqueue:${job.id}`;
|
||||||
await this.outboxRepo.createInTransaction(tx, {
|
await this.outboxRepo.createInTransaction(tx, {
|
||||||
eventType: 'ai.job.enqueue',
|
eventType: 'ai.job.enqueue',
|
||||||
@ -146,21 +128,41 @@ export class AiJobCreationService {
|
|||||||
availableAt: new Date(),
|
availableAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return job;
|
return job; // job 在事务成功路径中使用,P2002 catch 路径中不用
|
||||||
});
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await this.prisma.$transaction(createFn);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Job created: id=${job.id} type=${input.jobType} ` +
|
`Job created: id=${job.id} type=${input.jobType} ` +
|
||||||
`queue=${def.queue.queueName} snapshotHash=${contentHash}`,
|
`queue=${def.queue.queueName} snapshotHash=${contentHash}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
|
} catch (err: any) {
|
||||||
|
// 幂等:并发重复创建 → UNIQUE 冲突(P2002)→ 返回已有 Job
|
||||||
|
if (err?.code === 'P2002' && input.idempotencyKey) {
|
||||||
|
const existing = await this.prisma.aiJob.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
userId_jobType_idempotencyKey: {
|
||||||
|
userId: input.userId,
|
||||||
|
jobType: input.jobType,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Idempotent return (P2002): jobId=${existing.id} ` +
|
||||||
|
`key=${input.idempotencyKey}`,
|
||||||
|
);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── helpers ──
|
// ── helpers ──
|
||||||
|
|
||||||
private computeHash(content: string): string {
|
private computeHash(content: string): string {
|
||||||
const crypto = require('crypto');
|
|
||||||
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
|
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export interface CreateJobInput {
|
|||||||
credentialId?: string;
|
credentialId?: string;
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
|
promptKey?: string;
|
||||||
promptVersion?: string;
|
promptVersion?: string;
|
||||||
outputSchemaVersion?: string;
|
outputSchemaVersion?: string;
|
||||||
inputSchemaVersion?: string;
|
inputSchemaVersion?: string;
|
||||||
@ -98,6 +99,7 @@ export class AiJobLifecycleRepository {
|
|||||||
// 若未传入,依赖 Prisma schema @default 保证列非空。
|
// 若未传入,依赖 Prisma schema @default 保证列非空。
|
||||||
...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
|
...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
|
||||||
...(input.modelName ? { modelName: input.modelName } : {}),
|
...(input.modelName ? { modelName: input.modelName } : {}),
|
||||||
|
promptKey: input.promptKey ?? null,
|
||||||
promptVersion: input.promptVersion ?? null,
|
promptVersion: input.promptVersion ?? null,
|
||||||
outputSchemaVersion: input.outputSchemaVersion ?? null,
|
outputSchemaVersion: input.outputSchemaVersion ?? null,
|
||||||
inputSchemaVersion: input.inputSchemaVersion ?? null,
|
inputSchemaVersion: input.inputSchemaVersion ?? null,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user