refactor(pipeline, repo): rabbitmq

This commit is contained in:
Ivan Li
2021-05-30 22:36:06 +08:00
parent b3a2b11db9
commit 3ee41ece67
35 changed files with 287 additions and 1227 deletions

View File

@ -1,30 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PipelineTaskLogsService } from './pipeline-task-logs.service';
import { RedisService } from 'nestjs-redis';
import { getPubSubToken } from '../commons/pub-sub/utils/token';
describe('PipelineTaskLogsService', () => {
let service: PipelineTaskLogsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PipelineTaskLogsService,
{
provide: RedisService,
useValue: {},
},
{
provide: getPubSubToken(),
useValue: {},
},
],
}).compile();
service = module.get<PipelineTaskLogsService>(PipelineTaskLogsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -1,81 +0,0 @@
import { Injectable } from '@nestjs/common';
import { observableToAsyncIterable } from 'graphql-tools';
import { RedisService } from 'nestjs-redis';
import { find, omit, propEq } from 'ramda';
import { InjectPubSub } from '../commons/pub-sub/decorators/inject-pub-sub.decorator';
import { PubSub } from '../commons/pub-sub/pub-sub';
import { PipelineUnits } from './enums/pipeline-units.enum';
import { TaskStatuses } from './enums/task-statuses.enum';
import { PipelineTaskLogMessage } from './models/pipeline-task-log-message.module';
import { PipelineTaskLogs } from './models/pipeline-task-logs.model';
import { PipelineTask } from './pipeline-task.entity';
const LOG_TIMEOUT_SECONDS = 10_000;
@Injectable()
export class PipelineTaskLogsService {
constructor(
private readonly redisService: RedisService,
@InjectPubSub()
private readonly pubSub: PubSub,
) {}
get redis() {
return this.redisService.getClient();
}
getKeys(task: PipelineTask) {
return `ptl:${task.id}`;
}
async recordLog(log: PipelineTaskLogMessage) {
const logDto = omit(['task'], log);
await Promise.all([
this.pubSub.publish(this.getKeys(log.task), logDto),
this.redis
.expire(this.getKeys(log.task), LOG_TIMEOUT_SECONDS)
.then(() =>
this.redis.rpush(this.getKeys(log.task), JSON.stringify(logDto)),
),
]);
}
async readLog(task: PipelineTask): Promise<PipelineTaskLogMessage[]> {
return await this.redis.lrange(this.getKeys(task), 0, -1).then((items) =>
items.map((item) => {
const log = JSON.parse(item) as PipelineTaskLogMessage;
log.task = task;
log.time = new Date(log.time);
return log;
}),
);
}
async readLogsAsPipelineTaskLogs(
task: PipelineTask,
): Promise<PipelineTaskLogs[]> {
const logs = await this.readLog(task);
const taskLogs: PipelineTaskLogs[] = [];
for (const log of logs) {
const taskLog = find<PipelineTaskLogs>(
propEq('unit', log.unit),
taskLogs,
);
if (!taskLog) {
taskLogs.push({
unit: (log.unit as unknown) as PipelineUnits,
status: TaskStatuses.working,
startedAt: log.time,
logs: log.message,
});
} else {
taskLog.logs += log.message;
}
}
return taskLogs;
}
watchLogs(task: PipelineTask) {
return observableToAsyncIterable(this.pubSub.message$(this.getKeys(task)));
}
}

View File

@ -1,243 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Job } from 'bull';
import { join } from 'path';
import { ReposService } from '../repos/repos.service';
import { PipelineUnits } from './enums/pipeline-units.enum';
import { PipelineTaskConsumer } from './pipeline-task.consumer';
import { PipelineTask } from './pipeline-task.entity';
import { PipelineTasksService } from './pipeline-tasks.service';
import { PipelineTaskLogMessage } from './models/pipeline-task-log-message.module';
import { Pipeline } from '../pipelines/pipeline.entity';
import { Project } from '../projects/project.entity';
import { TaskStatuses } from './enums/task-statuses.enum';
import { PipelineTaskLogsService } from './pipeline-task-logs.service';
import { ApplicationException } from '../commons/exceptions/application.exception';
import { getLoggerToken, PinoLogger } from 'nestjs-pino';
describe('PipelineTaskConsumer', () => {
let consumer: PipelineTaskConsumer;
let tasksService: PipelineTasksService;
let logsService: PipelineTaskLogsService;
const getJob = () =>
({
data: {
pipelineId: 'test',
units: [PipelineUnits.checkout, PipelineUnits.test],
},
} as Job<PipelineTask>);
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PipelineTasksService,
useValue: {
doNextTask: () => undefined,
updateTask: async (value) => value,
},
},
{
provide: ReposService,
useValue: {
getWorkspaceRootByTask: () => 'workspace-root',
checkout: async () => undefined,
},
},
{
provide: PipelineTaskLogsService,
useValue: {
recordLog: async () => undefined,
readLogsAsPipelineTaskLogs: async () => [],
},
},
{
provide: getLoggerToken(PipelineTaskConsumer.name),
useValue: new PinoLogger({}),
},
PipelineTaskConsumer,
],
}).compile();
tasksService = module.get(PipelineTasksService);
logsService = module.get(PipelineTaskLogsService);
consumer = module.get(PipelineTaskConsumer);
});
it('should be defined', () => {
expect(consumer).toBeDefined();
});
describe('onCompleted', () => {
it('should call doNextTask()', () => {
const job = getJob();
const doNextTask = jest.spyOn(tasksService, 'doNextTask');
consumer.onCompleted(job);
expect(doNextTask).toHaveBeenCalledTimes(1);
});
});
describe('runScript', () => {
let logText: string;
let errorText: string;
let recordLog: jest.SpyInstance;
beforeEach(() => {
logText = '';
errorText = '';
recordLog = jest
.spyOn(logsService, 'recordLog')
.mockImplementation(async (log: PipelineTaskLogMessage) => {
logText += log.message;
if (log.isError) {
errorText += log.message;
}
});
});
it('should success and log right message', async () => {
await consumer.runScript(
'node one-second-work.js',
join(__dirname, '../../test/data'),
);
expect(logText).toMatch(
/node one-second-work\.js.+10.+20.+30.+40.+50.+60.+70.+80.+90/s,
);
expect(recordLog).toHaveBeenCalled();
});
it('should failed and log right message', async () => {
await expect(
consumer.runScript(
'node bad-work.js',
join(__dirname, '../../test/data'),
),
).rejects.toThrowError(/exec script failed/);
expect(errorText).toMatch(/Error Message/);
const logs = recordLog.mock.calls
.map((call) => ((call[0] as unknown) as PipelineTaskLogMessage).message)
.join('');
expect(logs).toMatch(/10.+20.+30.+40.+50/s);
});
it('should log with task', async () => {
const task = new PipelineTask();
task.id = 'test';
const recordLog = jest.spyOn(logsService, 'recordLog');
await expect(
consumer.runScript(
'node bad-work.js',
join(__dirname, '../../test/data'),
task,
),
).rejects.toThrowError(/exec script failed/);
expect(errorText).toMatch(/Error Message 2/);
expect(
((recordLog.mock.calls[2][0] as unknown) as PipelineTaskLogMessage)
.task,
).toMatchObject(task);
});
});
describe('doTask', () => {
let task: PipelineTask;
beforeEach(() => {
task = new PipelineTask();
task.id = 'test-id';
task.logs = [];
task.pipeline = new Pipeline();
task.pipeline.workUnitMetadata = {
version: 1,
units: [
{
type: PipelineUnits.checkout,
scripts: [],
},
{
type: PipelineUnits.installDependencies,
scripts: ["echo ' Hello, Fennec!'"],
},
],
};
task.units = task.pipeline.workUnitMetadata.units.map(
(unit) => unit.type,
);
task.pipeline.project = new Project();
task.pipeline.project.name = 'test-project';
});
it('success and update task on db', async () => {
const job: Job = ({
data: task,
update: jest.fn().mockImplementation(() => undefined),
} as unknown) as Job;
jest
.spyOn(consumer, 'runScript')
.mockImplementation(async () => undefined);
const updateTask = jest.spyOn(tasksService, 'updateTask');
await consumer.doTask(job);
expect(updateTask).toHaveBeenCalledTimes(4);
expect(updateTask.mock.calls[0][0].startedAt).toBeDefined();
expect(updateTask.mock.calls[1][0].endedAt).toBeDefined();
expect(updateTask.mock.calls[1][0].status).toEqual(TaskStatuses.success);
});
it('failed and update task on db', async () => {
const job: Job = ({
data: task,
update: jest.fn().mockImplementation(() => undefined),
} as unknown) as Job;
jest.spyOn(consumer, 'runScript').mockImplementation(async () => {
throw new ApplicationException('exec script failed');
});
const updateTask = jest.spyOn(tasksService, 'updateTask');
await consumer.doTask(job);
expect(updateTask).toHaveBeenCalledTimes(4);
expect(updateTask.mock.calls[0][0].startedAt).toBeDefined();
expect(updateTask.mock.calls[1][0].endedAt).toBeDefined();
expect(updateTask.mock.calls[1][0].status).toEqual(TaskStatuses.failed);
});
it('should do all task', async () => {
const job: Job = ({
data: task,
update: jest.fn().mockImplementation(() => undefined),
} as unknown) as Job;
const runScript = jest
.spyOn(consumer, 'runScript')
.mockImplementation(async () => undefined);
const updateTask = jest.spyOn(tasksService, 'updateTask');
await consumer.doTask(job);
expect(runScript).toHaveBeenCalledTimes(1);
expect(updateTask).toHaveBeenCalledTimes(4);
const taskDto: PipelineTask = updateTask.mock.calls[0][0];
expect(taskDto.logs).toHaveLength(2);
expect(taskDto.logs[0].status).toEqual(TaskStatuses.success);
expect(taskDto.logs[0].unit).toEqual(PipelineUnits.checkout);
});
it('should log error message', async () => {
const job: Job = ({
data: task,
update: jest.fn().mockImplementation(() => undefined),
} as unknown) as Job;
jest.spyOn(consumer, 'runScript').mockImplementation(async () => {
throw new Error('bad message');
});
const updateTask = jest.spyOn(tasksService, 'updateTask');
await consumer.doTask(job);
expect(updateTask).toHaveBeenCalledTimes(4);
const taskDto: PipelineTask = updateTask.mock.calls[0][0];
expect(taskDto.logs).toHaveLength(2);
expect(taskDto.logs[0].status).toEqual(TaskStatuses.success);
expect(taskDto.logs[1].status).toEqual(TaskStatuses.failed);
});
});
});

View File

@ -1,161 +0,0 @@
import { PipelineTaskLogs } from './models/pipeline-task-logs.model';
import { ReposService } from './../repos/repos.service';
import {
OnQueueCompleted,
OnQueueFailed,
Process,
Processor,
} from '@nestjs/bull';
import { Job } from 'bull';
import { spawn } from 'child_process';
import { PipelineTask } from './pipeline-task.entity';
import { PIPELINE_TASK_QUEUE } from './pipeline-tasks.constants';
import { PipelineTasksService } from './pipeline-tasks.service';
import { ApplicationException } from '../commons/exceptions/application.exception';
import { PipelineUnits } from './enums/pipeline-units.enum';
import { PipelineTaskLogMessage } from './models/pipeline-task-log-message.module';
import { TaskStatuses } from './enums/task-statuses.enum';
import { PipelineTaskLogsService } from './pipeline-task-logs.service';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@Processor(PIPELINE_TASK_QUEUE)
export class PipelineTaskConsumer {
constructor(
private readonly service: PipelineTasksService,
private readonly reposService: ReposService,
private readonly logsService: PipelineTaskLogsService,
@InjectPinoLogger(PipelineTaskConsumer.name)
private readonly logger: PinoLogger,
) {}
@Process()
async doTask(job: Job<PipelineTask>) {
let task = job.data;
if (task.pipeline.workUnitMetadata.version !== 1) {
throw new ApplicationException(
'work unit metadata version is not match.',
);
}
task.startedAt = new Date();
task.status = TaskStatuses.working;
task = await this.service.updateTask(task);
this.logger.info({ task }, 'running task [%s].', task.id);
await job.update(task);
const workspaceRoot = this.reposService.getWorkspaceRootByTask(task);
const units = task.units.map(
(type) =>
task.pipeline.workUnitMetadata.units.find(
(unit) => unit.type === type,
) ?? { type: type, scripts: [] },
);
this.logger.info({ units }, 'begin run units.');
try {
for (const unit of units) {
const unitLog = new PipelineTaskLogs();
unitLog.unit = unit.type;
unitLog.startedAt = new Date();
this.logger.info('curr unit is %s', unit.type);
try {
// 检出代码前执行 git checkout
if (unit.type === PipelineUnits.checkout) {
this.logger.debug('begin checkout');
await this.reposService.checkout(task, workspaceRoot);
unitLog.status = TaskStatuses.success;
this.logger.debug('end checkout');
}
for (const script of unit.scripts) {
unitLog.logs += `[RUN SCRIPT] ${script}`;
this.logger.debug('begin runScript %s', script);
await this.runScript(script, workspaceRoot, task, unit.type);
this.logger.debug('end runScript %s', script);
}
unitLog.status = TaskStatuses.success;
} catch (err) {
unitLog.status = TaskStatuses.failed;
unitLog.logs += err.message;
throw err;
} finally {
unitLog.endedAt = new Date();
unitLog.logs = await this.logsService
.readLogsAsPipelineTaskLogs(task)
.then(
(taskLogs) =>
taskLogs.find((tl) => tl.unit === unit.type)?.logs ?? '',
);
task.logs.push(unitLog);
task = await this.service.updateTask(task);
await job.update(task);
}
}
task.status = TaskStatuses.success;
this.logger.info({ task }, 'task [%s] completed.', task.id);
} catch (err) {
task.status = TaskStatuses.failed;
this.logger.error({ task, error: err }, 'task [%s] failed.', task.id);
} finally {
task.endedAt = new Date();
task = await this.service.updateTask(task);
await job.update(task);
}
}
async runScript(
script: string,
workspaceRoot: string,
task?: PipelineTask,
unit?: PipelineUnits,
): Promise<void> {
await this.logsService.recordLog(
PipelineTaskLogMessage.create(task, unit, script + '\n', false),
);
return new Promise((resolve, reject) => {
const sub = spawn(script, {
shell: true,
cwd: workspaceRoot,
});
let loggingCount = 0; // semaphore
sub.stderr.on('data', (data: Buffer) => {
const str = data.toString();
loggingCount++;
this.logsService
.recordLog(PipelineTaskLogMessage.create(task, unit, str, true))
.finally(() => loggingCount--);
});
sub.stdout.on('data', (data: Buffer) => {
const str = data.toString();
loggingCount++;
this.logsService
.recordLog(PipelineTaskLogMessage.create(task, unit, str, false))
.finally(() => loggingCount--);
});
sub.addListener('close', async (code) => {
await new Promise<void>(async (resolve) => {
for (let i = 0; i < 10 && loggingCount > 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 500));
this.logger.debug('waiting logging... (%dx500ms)', i);
}
resolve();
});
if (code === 0) {
return resolve();
}
return reject(new ApplicationException('exec script failed'));
});
});
}
@OnQueueCompleted()
onCompleted(job: Job<PipelineTask>) {
this.service.doNextTask(job.data.pipeline);
}
@OnQueueFailed()
onFailed(job: Job<PipelineTask>) {
this.service.doNextTask(job.data.pipeline);
}
}

View File

@ -8,7 +8,6 @@ import { TaskStatuses } from './enums/task-statuses.enum';
import { getLoggerToken, PinoLogger } from 'nestjs-pino';
import { PipelineTaskRunner } from './pipeline-task.runner';
import { WorkUnitMetadata } from './models/work-unit-metadata.model';
import { Code } from 'typeorm';
describe('PipelineTaskRunner', () => {
let runner: PipelineTaskRunner;
let reposService: ReposService;

View File

@ -1,2 +0,0 @@
export const PIPELINE_TASK_QUEUE = 'PIPELINE_TASK_QUEUE';
export const PIPELINE_TASK_LOG_QUEUE = 'PIPELINE_TASK_LOG_QUEUE';

View File

@ -6,11 +6,6 @@ import { PipelineTask } from './pipeline-task.entity';
import { Pipeline } from '../pipelines/pipeline.entity';
import { ReposModule } from '../repos/repos.module';
import { RedisModule } from 'nestjs-redis';
import { BullModule } from '@nestjs/bull';
import { PipelineTaskConsumer } from './pipeline-task.consumer';
import { PIPELINE_TASK_QUEUE } from './pipeline-tasks.constants';
import { PipelineTaskLogsService } from './pipeline-task-logs.service';
import { PubSubModule } from '../commons/pub-sub/pub-sub.module';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PipelineTaskRunner } from './pipeline-task.runner';
@ -19,10 +14,6 @@ import { spawn } from 'child_process';
@Module({
imports: [
TypeOrmModule.forFeature([PipelineTask, Pipeline]),
BullModule.registerQueue({
name: PIPELINE_TASK_QUEUE,
}),
PubSubModule.forFeature(),
RedisModule,
ReposModule,
@ -60,8 +51,6 @@ import { spawn } from 'child_process';
providers: [
PipelineTasksService,
PipelineTasksResolver,
PipelineTaskConsumer,
PipelineTaskLogsService,
PipelineTaskRunner,
{
provide: 'spawn',

View File

@ -1,6 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PipelineTasksResolver } from './pipeline-tasks.resolver';
import { PipelineTaskLogsService } from './pipeline-task-logs.service';
import { PipelineTasksService } from './pipeline-tasks.service';
describe('PipelineTasksResolver', () => {
@ -14,10 +13,6 @@ describe('PipelineTasksResolver', () => {
provide: PipelineTasksService,
useValue: {},
},
{
provide: PipelineTaskLogsService,
useValue: {},
},
],
}).compile();

View File

@ -4,15 +4,11 @@ import { PipelineTasksService } from './pipeline-tasks.service';
import { CreatePipelineTaskInput } from './dtos/create-pipeline-task.input';
import { PipelineTaskLogMessage } from './models/pipeline-task-log-message.module';
import { PipelineTaskLogArgs } from './dtos/pipeline-task-log.args';
import { PipelineTaskLogsService } from './pipeline-task-logs.service';
import { plainToClass } from 'class-transformer';
@Resolver()
export class PipelineTasksResolver {
constructor(
private readonly service: PipelineTasksService,
private readonly logsService: PipelineTaskLogsService,
) {}
constructor(private readonly service: PipelineTasksService) {}
@Mutation(() => PipelineTask)
async createPipelineTask(@Args('task') taskDto: CreatePipelineTaskInput) {
@ -26,9 +22,9 @@ export class PipelineTasksResolver {
},
})
async pipelineTaskLog(@Args() args: PipelineTaskLogArgs) {
const task = await this.service.findTaskById(args.taskId);
const asyncIterator = this.logsService.watchLogs(task);
return asyncIterator;
// const task = await this.service.findTaskById(args.taskId);
// const asyncIterator = this.logsService.watchLogs(task);
// return asyncIterator;
}
@Subscription(() => PipelineTask, {
@ -37,7 +33,7 @@ export class PipelineTasksResolver {
},
})
async pipelineTaskChanged(@Args('id') id: string) {
return await this.service.watchTaskUpdated(id);
// return await this.service.watchTaskUpdated(id);
}
@Query(() => [PipelineTask])

View File

@ -2,15 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing';
import { PipelineTasksService } from './pipeline-tasks.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PipelineTask } from './pipeline-task.entity';
import { PIPELINE_TASK_QUEUE } from './pipeline-tasks.constants';
import { getQueueToken } from '@nestjs/bull';
import { RedisService } from 'nestjs-redis';
import { Pipeline } from '../pipelines/pipeline.entity';
import { EntityNotFoundError } from 'typeorm/error/EntityNotFoundError';
import { Repository } from 'typeorm';
import { Queue } from 'bull';
import { LockFailedException } from '../commons/exceptions/lock-failed.exception';
import { getPubSubToken } from '../commons/pub-sub/utils/token';
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
describe('PipelineTasksService', () => {
let service: PipelineTasksService;
@ -29,25 +24,8 @@ describe('PipelineTasksService', () => {
} as Pipeline);
let redisClient;
let taskQueue: Queue;
const getTask = () =>
({
pipelineId: 'test',
commit: 'test',
pipeline: { branch: 'master' },
units: [],
} as PipelineTask);
beforeEach(async () => {
redisClient = (() => ({
set: jest.fn().mockImplementation(async () => 'OK'),
del: jest.fn().mockImplementation(async () => 'test'),
get: jest.fn().mockImplementation(async () => 'test'),
lpush: jest.fn().mockImplementation(async () => 1),
rpop: jest.fn().mockImplementation(async () => JSON.stringify(getTask())),
}))() as any;
taskQueue = (() => ({
add: jest.fn().mockImplementation(async () => null),
}))() as any;
module = await Test.createTestingModule({
providers: [
PipelineTasksService,
@ -60,17 +38,7 @@ describe('PipelineTasksService', () => {
useValue: new Repository(),
},
{
provide: getQueueToken(PIPELINE_TASK_QUEUE),
useValue: taskQueue,
},
{
provide: RedisService,
useValue: {
getClient: jest.fn(() => redisClient),
},
},
{
provide: getPubSubToken(),
provide: AmqpConnection,
useValue: {},
},
],
@ -92,119 +60,43 @@ describe('PipelineTasksService', () => {
expect(service).toBeDefined();
});
describe('addTask', () => {
beforeEach(() => {
jest
.spyOn(pipelineRepository, 'findOneOrFail')
.mockImplementation(async () => getBasePipeline());
});
it('pipeline not found', async () => {
jest.spyOn(taskRepository, 'findOneOrFail').mockImplementation(() => {
throw new EntityNotFoundError(Pipeline, {});
});
await expect(
service.addTask({ pipelineId: 'test', commit: 'test', units: [] }),
).rejects;
});
it('create task on db', async () => {
const save = jest
.spyOn(taskRepository, 'save')
.mockImplementation(async (data: any) => data);
const findOne = jest.spyOn(taskRepository, 'findOne');
jest
.spyOn(service, 'doNextTask')
.mockImplementation(async () => undefined);
await service.addTask({ pipelineId: 'test', commit: 'test', units: [] }),
expect(save.mock.calls[0][0]).toMatchObject({
pipelineId: 'test',
commit: 'test',
units: [],
});
expect(findOne).toBeCalled();
});
it('add task', async () => {
const lpush = jest.spyOn(redisClient, 'lpush');
const doNextTask = jest.spyOn(service, 'doNextTask');
jest
.spyOn(service, 'doNextTask')
.mockImplementation(async () => undefined);
await service.addTask({ pipelineId: 'test', commit: 'test', units: [] });
expect(typeof lpush.mock.calls[0][1] === 'string').toBeTruthy();
expect(JSON.parse(lpush.mock.calls[0][1] as string)).toMatchObject({
pipelineId: 'test',
commit: 'test',
units: [],
pipeline: getBasePipeline(),
});
expect(doNextTask).toHaveBeenCalledWith(getBasePipeline());
});
});
describe('doNextTask', () => {
it('add task to queue', async () => {
let lckValue: string;
const set = jest
.spyOn(redisClient, 'set')
.mockImplementation(async (...args) => (lckValue = args[3] as string));
const get = jest
.spyOn(redisClient, 'get')
.mockImplementation(async () => lckValue);
const del = jest.spyOn(redisClient, 'del');
const rpop = jest.spyOn(redisClient, 'rpop');
const add = jest.spyOn(taskQueue, 'add');
await service.doNextTask(getBasePipeline());
expect(add).toHaveBeenCalledWith(getTask());
expect(set).toHaveBeenCalledTimes(1);
expect(rpop).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledTimes(1);
});
it('pipeline is busy', async () => {
let remainTimes = 3;
let lckValue: string;
const set = jest
.spyOn(redisClient, 'set')
.mockImplementation(async (...args) => {
if (remainTimes-- > 0) {
throw new Error();
} else {
lckValue = args[3] as string;
}
});
const get = jest
.spyOn(redisClient, 'get')
.mockImplementation(async () => lckValue);
const del = jest.spyOn(redisClient, 'del');
const rpop = jest.spyOn(redisClient, 'rpop');
const add = jest.spyOn(taskQueue, 'add');
await service.doNextTask(getBasePipeline());
expect(rpop).toHaveBeenCalledTimes(1);
expect(set).toHaveBeenCalledTimes(4);
expect(get).toHaveBeenCalledTimes(1);
expect(del).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenCalledWith(getTask());
}, 10_000);
it('pipeline always busy and timeout', async () => {
const set = jest
.spyOn(redisClient, 'set')
.mockImplementation(async () => {
throw new Error();
});
const get = jest.spyOn(redisClient, 'get');
const del = jest.spyOn(redisClient, 'del');
await expect(
service.doNextTask(getBasePipeline()),
).rejects.toBeInstanceOf(LockFailedException);
expect(set).toHaveBeenCalledTimes(5);
expect(get).toHaveBeenCalledTimes(0);
expect(del).toHaveBeenCalledTimes(0);
}, 15_000);
});
// describe('addTask', () => {
// beforeEach(() => {
// jest
// .spyOn(pipelineRepository, 'findOneOrFail')
// .mockImplementation(async () => getBasePipeline());
// });
// it('pipeline not found', async () => {
// jest.spyOn(taskRepository, 'findOneOrFail').mockImplementation(() => {
// throw new EntityNotFoundError(Pipeline, {});
// });
// await expect(
// service.addTask({ pipelineId: 'test', commit: 'test', units: [] }),
// ).rejects;
// });
// it('create task on db', async () => {
// const save = jest
// .spyOn(taskRepository, 'save')
// .mockImplementation(async (data: any) => data);
// const findOne = jest.spyOn(taskRepository, 'findOne');
// await service.addTask({ pipelineId: 'test', commit: 'test', units: [] }),
// expect(save.mock.calls[0][0]).toMatchObject({
// pipelineId: 'test',
// commit: 'test',
// units: [],
// });
// expect(findOne).toBeCalled();
// });
// it('add task', async () => {
// const lpush = jest.spyOn(redisClient, 'lpush');
// await service.addTask({ pipelineId: 'test', commit: 'test', units: [] });
// expect(typeof lpush.mock.calls[0][1] === 'string').toBeTruthy();
// expect(JSON.parse(lpush.mock.calls[0][1] as string)).toMatchObject({
// pipelineId: 'test',
// commit: 'test',
// units: [],
// pipeline: getBasePipeline(),
// });
// });
// });
});

View File

@ -1,20 +1,10 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { PipelineTask } from './pipeline-task.entity';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { CreatePipelineTaskInput } from './dtos/create-pipeline-task.input';
import { RedisService } from 'nestjs-redis';
import { Pipeline } from '../pipelines/pipeline.entity';
import { InjectQueue } from '@nestjs/bull';
import { PIPELINE_TASK_QUEUE } from './pipeline-tasks.constants';
import { Queue } from 'bull';
import { LockFailedException } from '../commons/exceptions/lock-failed.exception';
import { TaskStatuses } from './enums/task-statuses.enum';
import { isNil } from 'ramda';
import debug from 'debug';
import { InjectPubSub } from '../commons/pub-sub/decorators/inject-pub-sub.decorator';
import { PubSub } from '../commons/pub-sub/pub-sub';
import { observableToAsyncIterable } from '@graphql-tools/utils';
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
const log = debug('fennec:pipeline-tasks:service');
@ -26,11 +16,6 @@ export class PipelineTasksService {
private readonly repository: Repository<PipelineTask>,
@InjectRepository(Pipeline)
private readonly pipelineRepository: Repository<Pipeline>,
@InjectQueue(PIPELINE_TASK_QUEUE)
private readonly queue: Queue<PipelineTask>,
private readonly redis: RedisService,
@InjectPubSub()
private readonly pubSub: PubSub,
private readonly amqpConnection: AmqpConnection,
) {}
async addTask(dto: CreatePipelineTaskInput) {
@ -69,61 +54,6 @@ export class PipelineTasksService {
return await this.repository.find({ commit: hash });
}
async doNextTask(pipeline: Pipeline) {
const [lckKey, tasksKey] = this.getRedisTokens(pipeline);
const redis = this.redis.getClient();
log('doNextTask()');
const unLck = await new Promise<() => Promise<void>>(
async (resolve, reject) => {
const lckValue = Date.now().toString();
for (let i = 0; i < 5; i++) {
if (
await redis
.set(lckKey, 0, 'EX', lckValue, 'NX')
.then(() => true)
.catch(() => false)
) {
resolve(async () => {
if ((await redis.get(lckKey)) === lckValue) {
await redis.del(lckKey);
}
});
return;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
reject(new LockFailedException(lckKey));
},
);
const task = JSON.parse(
(await redis.rpop(tasksKey).finally(() => unLck())) ?? 'null',
);
if (task) {
log(
'add task (%s:%s-%s) to queue',
task.id,
task.pipeline.branch,
task.commit.slice(0, 6),
);
await this.queue.add(task);
} else {
log('task is empty');
}
}
async updateTask(task: PipelineTask) {
this.pubSub.publish(`pipeline-task:${task.id}`, task);
return await this.repository.save(task);
}
async watchTaskUpdated(id: string) {
return observableToAsyncIterable(
this.pubSub.message$(`pipeline-task:${id}`),
);
}
getRedisTokens(pipeline: Pipeline): [string, string] {
return [`pipeline-${pipeline.id}:lck`, `pipeline-${pipeline.id}:tasks`];
}