Files
immich/server/src/services/job.service.spec.ts

326 lines
12 KiB
TypeScript
Raw Normal View History

import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config';
2025-02-11 17:15:56 -05:00
import { ImmichWorker, JobCommand, JobName, JobStatus, QueueName } from 'src/enum';
import { JobService } from 'src/services/job.service';
2025-02-11 17:15:56 -05:00
import { JobItem } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub';
2025-02-10 18:47:42 -05:00
import { newTestService, ServiceMocks } from 'test/utils';
describe(JobService.name, () => {
let sut: JobService;
2025-02-10 18:47:42 -05:00
let mocks: ServiceMocks;
beforeEach(() => {
2025-02-10 18:47:42 -05:00
({ sut, mocks } = newTestService(JobService, {}));
2024-11-05 08:07:51 -05:00
2025-07-15 14:50:13 -04:00
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('onConfigUpdate', () => {
it('should update concurrency', () => {
sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
2025-02-10 18:47:42 -05:00
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(15);
2025-07-15 14:50:13 -04:00
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.StorageTemplateMigration, 1);
});
});
2023-05-17 13:07:17 -04:00
describe('handleNightlyJobs', () => {
it('should run the scheduled jobs', async () => {
await sut.handleNightlyJobs();
2025-02-10 18:47:42 -05:00
expect(mocks.job.queueAll).toHaveBeenCalledWith([
2025-07-15 18:39:00 -04:00
{ name: JobName.AssetDeleteCheck },
2025-07-15 14:50:13 -04:00
{ name: JobName.UserDeleteCheck },
{ name: JobName.PersonCleanup },
2025-07-15 18:39:00 -04:00
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.AuditLogCleanup },
{ name: JobName.MemoryGenerate },
{ name: JobName.UserSyncUsage },
{ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } },
{ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } },
2023-05-17 13:07:17 -04:00
]);
});
});
describe('getAllJobStatus', () => {
it('should get all job statuses', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getJobCounts.mockResolvedValue({
active: 1,
completed: 1,
failed: 1,
delayed: 1,
waiting: 1,
paused: 1,
});
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({
isActive: true,
isPaused: true,
});
const expectedJobStatus = {
jobCounts: {
active: 1,
completed: 1,
delayed: 1,
failed: 1,
waiting: 1,
paused: 1,
},
queueStatus: {
isActive: true,
isPaused: true,
},
};
await expect(sut.getAllJobsStatus()).resolves.toEqual({
2025-07-15 14:50:13 -04:00
[QueueName.BackgroundTask]: expectedJobStatus,
[QueueName.DuplicateDetection]: expectedJobStatus,
[QueueName.SmartSearch]: expectedJobStatus,
[QueueName.MetadataExtraction]: expectedJobStatus,
[QueueName.Search]: expectedJobStatus,
[QueueName.StorageTemplateMigration]: expectedJobStatus,
[QueueName.Migration]: expectedJobStatus,
[QueueName.ThumbnailGeneration]: expectedJobStatus,
[QueueName.VideoConversion]: expectedJobStatus,
[QueueName.FaceDetection]: expectedJobStatus,
[QueueName.FacialRecognition]: expectedJobStatus,
[QueueName.Sidecar]: expectedJobStatus,
[QueueName.Library]: expectedJobStatus,
[QueueName.Notification]: expectedJobStatus,
[QueueName.BackupDatabase]: expectedJobStatus,
});
});
});
describe('handleCommand', () => {
it('should handle a pause command', async () => {
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Pause, force: false });
2025-07-15 14:50:13 -04:00
expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction);
});
it('should handle a resume command', async () => {
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Resume, force: false });
2025-07-15 14:50:13 -04:00
expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction);
});
it('should handle an empty command', async () => {
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Empty, force: false });
2025-07-15 14:50:13 -04:00
expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction);
});
it('should not start a job that is already running', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false });
await expect(
2025-07-15 14:50:13 -04:00
sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }),
).rejects.toBeInstanceOf(BadRequestException);
2025-02-10 18:47:42 -05:00
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
it('should handle a start video conversion command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false });
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } });
});
it('should handle a start storage template migration command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.StorageTemplateMigration, { command: JobCommand.Start, force: false });
2025-07-15 14:50:13 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration });
});
it('should handle a start smart search command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.SmartSearch, { command: JobCommand.Start, force: false });
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } });
});
it('should handle a start metadata extraction command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Start, force: false });
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.AssetExtractMetadataQueueAll,
data: { force: false },
});
});
it('should handle a start sidecar command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.Sidecar, { command: JobCommand.Start, force: false });
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } });
});
it('should handle a start thumbnail generation command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.ThumbnailGeneration, { command: JobCommand.Start, force: false });
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.AssetGenerateThumbnailsQueueAll,
data: { force: false },
});
});
feat(server): separate face clustering job (#5598) * separate facial clustering job * update api * fixed some tests * invert clustering * hdbscan * update api * remove commented code * wip dbscan * cleanup removed cluster endpoint remove commented code * fixes updated tests minor fixes and formatting fixed queuing refinements * scale search range based on library size * defer non-core faces * optimizations removed unused query option * assign faces individually for correctness fixed unit tests remove unused method * don't select face embedding update sql linting fixed ml typing * updated job mock * paginate people query * select face embeddings because typeorm * fix setting face detection concurrency * update sql formatting linting * simplify logic remove unused imports * more specific delete signature * more accurate typing for face stubs * add migration formatting * chore: better typing * don't select embedding by default remove unused import * updated sql * use normal try/catch * stricter concurrency typing and enforcement * update api * update job concurrency panel to show disabled queues formatting * check jobId in queueAll fix tests * remove outdated comment * better facial recognition icon * wording wording formatting * fixed tests * fix * formatting & sql * try to fix sql check * more detailed description * update sql * formatting * wording * update `minFaces` description --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-01-18 00:08:48 -05:00
it('should handle a start face detection command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.FaceDetection, { command: JobCommand.Start, force: false });
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } });
feat(server): separate face clustering job (#5598) * separate facial clustering job * update api * fixed some tests * invert clustering * hdbscan * update api * remove commented code * wip dbscan * cleanup removed cluster endpoint remove commented code * fixes updated tests minor fixes and formatting fixed queuing refinements * scale search range based on library size * defer non-core faces * optimizations removed unused query option * assign faces individually for correctness fixed unit tests remove unused method * don't select face embedding update sql linting fixed ml typing * updated job mock * paginate people query * select face embeddings because typeorm * fix setting face detection concurrency * update sql formatting linting * simplify logic remove unused imports * more specific delete signature * more accurate typing for face stubs * add migration formatting * chore: better typing * don't select embedding by default remove unused import * updated sql * use normal try/catch * stricter concurrency typing and enforcement * update api * update job concurrency panel to show disabled queues formatting * check jobId in queueAll fix tests * remove outdated comment * better facial recognition icon * wording wording formatting * fixed tests * fix * formatting & sql * try to fix sql check * more detailed description * update sql * formatting * wording * update `minFaces` description --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-01-18 00:08:48 -05:00
});
it('should handle a start facial recognition command', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
feat(server): separate face clustering job (#5598) * separate facial clustering job * update api * fixed some tests * invert clustering * hdbscan * update api * remove commented code * wip dbscan * cleanup removed cluster endpoint remove commented code * fixes updated tests minor fixes and formatting fixed queuing refinements * scale search range based on library size * defer non-core faces * optimizations removed unused query option * assign faces individually for correctness fixed unit tests remove unused method * don't select face embedding update sql linting fixed ml typing * updated job mock * paginate people query * select face embeddings because typeorm * fix setting face detection concurrency * update sql formatting linting * simplify logic remove unused imports * more specific delete signature * more accurate typing for face stubs * add migration formatting * chore: better typing * don't select embedding by default remove unused import * updated sql * use normal try/catch * stricter concurrency typing and enforcement * update api * update job concurrency panel to show disabled queues formatting * check jobId in queueAll fix tests * remove outdated comment * better facial recognition icon * wording wording formatting * fixed tests * fix * formatting & sql * try to fix sql check * more detailed description * update sql * formatting * wording * update `minFaces` description --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-01-18 00:08:48 -05:00
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.FacialRecognition, { command: JobCommand.Start, force: false });
feat(server): separate face clustering job (#5598) * separate facial clustering job * update api * fixed some tests * invert clustering * hdbscan * update api * remove commented code * wip dbscan * cleanup removed cluster endpoint remove commented code * fixes updated tests minor fixes and formatting fixed queuing refinements * scale search range based on library size * defer non-core faces * optimizations removed unused query option * assign faces individually for correctness fixed unit tests remove unused method * don't select face embedding update sql linting fixed ml typing * updated job mock * paginate people query * select face embeddings because typeorm * fix setting face detection concurrency * update sql formatting linting * simplify logic remove unused imports * more specific delete signature * more accurate typing for face stubs * add migration formatting * chore: better typing * don't select embedding by default remove unused import * updated sql * use normal try/catch * stricter concurrency typing and enforcement * update api * update job concurrency panel to show disabled queues formatting * check jobId in queueAll fix tests * remove outdated comment * better facial recognition icon * wording wording formatting * fixed tests * fix * formatting & sql * try to fix sql check * more detailed description * update sql * formatting * wording * update `minFaces` description --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-01-18 00:08:48 -05:00
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } });
});
it('should handle a start backup database command', async () => {
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
2025-07-15 14:50:13 -04:00
await sut.handleCommand(QueueName.BackupDatabase, { command: JobCommand.Start, force: false });
2025-07-15 18:39:00 -04:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } });
});
it('should throw a bad request when an invalid queue is used', async () => {
2025-02-10 18:47:42 -05:00
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await expect(
2025-07-15 14:50:13 -04:00
sut.handleCommand(QueueName.BackgroundTask, { command: JobCommand.Start, force: false }),
).rejects.toBeInstanceOf(BadRequestException);
2025-02-10 18:47:42 -05:00
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
});
describe('onJobStart', () => {
it('should process a successful job', async () => {
2025-07-15 14:50:13 -04:00
mocks.job.run.mockResolvedValue(JobStatus.Success);
2025-07-15 14:50:13 -04:00
await sut.onJobStart(QueueName.BackgroundTask, {
2025-07-15 18:39:00 -04:00
name: JobName.FileDelete,
data: { files: ['path/to/file'] },
});
2025-02-10 18:47:42 -05:00
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1);
expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1);
2025-07-15 18:39:00 -04:00
expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.file_delete.success', 1);
2025-02-10 18:47:42 -05:00
expect(mocks.logger.error).not.toHaveBeenCalled();
});
2023-06-01 16:07:45 -04:00
const tests: Array<{ item: JobItem; jobs: JobName[]; stub?: any }> = [
2023-06-01 16:07:45 -04:00
{
2025-07-15 14:50:13 -04:00
item: { name: JobName.SidecarSync, data: { id: 'asset-1' } },
2025-07-15 18:39:00 -04:00
jobs: [JobName.AssetExtractMetadata],
2023-06-01 16:07:45 -04:00
},
{
2025-07-15 14:50:13 -04:00
item: { name: JobName.SidecarDiscovery, data: { id: 'asset-1' } },
2025-07-15 18:39:00 -04:00
jobs: [JobName.AssetExtractMetadata],
2023-06-01 16:07:45 -04:00
},
{
2025-07-15 14:50:13 -04:00
item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1', source: 'upload' } },
2025-07-15 18:39:00 -04:00
jobs: [JobName.AssetGenerateThumbnails],
2023-06-01 16:07:45 -04:00
},
{
2025-07-15 14:50:13 -04:00
item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1' } },
2023-06-01 16:07:45 -04:00
jobs: [],
},
2023-12-08 11:15:46 -05:00
{
2025-07-15 18:39:00 -04:00
item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } },
2023-12-08 11:15:46 -05:00
jobs: [],
},
2023-06-01 16:07:45 -04:00
{
2025-07-15 18:39:00 -04:00
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
jobs: [],
stub: [assetStub.image],
},
{
2025-07-15 18:39:00 -04:00
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
jobs: [],
stub: [assetStub.video],
2023-06-01 16:07:45 -04:00
},
{
2025-07-15 18:39:00 -04:00
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } },
jobs: [JobName.SmartSearch, JobName.AssetDetectFaces],
stub: [assetStub.livePhotoStillAsset],
},
{
2025-07-15 18:39:00 -04:00
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } },
jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.AssetEncodeVideo],
stub: [assetStub.video],
},
2023-06-01 16:07:45 -04:00
{
2025-07-15 14:50:13 -04:00
item: { name: JobName.SmartSearch, data: { id: 'asset-1' } },
2023-12-08 11:15:46 -05:00
jobs: [],
2023-06-01 16:07:45 -04:00
},
{
2025-07-15 18:39:00 -04:00
item: { name: JobName.AssetDetectFaces, data: { id: 'asset-1' } },
jobs: [],
feat(server): separate face clustering job (#5598) * separate facial clustering job * update api * fixed some tests * invert clustering * hdbscan * update api * remove commented code * wip dbscan * cleanup removed cluster endpoint remove commented code * fixes updated tests minor fixes and formatting fixed queuing refinements * scale search range based on library size * defer non-core faces * optimizations removed unused query option * assign faces individually for correctness fixed unit tests remove unused method * don't select face embedding update sql linting fixed ml typing * updated job mock * paginate people query * select face embeddings because typeorm * fix setting face detection concurrency * update sql formatting linting * simplify logic remove unused imports * more specific delete signature * more accurate typing for face stubs * add migration formatting * chore: better typing * don't select embedding by default remove unused import * updated sql * use normal try/catch * stricter concurrency typing and enforcement * update api * update job concurrency panel to show disabled queues formatting * check jobId in queueAll fix tests * remove outdated comment * better facial recognition icon * wording wording formatting * fixed tests * fix * formatting & sql * try to fix sql check * more detailed description * update sql * formatting * wording * update `minFaces` description --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-01-18 00:08:48 -05:00
},
{
2025-07-15 14:50:13 -04:00
item: { name: JobName.FacialRecognition, data: { id: 'asset-1' } },
2023-12-08 11:15:46 -05:00
jobs: [],
2023-06-01 16:07:45 -04:00
},
];
for (const { item, jobs, stub } of tests) {
2023-06-01 16:07:45 -04:00
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
if (stub) {
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue(stub);
}
2023-06-01 16:07:45 -04:00
2025-07-15 14:50:13 -04:00
mocks.job.run.mockResolvedValue(JobStatus.Success);
2025-07-15 14:50:13 -04:00
await sut.onJobStart(QueueName.BackgroundTask, item);
2023-06-01 16:07:45 -04:00
if (jobs.length > 1) {
2025-02-10 18:47:42 -05:00
expect(mocks.job.queueAll).toHaveBeenCalledWith(
jobs.map((jobName) => ({ name: jobName, data: expect.anything() })),
);
} else {
2025-02-10 18:47:42 -05:00
expect(mocks.job.queue).toHaveBeenCalledTimes(jobs.length);
for (const jobName of jobs) {
2025-02-10 18:47:42 -05:00
expect(mocks.job.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() });
}
2023-06-01 16:07:45 -04:00
}
});
it(`should not queue any jobs when ${item.name} fails`, async () => {
2025-07-15 14:50:13 -04:00
mocks.job.run.mockResolvedValue(JobStatus.Failed);
2025-07-15 14:50:13 -04:00
await sut.onJobStart(QueueName.BackgroundTask, item);
2023-06-01 16:07:45 -04:00
2025-02-10 18:47:42 -05:00
expect(mocks.job.queueAll).not.toHaveBeenCalled();
2023-06-01 16:07:45 -04:00
});
}
});
});