mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:29:32 -05:00 
			
		
		
		
	fix(server): delete large album (#11042)
fix: large album asset operations
This commit is contained in:
		
							parent
							
								
									f0d1dbccf4
								
							
						
					
					
						commit
						66fae76af2
					
				@ -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,
 | 
				
			||||||
 | 
				
			|||||||
@ -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>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -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>) {
 | 
				
			||||||
 | 
				
			|||||||
@ -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);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -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[]> {
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user