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:
parent
f0d1dbccf4
commit
66fae76af2
5 changed files with 52 additions and 22 deletions
|
@ -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…
Add table
Reference in a new issue