mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	refactor: job events (#23161)
This commit is contained in:
		
							parent
							
								
									cd8d66f5dd
								
							
						
					
					
						commit
						8c27ba3e52
					
				| @ -582,7 +582,7 @@ describe('/tags', () => { | |||||||
|       expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); |       expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should remove duplicate assets only once', async () => { |     it.skip('should remove duplicate assets only once', async () => { | ||||||
|       const tagA = await create(user.accessToken, { name: 'TagA' }); |       const tagA = await create(user.accessToken, { name: 'TagA' }); | ||||||
|       await tagAssets( |       await tagAssets( | ||||||
|         { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, |         { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, | ||||||
|  | |||||||
| @ -17,7 +17,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; | |||||||
| import { NotificationDto } from 'src/dtos/notification.dto'; | import { NotificationDto } from 'src/dtos/notification.dto'; | ||||||
| import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; | import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; | ||||||
| import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; | import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; | ||||||
| import { ImmichWorker, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; | import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; | ||||||
| import { ConfigRepository } from 'src/repositories/config.repository'; | import { ConfigRepository } from 'src/repositories/config.repository'; | ||||||
| import { LoggingRepository } from 'src/repositories/logging.repository'; | import { LoggingRepository } from 'src/repositories/logging.repository'; | ||||||
| import { JobItem, JobSource } from 'src/types'; | import { JobItem, JobSource } from 'src/types'; | ||||||
| @ -66,8 +66,19 @@ type EventMap = { | |||||||
|   AssetDeleteAll: [{ assetIds: string[]; userId: string }]; |   AssetDeleteAll: [{ assetIds: string[]; userId: string }]; | ||||||
|   AssetRestoreAll: [{ assetIds: string[]; userId: string }]; |   AssetRestoreAll: [{ assetIds: string[]; userId: string }]; | ||||||
| 
 | 
 | ||||||
|  |   /** a worker receives a job and emits this event to run it */ | ||||||
|  |   JobRun: [QueueName, JobItem]; | ||||||
|  |   /** job pre-hook */ | ||||||
|   JobStart: [QueueName, JobItem]; |   JobStart: [QueueName, JobItem]; | ||||||
|   JobFailed: [{ job: JobItem; error: Error | any }]; |   /** job post-hook */ | ||||||
|  |   JobComplete: [QueueName, JobItem]; | ||||||
|  |   /** job finishes without error */ | ||||||
|  |   JobSuccess: [JobSuccessEvent]; | ||||||
|  |   /** job finishes with error */ | ||||||
|  |   JobError: [JobErrorEvent]; | ||||||
|  | 
 | ||||||
|  |   // queue events
 | ||||||
|  |   QueueStart: [QueueStartEvent]; | ||||||
| 
 | 
 | ||||||
|   // session events
 |   // session events
 | ||||||
|   SessionDelete: [{ sessionId: string }]; |   SessionDelete: [{ sessionId: string }]; | ||||||
| @ -90,6 +101,13 @@ type EventMap = { | |||||||
|   WebsocketConnect: [{ userId: string }]; |   WebsocketConnect: [{ userId: string }]; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | type JobSuccessEvent = { job: JobItem; response?: JobStatus }; | ||||||
|  | type JobErrorEvent = { job: JobItem; error: Error | any }; | ||||||
|  | 
 | ||||||
|  | type QueueStartEvent = { | ||||||
|  |   name: QueueName; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| type UserEvent = { | type UserEvent = { | ||||||
|   name: string; |   name: string; | ||||||
|   id: string; |   id: string; | ||||||
|  | |||||||
| @ -89,7 +89,7 @@ export class JobRepository { | |||||||
|       this.logger.debug(`Starting worker for queue: ${queueName}`); |       this.logger.debug(`Starting worker for queue: ${queueName}`); | ||||||
|       this.workers[queueName] = new Worker( |       this.workers[queueName] = new Worker( | ||||||
|         queueName, |         queueName, | ||||||
|         (job) => this.eventRepository.emit('JobStart', queueName, job as JobItem), |         (job) => this.eventRepository.emit('JobRun', queueName, job as JobItem), | ||||||
|         { ...bull.config, concurrency: 1 }, |         { ...bull.config, concurrency: 1 }, | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -222,18 +222,16 @@ describe(JobService.name, () => { | |||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('onJobStart', () => { |   describe('onJobRun', () => { | ||||||
|     it('should process a successful job', async () => { |     it('should process a successful job', async () => { | ||||||
|       mocks.job.run.mockResolvedValue(JobStatus.Success); |       mocks.job.run.mockResolvedValue(JobStatus.Success); | ||||||
| 
 | 
 | ||||||
|       await sut.onJobStart(QueueName.BackgroundTask, { |       const job: JobItem = { name: JobName.FileDelete, data: { files: ['path/to/file'] } }; | ||||||
|         name: JobName.FileDelete, |       await sut.onJobRun(QueueName.BackgroundTask, job); | ||||||
|         data: { files: ['path/to/file'] }, |  | ||||||
|       }); |  | ||||||
| 
 | 
 | ||||||
|       expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); |       expect(mocks.event.emit).toHaveBeenCalledWith('JobStart', QueueName.BackgroundTask, job); | ||||||
|       expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); |       expect(mocks.event.emit).toHaveBeenCalledWith('JobSuccess', { job, response: JobStatus.Success }); | ||||||
|       expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.file_delete.success', 1); |       expect(mocks.event.emit).toHaveBeenCalledWith('JobComplete', QueueName.BackgroundTask, job); | ||||||
|       expect(mocks.logger.error).not.toHaveBeenCalled(); |       expect(mocks.logger.error).not.toHaveBeenCalled(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
| @ -300,7 +298,7 @@ describe(JobService.name, () => { | |||||||
| 
 | 
 | ||||||
|         mocks.job.run.mockResolvedValue(JobStatus.Success); |         mocks.job.run.mockResolvedValue(JobStatus.Success); | ||||||
| 
 | 
 | ||||||
|         await sut.onJobStart(QueueName.BackgroundTask, item); |         await sut.onJobRun(QueueName.BackgroundTask, item); | ||||||
| 
 | 
 | ||||||
|         if (jobs.length > 1) { |         if (jobs.length > 1) { | ||||||
|           expect(mocks.job.queueAll).toHaveBeenCalledWith( |           expect(mocks.job.queueAll).toHaveBeenCalledWith( | ||||||
| @ -317,7 +315,7 @@ describe(JobService.name, () => { | |||||||
|       it(`should not queue any jobs when ${item.name} fails`, async () => { |       it(`should not queue any jobs when ${item.name} fails`, async () => { | ||||||
|         mocks.job.run.mockResolvedValue(JobStatus.Failed); |         mocks.job.run.mockResolvedValue(JobStatus.Failed); | ||||||
| 
 | 
 | ||||||
|         await sut.onJobStart(QueueName.BackgroundTask, item); |         await sut.onJobRun(QueueName.BackgroundTask, item); | ||||||
| 
 | 
 | ||||||
|         expect(mocks.job.queueAll).not.toHaveBeenCalled(); |         expect(mocks.job.queueAll).not.toHaveBeenCalled(); | ||||||
|       }); |       }); | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| import { BadRequestException, Injectable } from '@nestjs/common'; | import { BadRequestException, Injectable } from '@nestjs/common'; | ||||||
| import { ClassConstructor } from 'class-transformer'; | import { ClassConstructor } from 'class-transformer'; | ||||||
| import { snakeCase } from 'lodash'; |  | ||||||
| import { SystemConfig } from 'src/config'; | import { SystemConfig } from 'src/config'; | ||||||
| import { OnEvent } from 'src/decorators'; | import { OnEvent } from 'src/decorators'; | ||||||
| import { mapAsset } from 'src/dtos/asset-response.dto'; | import { mapAsset } from 'src/dtos/asset-response.dto'; | ||||||
| @ -186,7 +185,7 @@ export class JobService extends BaseService { | |||||||
|       throw new BadRequestException(`Job is already running`); |       throw new BadRequestException(`Job is already running`); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1); |     await this.eventRepository.emit('QueueStart', { name }); | ||||||
| 
 | 
 | ||||||
|     switch (name) { |     switch (name) { | ||||||
|       case QueueName.VideoConversion: { |       case QueueName.VideoConversion: { | ||||||
| @ -243,21 +242,19 @@ export class JobService extends BaseService { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @OnEvent({ name: 'JobStart' }) |   @OnEvent({ name: 'JobRun' }) | ||||||
|   async onJobStart(...[queueName, job]: ArgsOf<'JobStart'>) { |   async onJobRun(...[queueName, job]: ArgsOf<'JobRun'>) { | ||||||
|     const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; |  | ||||||
|     this.telemetryRepository.jobs.addToGauge(queueMetric, 1); |  | ||||||
|     try { |     try { | ||||||
|       const status = await this.jobRepository.run(job); |       await this.eventRepository.emit('JobStart', queueName, job); | ||||||
|       const jobMetric = `immich.jobs.${snakeCase(job.name)}.${status}`; |       const response = await this.jobRepository.run(job); | ||||||
|       this.telemetryRepository.jobs.addToCounter(jobMetric, 1); |       await this.eventRepository.emit('JobSuccess', { job, response }); | ||||||
|       if (status === JobStatus.Success || status == JobStatus.Skipped) { |       if (response && typeof response === 'string' && [JobStatus.Success, JobStatus.Skipped].includes(response)) { | ||||||
|         await this.onDone(job); |         await this.onDone(job); | ||||||
|       } |       } | ||||||
|     } catch (error: Error | any) { |     } catch (error: Error | any) { | ||||||
|       await this.eventRepository.emit('JobFailed', { job, error }); |       await this.eventRepository.emit('JobError', { job, error }); | ||||||
|     } finally { |     } finally { | ||||||
|       this.telemetryRepository.jobs.addToGauge(queueMetric, -1); |       await this.eventRepository.emit('JobComplete', queueName, job); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -78,8 +78,8 @@ export class NotificationService extends BaseService { | |||||||
|     await this.notificationRepository.cleanup(); |     await this.notificationRepository.cleanup(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @OnEvent({ name: 'JobFailed' }) |   @OnEvent({ name: 'JobError' }) | ||||||
|   async onJobFailed({ job, error }: ArgOf<'JobFailed'>) { |   async onJobError({ job, error }: ArgOf<'JobError'>) { | ||||||
|     const admin = await this.userRepository.getAdmin(); |     const admin = await this.userRepository.getAdmin(); | ||||||
|     if (!admin) { |     if (!admin) { | ||||||
|       return; |       return; | ||||||
|  | |||||||
| @ -1,5 +1,7 @@ | |||||||
|  | import { snakeCase } from 'lodash'; | ||||||
| import { OnEvent } from 'src/decorators'; | import { OnEvent } from 'src/decorators'; | ||||||
| import { ImmichWorker } from 'src/enum'; | import { ImmichWorker, JobStatus } from 'src/enum'; | ||||||
|  | import { ArgOf, ArgsOf } from 'src/repositories/event.repository'; | ||||||
| import { BaseService } from 'src/services/base.service'; | import { BaseService } from 'src/services/base.service'; | ||||||
| 
 | 
 | ||||||
| export class TelemetryService extends BaseService { | export class TelemetryService extends BaseService { | ||||||
| @ -23,4 +25,35 @@ export class TelemetryService extends BaseService { | |||||||
|   onUserRestore() { |   onUserRestore() { | ||||||
|     this.telemetryRepository.api.addToGauge(`immich.users.total`, 1); |     this.telemetryRepository.api.addToGauge(`immich.users.total`, 1); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   @OnEvent({ name: 'JobStart' }) | ||||||
|  |   onJobStart(...[queueName]: ArgsOf<'JobStart'>) { | ||||||
|  |     const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; | ||||||
|  |     this.telemetryRepository.jobs.addToGauge(queueMetric, 1); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @OnEvent({ name: 'JobSuccess' }) | ||||||
|  |   onJobSuccess({ job, response }: ArgOf<'JobSuccess'>) { | ||||||
|  |     if (response && Object.values(JobStatus).includes(response as JobStatus)) { | ||||||
|  |       const jobMetric = `immich.jobs.${snakeCase(job.name)}.${response}`; | ||||||
|  |       this.telemetryRepository.jobs.addToCounter(jobMetric, 1); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @OnEvent({ name: 'JobError' }) | ||||||
|  |   onJobError({ job }: ArgOf<'JobError'>) { | ||||||
|  |     const jobMetric = `immich.jobs.${snakeCase(job.name)}.${JobStatus.Failed}`; | ||||||
|  |     this.telemetryRepository.jobs.addToCounter(jobMetric, 1); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @OnEvent({ name: 'JobComplete' }) | ||||||
|  |   onJobComplete(...[queueName]: ArgsOf<'JobComplete'>) { | ||||||
|  |     const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; | ||||||
|  |     this.telemetryRepository.jobs.addToGauge(queueMetric, -1); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @OnEvent({ name: 'QueueStart' }) | ||||||
|  |   onQueueStart({ name }: ArgOf<'QueueStart'>) { | ||||||
|  |     this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user