perf(server): stop aggregating every album asset when loading a shared link (#32412)
SharedLinkRepository.get (shared link page load, /shared-links/me, and the OpenGraph tags rendered for /share/:key) json_agg'd every asset of the album, with its full exif row, ordered by fileCreatedAt. The only things used from it are the asset count and the first/last localDateTime. Load the album without assets and fill assetCount/startDate/endDate from getMetadataForIds, which is what GET /albums/:id already uses. The counts now also respect the default visibility filter, and the dates are formatted the same as on /albums/:id. 150k-asset album link, PG 14: | | before | after | |------------------------|-----------------|--------| | query | 4.2-6.5 s | ~0.26 s (metadata aggregate) | | JSON built by postgres | 261 MB | - | | GET /shared-links/me | - | 0.28 s, 961 B | (cherry picked from commit ac453b5557e42ad45962d3079a4e4450671053e9)
This commit is contained in:
@@ -97,75 +97,12 @@ from
|
||||
"album_user"."role" = 'owner'
|
||||
and "album_user"."albumId" = "album"."id"
|
||||
) as agg
|
||||
) as "albumUsers",
|
||||
coalesce(
|
||||
json_agg(
|
||||
"assets"
|
||||
order by
|
||||
"assets"."fileCreatedAt" asc
|
||||
) filter (
|
||||
where
|
||||
"assets"."id" is not null
|
||||
),
|
||||
'[]'
|
||||
) as "assets"
|
||||
) as "albumUsers"
|
||||
from
|
||||
"album"
|
||||
left join "album_asset" on "album_asset"."albumId" = "album"."id"
|
||||
left join lateral (
|
||||
select
|
||||
"asset".*,
|
||||
to_json("exifInfo") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
inner join lateral (
|
||||
select
|
||||
"asset_exif"."assetId",
|
||||
"asset_exif"."autoStackId",
|
||||
"asset_exif"."bitsPerSample",
|
||||
"asset_exif"."city",
|
||||
"asset_exif"."colorspace",
|
||||
"asset_exif"."country",
|
||||
"asset_exif"."dateTimeOriginal",
|
||||
"asset_exif"."description",
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."exposureTime",
|
||||
"asset_exif"."fileSizeInByte",
|
||||
"asset_exif"."fNumber",
|
||||
"asset_exif"."focalLength",
|
||||
"asset_exif"."fps",
|
||||
"asset_exif"."iso",
|
||||
"asset_exif"."latitude",
|
||||
"asset_exif"."lensModel",
|
||||
"asset_exif"."livePhotoCID",
|
||||
"asset_exif"."longitude",
|
||||
"asset_exif"."make",
|
||||
"asset_exif"."model",
|
||||
"asset_exif"."modifyDate",
|
||||
"asset_exif"."orientation",
|
||||
"asset_exif"."profileDescription",
|
||||
"asset_exif"."projectionType",
|
||||
"asset_exif"."rating",
|
||||
"asset_exif"."state",
|
||||
"asset_exif"."tags",
|
||||
"asset_exif"."timeZone"
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
"asset_exif"."assetId" = "asset"."id"
|
||||
) as "exifInfo" on true
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
) as "assets" on true
|
||||
where
|
||||
"album"."id" = "shared_link"."albumId"
|
||||
and "album"."deletedAt" is null
|
||||
group by
|
||||
"album"."id"
|
||||
) as "album" on true
|
||||
where
|
||||
"shared_link"."id" = $1
|
||||
|
||||
@@ -82,37 +82,7 @@ export class SharedLinkRepository {
|
||||
).as('assets'),
|
||||
)
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
withSharedLinkAlbum(eb)
|
||||
.leftJoin('album_asset', 'album_asset.albumId', 'album.id')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
|
||||
.select((eb) => eb.fn.toJson(eb.table('exifInfo')).as('exifInfo'))
|
||||
.orderBy('asset.fileCreatedAt', 'asc')
|
||||
.as('assets'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select(withAlbumOwner)
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.coalesce(
|
||||
eb.fn
|
||||
.jsonAgg('assets')
|
||||
.orderBy('assets.fileCreatedAt', 'asc')
|
||||
.filterWhere('assets.id', 'is not', null),
|
||||
|
||||
sql`'[]'`,
|
||||
)
|
||||
.as('assets'),
|
||||
)
|
||||
.groupBy('album.id')
|
||||
.as('album'),
|
||||
(eb) => withSharedLinkAlbum(eb).select(withAlbumOwner).as('album'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PostgresError } from 'postgres';
|
||||
import { SharedLink } from 'src/database.js';
|
||||
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto.js';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto.js';
|
||||
import { AuthDto } from 'src/dtos/auth.dto.js';
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from 'src/dtos/shared-link.dto.js';
|
||||
import { Permission, SharedLinkType } from 'src/enum.js';
|
||||
import { BaseService } from 'src/services/base.service.js';
|
||||
import { asDateTimeString } from 'src/utils/date.js';
|
||||
import { OpenGraphTags, findOrFail, getExternalDomain } from 'src/utils/misc.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -41,7 +43,7 @@ export class SharedLinkService extends BaseService {
|
||||
}
|
||||
|
||||
return {
|
||||
sharedLink: mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }),
|
||||
sharedLink: await this.mapWithAlbumMetadata(sharedLink, { stripAssetMetadata: !sharedLink.showExif }),
|
||||
token: this.asToken({ id, password }),
|
||||
};
|
||||
}
|
||||
@@ -58,12 +60,12 @@ export class SharedLinkService extends BaseService {
|
||||
throw new UnauthorizedException('Password required');
|
||||
}
|
||||
|
||||
return mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
|
||||
return this.mapWithAlbumMetadata(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
|
||||
const sharedLink = await this.findOrFail(auth.user.id, id);
|
||||
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
|
||||
return this.mapWithAlbumMetadata(sharedLink, { stripAssetMetadata: false });
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
|
||||
@@ -220,7 +222,8 @@ export class SharedLinkService extends BaseService {
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id);
|
||||
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
|
||||
const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets?.length || 0;
|
||||
const [albumMetadata] = sharedLink.album ? await this.albumRepository.getMetadataForIds([sharedLink.album.id]) : [];
|
||||
const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : albumMetadata?.assetCount || 0;
|
||||
const imagePath = assetId
|
||||
? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
|
||||
: '/feature-panel.png';
|
||||
@@ -232,6 +235,18 @@ export class SharedLinkService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async mapWithAlbumMetadata(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }) {
|
||||
const response = mapSharedLink(sharedLink, options);
|
||||
if (response.album) {
|
||||
const [metadata] = await this.albumRepository.getMetadataForIds([response.album.id]);
|
||||
response.album.startDate = asDateTimeString(metadata?.startDate ?? undefined);
|
||||
response.album.endDate = asDateTimeString(metadata?.endDate ?? undefined);
|
||||
response.album.assetCount = metadata?.assetCount ?? 0;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private asToken(sharedLink: { id: string; password: string }) {
|
||||
return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`).toString('base64');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Kysely } from 'kysely';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { AlbumUserRole, SharedLinkType } from 'src/enum.js';
|
||||
import { AccessRepository } from 'src/repositories/access.repository.js';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository.js';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository.js';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository.js';
|
||||
@@ -18,7 +19,7 @@ let defaultDatabase: Kysely<DB>;
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(SharedLinkService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AccessRepository, DatabaseRepository, SharedLinkRepository, SharedLinkAssetRepository],
|
||||
real: [AccessRepository, AlbumRepository, DatabaseRepository, SharedLinkRepository, SharedLinkAssetRepository],
|
||||
mock: [LoggingRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
@@ -57,8 +58,8 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
|
||||
album: expect.objectContaining({
|
||||
startDate: '2020-01-01T00:00:00+00:00',
|
||||
endDate: '2022-01-01T00:00:00+00:00',
|
||||
startDate: '2020-01-01T00:00:00.000Z',
|
||||
endDate: '2022-01-01T00:00:00.000Z',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user