feat(server): dynamic job concurrency (#2622)

* feat(server): dynamic job concurrency

* styling and add setting info to top of the job list

* regenerate api

* remove DETECT_OBJECT job

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2023-06-01 06:32:51 -04:00
committed by GitHub
parent 656dc08406
commit 2493dfaba3
48 changed files with 1454 additions and 490 deletions

View File

@@ -19,6 +19,6 @@ export class JobController {
@Put('/:jobId')
async sendJobCommand(@Param() { jobId }: JobIdDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
await this.service.handleCommand(jobId, dto);
return await this.service.getJobStatus(jobId);
return this.service.getJobStatus(jobId);
}
}

View File

@@ -0,0 +1,75 @@
import {
FacialRecognitionService,
IDeleteFilesJob,
JobName,
JobService,
MediaService,
MetadataService,
PersonService,
SearchService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
UserService,
} from '@app/domain';
import { Injectable } from '@nestjs/common';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
@Injectable()
export class AppService {
constructor(
// TODO refactor to domain
private metadataProcessor: MetadataExtractionProcessor,
private facialRecognitionService: FacialRecognitionService,
private jobService: JobService,
private mediaService: MediaService,
private metadataService: MetadataService,
private personService: PersonService,
private searchService: SearchService,
private smartInfoService: SmartInfoService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private systemConfigService: SystemConfigService,
private userService: UserService,
) {}
async init() {
await this.jobService.registerHandlers({
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
[JobName.CLASSIFY_IMAGE]: (data) => this.smartInfoService.handleClassifyImage(data),
[JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.SEARCH_INDEX_ALBUMS]: () => this.searchService.handleIndexAlbums(),
[JobName.SEARCH_INDEX_ASSETS]: () => this.searchService.handleIndexAssets(),
[JobName.SEARCH_INDEX_FACES]: () => this.searchService.handleIndexFaces(),
[JobName.SEARCH_INDEX_ALBUM]: (data) => this.searchService.handleIndexAlbum(data),
[JobName.SEARCH_INDEX_ASSET]: (data) => this.searchService.handleIndexAsset(data),
[JobName.SEARCH_INDEX_FACE]: (data) => this.searchService.handleIndexFace(data),
[JobName.SEARCH_REMOVE_ALBUM]: (data) => this.searchService.handleRemoveAlbum(data),
[JobName.SEARCH_REMOVE_ASSET]: (data) => this.searchService.handleRemoveAsset(data),
[JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
[JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data),
[JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
});
}
}

View File

@@ -2,8 +2,8 @@ import { getLogLevels, SERVER_VERSION } from '@app/domain';
import { RedisIoAdapter } from '@app/infra';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppService } from './app.service';
import { MicroservicesModule } from './microservices.module';
import { ProcessorService } from './processor.service';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
const logger = new Logger('ImmichMicroservice');
@@ -15,7 +15,7 @@ async function bootstrap() {
const listeningPort = Number(process.env.MICROSERVICES_PORT) || 3002;
await app.get(ProcessorService).init();
await app.get(AppService).init();
app.useWebSocketAdapter(new RedisIoAdapter(app));

View File

@@ -3,7 +3,7 @@ import { InfraModule } from '@app/infra';
import { ExifEntity } from '@app/infra/entities';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProcessorService } from './processor.service';
import { AppService } from './app.service';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
@Module({
@@ -12,6 +12,6 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr
DomainModule.register({ imports: [InfraModule] }),
TypeOrmModule.forFeature([ExifEntity]),
],
providers: [MetadataExtractionProcessor, ProcessorService],
providers: [MetadataExtractionProcessor, AppService],
})
export class MicroservicesModule {}

View File

@@ -1,113 +0,0 @@
import {
FacialRecognitionService,
IDeleteFilesJob,
JobItem,
JobName,
JobService,
JOBS_TO_QUEUE,
MediaService,
MetadataService,
PersonService,
QueueName,
QUEUE_TO_CONCURRENCY,
SearchService,
SmartInfoService,
StorageService,
StorageTemplateService,
SystemConfigService,
UserService,
} from '@app/domain';
import { getQueueToken } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Queue } from 'bull';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
@Injectable()
export class ProcessorService {
constructor(
private moduleRef: ModuleRef,
// TODO refactor to domain
private metadataProcessor: MetadataExtractionProcessor,
private facialRecognitionService: FacialRecognitionService,
private jobService: JobService,
private mediaService: MediaService,
private metadataService: MetadataService,
private personService: PersonService,
private searchService: SearchService,
private smartInfoService: SmartInfoService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private systemConfigService: SystemConfigService,
private userService: UserService,
) {}
private logger = new Logger(ProcessorService.name);
private handlers: Record<JobName, JobHandler> = {
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.QUEUE_OBJECT_TAGGING]: (data) => this.smartInfoService.handleQueueObjectTagging(data),
[JobName.CLASSIFY_IMAGE]: (data) => this.smartInfoService.handleClassifyImage(data),
[JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.SEARCH_INDEX_ALBUMS]: () => this.searchService.handleIndexAlbums(),
[JobName.SEARCH_INDEX_ASSETS]: () => this.searchService.handleIndexAssets(),
[JobName.SEARCH_INDEX_FACES]: () => this.searchService.handleIndexFaces(),
[JobName.SEARCH_INDEX_ALBUM]: (data) => this.searchService.handleIndexAlbum(data),
[JobName.SEARCH_INDEX_ASSET]: (data) => this.searchService.handleIndexAsset(data),
[JobName.SEARCH_INDEX_FACE]: (data) => this.searchService.handleIndexFace(data),
[JobName.SEARCH_REMOVE_ALBUM]: (data) => this.searchService.handleRemoveAlbum(data),
[JobName.SEARCH_REMOVE_ASSET]: (data) => this.searchService.handleRemoveAsset(data),
[JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
[JobName.SYSTEM_CONFIG_CHANGE]: () => this.systemConfigService.refreshConfig(),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data),
[JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),
[JobName.METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleMetadataExtraction(data),
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
};
async init() {
const queueSeen: Partial<Record<QueueName, boolean>> = {};
for (const jobName of Object.values(JobName)) {
const handler = this.handlers[jobName];
const queueName = JOBS_TO_QUEUE[jobName];
const queue = this.moduleRef.get<Queue>(getQueueToken(queueName), { strict: false });
// only set concurrency on the first job for a queue, since concurrency stacks
const seen = queueSeen[queueName];
const concurrency = seen ? 0 : QUEUE_TO_CONCURRENCY[queueName];
queueSeen[queueName] = true;
await queue.isReady();
queue.process(jobName, concurrency, async (job): Promise<void> => {
try {
const success = await handler(job.data);
if (success) {
await this.jobService.onDone({ name: jobName, data: job.data } as JobItem);
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler: ${error}`, error?.stack, job.data);
}
});
}
}
}

View File

@@ -5106,63 +5106,63 @@
"AllJobStatusResponseDto": {
"type": "object",
"properties": {
"thumbnail-generation-queue": {
"thumbnailGeneration": {
"$ref": "#/components/schemas/JobStatusDto"
},
"metadata-extraction-queue": {
"metadataExtraction": {
"$ref": "#/components/schemas/JobStatusDto"
},
"video-conversion-queue": {
"videoConversion": {
"$ref": "#/components/schemas/JobStatusDto"
},
"object-tagging-queue": {
"objectTagging": {
"$ref": "#/components/schemas/JobStatusDto"
},
"clip-encoding-queue": {
"clipEncoding": {
"$ref": "#/components/schemas/JobStatusDto"
},
"storage-template-migration-queue": {
"storageTemplateMigration": {
"$ref": "#/components/schemas/JobStatusDto"
},
"background-task-queue": {
"backgroundTask": {
"$ref": "#/components/schemas/JobStatusDto"
},
"search-queue": {
"search": {
"$ref": "#/components/schemas/JobStatusDto"
},
"recognize-faces-queue": {
"recognizeFaces": {
"$ref": "#/components/schemas/JobStatusDto"
},
"sidecar-queue": {
"sidecar": {
"$ref": "#/components/schemas/JobStatusDto"
}
},
"required": [
"thumbnail-generation-queue",
"metadata-extraction-queue",
"video-conversion-queue",
"object-tagging-queue",
"clip-encoding-queue",
"storage-template-migration-queue",
"background-task-queue",
"search-queue",
"recognize-faces-queue",
"sidecar-queue"
"thumbnailGeneration",
"metadataExtraction",
"videoConversion",
"objectTagging",
"clipEncoding",
"storageTemplateMigration",
"backgroundTask",
"search",
"recognizeFaces",
"sidecar"
]
},
"JobName": {
"type": "string",
"enum": [
"thumbnail-generation-queue",
"metadata-extraction-queue",
"video-conversion-queue",
"object-tagging-queue",
"recognize-faces-queue",
"clip-encoding-queue",
"background-task-queue",
"storage-template-migration-queue",
"search-queue",
"sidecar-queue"
"thumbnailGeneration",
"metadataExtraction",
"videoConversion",
"objectTagging",
"recognizeFaces",
"clipEncoding",
"backgroundTask",
"storageTemplateMigration",
"search",
"sidecar"
]
},
"JobCommand": {
@@ -5733,6 +5733,64 @@
"template"
]
},
"JobSettingsDto": {
"type": "object",
"properties": {
"concurrency": {
"type": "integer"
}
},
"required": [
"concurrency"
]
},
"SystemConfigJobDto": {
"type": "object",
"properties": {
"thumbnailGeneration": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"metadataExtraction": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"videoConversion": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"objectTagging": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"clipEncoding": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"storageTemplateMigration": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"backgroundTask": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"search": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"recognizeFaces": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"sidecar": {
"$ref": "#/components/schemas/JobSettingsDto"
}
},
"required": [
"thumbnailGeneration",
"metadataExtraction",
"videoConversion",
"objectTagging",
"clipEncoding",
"storageTemplateMigration",
"backgroundTask",
"search",
"recognizeFaces",
"sidecar"
]
},
"SystemConfigDto": {
"type": "object",
"properties": {
@@ -5747,13 +5805,17 @@
},
"storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
},
"job": {
"$ref": "#/components/schemas/SystemConfigJobDto"
}
},
"required": [
"ffmpeg",
"oauth",
"passwordLogin",
"storageTemplate"
"storageTemplate",
"job"
]
},
"SystemConfigTemplateStorageOptionDto": {

View File

@@ -1,14 +1,14 @@
export enum QueueName {
THUMBNAIL_GENERATION = 'thumbnail-generation-queue',
METADATA_EXTRACTION = 'metadata-extraction-queue',
VIDEO_CONVERSION = 'video-conversion-queue',
OBJECT_TAGGING = 'object-tagging-queue',
RECOGNIZE_FACES = 'recognize-faces-queue',
CLIP_ENCODING = 'clip-encoding-queue',
BACKGROUND_TASK = 'background-task-queue',
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
SEARCH = 'search-queue',
SIDECAR = 'sidecar-queue',
THUMBNAIL_GENERATION = 'thumbnailGeneration',
METADATA_EXTRACTION = 'metadataExtraction',
VIDEO_CONVERSION = 'videoConversion',
OBJECT_TAGGING = 'objectTagging',
RECOGNIZE_FACES = 'recognizeFaces',
CLIP_ENCODING = 'clipEncoding',
BACKGROUND_TASK = 'backgroundTask',
STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration',
SEARCH = 'search',
SIDECAR = 'sidecar',
}
export enum JobCommand {
@@ -135,17 +135,3 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
};
// max concurrency for each queue (total concurrency across all jobs)
export const QUEUE_TO_CONCURRENCY: Record<QueueName, number> = {
[QueueName.BACKGROUND_TASK]: 5,
[QueueName.CLIP_ENCODING]: 2,
[QueueName.METADATA_EXTRACTION]: 5,
[QueueName.OBJECT_TAGGING]: 2,
[QueueName.RECOGNIZE_FACES]: 2,
[QueueName.SEARCH]: 5,
[QueueName.SIDECAR]: 5,
[QueueName.STORAGE_TEMPLATE_MIGRATION]: 5,
[QueueName.THUMBNAIL_GENERATION]: 5,
[QueueName.VIDEO_CONVERSION]: 1,
};

View File

@@ -33,13 +33,13 @@ export type JobItem =
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
// User Deletion
| { name: JobName.USER_DELETE_CHECK }
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
| { name: JobName.USER_DELETION; data: IEntityJob }
// Storage Template
| { name: JobName.STORAGE_TEMPLATE_MIGRATION }
| { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob }
| { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob }
| { name: JobName.SYSTEM_CONFIG_CHANGE }
| { name: JobName.SYSTEM_CONFIG_CHANGE; data?: IBaseJob }
// Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
@@ -67,22 +67,26 @@ export type JobItem =
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
// Asset Deletion
| { name: JobName.PERSON_CLEANUP }
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
// Search
| { name: JobName.SEARCH_INDEX_ASSETS }
| { name: JobName.SEARCH_INDEX_ASSETS; data?: IBaseJob }
| { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob }
| { name: JobName.SEARCH_INDEX_FACES }
| { name: JobName.SEARCH_INDEX_FACES; data?: IBaseJob }
| { name: JobName.SEARCH_INDEX_FACE; data: IAssetFaceJob }
| { name: JobName.SEARCH_INDEX_ALBUMS }
| { name: JobName.SEARCH_INDEX_ALBUMS; data?: IBaseJob }
| { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob }
| { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob };
export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
export const IJobRepository = 'IJobRepository';
export interface IJobRepository {
addHandler(queueName: QueueName, concurrency: number, handler: (job: JobItem) => Promise<void>): void;
setConcurrency(queueName: QueueName, concurrency: number): void;
queue(item: JobItem): Promise<void>;
pause(name: QueueName): Promise<void>;
resume(name: QueueName): Promise<void>;

View File

@@ -1,20 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { newAssetRepositoryMock, newCommunicationRepositoryMock, newJobRepositoryMock } from '../../test';
import {
newAssetRepositoryMock,
newCommunicationRepositoryMock,
newJobRepositoryMock,
newSystemConfigRepositoryMock,
} from '../../test';
import { IAssetRepository } from '../asset';
import { ICommunicationRepository } from '../communication';
import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job';
import { IJobRepository, JobCommand, JobHandler, JobName, JobService, QueueName } from '../job';
import { ISystemConfigRepository } from '../system-config';
describe(JobService.name, () => {
let sut: JobService;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let jobMock: jest.Mocked<IJobRepository>;
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
communicationMock = newCommunicationRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new JobService(assetMock, communicationMock, jobMock);
sut = new JobService(assetMock, communicationMock, jobMock, configMock);
});
it('should work', () => {
@@ -64,16 +72,16 @@ describe(JobService.name, () => {
};
await expect(sut.getAllJobsStatus()).resolves.toEqual({
'background-task-queue': expectedJobStatus,
'clip-encoding-queue': expectedJobStatus,
'metadata-extraction-queue': expectedJobStatus,
'object-tagging-queue': expectedJobStatus,
'search-queue': expectedJobStatus,
'storage-template-migration-queue': expectedJobStatus,
'thumbnail-generation-queue': expectedJobStatus,
'video-conversion-queue': expectedJobStatus,
'recognize-faces-queue': expectedJobStatus,
'sidecar-queue': expectedJobStatus,
[QueueName.BACKGROUND_TASK]: expectedJobStatus,
[QueueName.CLIP_ENCODING]: expectedJobStatus,
[QueueName.METADATA_EXTRACTION]: expectedJobStatus,
[QueueName.OBJECT_TAGGING]: expectedJobStatus,
[QueueName.SEARCH]: expectedJobStatus,
[QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus,
[QueueName.THUMBNAIL_GENERATION]: expectedJobStatus,
[QueueName.VIDEO_CONVERSION]: expectedJobStatus,
[QueueName.RECOGNIZE_FACES]: expectedJobStatus,
[QueueName.SIDECAR]: expectedJobStatus,
});
});
});
@@ -147,6 +155,14 @@ describe(JobService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } });
});
it('should handle a start sidecar command', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } });
});
it('should handle a start thumbnail generation command', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
@@ -155,6 +171,14 @@ describe(JobService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
});
it('should handle a start recognize faces command', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.handleCommand(QueueName.RECOGNIZE_FACES, { command: JobCommand.START, force: false });
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force: false } });
});
it('should throw a bad request when an invalid queue is used', async () => {
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
@@ -165,4 +189,19 @@ describe(JobService.name, () => {
expect(jobMock.queue).not.toHaveBeenCalled();
});
});
describe('registerHandlers', () => {
it('should register a handler for each queue', async () => {
const mock = jest.fn();
const handlers = Object.values(JobName).reduce((map, jobName) => ({ ...map, [jobName]: mock }), {}) as Record<
JobName,
JobHandler
>;
await sut.registerHandlers(handlers);
expect(configMock.load).toHaveBeenCalled();
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
});
});
});

View File

@@ -2,20 +2,26 @@ import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'
import { IAssetRepository, mapAsset } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
import { assertMachineLearningEnabled } from '../domain.constant';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { JobCommandDto } from './dto';
import { JobCommand, JobName, QueueName } from './job.constants';
import { IJobRepository, JobItem } from './job.repository';
import { IJobRepository, JobHandler, JobItem } from './job.repository';
import { AllJobStatusResponseDto, JobStatusDto } from './response-dto';
@Injectable()
export class JobService {
private logger = new Logger(JobService.name);
private configCore: SystemConfigCore;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {}
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
) {
this.configCore = new SystemConfigCore(configRepository);
}
handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<void> {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
@@ -90,6 +96,36 @@ export class JobService {
}
}
async registerHandlers(jobHandlers: Record<JobName, JobHandler>) {
const config = await this.configCore.getConfig();
for (const queueName of Object.values(QueueName)) {
const concurrency = config.job[queueName].concurrency;
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`);
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
const { name, data } = item;
try {
const handler = jobHandlers[name];
const success = await handler(data);
if (success) {
await this.onDone(item);
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler: ${error}`, error?.stack, data);
}
});
}
this.configCore.config$.subscribe((config) => {
this.logger.log(`Updating queue concurrency settings`);
for (const queueName of Object.values(QueueName)) {
const concurrency = config.job[queueName].concurrency;
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
this.jobRepository.setConcurrency(queueName, concurrency);
}
});
}
async handleNightlyJobs() {
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });

View File

@@ -0,0 +1,73 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { QueueName } from '../../job';
export class JobSettingsDto {
@IsInt()
@IsPositive()
@ApiProperty({ type: 'integer' })
concurrency!: number;
}
export class SystemConfigJobDto implements Record<QueueName, JobSettingsDto> {
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.THUMBNAIL_GENERATION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.METADATA_EXTRACTION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.VIDEO_CONVERSION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.OBJECT_TAGGING]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.CLIP_ENCODING]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.BACKGROUND_TASK]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.SEARCH]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.RECOGNIZE_FACES]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.SIDECAR]!: JobSettingsDto;
}

View File

@@ -1,6 +1,7 @@
import { SystemConfig } from '@app/infra/entities';
import { Type } from 'class-transformer';
import { IsObject, ValidateNested } from 'class-validator';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
@@ -26,6 +27,11 @@ export class SystemConfigDto {
@ValidateNested()
@IsObject()
storageTemplate!: SystemConfigStorageTemplateDto;
@Type(() => SystemConfigJobDto)
@ValidateNested()
@IsObject()
job!: SystemConfigJobDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {

View File

@@ -1,13 +1,20 @@
import { SystemConfig, SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities';
import {
SystemConfig,
SystemConfigEntity,
SystemConfigKey,
SystemConfigValue,
TranscodePreset,
} from '@app/infra/entities';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial } from 'typeorm';
import { QueueName } from '../job/job.constants';
import { ISystemConfigRepository } from './system-config.repository';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
const defaults: SystemConfig = Object.freeze({
const defaults = Object.freeze<SystemConfig>({
ffmpeg: {
crf: 23,
threads: 0,
@@ -19,6 +26,18 @@ const defaults: SystemConfig = Object.freeze({
twoPass: false,
transcode: TranscodePreset.REQUIRED,
},
job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
[QueueName.CLIP_ENCODING]: { concurrency: 2 },
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
[QueueName.SEARCH]: { concurrency: 5 },
[QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
oauth: {
enabled: false,
issuerUrl: '',
@@ -85,7 +104,7 @@ export class SystemConfigCore {
for (const key of Object.values(SystemConfigKey)) {
// get via dot notation
const item = { key, value: _.get(config, key) };
const item = { key, value: _.get(config, key) as SystemConfigValue };
const defaultValue = _.get(defaults, key);
const isMissing = !_.has(config, key);

View File

@@ -1,7 +1,7 @@
import { SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities';
import { SystemConfig, SystemConfigEntity, SystemConfigKey, TranscodePreset } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test';
import { IJobRepository, JobName } from '../job';
import { IJobRepository, JobName, QueueName } from '../job';
import { SystemConfigValidator } from './system-config.core';
import { ISystemConfigRepository } from './system-config.repository';
import { SystemConfigService } from './system-config.service';
@@ -11,7 +11,19 @@ const updates: SystemConfigEntity[] = [
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
];
const updatedConfig = Object.freeze({
const updatedConfig = Object.freeze<SystemConfig>({
job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
[QueueName.CLIP_ENCODING]: { concurrency: 2 },
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
[QueueName.SEARCH]: { concurrency: 5 },
[QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
ffmpeg: {
crf: 30,
threads: 0,

View File

@@ -23,6 +23,7 @@ import {
AuthUserDto,
ExifResponseDto,
mapUser,
QueueName,
SearchResult,
SharedLinkResponseDto,
TagResponseDto,
@@ -531,6 +532,18 @@ export const systemConfigStub = {
twoPass: false,
transcode: TranscodePreset.REQUIRED,
},
job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
[QueueName.CLIP_ENCODING]: { concurrency: 2 },
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
[QueueName.SEARCH]: { concurrency: 5 },
[QueueName.SIDECAR]: { concurrency: 5 },
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
oauth: {
autoLaunch: false,
autoRegister: true,

View File

@@ -2,6 +2,8 @@ import { IJobRepository } from '../src';
export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
return {
addHandler: jest.fn(),
setConcurrency: jest.fn(),
empty: jest.fn(),
pause: jest.fn(),
resume: jest.fn(),

View File

@@ -1,7 +1,8 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { QueueName } from '../../../domain/src';
@Entity('system_config')
export class SystemConfigEntity<T = string | boolean | number> {
export class SystemConfigEntity<T = SystemConfigValue> {
@PrimaryColumn()
key!: SystemConfigKey;
@@ -9,7 +10,7 @@ export class SystemConfigEntity<T = string | boolean | number> {
value!: T;
}
export type SystemConfigValue = any;
export type SystemConfigValue = string | number | boolean;
// dot notation matches path in `SystemConfig`
export enum SystemConfigKey {
@@ -22,6 +23,18 @@ export enum SystemConfigKey {
FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
FFMPEG_TRANSCODE = 'ffmpeg.transcode',
JOB_THUMBNAIL_GENERATION_CONCURRENCY = 'job.thumbnailGeneration.concurrency',
JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
JOB_OBJECT_TAGGING_CONCURRENCY = 'job.objectTagging.concurrency',
JOB_RECOGNIZE_FACES_CONCURRENCY = 'job.recognizeFaces.concurrency',
JOB_CLIP_ENCODING_CONCURRENCY = 'job.clipEncoding.concurrency',
JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency',
OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_CLIENT_ID = 'oauth.clientId',
@@ -32,7 +45,9 @@ export enum SystemConfigKey {
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
STORAGE_TEMPLATE = 'storageTemplate.template',
}
@@ -55,6 +70,7 @@ export interface SystemConfig {
twoPass: boolean;
transcode: TranscodePreset;
};
job: Record<QueueName, { concurrency: number }>;
oauth: {
enabled: boolean;
issuerUrl: string;

View File

@@ -1,5 +1,6 @@
import { QueueName } from '@app/domain';
import { BullModuleOptions } from '@nestjs/bull';
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { QueueOptions } from 'bullmq';
import { RedisOptions } from 'ioredis';
import { InitOptions } from 'local-reverse-geocoder';
import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration';
@@ -26,9 +27,9 @@ function parseRedisConfig(): RedisOptions {
export const redisConfig: RedisOptions = parseRedisConfig();
export const bullConfig: BullModuleOptions = {
export const bullConfig: QueueOptions = {
prefix: 'immich_bull',
redis: redisConfig,
connection: redisConfig,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
@@ -36,7 +37,7 @@ export const bullConfig: BullModuleOptions = {
},
};
export const bullQueues: BullModuleOptions[] = Object.values(QueueName).map((name) => ({ name }));
export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name }));
function parseTypeSenseConfig(): ConfigurationOptions {
const typesenseURL = process.env.TYPESENSE_URL;

View File

@@ -21,7 +21,7 @@ import {
IUserRepository,
IUserTokenRepository,
} from '@app/domain';
import { BullModule } from '@nestjs/bull';
import { BullModule } from '@nestjs/bullmq';
import { Global, Module, Provider } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

View File

@@ -1,13 +1,33 @@
import { IJobRepository, JobCounts, JobItem, JobName, JOBS_TO_QUEUE, QueueName, QueueStatus } from '@app/domain';
import { getQueueToken } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { getQueueToken } from '@nestjs/bullmq';
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { JobOptions, Queue, type JobCounts as BullJobCounts } from 'bull';
import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
import { bullConfig } from '../infra.config';
@Injectable()
export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private logger = new Logger(JobRepository.name);
constructor(private moduleRef: ModuleRef) {}
addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise<void>) {
const workerHandler: Processor = async (job: Job) => handler(job as JobItem);
const workerOptions: WorkerOptions = { ...bullConfig, concurrency };
this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions);
}
setConcurrency(queueName: QueueName, concurrency: number) {
const worker = this.workers[queueName];
if (!worker) {
this.logger.warn(`Unable to set queue concurrency, worker not found: '${queueName}'`);
return;
}
worker.concurrency = concurrency;
}
async getQueueStatus(name: QueueName): Promise<QueueStatus> {
const queue = this.getQueue(name);
@@ -26,13 +46,18 @@ export class JobRepository implements IJobRepository {
}
empty(name: QueueName) {
return this.getQueue(name).empty();
return this.getQueue(name).drain();
}
getJobCounts(name: QueueName): Promise<JobCounts> {
// Typecast needed because the `paused` key is missing from Bull's
// type definition. Can be removed once fixed upstream.
return this.getQueue(name).getJobCounts() as Promise<BullJobCounts & { paused: number }>;
return this.getQueue(name).getJobCounts(
'active',
'completed',
'failed',
'delayed',
'waiting',
'paused',
) as unknown as Promise<JobCounts>;
}
async queue(item: JobItem): Promise<void> {
@@ -43,7 +68,7 @@ export class JobRepository implements IJobRepository {
await this.getQueue(JOBS_TO_QUEUE[jobName]).add(jobName, jobData, jobOptions);
}
private getJobOptions(item: JobItem): JobOptions | null {
private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) {
case JobName.GENERATE_FACE_THUMBNAIL:
return { priority: 1 };

View File

@@ -9,7 +9,7 @@ export class SystemConfigRepository implements ISystemConfigRepository {
private repository: Repository<SystemConfigEntity>,
) {}
load(): Promise<SystemConfigEntity<string | boolean | number>[]> {
load(): Promise<SystemConfigEntity[]> {
return this.repository.find();
}

204
server/package-lock.json generated
View File

@@ -10,7 +10,7 @@
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@nestjs/bull": "^0.6.2",
"@nestjs/bullmq": "^1.1.0",
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1",
@@ -24,7 +24,7 @@
"archiver": "^5.3.1",
"axios": "^0.26.0",
"bcrypt": "^5.0.1",
"bull": "^4.10.2",
"bullmq": "^3.14.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
@@ -1507,20 +1507,6 @@
"win32"
]
},
"node_modules/@nestjs/bull": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-0.6.3.tgz",
"integrity": "sha512-CckH9O3t9qSiO4RCzdYvtFSaaMfIhTXMYagV/rtmVvI1SX5XNnxEaQXvtjxDBXF9DB1JE/5AejIl6ICym+MJIw==",
"dependencies": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
},
"peerDependencies": {
"@nestjs/common": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0",
"bull": "^3.3 || ^4.0.0"
}
},
"node_modules/@nestjs/bull-shared": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz",
@@ -1533,6 +1519,20 @@
"@nestjs/core": "^6.10.11 || ^7.0.0 || ^8.0.0 || ^9.0.0"
}
},
"node_modules/@nestjs/bullmq": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-1.1.0.tgz",
"integrity": "sha512-XloO39ACm9TuB8XOX53iMUaCW5BTQAnZADtX4a9kczJZU/5/cQVBfSCyfzq9L6dfi5EX6w/1Ayyv+5qBQ5yrzw==",
"dependencies": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0",
"bullmq": "^3.0.0"
}
},
"node_modules/@nestjs/cli": {
"version": "9.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.4.2.tgz",
@@ -4232,30 +4232,56 @@
"node": ">=0.2.0"
}
},
"node_modules/bull": {
"version": "4.10.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz",
"integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==",
"node_modules/bullmq": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"dependencies": {
"cron-parser": "^4.2.1",
"debuglog": "^1.0.0",
"get-port": "^5.1.1",
"ioredis": "^5.0.0",
"cron-parser": "^4.6.0",
"glob": "^8.0.3",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.5.2",
"semver": "^7.3.2",
"uuid": "^8.3.0"
"msgpackr": "^1.6.2",
"semver": "^7.3.7",
"tslib": "^2.0.0",
"uuid": "^9.0.0"
}
},
"node_modules/bullmq/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/bullmq/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/bull/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
"node_modules/bullmq/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/busboy": {
@@ -5013,14 +5039,6 @@
}
}
},
"node_modules/debuglog": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz",
"integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==",
"engines": {
"node": "*"
}
},
"node_modules/decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@@ -6422,17 +6440,6 @@
"node": ">=8.0.0"
}
},
"node_modules/get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -8429,9 +8436,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/msgpackr": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.1.tgz",
"integrity": "sha512-jJdrNH8tzfCtT0rjPFryBXjRDQE7rqfLkah4/8B4gYa7NNZYFBcGxqWBtfQpGC+oYyBwlkj3fARk4aooKNPHxg==",
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz",
"integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
@@ -13122,15 +13129,6 @@
"integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==",
"optional": true
},
"@nestjs/bull": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-0.6.3.tgz",
"integrity": "sha512-CckH9O3t9qSiO4RCzdYvtFSaaMfIhTXMYagV/rtmVvI1SX5XNnxEaQXvtjxDBXF9DB1JE/5AejIl6ICym+MJIw==",
"requires": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
}
},
"@nestjs/bull-shared": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-0.1.3.tgz",
@@ -13139,6 +13137,15 @@
"tslib": "2.5.0"
}
},
"@nestjs/bullmq": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-1.1.0.tgz",
"integrity": "sha512-XloO39ACm9TuB8XOX53iMUaCW5BTQAnZADtX4a9kczJZU/5/cQVBfSCyfzq9L6dfi5EX6w/1Ayyv+5qBQ5yrzw==",
"requires": {
"@nestjs/bull-shared": "^0.1.3",
"tslib": "2.5.0"
}
},
"@nestjs/cli": {
"version": "9.4.2",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-9.4.2.tgz",
@@ -15212,25 +15219,48 @@
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="
},
"bull": {
"version": "4.10.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz",
"integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==",
"bullmq": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==",
"requires": {
"cron-parser": "^4.2.1",
"debuglog": "^1.0.0",
"get-port": "^5.1.1",
"ioredis": "^5.0.0",
"cron-parser": "^4.6.0",
"glob": "^8.0.3",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.5.2",
"semver": "^7.3.2",
"uuid": "^8.3.0"
"msgpackr": "^1.6.2",
"semver": "^7.3.7",
"tslib": "^2.0.0",
"uuid": "^9.0.0"
},
"dependencies": {
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
}
},
"minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
@@ -15800,11 +15830,6 @@
"ms": "2.1.2"
}
},
"debuglog": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz",
"integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw=="
},
"decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@@ -16867,11 +16892,6 @@
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true
},
"get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ=="
},
"get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -18386,9 +18406,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"msgpackr": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.1.tgz",
"integrity": "sha512-jJdrNH8tzfCtT0rjPFryBXjRDQE7rqfLkah4/8B4gYa7NNZYFBcGxqWBtfQpGC+oYyBwlkj3fARk4aooKNPHxg==",
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz",
"integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==",
"requires": {
"msgpackr-extract": "^3.0.2"
}

View File

@@ -41,7 +41,7 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@nestjs/bull": "^0.6.2",
"@nestjs/bullmq": "^1.1.0",
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.2.1",
@@ -55,7 +55,7 @@
"archiver": "^5.3.1",
"axios": "^0.26.0",
"bcrypt": "^5.0.1",
"bull": "^4.10.2",
"bullmq": "^3.14.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
@@ -140,9 +140,9 @@
"coverageThreshold": {
"./libs/domain/": {
"branches": 80,
"functions": 85,
"lines": 93,
"statements": 93
"functions": 80,
"lines": 90,
"statements": 90
}
},
"setupFilesAfterEnv": [