0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-28 00:59:18 -05:00

fix(server): delete large album (#11042)

fix: large album asset operations
This commit is contained in:
Jason Rasmussen 2024-07-17 07:43:35 -04:00 committed by GitHub
parent f0d1dbccf4
commit 66fae76af2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 22 deletions

View file

@ -49,23 +49,26 @@ function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>>
* @param options.paramIndex The index of the function parameter to chunk. Defaults to 0. * @param options.paramIndex The index of the function parameter to chunk. Defaults to 0.
* @param options.flatten Whether to flatten the results. Defaults to false. * @param options.flatten Whether to flatten the results. Defaults to false.
*/ */
export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator { export function Chunked(
options: { paramIndex?: number; chunkSize?: number; mergeFn?: (results: any) => any } = {},
): MethodDecorator {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
const parameterIndex = options.paramIndex ?? 0; const parameterIndex = options.paramIndex ?? 0;
const chunkSize = options.chunkSize || DATABASE_PARAMETER_CHUNK_SIZE;
descriptor.value = async function (...arguments_: any[]) { descriptor.value = async function (...arguments_: any[]) {
const argument = arguments_[parameterIndex]; const argument = arguments_[parameterIndex];
// Early return if argument length is less than or equal to the chunk size. // Early return if argument length is less than or equal to the chunk size.
if ( if (
(Array.isArray(argument) && argument.length <= DATABASE_PARAMETER_CHUNK_SIZE) || (Array.isArray(argument) && argument.length <= chunkSize) ||
(argument instanceof Set && argument.size <= DATABASE_PARAMETER_CHUNK_SIZE) (argument instanceof Set && argument.size <= chunkSize)
) { ) {
return await originalMethod.apply(this, arguments_); return await originalMethod.apply(this, arguments_);
} }
return Promise.all( return Promise.all(
chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => { chunks(argument, chunkSize).map(async (chunk) => {
await Reflect.apply(originalMethod, this, [ await Reflect.apply(originalMethod, this, [
...arguments_.slice(0, parameterIndex), ...arguments_.slice(0, parameterIndex),
chunk, chunk,

View file

@ -30,6 +30,6 @@ export interface IAlbumRepository extends IBulkAsset {
getAll(): Promise<AlbumEntity[]>; getAll(): Promise<AlbumEntity[]>;
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>; create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>; update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
delete(album: AlbumEntity): Promise<void>; delete(id: string): Promise<void>;
updateThumbnails(): Promise<number | undefined>; updateThumbnails(): Promise<number | undefined>;
} }

View file

@ -5,7 +5,16 @@ import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; import {
DataSource,
EntityManager,
FindOptionsOrder,
FindOptionsRelations,
In,
IsNull,
Not,
Repository,
} from 'typeorm';
const withoutDeletedUsers = <T extends AlbumEntity | null>(album: T) => { const withoutDeletedUsers = <T extends AlbumEntity | null>(album: T) => {
if (album) { if (album) {
@ -255,24 +264,46 @@ export class AlbumRepository implements IAlbumRepository {
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> { async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.dataSource await this.addAssets(this.dataSource.manager, albumId, assetIds);
.createQueryBuilder()
.insert()
.into('albums_assets_assets', ['albumsId', 'assetsId'])
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
.execute();
} }
create(album: Partial<AlbumEntity>): Promise<AlbumEntity> { create(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album); return this.dataSource.transaction<AlbumEntity>(async (manager) => {
const { id } = await manager.save(AlbumEntity, { ...album, assets: [] });
const assetIds = (album.assets || []).map((asset) => asset.id);
await this.addAssets(manager, id, assetIds);
return manager.findOneOrFail(AlbumEntity, {
where: { id },
relations: {
owner: true,
albumUsers: { user: true },
sharedLinks: true,
assets: true,
},
});
});
} }
update(album: Partial<AlbumEntity>): Promise<AlbumEntity> { update(album: Partial<AlbumEntity>): Promise<AlbumEntity> {
return this.save(album); return this.save(album);
} }
async delete(album: AlbumEntity): Promise<void> { async delete(id: string): Promise<void> {
await this.repository.remove(album); await this.repository.delete({ id });
}
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
private async addAssets(manager: EntityManager, albumId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await manager
.createQueryBuilder()
.insert()
.into('albums_assets_assets', ['albumsId', 'assetsId'])
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
.execute();
} }
private async save(album: Partial<AlbumEntity>) { private async save(album: Partial<AlbumEntity>) {

View file

@ -302,8 +302,7 @@ describe(AlbumService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should throw an error for an album not found', async () => { it('should throw an error for an album not found', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set());
albumMock.getById.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -329,7 +328,7 @@ describe(AlbumService.name, () => {
await sut.delete(authStub.admin, albumStub.empty.id); await sut.delete(authStub.admin, albumStub.empty.id);
expect(albumMock.delete).toHaveBeenCalledTimes(1); expect(albumMock.delete).toHaveBeenCalledTimes(1);
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty); expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty.id);
}); });
}); });

View file

@ -165,10 +165,7 @@ export class AlbumService {
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.ALBUM_DELETE, id); await this.access.requirePermission(auth, Permission.ALBUM_DELETE, id);
await this.albumRepository.delete(id);
const album = await this.findOrFail(id, { withAssets: false });
await this.albumRepository.delete(album);
} }
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {