mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -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