fix(server): proper asset sync (#10019)

* fix(server,mobile): proper asset sync

* fix CI issues

* only use id instead of createdAt+id

* remove createdAt index

* fix typo

* cleanup createdAt usage

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Fynn Petersen-Frey 2024-06-09 21:19:28 +02:00 committed by GitHub
parent 69795a3763
commit 972c66d467
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 38 additions and 121 deletions

View File

@ -101,7 +101,6 @@ class AssetService {
const int chunkSize = 10000; const int chunkSize = 10000;
try { try {
final List<Asset> allAssets = []; final List<Asset> allAssets = [];
DateTime? lastCreationDate;
String? lastId; String? lastId;
// will break on error or once all assets are loaded // will break on error or once all assets are loaded
while (true) { while (true) {
@ -109,15 +108,17 @@ class AssetService {
limit: chunkSize, limit: chunkSize,
updatedUntil: until, updatedUntil: until,
lastId: lastId, lastId: lastId,
lastCreationDate: lastCreationDate,
userId: user.id, userId: user.id,
); );
log.fine("Requesting $chunkSize assets from $lastId");
final List<AssetResponseDto>? assets = final List<AssetResponseDto>? assets =
await _apiService.syncApi.getFullSyncForUser(dto); await _apiService.syncApi.getFullSyncForUser(dto);
if (assets == null) return null; if (assets == null) return null;
log.fine(
"Received ${assets.length} assets from ${assets.firstOrNull?.id} to ${assets.lastOrNull?.id}",
);
allAssets.addAll(assets.map(Asset.remote)); allAssets.addAll(assets.map(Asset.remote));
if (assets.isEmpty) break; if (assets.length != chunkSize) break;
lastCreationDate = assets.last.fileCreatedAt;
lastId = assets.last.id; lastId = assets.last.id;
} }
return allAssets; return allAssets;

View File

@ -13,21 +13,12 @@ part of openapi.api;
class AssetFullSyncDto { class AssetFullSyncDto {
/// Returns a new [AssetFullSyncDto] instance. /// Returns a new [AssetFullSyncDto] instance.
AssetFullSyncDto({ AssetFullSyncDto({
this.lastCreationDate,
this.lastId, this.lastId,
required this.limit, required this.limit,
required this.updatedUntil, required this.updatedUntil,
this.userId, this.userId,
}); });
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? lastCreationDate;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
@ -51,7 +42,6 @@ class AssetFullSyncDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetFullSyncDto && bool operator ==(Object other) => identical(this, other) || other is AssetFullSyncDto &&
other.lastCreationDate == lastCreationDate &&
other.lastId == lastId && other.lastId == lastId &&
other.limit == limit && other.limit == limit &&
other.updatedUntil == updatedUntil && other.updatedUntil == updatedUntil &&
@ -60,22 +50,16 @@ class AssetFullSyncDto {
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(lastCreationDate == null ? 0 : lastCreationDate!.hashCode) +
(lastId == null ? 0 : lastId!.hashCode) + (lastId == null ? 0 : lastId!.hashCode) +
(limit.hashCode) + (limit.hashCode) +
(updatedUntil.hashCode) + (updatedUntil.hashCode) +
(userId == null ? 0 : userId!.hashCode); (userId == null ? 0 : userId!.hashCode);
@override @override
String toString() => 'AssetFullSyncDto[lastCreationDate=$lastCreationDate, lastId=$lastId, limit=$limit, updatedUntil=$updatedUntil, userId=$userId]'; String toString() => 'AssetFullSyncDto[lastId=$lastId, limit=$limit, updatedUntil=$updatedUntil, userId=$userId]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (this.lastCreationDate != null) {
json[r'lastCreationDate'] = this.lastCreationDate!.toUtc().toIso8601String();
} else {
// json[r'lastCreationDate'] = null;
}
if (this.lastId != null) { if (this.lastId != null) {
json[r'lastId'] = this.lastId; json[r'lastId'] = this.lastId;
} else { } else {
@ -99,7 +83,6 @@ class AssetFullSyncDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetFullSyncDto( return AssetFullSyncDto(
lastCreationDate: mapDateTime(json, r'lastCreationDate', r''),
lastId: mapValueOfType<String>(json, r'lastId'), lastId: mapValueOfType<String>(json, r'lastId'),
limit: mapValueOfType<int>(json, r'limit')!, limit: mapValueOfType<int>(json, r'limit')!,
updatedUntil: mapDateTime(json, r'updatedUntil', r'')!, updatedUntil: mapDateTime(json, r'updatedUntil', r'')!,

View File

@ -7432,10 +7432,6 @@
}, },
"AssetFullSyncDto": { "AssetFullSyncDto": {
"properties": { "properties": {
"lastCreationDate": {
"format": "date-time",
"type": "string"
},
"lastId": { "lastId": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"

View File

@ -904,7 +904,6 @@ export type AssetDeltaSyncResponseDto = {
upserted: AssetResponseDto[]; upserted: AssetResponseDto[];
}; };
export type AssetFullSyncDto = { export type AssetFullSyncDto = {
lastCreationDate?: string;
lastId?: string; lastId?: string;
limit: number; limit: number;
updatedUntil: string; updatedUntil: string;

View File

@ -127,10 +127,10 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, stackParentId: withStack ? entity.stack?.primaryAssetId : undefined,
stack: withStack stack: withStack
? entity.stack?.assets ? entity.stack?.assets
.filter((a) => a.id !== entity.stack?.primaryAssetId) ?.filter((a) => a.id !== entity.stack?.primaryAssetId)
.map((a) => mapAsset(a, { stripMetadata, auth: options.auth })) ?.map((a) => mapAsset(a, { stripMetadata, auth: options.auth }))
: undefined, : undefined,
stackCount: entity.stack?.assets?.length ?? null, stackCount: entity.stack?.assetCount ?? entity.stack?.assets?.length ?? null,
isOffline: entity.isOffline, isOffline: entity.isOffline,
hasMetadata: true, hasMetadata: true,
duplicateId: entity.duplicateId, duplicateId: entity.duplicateId,

View File

@ -7,9 +7,6 @@ export class AssetFullSyncDto {
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true })
lastId?: string; lastId?: string;
@ValidateDate({ optional: true })
lastCreationDate?: Date;
@ValidateDate() @ValidateDate()
updatedUntil!: Date; updatedUntil!: Date;

View File

@ -16,4 +16,6 @@ export class AssetStackEntity {
@Column({ nullable: false }) @Column({ nullable: false })
primaryAssetId!: string; primaryAssetId!: string;
assetCount?: number;
} }

View File

@ -122,7 +122,6 @@ export interface AssetExploreOptions extends AssetExploreFieldOptions {
export interface AssetFullSyncOptions { export interface AssetFullSyncOptions {
ownerId: string; ownerId: string;
lastCreationDate?: Date;
lastId?: string; lastId?: string;
updatedUntil: Date; updatedUntil: Date;
limit: number; limit: number;

View File

@ -1049,50 +1049,18 @@ SELECT
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."primaryAssetId" AS "stack_primaryAssetId", "stack"."primaryAssetId" AS "stack_primaryAssetId"
"stackedAssets"."id" AS "stackedAssets_id",
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."previewPath" AS "stackedAssets_previewPath",
"stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
"stackedAssets"."checksum" AS "stackedAssets_checksum",
"stackedAssets"."duration" AS "stackedAssets_duration",
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
"stackedAssets"."stackId" AS "stackedAssets_stackId",
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
FROM FROM
"assets" "asset" "assets" "asset"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
AND ("stackedAssets"."deletedAt" IS NULL)
WHERE WHERE
"asset"."isVisible" = true "asset"."isVisible" = true
AND "asset"."ownerId" IN ($1) AND "asset"."ownerId" IN ($1)
AND ("asset"."fileCreatedAt", "asset"."id") < ($2, $3) AND "asset"."id" > $2
AND "asset"."updatedAt" <= $4 AND "asset"."updatedAt" <= $3
ORDER BY ORDER BY
"asset"."fileCreatedAt" DESC, "asset"."id" ASC
"asset"."id" DESC
LIMIT LIMIT
10 10
@ -1156,42 +1124,11 @@ SELECT
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."fps" AS "exifInfo_fps", "exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id", "stack"."id" AS "stack_id",
"stack"."primaryAssetId" AS "stack_primaryAssetId", "stack"."primaryAssetId" AS "stack_primaryAssetId"
"stackedAssets"."id" AS "stackedAssets_id",
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."previewPath" AS "stackedAssets_previewPath",
"stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
"stackedAssets"."checksum" AS "stackedAssets_checksum",
"stackedAssets"."duration" AS "stackedAssets_duration",
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
"stackedAssets"."stackId" AS "stackedAssets_stackId",
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
FROM FROM
"assets" "asset" "assets" "asset"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
AND ("stackedAssets"."deletedAt" IS NULL)
WHERE WHERE
"asset"."isVisible" = true "asset"."isVisible" = true
AND "asset"."ownerId" IN ($1) AND "asset"."ownerId" IN ($1)

View File

@ -763,36 +763,40 @@ export class AssetRepository implements IAssetRepository {
], ],
}) })
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]> { getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]> {
const { ownerId, lastCreationDate, lastId, updatedUntil, limit } = options; const { ownerId, lastId, updatedUntil, limit } = options;
const builder = this.getBuilder({ const builder = this.getBuilder({
userIds: [ownerId], userIds: [ownerId],
exifInfo: true, // also joins stack information exifInfo: false, // need to do this manually because `exifInfo: true` also loads stacked assets messing with `limit`
withStacked: false, // return all assets individually as expected by the app withStacked: false, // return all assets individually as expected by the app
}); })
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.leftJoinAndSelect('asset.stack', 'stack')
.loadRelationCountAndMap('stack.assetCount', 'stack.assets', 'stackedAssetsCount');
if (lastCreationDate !== undefined && lastId !== undefined) { if (lastId !== undefined) {
builder.andWhere('(asset.fileCreatedAt, asset.id) < (:lastCreationDate, :lastId)', { builder.andWhere('asset.id > :lastId', { lastId });
lastCreationDate,
lastId,
});
} }
builder
return builder
.andWhere('asset.updatedAt <= :updatedUntil', { updatedUntil }) .andWhere('asset.updatedAt <= :updatedUntil', { updatedUntil })
.orderBy('asset.fileCreatedAt', 'DESC') .orderBy('asset.id', 'ASC')
.addOrderBy('asset.id', 'DESC') .limit(limit) // cannot use `take` for performance reasons
.limit(limit) .withDeleted();
.withDeleted() return builder.getMany();
.getMany();
} }
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE }] }) @GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE }] })
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]> { getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]> {
const builder = this.getBuilder({ userIds: options.userIds, exifInfo: true, withStacked: false }) const builder = this.getBuilder({
userIds: options.userIds,
exifInfo: false, // need to do this manually because `exifInfo: true` also loads stacked assets messing with `limit`
withStacked: false, // return all assets individually as expected by the app
})
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.leftJoinAndSelect('asset.stack', 'stack')
.loadRelationCountAndMap('stack.assetCount', 'stack.assets', 'stackedAssetsCount')
.andWhere({ updatedAt: MoreThan(options.updatedAfter) }) .andWhere({ updatedAt: MoreThan(options.updatedAfter) })
.limit(options.limit) .limit(options.limit) // cannot use `take` for performance reasons
.withDeleted(); .withDeleted();
return builder.getMany(); return builder.getMany();
} }
} }

View File

@ -32,7 +32,6 @@ export class SyncService {
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const assets = await this.assetRepository.getAllForUserFullSync({ const assets = await this.assetRepository.getAllForUserFullSync({
ownerId: userId, ownerId: userId,
lastCreationDate: dto.lastCreationDate,
updatedUntil: dto.updatedUntil, updatedUntil: dto.updatedUntil,
lastId: dto.lastId, lastId: dto.lastId,
limit: dto.limit, limit: dto.limit,