mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:35:00 -04:00 
			
		
		
		
	fix(server): improve library scan queuing performance (#4418)
* fix: inline mark asset as offline * fix: improve log message * chore: lint * fix: offline asset algorithm * fix: use set comparison to check what to import * fix: only mark new offline files as offline * fix: compare the correct array * fix: set default library concurrency to 5 * fix: remove one db call when scanning new files * chore: remove unused import --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		
							parent
							
								
									99e9c2ada6
								
							
						
					
					
						commit
						56eb7bf0fc
					
				| @ -69,7 +69,6 @@ export enum JobName { | |||||||
|   LIBRARY_SCAN = 'library-refresh', |   LIBRARY_SCAN = 'library-refresh', | ||||||
|   LIBRARY_SCAN_ASSET = 'library-refresh-asset', |   LIBRARY_SCAN_ASSET = 'library-refresh-asset', | ||||||
|   LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', |   LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', | ||||||
|   LIBRARY_MARK_ASSET_OFFLINE = 'library-mark-asset-offline', |  | ||||||
|   LIBRARY_DELETE = 'library-delete', |   LIBRARY_DELETE = 'library-delete', | ||||||
|   LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', |   LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', | ||||||
|   LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', |   LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', | ||||||
| @ -172,7 +171,6 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = { | |||||||
| 
 | 
 | ||||||
|   // Library managment
 |   // Library managment
 | ||||||
|   [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, |   [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, | ||||||
|   [JobName.LIBRARY_MARK_ASSET_OFFLINE]: QueueName.LIBRARY, |  | ||||||
|   [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, |   [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, | ||||||
|   [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, |   [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, | ||||||
|   [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, |   [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, | ||||||
|  | |||||||
| @ -16,10 +16,6 @@ export interface IAssetDeletionJob extends IEntityJob { | |||||||
|   fromExternal?: boolean; |   fromExternal?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface IOfflineLibraryFileJob extends IEntityJob { |  | ||||||
|   assetPath: string; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export interface ILibraryFileJob extends IEntityJob { | export interface ILibraryFileJob extends IEntityJob { | ||||||
|   ownerId: string; |   ownerId: string; | ||||||
|   assetPath: string; |   assetPath: string; | ||||||
|  | |||||||
| @ -16,7 +16,7 @@ import { | |||||||
|   userStub, |   userStub, | ||||||
| } from '@test'; | } from '@test'; | ||||||
| import { Stats } from 'fs'; | import { Stats } from 'fs'; | ||||||
| import { ILibraryFileJob, ILibraryRefreshJob, IOfflineLibraryFileJob, JobName } from '../job'; | import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job'; | ||||||
| import { | import { | ||||||
|   IAssetRepository, |   IAssetRepository, | ||||||
|   ICryptoRepository, |   ICryptoRepository, | ||||||
| @ -126,14 +126,11 @@ describe(LibraryService.name, () => { | |||||||
| 
 | 
 | ||||||
|       await sut.handleQueueAssetRefresh(mockLibraryJob); |       await sut.handleQueueAssetRefresh(mockLibraryJob); | ||||||
| 
 | 
 | ||||||
|       expect(jobMock.queue.mock.calls).toEqual([ |       expect(assetMock.updateAll.mock.calls).toEqual([ | ||||||
|         [ |         [ | ||||||
|  |           [assetStub.external.id], | ||||||
|           { |           { | ||||||
|             name: JobName.LIBRARY_MARK_ASSET_OFFLINE, |             isOffline: true, | ||||||
|             data: { |  | ||||||
|               id: libraryStub.externalLibrary1.id, |  | ||||||
|               assetPath: '/data/user1/photo.jpg', |  | ||||||
|             }, |  | ||||||
|           }, |           }, | ||||||
|         ], |         ], | ||||||
|       ]); |       ]); | ||||||
| @ -150,7 +147,7 @@ describe(LibraryService.name, () => { | |||||||
| 
 | 
 | ||||||
|       userMock.get.mockResolvedValue(userStub.user1); |       userMock.get.mockResolvedValue(userStub.user1); | ||||||
| 
 | 
 | ||||||
|       expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); |       await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should not scan upload libraries', async () => { |     it('should not scan upload libraries', async () => { | ||||||
| @ -162,7 +159,7 @@ describe(LibraryService.name, () => { | |||||||
| 
 | 
 | ||||||
|       libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); |       libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); | ||||||
| 
 | 
 | ||||||
|       expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); |       await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
| @ -545,24 +542,6 @@ describe(LibraryService.name, () => { | |||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('handleOfflineAsset', () => { |  | ||||||
|     it('should mark an asset as offline', async () => { |  | ||||||
|       const offlineJob: IOfflineLibraryFileJob = { |  | ||||||
|         id: libraryStub.externalLibrary1.id, |  | ||||||
|         assetPath: '/data/user1/photo.jpg', |  | ||||||
|       }; |  | ||||||
| 
 |  | ||||||
|       assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); |  | ||||||
| 
 |  | ||||||
|       await expect(sut.handleOfflineAsset(offlineJob)).resolves.toBe(true); |  | ||||||
| 
 |  | ||||||
|       expect(assetMock.save).toHaveBeenCalledWith({ |  | ||||||
|         id: assetStub.image.id, |  | ||||||
|         isOffline: true, |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| 
 |  | ||||||
|   describe('delete', () => { |   describe('delete', () => { | ||||||
|     it('should delete a library', async () => { |     it('should delete a library', async () => { | ||||||
|       assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); |       assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); | ||||||
|  | |||||||
| @ -8,15 +8,8 @@ import { AccessCore, Permission } from '../access'; | |||||||
| import { AuthUserDto } from '../auth'; | import { AuthUserDto } from '../auth'; | ||||||
| import { mimeTypes } from '../domain.constant'; | import { mimeTypes } from '../domain.constant'; | ||||||
| import { usePagination } from '../domain.util'; | import { usePagination } from '../domain.util'; | ||||||
| import { | import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; | ||||||
|   IBaseJob, | 
 | ||||||
|   IEntityJob, |  | ||||||
|   ILibraryFileJob, |  | ||||||
|   ILibraryRefreshJob, |  | ||||||
|   IOfflineLibraryFileJob, |  | ||||||
|   JOBS_ASSET_PAGINATION_SIZE, |  | ||||||
|   JobName, |  | ||||||
| } from '../job'; |  | ||||||
| import { | import { | ||||||
|   IAccessRepository, |   IAccessRepository, | ||||||
|   IAssetRepository, |   IAssetRepository, | ||||||
| @ -371,28 +364,26 @@ export class LibraryService { | |||||||
| 
 | 
 | ||||||
|     this.logger.debug(`Found ${crawledAssetPaths.length} assets when crawling import paths ${library.importPaths}`); |     this.logger.debug(`Found ${crawledAssetPaths.length} assets when crawling import paths ${library.importPaths}`); | ||||||
|     const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]); |     const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]); | ||||||
|     const offlineAssets = assetsInLibrary.filter((asset) => !crawledAssetPaths.includes(asset.originalPath)); |     const onlineFiles = new Set(crawledAssetPaths); | ||||||
|     this.logger.debug(`${offlineAssets.length} assets in library are not present on disk and will be marked offline`); |     const offlineAssetIds = assetsInLibrary | ||||||
|  |       .filter((asset) => !onlineFiles.has(asset.originalPath)) | ||||||
|  |       .filter((asset) => !asset.isOffline) | ||||||
|  |       .map((asset) => asset.id); | ||||||
|  |     this.logger.debug(`Marking ${offlineAssetIds.length} assets as offline`); | ||||||
| 
 | 
 | ||||||
|     for (const offlineAsset of offlineAssets) { |     await this.assetRepository.updateAll(offlineAssetIds, { isOffline: true }); | ||||||
|       const offlineJobData: IOfflineLibraryFileJob = { |  | ||||||
|         id: job.id, |  | ||||||
|         assetPath: offlineAsset.originalPath, |  | ||||||
|       }; |  | ||||||
| 
 |  | ||||||
|       await this.jobRepository.queue({ name: JobName.LIBRARY_MARK_ASSET_OFFLINE, data: offlineJobData }); |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     if (crawledAssetPaths.length > 0) { |     if (crawledAssetPaths.length > 0) { | ||||||
|       let filteredPaths: string[] = []; |       let filteredPaths: string[] = []; | ||||||
|       if (job.refreshAllFiles || job.refreshModifiedFiles) { |       if (job.refreshAllFiles || job.refreshModifiedFiles) { | ||||||
|         filteredPaths = crawledAssetPaths; |         filteredPaths = crawledAssetPaths; | ||||||
|       } else { |       } else { | ||||||
|         const existingPaths = await this.repository.getOnlineAssetPaths(job.id); |         const onlinePathsInLibrary = new Set( | ||||||
|         this.logger.debug(`Found ${existingPaths.length} existing asset(s) in library ${job.id}`); |           assetsInLibrary.filter((asset) => !asset.isOffline).map((asset) => asset.originalPath), | ||||||
|  |         ); | ||||||
|  |         filteredPaths = crawledAssetPaths.filter((assetPath) => !onlinePathsInLibrary.has(assetPath)); | ||||||
| 
 | 
 | ||||||
|         filteredPaths = crawledAssetPaths.filter((assetPath) => !existingPaths.includes(assetPath)); |         this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`); | ||||||
|         this.logger.debug(`After db comparison, ${filteredPaths.length} asset(s) remain to be imported`); |  | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       for (const assetPath of filteredPaths) { |       for (const assetPath of filteredPaths) { | ||||||
| @ -412,17 +403,6 @@ export class LibraryService { | |||||||
|     return true; |     return true; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async handleOfflineAsset(job: IOfflineLibraryFileJob): Promise<boolean> { |  | ||||||
|     const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, job.assetPath); |  | ||||||
| 
 |  | ||||||
|     if (existingAssetEntity) { |  | ||||||
|       this.logger.verbose(`Marking asset as offline: ${job.assetPath}`); |  | ||||||
|       await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return true; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private async findOrFail(id: string) { |   private async findOrFail(id: string) { | ||||||
|     const library = await this.repository.get(id); |     const library = await this.repository.get(id); | ||||||
|     if (!library) { |     if (!library) { | ||||||
|  | |||||||
| @ -9,7 +9,6 @@ import { | |||||||
|   IEntityJob, |   IEntityJob, | ||||||
|   ILibraryFileJob, |   ILibraryFileJob, | ||||||
|   ILibraryRefreshJob, |   ILibraryRefreshJob, | ||||||
|   IOfflineLibraryFileJob, |  | ||||||
| } from '../job/job.interface'; | } from '../job/job.interface'; | ||||||
| 
 | 
 | ||||||
| export interface JobCounts { | export interface JobCounts { | ||||||
| @ -88,7 +87,6 @@ export type JobItem = | |||||||
| 
 | 
 | ||||||
|   // Library Managment
 |   // Library Managment
 | ||||||
|   | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } |   | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } | ||||||
|   | { name: JobName.LIBRARY_MARK_ASSET_OFFLINE; data: IOfflineLibraryFileJob } |  | ||||||
|   | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } |   | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } | ||||||
|   | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } |   | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } | ||||||
|   | { name: JobName.LIBRARY_DELETE; data: IEntityJob } |   | { name: JobName.LIBRARY_DELETE; data: IEntityJob } | ||||||
|  | |||||||
| @ -52,7 +52,7 @@ export const defaults = Object.freeze<SystemConfig>({ | |||||||
|     [QueueName.RECOGNIZE_FACES]: { concurrency: 2 }, |     [QueueName.RECOGNIZE_FACES]: { concurrency: 2 }, | ||||||
|     [QueueName.SEARCH]: { concurrency: 5 }, |     [QueueName.SEARCH]: { concurrency: 5 }, | ||||||
|     [QueueName.SIDECAR]: { concurrency: 5 }, |     [QueueName.SIDECAR]: { concurrency: 5 }, | ||||||
|     [QueueName.LIBRARY]: { concurrency: 1 }, |     [QueueName.LIBRARY]: { concurrency: 5 }, | ||||||
|     [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, |     [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, | ||||||
|     [QueueName.MIGRATION]: { concurrency: 5 }, |     [QueueName.MIGRATION]: { concurrency: 5 }, | ||||||
|     [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, |     [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, | ||||||
|  | |||||||
| @ -33,7 +33,7 @@ const updatedConfig = Object.freeze<SystemConfig>({ | |||||||
|     [QueueName.RECOGNIZE_FACES]: { concurrency: 2 }, |     [QueueName.RECOGNIZE_FACES]: { concurrency: 2 }, | ||||||
|     [QueueName.SEARCH]: { concurrency: 5 }, |     [QueueName.SEARCH]: { concurrency: 5 }, | ||||||
|     [QueueName.SIDECAR]: { concurrency: 5 }, |     [QueueName.SIDECAR]: { concurrency: 5 }, | ||||||
|     [QueueName.LIBRARY]: { concurrency: 1 }, |     [QueueName.LIBRARY]: { concurrency: 5 }, | ||||||
|     [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, |     [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, | ||||||
|     [QueueName.MIGRATION]: { concurrency: 5 }, |     [QueueName.MIGRATION]: { concurrency: 5 }, | ||||||
|     [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, |     [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, | ||||||
|  | |||||||
| @ -182,8 +182,6 @@ const tests: Test[] = [ | |||||||
| describe(FilesystemProvider.name, () => { | describe(FilesystemProvider.name, () => { | ||||||
|   const sut = new FilesystemProvider(); |   const sut = new FilesystemProvider(); | ||||||
| 
 | 
 | ||||||
|   console.log(process.cwd()); |  | ||||||
| 
 |  | ||||||
|   afterEach(() => { |   afterEach(() => { | ||||||
|     mockfs.restore(); |     mockfs.restore(); | ||||||
|   }); |   }); | ||||||
|  | |||||||
| @ -83,7 +83,6 @@ export class AppService { | |||||||
|       [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), |       [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), | ||||||
|       [JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(), |       [JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(), | ||||||
|       [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), |       [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), | ||||||
|       [JobName.LIBRARY_MARK_ASSET_OFFLINE]: (data) => this.libraryService.handleOfflineAsset(data), |  | ||||||
|       [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), |       [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), | ||||||
|       [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), |       [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), | ||||||
|       [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), |       [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), | ||||||
|  | |||||||
| @ -41,7 +41,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | |||||||
| 
 | 
 | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     await db.reset(); |     await db.reset(); | ||||||
|     restoreTempFolder(); |     await restoreTempFolder(); | ||||||
|     await api.authApi.adminSignUp(server); |     await api.authApi.adminSignUp(server); | ||||||
|     admin = await api.authApi.adminLogin(server); |     admin = await api.authApi.adminLogin(server); | ||||||
|   }); |   }); | ||||||
| @ -49,7 +49,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | |||||||
|   afterAll(async () => { |   afterAll(async () => { | ||||||
|     await db.disconnect(); |     await db.disconnect(); | ||||||
|     await app.close(); |     await app.close(); | ||||||
|     restoreTempFolder(); |     await restoreTempFolder(); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   describe('GET /library', () => { |   describe('GET /library', () => { | ||||||
| @ -407,7 +407,7 @@ describe(`${LibraryController.name} (e2e)`, () => { | |||||||
|       ); |       ); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should delete an extnernal library with assets', async () => { |     it('should delete an external library with assets', async () => { | ||||||
|       const library = await api.libraryApi.create(server, admin.accessToken, { |       const library = await api.libraryApi.create(server, admin.accessToken, { | ||||||
|         type: LibraryType.EXTERNAL, |         type: LibraryType.EXTERNAL, | ||||||
|         importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], |         importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user