feat(server): de-duplication (#557)

* feat(server): remove un-used deviceAssetId cols.

* feat(server): return 409 if asset is duplicated

* feat(server): replace old unique constaint

* feat(server): strip deviceId in file path

* feat(server): skip duplicate asset

* chore(server): revert changes

* fix(server): asset test spec

* fix(server): checksum generation for uploaded assets

* fix(server): make sure generation queue run after migraion

* feat(server): remove temp file

* chore(server): remove dead code
This commit is contained in:
Thanh Pham 2022-09-06 02:45:38 +07:00 committed by GitHub
parent 2677ddccaa
commit a467936e73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 64 additions and 18 deletions

View File

@ -26,6 +26,7 @@ export interface IAssetRepository {
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>; getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>; getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>; getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
} }
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY'; export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@ -208,4 +209,20 @@ export class AssetRepository implements IAssetRepository {
return res; return res;
} }
/**
* Get asset by checksum on the database
* @param userId
* @param checksum
*
*/
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity> {
return this.assetRepository.findOneOrFail({
where: {
userId,
checksum
},
relations: ['exifInfo'],
});
}
} }

View File

@ -60,6 +60,7 @@ describe('AssetService', () => {
getLocationsByUserId: jest.fn(), getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(), getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(), getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
}; };
sui = new AssetService(assetRepositoryMock, a); sui = new AssetService(assetRepositoryMock, a);

View File

@ -10,7 +10,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { Repository } from 'typeorm'; import { QueryFailedError, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { constants, createReadStream, ReadStream, stat } from 'fs'; import { constants, createReadStream, ReadStream, stat } from 'fs';
@ -55,6 +55,8 @@ export class AssetService {
mimeType: string, mimeType: string,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
const checksum = await this.calculateChecksum(originalPath); const checksum = await this.calculateChecksum(originalPath);
try {
const assetEntity = await this._assetRepository.create( const assetEntity = await this._assetRepository.create(
createAssetDto, createAssetDto,
authUser.id, authUser.id,
@ -64,6 +66,18 @@ export class AssetService {
); );
return assetEntity; return assetEntity;
} catch (err) {
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
const [assetEntity, _] = await Promise.all([
this._assetRepository.getAssetByChecksum(authUser.id, checksum),
fs.unlink(originalPath)
]);
return assetEntity;
}
throw err;
}
} }
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) { public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {

View File

@ -3,7 +3,7 @@ import { HttpException, HttpStatus } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname, join } from 'path';
import { Request } from 'express'; import { Request } from 'express';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
@ -29,7 +29,7 @@ export const assetUploadOption: MulterOptions = {
return; return;
} }
const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`; const originalUploadFolder = join(basePath, req.user.id, 'original', req.body['deviceId']);
if (!existsSync(originalUploadFolder)) { if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true }); mkdirSync(originalUploadFolder, { recursive: true });

View File

@ -12,6 +12,8 @@ export class MicroservicesService implements OnModuleInit {
) {} ) {}
async onModuleInit() { async onModuleInit() {
await this.generateChecksumQueue.add({}, { jobId: randomUUID() },); await this.generateChecksumQueue.add({}, {
jobId: randomUUID(), delay: 10000 // wait for migration
});
} }
} }

View File

@ -19,14 +19,12 @@ export class GenerateChecksumProcessor {
async generateChecksum() { async generateChecksum() {
let hasNext = true; let hasNext = true;
let pageSize = 200; let pageSize = 200;
let offset = 0;
while (hasNext) { while (hasNext) {
const assets = await this.assetRepository.find({ const assets = await this.assetRepository.find({
where: { where: {
checksum: IsNull() checksum: IsNull()
}, },
skip: offset,
take: pageSize, take: pageSize,
}); });
@ -43,8 +41,6 @@ export class GenerateChecksumProcessor {
if (assets.length < pageSize) { if (assets.length < pageSize) {
hasNext = false; hasNext = false;
} else {
offset += pageSize;
} }
} }
} }

View File

@ -3,7 +3,7 @@ import { ExifEntity } from './exif.entity';
import { SmartInfoEntity } from './smart-info.entity'; import { SmartInfoEntity } from './smart-info.entity';
@Entity('assets') @Entity('assets')
@Unique(['deviceAssetId', 'userId', 'deviceId']) @Unique('UQ_userid_checksum', ['userId', 'checksum'])
export class AssetEntity { export class AssetEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;

View File

@ -1,4 +1,4 @@
import { MigrationInterface, QueryRunner } from "typeorm"; import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAssetChecksum1661881837496 implements MigrationInterface { export class AddAssetChecksum1661881837496 implements MigrationInterface {
name = 'AddAssetChecksum1661881837496' name = 'AddAssetChecksum1661881837496'

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements MigrationInterface {
name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e"`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_userid_checksum" UNIQUE ("userId", "checksum")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_userid_checksum"`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e" UNIQUE ("deviceAssetId", "userId", "deviceId")`);
}
}