feat: M-AI-03 AiJob 状态机与原子状态仓储
- AiJobStateMachine: 5 状态(queued/running/succeeded/failed/cancelled)、 合法/非法迁移验证、终态判断、双向 Shadow Write 映射 - AiJobLifecycleRepository: 唯一 lifecycleStatus 写入入口、 CAS(条件 UPDATE WHERE lifecycleStatus IN (...))抢锁/续锁/标记成功/失败/取消、 可选 tx 参数支持 Projector 事务内调用 - requestCancellation: queued 直接取消 → cancelled、 running 设置 cancelRequestedAt 信号 - 6 个类型化错误(JobLockConflict/JobNotActive/JobAlreadyTerminal/ JobNotCancellable/JobExtendLock/IllegalStateTransition) - 41 个单元测试全部通过 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8b6c7cef72
commit
b08d27a449
407
src/modules/ai-job/ai-job-lifecycle.repository.spec.ts
Normal file
407
src/modules/ai-job/ai-job-lifecycle.repository.spec.ts
Normal file
@ -0,0 +1,407 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AiJobStateMachine, LifecycleStatus } from './ai-job-state-machine';
|
||||
import { AiJobLifecycleRepository, CreateJobInput } from './ai-job-lifecycle.repository';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import {
|
||||
JobLockConflictError,
|
||||
JobNotActiveError,
|
||||
JobAlreadyTerminalError,
|
||||
JobNotCancellableError,
|
||||
JobExtendLockError,
|
||||
IllegalStateTransitionError,
|
||||
} from './ai-job.errors';
|
||||
|
||||
describe('AiJobStateMachine', () => {
|
||||
let sm: AiJobStateMachine;
|
||||
|
||||
beforeEach(() => {
|
||||
sm = new AiJobStateMachine();
|
||||
});
|
||||
|
||||
describe('合法迁移', () => {
|
||||
it.each([
|
||||
['queued', 'running'],
|
||||
['queued', 'cancelled'],
|
||||
['running', 'succeeded'],
|
||||
['running', 'failed'],
|
||||
['running', 'cancelled'],
|
||||
])('%s → %s', (from, to) => {
|
||||
expect(() => sm.validate(from as LifecycleStatus, to as LifecycleStatus)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('非法迁移', () => {
|
||||
it.each([
|
||||
['queued', 'succeeded'],
|
||||
['queued', 'failed'],
|
||||
['succeeded', 'running'],
|
||||
['succeeded', 'failed'],
|
||||
['succeeded', 'cancelled'],
|
||||
['failed', 'running'],
|
||||
['failed', 'succeeded'],
|
||||
['failed', 'cancelled'],
|
||||
['cancelled', 'running'],
|
||||
['cancelled', 'succeeded'],
|
||||
['cancelled', 'failed'],
|
||||
['running', 'queued'],
|
||||
])('%s → %s 抛出 IllegalStateTransitionError', (from, to) => {
|
||||
expect(() => sm.validateForJob('test-job', from as LifecycleStatus, to as LifecycleStatus))
|
||||
.toThrow(IllegalStateTransitionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('终态判断', () => {
|
||||
it.each(['succeeded', 'failed', 'cancelled'] as LifecycleStatus[])('%s 是终态', (s) => {
|
||||
expect(sm.isTerminal(s)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['queued', 'running'] as LifecycleStatus[])('%s 不是终态', (s) => {
|
||||
expect(sm.isTerminal(s)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
it('有效字符串返回 LifecycleStatus', () => {
|
||||
expect(sm.parse('queued')).toBe('queued');
|
||||
expect(sm.parse('running')).toBe('running');
|
||||
expect(sm.parse('succeeded')).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('null/undefined 返回 queued', () => {
|
||||
expect(sm.parse(null)).toBe('queued');
|
||||
expect(sm.parse(undefined)).toBe('queued');
|
||||
});
|
||||
|
||||
it('无效字符串抛出错误', () => {
|
||||
expect(() => sm.parse('invalid')).toThrow(IllegalStateTransitionError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiJobLifecycleRepository', () => {
|
||||
let repo: AiJobLifecycleRepository;
|
||||
let prisma: PrismaService;
|
||||
let tx: any; // mock transaction client
|
||||
|
||||
// 伪造 Prisma TransactionClient
|
||||
const makeMockTx = () => ({
|
||||
aiJob: {
|
||||
create: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const txMock = makeMockTx();
|
||||
|
||||
const mockPrisma = {
|
||||
aiJob: {
|
||||
create: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiJobStateMachine,
|
||||
AiJobLifecycleRepository,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
repo = module.get(AiJobLifecycleRepository);
|
||||
prisma = module.get(PrismaService);
|
||||
tx = txMock;
|
||||
});
|
||||
|
||||
describe('createJob', () => {
|
||||
it('在事务中创建 queued 状态的 Job,含 Shadow Write', async () => {
|
||||
const input: CreateJobInput = {
|
||||
userId: 'user-1',
|
||||
jobType: 'test_job',
|
||||
triggerType: 'user_api',
|
||||
};
|
||||
const createdJob = {
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'queued',
|
||||
status: 'pending',
|
||||
userId: 'user-1',
|
||||
jobType: 'test_job',
|
||||
queuedAt: new Date(),
|
||||
attemptCount: 0,
|
||||
progress: 0,
|
||||
};
|
||||
tx.aiJob.create.mockResolvedValue(createdJob);
|
||||
|
||||
const result = await repo.createJob(tx, input);
|
||||
expect(tx.aiJob.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
lifecycleStatus: 'queued',
|
||||
status: 'pending', // Shadow Write
|
||||
queuedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.lifecycleStatus).toBe('queued');
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lockJob', () => {
|
||||
it('CAS 抢锁成功:queued → running,含 Shadow Write', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
||||
(prisma.aiJob.findUniqueOrThrow as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'running',
|
||||
status: 'processing',
|
||||
lockedBy: 'worker-1',
|
||||
lockedAt: expect.any(Date),
|
||||
lockUntil: expect.any(Date),
|
||||
});
|
||||
|
||||
const result = await repo.lockJob('job-001', 'worker-1');
|
||||
|
||||
const callArgs = (prisma.aiJob.updateMany as jest.Mock).mock.calls[0][0];
|
||||
expect(callArgs.where).toEqual({ id: 'job-001', lifecycleStatus: 'queued' });
|
||||
expect(callArgs.data.lifecycleStatus).toBe('running');
|
||||
expect(callArgs.data.status).toBe('processing'); // Shadow Write
|
||||
expect(callArgs.data.lockedBy).toBe('worker-1');
|
||||
expect(callArgs.data.startedAt).toBeTruthy();
|
||||
expect(callArgs.data.lockUntil).toBeTruthy();
|
||||
expect(result.lifecycleStatus).toBe('running');
|
||||
});
|
||||
|
||||
it('CAS 抢锁失败(已进入终态)抛出 JobAlreadyTerminalError', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
||||
(prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({
|
||||
lifecycleStatus: 'succeeded',
|
||||
});
|
||||
|
||||
await expect(repo.lockJob('job-001', 'worker-1')).rejects.toThrow(JobAlreadyTerminalError);
|
||||
});
|
||||
|
||||
it('CAS 抢锁失败(已被其他 Worker 锁定)抛出 JobLockConflictError', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
||||
(prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({
|
||||
lifecycleStatus: 'running',
|
||||
});
|
||||
|
||||
await expect(repo.lockJob('job-001', 'worker-1')).rejects.toThrow(JobLockConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extendLock', () => {
|
||||
it('续约成功返回 cancelRequested=false', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
||||
(prisma.aiJob.findUniqueOrThrow as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'running',
|
||||
cancelRequestedAt: null,
|
||||
});
|
||||
|
||||
const before = Date.now();
|
||||
const result = await repo.extendLock('job-001', 'worker-1');
|
||||
const after = Date.now();
|
||||
|
||||
expect(result.cancelRequested).toBe(false);
|
||||
// lockUntil = now + 60000ms,允许 ±1000ms 的时钟漂移
|
||||
expect(result.lockUntil).toBeGreaterThanOrEqual(before + 60000 - 1000);
|
||||
expect(result.lockUntil).toBeLessThanOrEqual(after + 60000 + 1000);
|
||||
});
|
||||
|
||||
it('续约成功返回 cancelRequested=true', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
||||
(prisma.aiJob.findUniqueOrThrow as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'running',
|
||||
cancelRequestedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.extendLock('job-001', 'worker-1');
|
||||
expect(result.cancelRequested).toBe(true);
|
||||
});
|
||||
|
||||
it('续约失败(lockedBy 不匹配)抛出 JobExtendLockError', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
||||
(prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({
|
||||
lifecycleStatus: 'running',
|
||||
lockedBy: 'worker-2',
|
||||
});
|
||||
|
||||
await expect(repo.extendLock('job-001', 'worker-1')).rejects.toThrow(JobExtendLockError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markSucceeded', () => {
|
||||
it('CAS 标记成功:running → succeeded,清除错误和时间字段', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
||||
(prisma.aiJob.findUniqueOrThrow as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'succeeded',
|
||||
status: 'completed',
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.markSucceeded('job-001');
|
||||
|
||||
// 验证 CAS where 条件
|
||||
const callArgs = (prisma.aiJob.updateMany as jest.Mock).mock.calls[0][0];
|
||||
expect(callArgs.where).toEqual({ id: 'job-001', lifecycleStatus: 'running' });
|
||||
expect(callArgs.data.lifecycleStatus).toBe('succeeded');
|
||||
expect(callArgs.data.status).toBe('completed'); // Shadow Write
|
||||
expect(callArgs.data.finishedAt).toBeTruthy();
|
||||
expect(callArgs.data.errorCode).toBeNull(); // 成功时清除错误
|
||||
expect(callArgs.data.lockedBy).toBeNull(); // 清除运行时字段
|
||||
expect(result.lifecycleStatus).toBe('succeeded');
|
||||
});
|
||||
|
||||
it('CAS 失败(已终态)抛出 JobAlreadyTerminalError', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
||||
(prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({
|
||||
lifecycleStatus: 'failed',
|
||||
});
|
||||
|
||||
await expect(repo.markSucceeded('job-001')).rejects.toThrow(JobAlreadyTerminalError);
|
||||
});
|
||||
|
||||
it('接受可选 tx 参数,复用调用方事务', async () => {
|
||||
tx.aiJob.updateMany.mockResolvedValue({ count: 1 });
|
||||
tx.aiJob.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'succeeded',
|
||||
});
|
||||
|
||||
await repo.markSucceeded('job-001', tx);
|
||||
expect(tx.aiJob.updateMany).toHaveBeenCalled();
|
||||
// 不应该调用 this.prisma
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFailed', () => {
|
||||
it('CAS 标记失败:running → failed,含错误信息', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
||||
(prisma.aiJob.findUniqueOrThrow as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'failed',
|
||||
status: 'failed',
|
||||
errorCode: 'PROVIDER_TIMEOUT',
|
||||
});
|
||||
|
||||
const result = await repo.markFailed('job-001', {
|
||||
errorCode: 'PROVIDER_TIMEOUT',
|
||||
publicErrorMessage: 'AI 服务暂时不可用',
|
||||
internalErrorMessage: 'DeepSeek API timeout after 30s',
|
||||
});
|
||||
|
||||
expect(prisma.aiJob.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
lifecycleStatus: 'failed',
|
||||
status: 'failed',
|
||||
errorCode: 'PROVIDER_TIMEOUT',
|
||||
publicErrorMessage: 'AI 服务暂时不可用',
|
||||
internalErrorMessage: 'DeepSeek API timeout after 30s',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.lifecycleStatus).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markCancelled', () => {
|
||||
it('从 queued 取消:queued → cancelled', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
||||
(prisma.aiJob.findUniqueOrThrow as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'cancelled',
|
||||
status: 'failed',
|
||||
errorCode: 'CANCELLED',
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.markCancelled('job-001');
|
||||
expect(prisma.aiJob.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
lifecycleStatus: { in: ['queued', 'running'] },
|
||||
}),
|
||||
data: expect.objectContaining({
|
||||
lifecycleStatus: 'cancelled',
|
||||
status: 'failed', // Shadow Write
|
||||
errorCode: 'CANCELLED',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.errorCode).toBe('CANCELLED');
|
||||
});
|
||||
|
||||
it('取消已终态的 Job 抛出 JobNotCancellableError', async () => {
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
||||
(prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({
|
||||
lifecycleStatus: 'succeeded',
|
||||
});
|
||||
|
||||
await expect(repo.markCancelled('job-001')).rejects.toThrow(JobNotCancellableError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestCancellation', () => {
|
||||
it('queued Job 直接取消', async () => {
|
||||
(prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({ lifecycleStatus: 'queued' });
|
||||
(prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
||||
(prisma.aiJob.findUniqueOrThrow as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'cancelled',
|
||||
errorCode: 'CANCELLED',
|
||||
});
|
||||
|
||||
const result = await repo.requestCancellation('job-001');
|
||||
expect(result.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('running Job 设置 cancelRequestedAt 信号', async () => {
|
||||
(prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({ lifecycleStatus: 'running' });
|
||||
(prisma.aiJob.update as jest.Mock).mockResolvedValue({
|
||||
id: 'job-001',
|
||||
lifecycleStatus: 'running',
|
||||
cancelRequestedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await repo.requestCancellation('job-001');
|
||||
expect(result.status).toBe('cancel_requested');
|
||||
expect(prisma.aiJob.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'job-001', lifecycleStatus: 'running' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByLifecycleStatus', () => {
|
||||
it('按状态过滤并按优先级/创建时间排序', async () => {
|
||||
(prisma.aiJob.findMany as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
await repo.findByLifecycleStatus(['queued'], { queueName: 'ai-interactive', limit: 10 });
|
||||
expect(prisma.aiJob.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
lifecycleStatus: { in: ['queued'] },
|
||||
queueName: 'ai-interactive',
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
take: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
430
src/modules/ai-job/ai-job-lifecycle.repository.ts
Normal file
430
src/modules/ai-job/ai-job-lifecycle.repository.ts
Normal file
@ -0,0 +1,430 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
JobLockConflictError,
|
||||
JobNotActiveError,
|
||||
JobAlreadyTerminalError,
|
||||
JobNotCancellableError,
|
||||
JobExtendLockError,
|
||||
} from './ai-job.errors';
|
||||
import {
|
||||
AiJobStateMachine,
|
||||
LifecycleStatus,
|
||||
LIFECYCLE_TO_STATUS,
|
||||
} from './ai-job-state-machine';
|
||||
|
||||
/**
|
||||
* ── Job 创建输入 ──
|
||||
*/
|
||||
export interface CreateJobInput {
|
||||
userId: string;
|
||||
jobType: string;
|
||||
triggerType?: string;
|
||||
queueName?: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
parentJobId?: string;
|
||||
idempotencyKey?: string;
|
||||
priority?: number;
|
||||
credentialMode?: string;
|
||||
credentialId?: string;
|
||||
modelProvider?: string;
|
||||
modelName?: string;
|
||||
promptVersion?: string;
|
||||
outputSchemaVersion?: string;
|
||||
inputSchemaVersion?: string;
|
||||
inputRef?: string;
|
||||
maxAttempts?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ── Job 错误信息 ──
|
||||
*/
|
||||
export interface JobErrorInfo {
|
||||
errorCode: string;
|
||||
publicErrorMessage?: string;
|
||||
internalErrorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* M-AI-03-03: AiJobLifecycleRepository
|
||||
*
|
||||
* 统一 lifecycleStatus 的唯一写入入口。
|
||||
*
|
||||
* 约束:
|
||||
* - 所有状态变更使用 CAS(条件 UPDATE WHERE lifecycleStatus IN (...))
|
||||
* - 禁止先读后无条件 update
|
||||
* - 可选 tx 参数支持在外部 Prisma Transaction 中复用事务
|
||||
* - lockJob 不使用 tx(CAS 必须立即对其他 Worker 可见)
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiJobLifecycleRepository {
|
||||
private readonly LOCK_TTL_MS = 60_000; // ADR-003:60s
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stateMachine: AiJobStateMachine,
|
||||
) {}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 创建
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 在给定事务中创建 Job(lifecycleStatus = 'queued')。
|
||||
* 调用方负责在同一 tx 中写入 OutboxEvent。
|
||||
*/
|
||||
async createJob(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: CreateJobInput,
|
||||
) {
|
||||
const job = await tx.aiJob.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
jobType: input.jobType,
|
||||
triggerType: input.triggerType ?? 'user_api',
|
||||
queueName: input.queueName ?? 'ai-interactive',
|
||||
targetType: input.targetType ?? null,
|
||||
targetId: input.targetId ?? null,
|
||||
parentJobId: input.parentJobId ?? null,
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
priority: input.priority ?? 0,
|
||||
credentialMode: input.credentialMode ?? 'platform_key',
|
||||
credentialId: input.credentialId ?? null,
|
||||
modelProvider: input.modelProvider ?? 'deepseek',
|
||||
modelName: input.modelName ?? 'deepseek-chat',
|
||||
promptVersion: input.promptVersion ?? null,
|
||||
outputSchemaVersion: input.outputSchemaVersion ?? null,
|
||||
inputSchemaVersion: input.inputSchemaVersion ?? null,
|
||||
inputRef: input.inputRef ?? null,
|
||||
maxAttempts: input.maxAttempts ?? 3,
|
||||
timeoutMs: input.timeoutMs ?? 120000,
|
||||
// ── 生命周期 ──
|
||||
lifecycleStatus: 'queued',
|
||||
status: LIFECYCLE_TO_STATUS['queued'], // Shadow Write: pending
|
||||
queuedAt: new Date(),
|
||||
attemptCount: 0,
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 抢锁(CAS: queued → running)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* CAS 抢锁。只有 lifecycleStatus = 'queued' 的 Job 才能被锁定。
|
||||
*
|
||||
* 必须是独立操作(不传 tx)—— CAS 需要立即对其他 Worker 可见。
|
||||
*/
|
||||
async lockJob(jobId: string, instanceId: string) {
|
||||
const now = new Date();
|
||||
const lockUntil = new Date(now.getTime() + this.LOCK_TTL_MS);
|
||||
|
||||
// CAS: queued → running,仅当 status 仍为 queued 时成功
|
||||
const result = await this.prisma.aiJob.updateMany({
|
||||
where: {
|
||||
id: jobId,
|
||||
lifecycleStatus: 'queued',
|
||||
},
|
||||
data: {
|
||||
lifecycleStatus: 'running',
|
||||
status: LIFECYCLE_TO_STATUS['running'], // Shadow Write: processing
|
||||
lockedBy: instanceId,
|
||||
lockedAt: now,
|
||||
lockUntil,
|
||||
startedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
// 抢锁失败 —— 检查原因以提供更精确的错误
|
||||
const current = await this.prisma.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { lifecycleStatus: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw new JobLockConflictError(jobId); // Job 不存在
|
||||
}
|
||||
if (this.stateMachine.isTerminal(current.lifecycleStatus as LifecycleStatus)) {
|
||||
throw new JobAlreadyTerminalError(jobId, current.lifecycleStatus!);
|
||||
}
|
||||
if (current.lifecycleStatus === 'running') {
|
||||
throw new JobLockConflictError(jobId); // 已被其他 Worker 抢走
|
||||
}
|
||||
throw new JobLockConflictError(jobId);
|
||||
}
|
||||
|
||||
const job = await this.prisma.aiJob.findUniqueOrThrow({
|
||||
where: { id: jobId },
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 续锁(Heartbeat)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 续约锁定期。仅当 Job 处于 'running' 状态且 lockedBy 匹配时成功。
|
||||
*/
|
||||
async extendLock(jobId: string, instanceId: string) {
|
||||
const now = new Date();
|
||||
const lockUntil = new Date(now.getTime() + this.LOCK_TTL_MS);
|
||||
|
||||
const result = await this.prisma.aiJob.updateMany({
|
||||
where: {
|
||||
id: jobId,
|
||||
lifecycleStatus: 'running',
|
||||
lockedBy: instanceId,
|
||||
},
|
||||
data: { lockUntil },
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
const current = await this.prisma.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { lifecycleStatus: true, lockedBy: true },
|
||||
});
|
||||
if (!current || current.lifecycleStatus !== 'running') {
|
||||
throw new JobNotActiveError(jobId, current?.lifecycleStatus ?? 'unknown');
|
||||
}
|
||||
throw new JobExtendLockError(jobId, instanceId);
|
||||
}
|
||||
|
||||
// 读取当前状态以检查 cancelRequestedAt
|
||||
const job = await this.prisma.aiJob.findUniqueOrThrow({
|
||||
where: { id: jobId },
|
||||
});
|
||||
|
||||
return {
|
||||
jobId,
|
||||
lockUntil: lockUntil.getTime(),
|
||||
cancelRequested: job.cancelRequestedAt != null,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 标记成功
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 标记 Job 成功。
|
||||
*
|
||||
* @param tx 可选事务客户端。Projector 路径传入以共享事务;非 Projector 路径不传。
|
||||
*/
|
||||
async markSucceeded(jobId: string, tx?: Prisma.TransactionClient): Promise<any> {
|
||||
const client = tx ?? this.prisma;
|
||||
const now = new Date();
|
||||
|
||||
const result = await client.aiJob.updateMany({
|
||||
where: {
|
||||
id: jobId,
|
||||
lifecycleStatus: 'running',
|
||||
},
|
||||
data: {
|
||||
lifecycleStatus: 'succeeded',
|
||||
status: LIFECYCLE_TO_STATUS['succeeded'], // Shadow Write: completed
|
||||
finishedAt: now,
|
||||
// 清除运行时字段
|
||||
lockedBy: null,
|
||||
lockedAt: null,
|
||||
lockUntil: null,
|
||||
errorCode: null,
|
||||
publicErrorMessage: null,
|
||||
internalErrorMessage: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
const current = await client.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { lifecycleStatus: true },
|
||||
});
|
||||
if (current && this.stateMachine.isTerminal(current.lifecycleStatus as LifecycleStatus)) {
|
||||
throw new JobAlreadyTerminalError(jobId, current.lifecycleStatus!);
|
||||
}
|
||||
throw new JobNotActiveError(jobId, current?.lifecycleStatus ?? 'unknown');
|
||||
}
|
||||
|
||||
return client.aiJob.findUniqueOrThrow({ where: { id: jobId } });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 标记失败
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 标记 Job 失败(终态)。
|
||||
*
|
||||
* @param tx 可选事务客户端。Engine 非 Projector 错误路径不传。
|
||||
*/
|
||||
async markFailed(jobId: string, error: JobErrorInfo, tx?: Prisma.TransactionClient): Promise<any> {
|
||||
const client = tx ?? this.prisma;
|
||||
const now = new Date();
|
||||
|
||||
const result = await client.aiJob.updateMany({
|
||||
where: {
|
||||
id: jobId,
|
||||
lifecycleStatus: 'running',
|
||||
},
|
||||
data: {
|
||||
lifecycleStatus: 'failed',
|
||||
status: LIFECYCLE_TO_STATUS['failed'], // Shadow Write: failed
|
||||
finishedAt: now,
|
||||
lockedBy: null,
|
||||
lockedAt: null,
|
||||
lockUntil: null,
|
||||
errorCode: error.errorCode,
|
||||
publicErrorMessage: error.publicErrorMessage ?? null,
|
||||
internalErrorMessage: error.internalErrorMessage ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
const current = await client.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { lifecycleStatus: true },
|
||||
});
|
||||
if (current && this.stateMachine.isTerminal(current.lifecycleStatus as LifecycleStatus)) {
|
||||
throw new JobAlreadyTerminalError(jobId, current.lifecycleStatus!);
|
||||
}
|
||||
throw new JobNotActiveError(jobId, current?.lifecycleStatus ?? 'unknown');
|
||||
}
|
||||
|
||||
return client.aiJob.findUniqueOrThrow({ where: { id: jobId } });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 标记取消
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 标记 Job 取消。
|
||||
*
|
||||
* queued 状态可直接取消;running 状态需先设置 cancelRequestedAt,
|
||||
* Engine/Heartbeat 检测后调用此方法。
|
||||
*
|
||||
* @param tx 可选事务客户端。取消路径通常不传(独立操作)。
|
||||
*/
|
||||
async markCancelled(jobId: string, tx?: Prisma.TransactionClient): Promise<any> {
|
||||
const client = tx ?? this.prisma;
|
||||
const now = new Date();
|
||||
|
||||
// 尝试从 queued 或 running 取消
|
||||
const result = await client.aiJob.updateMany({
|
||||
where: {
|
||||
id: jobId,
|
||||
lifecycleStatus: { in: ['queued', 'running'] },
|
||||
},
|
||||
data: {
|
||||
lifecycleStatus: 'cancelled',
|
||||
// ── Shadow Write:cancelled 在旧 status 中不存在,映射为 failed + errorCode=CANCELLED ──
|
||||
status: LIFECYCLE_TO_STATUS['cancelled'], // 'failed'
|
||||
finishedAt: now,
|
||||
lockedBy: null,
|
||||
lockedAt: null,
|
||||
lockUntil: null,
|
||||
errorCode: 'CANCELLED',
|
||||
publicErrorMessage: 'Job was cancelled',
|
||||
internalErrorMessage: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
const current = await client.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { lifecycleStatus: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw new JobLockConflictError(jobId); // Job 不存在,复用此错误
|
||||
}
|
||||
if (this.stateMachine.isTerminal(current.lifecycleStatus as LifecycleStatus)) {
|
||||
throw new JobNotCancellableError(jobId, current.lifecycleStatus!);
|
||||
}
|
||||
throw new JobNotCancellableError(jobId, current.lifecycleStatus ?? 'unknown');
|
||||
}
|
||||
|
||||
return client.aiJob.findUniqueOrThrow({ where: { id: jobId } });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 设置取消请求(不改变 lifecycleStatus)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 对 running 状态的 Job 设置 cancelRequestedAt 信号。
|
||||
* 不改变 lifecycleStatus — Engine/Heartbeat 检测后调用 markCancelled。
|
||||
*
|
||||
* 对 queued 状态的 Job 直接取消。
|
||||
*/
|
||||
async requestCancellation(jobId: string): Promise<{ status: 'cancelled' | 'cancel_requested' }> {
|
||||
const job = await this.prisma.aiJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { lifecycleStatus: true },
|
||||
});
|
||||
|
||||
if (!job) throw new JobLockConflictError(jobId);
|
||||
|
||||
const status = this.stateMachine.parse(job.lifecycleStatus);
|
||||
|
||||
if (status === 'queued') {
|
||||
// 直接取消
|
||||
await this.markCancelled(jobId);
|
||||
return { status: 'cancelled' };
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
// 设置取消信号
|
||||
await this.prisma.aiJob.update({
|
||||
where: { id: jobId, lifecycleStatus: 'running' },
|
||||
data: { cancelRequestedAt: new Date() },
|
||||
});
|
||||
return { status: 'cancel_requested' };
|
||||
}
|
||||
|
||||
// 终态不可取消
|
||||
throw new JobNotCancellableError(jobId, status);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 只读查询
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
async findByLifecycleStatus(
|
||||
statuses: LifecycleStatus[],
|
||||
opts?: { queueName?: string; limit?: number },
|
||||
) {
|
||||
const where: Prisma.AiJobWhereInput = {
|
||||
lifecycleStatus: { in: statuses },
|
||||
};
|
||||
if (opts?.queueName) where.queueName = opts.queueName;
|
||||
|
||||
return this.prisma.aiJob.findMany({
|
||||
where,
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
take: opts?.limit ?? 50,
|
||||
});
|
||||
}
|
||||
|
||||
async findExpiredLocks(thresholdMs: number = 90_000) {
|
||||
const cutoff = new Date(Date.now() - thresholdMs);
|
||||
return this.prisma.aiJob.findMany({
|
||||
where: {
|
||||
lifecycleStatus: 'running',
|
||||
lockUntil: { lt: cutoff },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(jobId: string) {
|
||||
return this.prisma.aiJob.findUnique({ where: { id: jobId } });
|
||||
}
|
||||
}
|
||||
104
src/modules/ai-job/ai-job-state-machine.ts
Normal file
104
src/modules/ai-job/ai-job-state-machine.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IllegalStateTransitionError } from './ai-job.errors';
|
||||
|
||||
/**
|
||||
* M-AI-03-03: AiJob 统一状态机
|
||||
*
|
||||
* 定义合法状态迁移,所有 lifecycleStatus 变更必须经过此验证。
|
||||
*
|
||||
* 状态机(ADR-003 §1.1):
|
||||
* queued → running → succeeded / failed / cancelled
|
||||
* queued → cancelled(直接取消,无需先 running)
|
||||
*
|
||||
* cancel_requested 不是独立状态 — 它是 running 状态上的信号字段(cancelRequestedAt)。
|
||||
*/
|
||||
|
||||
export const LIFECYCLE_STATUSES = [
|
||||
'queued',
|
||||
'running',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
] as const;
|
||||
|
||||
export type LifecycleStatus = (typeof LIFECYCLE_STATUSES)[number];
|
||||
|
||||
export const TERMINAL_STATUSES: ReadonlySet<LifecycleStatus> = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
/** 合法状态迁移(from → to) */
|
||||
const ALLOWED_TRANSITIONS: Record<LifecycleStatus, ReadonlySet<LifecycleStatus>> = {
|
||||
queued: new Set<LifecycleStatus>(['running', 'cancelled']),
|
||||
running: new Set<LifecycleStatus>(['succeeded', 'failed', 'cancelled']),
|
||||
succeeded: new Set<LifecycleStatus>([]), // 终态
|
||||
failed: new Set<LifecycleStatus>([]), // 终态
|
||||
cancelled: new Set<LifecycleStatus>([]), // 终态
|
||||
};
|
||||
|
||||
/**
|
||||
* ── M-AI-02-10 Legacy status Shadow Write ──
|
||||
*
|
||||
* 新 lifecycleStatus → 旧 status 列映射。
|
||||
* 双向均可查询:
|
||||
* - STATUS_TO_LIFECYCLE: 旧 → 新(ai-analysis.repository.ts 已在用)
|
||||
* - LIFECYCLE_TO_STATUS: 新 → 旧(本 Repository Shadow Write 使用)
|
||||
*/
|
||||
export const LIFECYCLE_TO_STATUS: Record<LifecycleStatus, string> = {
|
||||
queued: 'pending',
|
||||
running: 'processing',
|
||||
succeeded: 'completed',
|
||||
failed: 'failed',
|
||||
cancelled: 'failed', // cancelled 在旧 status 中不存在,映射为 failed + errorCode='CANCELLED'
|
||||
};
|
||||
|
||||
export const STATUS_TO_LIFECYCLE: Record<string, LifecycleStatus> = {
|
||||
pending: 'queued',
|
||||
processing: 'running',
|
||||
completed: 'succeeded',
|
||||
failed: 'failed',
|
||||
cancelled: 'cancelled',
|
||||
cancel_requested: 'cancelled',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiJobStateMachine {
|
||||
/**
|
||||
* 校验 from → to 是否为合法迁移。
|
||||
* 合法 → 无返回值;非法 → 抛出 IllegalStateTransitionError。
|
||||
*/
|
||||
validate(from: LifecycleStatus, to: LifecycleStatus): void {
|
||||
const allowed = ALLOWED_TRANSITIONS[from];
|
||||
if (!allowed || !allowed.has(to)) {
|
||||
throw new IllegalStateTransitionError('unknown', from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验带 jobId 的迁移。提供更精确的错误信息。
|
||||
*/
|
||||
validateForJob(jobId: string, from: LifecycleStatus, to: LifecycleStatus): void {
|
||||
const allowed = ALLOWED_TRANSITIONS[from];
|
||||
if (!allowed || !allowed.has(to)) {
|
||||
throw new IllegalStateTransitionError(jobId, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为终态 */
|
||||
isTerminal(status: LifecycleStatus): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对于从数据库读取的状态字符串,校验并转换为类型化状态。
|
||||
*/
|
||||
parse(status: string | null | undefined): LifecycleStatus {
|
||||
if (!status) return 'queued';
|
||||
if ((LIFECYCLE_STATUSES as readonly string[]).includes(status)) {
|
||||
return status as LifecycleStatus;
|
||||
}
|
||||
throw new IllegalStateTransitionError('unknown', status, 'any');
|
||||
}
|
||||
}
|
||||
58
src/modules/ai-job/ai-job.errors.ts
Normal file
58
src/modules/ai-job/ai-job.errors.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* M-AI-03-03: AiJob 状态机错误类型
|
||||
*
|
||||
* 所有违反状态机的操作抛出这些类型化错误,
|
||||
* 调用方可区分"Job 已被抢走"、"Job 已结束"等场景。
|
||||
*/
|
||||
|
||||
export class JobLockConflictError extends Error {
|
||||
public readonly code = 'JOB_LOCK_CONFLICT';
|
||||
constructor(public readonly jobId: string) {
|
||||
super(`Job ${jobId} is not in 'queued' status or already locked by another worker`);
|
||||
this.name = 'JobLockConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class JobNotActiveError extends Error {
|
||||
public readonly code = 'JOB_NOT_ACTIVE';
|
||||
constructor(public readonly jobId: string, public readonly currentStatus: string) {
|
||||
super(`Job ${jobId} is in '${currentStatus}' status, expected 'running' or 'locked'`);
|
||||
this.name = 'JobNotActiveError';
|
||||
}
|
||||
}
|
||||
|
||||
export class JobAlreadyTerminalError extends Error {
|
||||
public readonly code = 'JOB_ALREADY_TERMINAL';
|
||||
constructor(public readonly jobId: string, public readonly currentStatus: string) {
|
||||
super(`Job ${jobId} is already in terminal status '${currentStatus}'`);
|
||||
this.name = 'JobAlreadyTerminalError';
|
||||
}
|
||||
}
|
||||
|
||||
export class JobNotCancellableError extends Error {
|
||||
public readonly code = 'JOB_NOT_CANCELLABLE';
|
||||
constructor(public readonly jobId: string, public readonly currentStatus: string) {
|
||||
super(`Job ${jobId} in '${currentStatus}' status cannot be cancelled`);
|
||||
this.name = 'JobNotCancellableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class JobExtendLockError extends Error {
|
||||
public readonly code = 'JOB_EXTEND_LOCK_ERROR';
|
||||
constructor(public readonly jobId: string, public readonly instanceId: string) {
|
||||
super(`Cannot extend lock for job ${jobId}: not locked by instance '${instanceId}'`);
|
||||
this.name = 'JobExtendLockError';
|
||||
}
|
||||
}
|
||||
|
||||
export class IllegalStateTransitionError extends Error {
|
||||
public readonly code = 'ILLEGAL_STATE_TRANSITION';
|
||||
constructor(
|
||||
public readonly jobId: string,
|
||||
public readonly from: string,
|
||||
public readonly to: string,
|
||||
) {
|
||||
super(`Illegal state transition for job ${jobId}: '${from}' → '${to}'`);
|
||||
this.name = 'IllegalStateTransitionError';
|
||||
}
|
||||
}
|
||||
22
src/modules/ai-job/ai-job.module.ts
Normal file
22
src/modules/ai-job/ai-job.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||
import { AiJobStateMachine } from './ai-job-state-machine';
|
||||
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
|
||||
|
||||
/**
|
||||
* M-AI-03: AiJob 统一 Job Engine 模块
|
||||
*
|
||||
* 当前仅包含状态机与生命周期仓储。
|
||||
* 后续 Issue(#287–#293)在此模块中逐步新增:
|
||||
* - JobDefinitionRegistry
|
||||
* - AiJobService
|
||||
* - AiJobExecutionEngine
|
||||
* - OutboxDispatcher
|
||||
* - ResultProjector
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AiJobStateMachine, AiJobLifecycleRepository],
|
||||
exports: [AiJobStateMachine, AiJobLifecycleRepository],
|
||||
})
|
||||
export class AiJobModule {}
|
||||
Loading…
x
Reference in New Issue
Block a user