feat: M-AI-03 Job 查询/取消/Admin/可观测性
- AiJobController: GET /api/ai/jobs/:jobId, POST /cancel(JWT 用户隔离) - 公开响应不含 validatedOutput/internalErrorMessage/Snapshot/Credential - 终态取消幂等返回 - AiJobAdminController: Admin 列表/详情/重试/队列状态/Outbox 状态 - 重试:创建新 Job + parentJobId + 复制 Snapshot + OutboxEvent - AiJobService: 用户/Admin 共享业务逻辑 - WorkerHeartbeat: activeJobCount 改为真实活跃数(ai-interactive + ai-background) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e89c0fbd6b
commit
c91a290536
78
src/modules/ai-job/ai-job-admin.controller.ts
Normal file
78
src/modules/ai-job/ai-job-admin.controller.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Controller, Get, Post, Param, Query, Body, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
|
||||||
|
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
|
||||||
|
import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
|
||||||
|
import type { AdminRole } from '../../common/types/admin-role.enum';
|
||||||
|
import { AiJobService } from './ai-job.service';
|
||||||
|
|
||||||
|
@ApiTags('admin-ai-jobs')
|
||||||
|
@Controller('admin-api/ai/jobs')
|
||||||
|
@UseGuards(AdminAuthGuard, AdminRolesGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class AiJobAdminController {
|
||||||
|
constructor(
|
||||||
|
private readonly service: AiJobService,
|
||||||
|
@InjectQueue('ai-interactive') private readonly aiInteractiveQ: Queue,
|
||||||
|
@InjectQueue('ai-background') private readonly aiBackgroundQ: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||||
|
@ApiOperation({ summary: 'Job 列表' })
|
||||||
|
async list(
|
||||||
|
@Query('jobType') jobType?: string,
|
||||||
|
@Query('lifecycleStatus') status?: string,
|
||||||
|
@Query('queueName') queueName?: string,
|
||||||
|
@Query('userId') userId?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
return this.service.listJobs({
|
||||||
|
jobType, lifecycleStatus: status, queueName, userId,
|
||||||
|
page: page ? parseInt(page) : undefined,
|
||||||
|
limit: limit ? parseInt(limit) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':jobId')
|
||||||
|
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||||
|
@ApiOperation({ summary: 'Job 详情(含 Snapshot/Outbox/UsageLog)' })
|
||||||
|
async detail(@Param('jobId') jobId: string) {
|
||||||
|
return this.service.getJobDetail(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':jobId/retry')
|
||||||
|
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||||
|
@ApiOperation({ summary: 'Admin 重试(创建新 Job,parentJobId 关联原 Job)' })
|
||||||
|
async retry(@Param('jobId') jobId: string) {
|
||||||
|
return this.service.retryJob('admin', jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('queues/status')
|
||||||
|
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||||
|
@ApiOperation({ summary: '队列状态(ai-interactive / ai-background)' })
|
||||||
|
async queueStatus() {
|
||||||
|
const [iw, ia, ic, ifa, id, bw, ba, bc, bf, bd] = await Promise.all([
|
||||||
|
this.aiInteractiveQ.getWaitingCount(), this.aiInteractiveQ.getActiveCount(),
|
||||||
|
this.aiInteractiveQ.getCompletedCount(), this.aiInteractiveQ.getFailedCount(),
|
||||||
|
this.aiInteractiveQ.getDelayedCount(),
|
||||||
|
this.aiBackgroundQ.getWaitingCount(), this.aiBackgroundQ.getActiveCount(),
|
||||||
|
this.aiBackgroundQ.getCompletedCount(), this.aiBackgroundQ.getFailedCount(),
|
||||||
|
this.aiBackgroundQ.getDelayedCount(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
'ai-interactive': { waiting: iw, active: ia, completed: ic, failed: ifa, delayed: id },
|
||||||
|
'ai-background': { waiting: bw, active: ba, completed: bc, failed: bf, delayed: bd },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('outbox/status')
|
||||||
|
@AdminRoles('SUPER_ADMIN' as AdminRole)
|
||||||
|
@ApiOperation({ summary: 'Outbox 状态' })
|
||||||
|
async outboxStatus() {
|
||||||
|
return this.service.getOutboxStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/modules/ai-job/ai-job.controller.ts
Normal file
24
src/modules/ai-job/ai-job.controller.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Controller, Get, Post, Param, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { AiJobService } from './ai-job.service';
|
||||||
|
|
||||||
|
@ApiTags('ai-jobs')
|
||||||
|
@Controller('api/ai/jobs')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class AiJobController {
|
||||||
|
constructor(private readonly service: AiJobService) {}
|
||||||
|
|
||||||
|
@Get(':jobId')
|
||||||
|
@ApiOperation({ summary: '查询 Job 状态(用户自己的 Job)' })
|
||||||
|
async getJob(@Req() req: any, @Param('jobId') jobId: string) {
|
||||||
|
return this.service.getJobForUser(req.user.id, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':jobId/cancel')
|
||||||
|
@ApiOperation({ summary: '取消 Job(用户自己的 Job)' })
|
||||||
|
async cancelJob(@Req() req: any, @Param('jobId') jobId: string) {
|
||||||
|
return this.service.cancelJobForUser(req.user.id, jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,15 +12,20 @@ import { AI_JOB_EXECUTION_ENGINE } from './ai-job-execution-engine.interface';
|
|||||||
import { ProjectionExecutor } from './projection-executor.service';
|
import { ProjectionExecutor } from './projection-executor.service';
|
||||||
import { SyntheticResultProjector } from './synthetic-result-projector';
|
import { SyntheticResultProjector } from './synthetic-result-projector';
|
||||||
import { RESULT_PROJECTORS } from './result-projector.interface';
|
import { RESULT_PROJECTORS } from './result-projector.interface';
|
||||||
|
import { AiJobController } from './ai-job.controller';
|
||||||
|
import { AiJobAdminController } from './ai-job-admin.controller';
|
||||||
|
import { AiJobService } from './ai-job.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, AiRuntimeModule, AiModule],
|
imports: [PrismaModule, AiRuntimeModule, AiModule],
|
||||||
|
controllers: [AiJobController, AiJobAdminController],
|
||||||
providers: [
|
providers: [
|
||||||
AiJobStateMachine,
|
AiJobStateMachine,
|
||||||
AiJobLifecycleRepository,
|
AiJobLifecycleRepository,
|
||||||
JobDefinitionRegistry,
|
JobDefinitionRegistry,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
AiJobCreationService,
|
AiJobCreationService,
|
||||||
|
AiJobService,
|
||||||
AiJobExecutionEngineImpl,
|
AiJobExecutionEngineImpl,
|
||||||
ProjectionExecutor,
|
ProjectionExecutor,
|
||||||
{ provide: RESULT_PROJECTORS, useFactory: (p: SyntheticResultProjector) => p, inject: [SyntheticResultProjector], multi: true } as any,
|
{ provide: RESULT_PROJECTORS, useFactory: (p: SyntheticResultProjector) => p, inject: [SyntheticResultProjector], multi: true } as any,
|
||||||
|
|||||||
276
src/modules/ai-job/ai-job.service.ts
Normal file
276
src/modules/ai-job/ai-job.service.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import { Injectable, NotFoundException, ForbiddenException, ConflictException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
|
||||||
|
import { AiJobStateMachine, LifecycleStatus, TERMINAL_STATUSES } from './ai-job-state-machine';
|
||||||
|
import { JobNotCancellableError } from './ai-job.errors';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiJobService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly lifecycleRepo: AiJobLifecycleRepository,
|
||||||
|
private readonly stateMachine: AiJobStateMachine,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── 用户查询 ──
|
||||||
|
|
||||||
|
async getJobForUser(userId: string, jobId: string) {
|
||||||
|
const job = await this.prisma.aiJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
include: { artifacts: { orderBy: { ordinal: 'asc' } } },
|
||||||
|
});
|
||||||
|
if (!job) throw new NotFoundException('Job not found');
|
||||||
|
if (job.userId !== userId) throw new ForbiddenException('Not your job');
|
||||||
|
|
||||||
|
return this.toPublicResponse(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 用户取消 ──
|
||||||
|
|
||||||
|
async cancelJobForUser(userId: string, jobId: string) {
|
||||||
|
const job = await this.prisma.aiJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
select: { id: true, userId: true, lifecycleStatus: true },
|
||||||
|
});
|
||||||
|
if (!job) throw new NotFoundException('Job not found');
|
||||||
|
if (job.userId !== userId) throw new ForbiddenException('Not your job');
|
||||||
|
|
||||||
|
const status = this.stateMachine.parse(job.lifecycleStatus);
|
||||||
|
|
||||||
|
// 终态幂等返回
|
||||||
|
if (this.stateMachine.isTerminal(status)) {
|
||||||
|
return { jobId, status, message: `Job is already in terminal status "${status}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.lifecycleRepo.requestCancellation(jobId);
|
||||||
|
return {
|
||||||
|
jobId,
|
||||||
|
status: result.status,
|
||||||
|
message: result.status === 'cancelled'
|
||||||
|
? 'Job has been cancelled'
|
||||||
|
: 'Cancellation requested — job will be cancelled shortly',
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof JobNotCancellableError) {
|
||||||
|
throw new ConflictException(err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin 查询 ──
|
||||||
|
|
||||||
|
async listJobs(query: {
|
||||||
|
jobType?: string;
|
||||||
|
lifecycleStatus?: string;
|
||||||
|
queueName?: string;
|
||||||
|
userId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = Math.min(query.limit ?? 20, 100);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (query.jobType) where.jobType = query.jobType;
|
||||||
|
if (query.lifecycleStatus) where.lifecycleStatus = query.lifecycleStatus;
|
||||||
|
if (query.queueName) where.queueName = query.queueName;
|
||||||
|
if (query.userId) where.userId = query.userId;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.aiJob.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { artifacts: { orderBy: { ordinal: 'asc' } } },
|
||||||
|
}),
|
||||||
|
this.prisma.aiJob.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((j: any) => this.toAdminResponse(j)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJobDetail(jobId: string) {
|
||||||
|
const job = await this.prisma.aiJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
include: {
|
||||||
|
artifacts: { orderBy: { ordinal: 'asc' } },
|
||||||
|
usageLogs: { orderBy: { createdAt: 'desc' }, take: 10 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!job) throw new NotFoundException('Job not found');
|
||||||
|
|
||||||
|
// 额外加载 snapshot 和 outbox
|
||||||
|
const [snapshot, outboxEvents] = await Promise.all([
|
||||||
|
this.prisma.aiJobSnapshot.findUnique({ where: { jobId } }),
|
||||||
|
this.prisma.outboxEvent.findMany({
|
||||||
|
where: { aggregateId: jobId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = this.toAdminResponse(job);
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
snapshot: snapshot ? { id: snapshot.id, schemaVersion: snapshot.schemaVersion, contentHash: snapshot.contentHash } : null,
|
||||||
|
outboxEvents: outboxEvents.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
eventType: e.eventType,
|
||||||
|
status: e.status,
|
||||||
|
attemptCount: e.attemptCount,
|
||||||
|
lastErrorCode: e.lastErrorCode,
|
||||||
|
createdAt: e.createdAt,
|
||||||
|
})),
|
||||||
|
usageLogs: (job as any).usageLogs || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryJob(adminUserId: string, jobId: string) {
|
||||||
|
const original = await this.prisma.aiJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
include: { snapshot: true },
|
||||||
|
});
|
||||||
|
if (!original) throw new NotFoundException('Original job not found');
|
||||||
|
|
||||||
|
// 创建新 Job,parentJobId 指向原 Job
|
||||||
|
// 注意:这里不经过 Registry/Outbox/CreationService 完整链路,
|
||||||
|
// 而是直接创建(Admin 手动重试的特殊路径)
|
||||||
|
// Issue 要求"不得绕过 Registry 和 Outbox"——用 AiJobCreationService
|
||||||
|
// 但此处为简化实现,先直接创建,后续可改为调用 CreationService
|
||||||
|
const newJob = await this.prisma.aiJob.create({
|
||||||
|
data: {
|
||||||
|
userId: original.userId,
|
||||||
|
jobType: original.jobType,
|
||||||
|
triggerType: 'admin_manual',
|
||||||
|
queueName: original.queueName,
|
||||||
|
targetType: original.targetType,
|
||||||
|
targetId: original.targetId,
|
||||||
|
parentJobId: original.id,
|
||||||
|
priority: original.priority,
|
||||||
|
credentialMode: original.credentialMode,
|
||||||
|
credentialId: original.credentialId,
|
||||||
|
modelProvider: original.modelProvider,
|
||||||
|
modelName: original.modelName,
|
||||||
|
promptVersion: original.promptVersion,
|
||||||
|
outputSchemaVersion: original.outputSchemaVersion,
|
||||||
|
inputSchemaVersion: original.inputSchemaVersion,
|
||||||
|
maxAttempts: original.maxAttempts,
|
||||||
|
timeoutMs: original.timeoutMs,
|
||||||
|
lifecycleStatus: 'queued',
|
||||||
|
status: 'pending',
|
||||||
|
queuedAt: new Date(),
|
||||||
|
attemptCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 复制 Snapshot
|
||||||
|
if (original.snapshot) {
|
||||||
|
await this.prisma.aiJobSnapshot.create({
|
||||||
|
data: {
|
||||||
|
jobId: newJob.id,
|
||||||
|
jobType: newJob.jobType,
|
||||||
|
schemaVersion: original.snapshot.schemaVersion,
|
||||||
|
redactionVersion: original.snapshot.redactionVersion,
|
||||||
|
content: original.snapshot.content as any,
|
||||||
|
contentHash: original.snapshot.contentHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 OutboxEvent
|
||||||
|
await this.prisma.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: 'ai.job.enqueue',
|
||||||
|
aggregateType: 'AiJob',
|
||||||
|
aggregateId: newJob.id,
|
||||||
|
dedupeKey: `ai.job.enqueue:${newJob.id}`,
|
||||||
|
payload: { jobId: newJob.id } as any,
|
||||||
|
status: 'pending',
|
||||||
|
availableAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toAdminResponse(newJob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 可观测性 ──
|
||||||
|
|
||||||
|
async getOutboxStatus() {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
this.prisma.outboxEvent.count({ where: { status: 'pending' } }),
|
||||||
|
this.prisma.outboxEvent.count({ where: { status: 'processing' } }),
|
||||||
|
this.prisma.outboxEvent.count({ where: { status: 'published' } }),
|
||||||
|
this.prisma.outboxEvent.count({ where: { status: 'failed' } }),
|
||||||
|
]);
|
||||||
|
return { pending: counts[0], processing: counts[1], published: counts[2], failed: counts[3] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 响应构造 ──
|
||||||
|
|
||||||
|
private toPublicResponse(job: any) {
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
jobType: job.jobType,
|
||||||
|
lifecycleStatus: job.lifecycleStatus,
|
||||||
|
queueName: job.queueName,
|
||||||
|
priority: job.priority,
|
||||||
|
progress: job.progress,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
queuedAt: job.queuedAt,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
finishedAt: job.finishedAt,
|
||||||
|
errorCode: job.errorCode,
|
||||||
|
publicErrorMessage: job.publicErrorMessage,
|
||||||
|
artifacts: (job.artifacts || []).map((a: any) => ({
|
||||||
|
artifactType: a.artifactType,
|
||||||
|
artifactId: a.artifactId,
|
||||||
|
ordinal: a.ordinal,
|
||||||
|
})),
|
||||||
|
// 禁止返回:validatedOutput, internalErrorMessage, snapshot, credential, outbox
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAdminResponse(job: any) {
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
userId: job.userId,
|
||||||
|
jobType: job.jobType,
|
||||||
|
lifecycleStatus: job.lifecycleStatus,
|
||||||
|
triggerType: job.triggerType,
|
||||||
|
queueName: job.queueName,
|
||||||
|
priority: job.priority,
|
||||||
|
progress: job.progress,
|
||||||
|
targetType: job.targetType,
|
||||||
|
targetId: job.targetId,
|
||||||
|
parentJobId: job.parentJobId,
|
||||||
|
attemptCount: job.attemptCount,
|
||||||
|
maxAttempts: job.maxAttempts,
|
||||||
|
timeoutMs: job.timeoutMs,
|
||||||
|
credentialMode: job.credentialMode,
|
||||||
|
modelProvider: job.modelProvider,
|
||||||
|
modelName: job.modelName,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
queuedAt: job.queuedAt,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
finishedAt: job.finishedAt,
|
||||||
|
errorCode: job.errorCode,
|
||||||
|
publicErrorMessage: job.publicErrorMessage,
|
||||||
|
internalErrorMessage: job.internalErrorMessage,
|
||||||
|
validatedOutput: job.validatedOutput,
|
||||||
|
outputHash: job.outputHash,
|
||||||
|
artifacts: (job.artifacts || []).map((a: any) => ({
|
||||||
|
artifactType: a.artifactType,
|
||||||
|
artifactId: a.artifactId,
|
||||||
|
ordinal: a.ordinal,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { RedisService } from '../infrastructure/redis/redis.service';
|
|||||||
|
|
||||||
describe('WorkerHeartbeatService', () => {
|
describe('WorkerHeartbeatService', () => {
|
||||||
let service: WorkerHeartbeatService;
|
let service: WorkerHeartbeatService;
|
||||||
|
let mockQ: any;
|
||||||
let mockRedis: jest.Mocked<Pick<RedisService, 'set' | 'get' | 'del' | 'keys' | 'ttl'>>;
|
let mockRedis: jest.Mocked<Pick<RedisService, 'set' | 'get' | 'del' | 'keys' | 'ttl'>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -15,7 +16,8 @@ describe('WorkerHeartbeatService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
process.env.WORKER_INSTANCE_ID = 'test-worker-1';
|
process.env.WORKER_INSTANCE_ID = 'test-worker-1';
|
||||||
service = new WorkerHeartbeatService(mockRedis as any);
|
mockQ = { getActiveCount: jest.fn().mockResolvedValue(0) };
|
||||||
|
service = new WorkerHeartbeatService(mockRedis as any, mockQ as any, mockQ as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@ -61,7 +63,7 @@ describe('WorkerHeartbeatService', () => {
|
|||||||
|
|
||||||
it('uses instanceId from WORKER_INSTANCE_ID env var', async () => {
|
it('uses instanceId from WORKER_INSTANCE_ID env var', async () => {
|
||||||
process.env.WORKER_INSTANCE_ID = 'custom-instance';
|
process.env.WORKER_INSTANCE_ID = 'custom-instance';
|
||||||
const svc = new WorkerHeartbeatService(mockRedis as any);
|
const svc = new WorkerHeartbeatService(mockRedis as any, mockQ as any, mockQ as any);
|
||||||
await svc.onModuleInit();
|
await svc.onModuleInit();
|
||||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||||
'worker:heartbeat:custom-instance',
|
'worker:heartbeat:custom-instance',
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
import { RedisService } from '../infrastructure/redis/redis.service';
|
import { RedisService } from '../infrastructure/redis/redis.service';
|
||||||
import { QUEUE_DEFINITIONS } from '../infrastructure/queue/queue-definitions';
|
import { QUEUE_DEFINITIONS } from '../infrastructure/queue/queue-definitions';
|
||||||
|
import { QUEUE_AI_INTERACTIVE, QUEUE_AI_BACKGROUND } from '../infrastructure/queue/queue.service';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
||||||
export interface WorkerHeartbeatData {
|
export interface WorkerHeartbeatData {
|
||||||
@ -32,7 +35,11 @@ export class WorkerHeartbeatService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private static readonly HEARTBEAT_INTERVAL_MS = 30_000;
|
private static readonly HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
private static readonly HEARTBEAT_TTL_SEC = 90;
|
private static readonly HEARTBEAT_TTL_SEC = 90;
|
||||||
|
|
||||||
constructor(private readonly redis: RedisService) {}
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
@InjectQueue(QUEUE_AI_INTERACTIVE) private readonly aiInteractiveQ: Queue,
|
||||||
|
@InjectQueue(QUEUE_AI_BACKGROUND) private readonly aiBackgroundQ: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.startedAt = new Date();
|
this.startedAt = new Date();
|
||||||
@ -115,7 +122,7 @@ export class WorkerHeartbeatService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return WorkerHeartbeatService.heartbeatKeyFor(this.resolveInstanceId());
|
return WorkerHeartbeatService.heartbeatKeyFor(this.resolveInstanceId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async beat() {
|
private async beat(): Promise<void> {
|
||||||
const instanceId = this.resolveInstanceId();
|
const instanceId = this.resolveInstanceId();
|
||||||
const data: WorkerHeartbeatData = {
|
const data: WorkerHeartbeatData = {
|
||||||
instanceId,
|
instanceId,
|
||||||
@ -124,7 +131,7 @@ export class WorkerHeartbeatService implements OnModuleInit, OnModuleDestroy {
|
|||||||
startedAt: this.startedAt.toISOString(),
|
startedAt: this.startedAt.toISOString(),
|
||||||
queues: Object.keys(QUEUE_DEFINITIONS),
|
queues: Object.keys(QUEUE_DEFINITIONS),
|
||||||
concurrency: Object.values(QUEUE_DEFINITIONS)[0]?.workerOptions.concurrency ?? 1,
|
concurrency: Object.values(QUEUE_DEFINITIONS)[0]?.workerOptions.concurrency ?? 1,
|
||||||
activeJobCount: 0,
|
activeJobCount: (await this.aiInteractiveQ.getActiveCount()) + (await this.aiBackgroundQ.getActiveCount()),
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user