From 4fe7d42cfc6ffc45d450b262c09e87c08182a96f Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 14 Oct 2026 00:39:08 +0200 Subject: [PATCH] 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) --- server/src/queries/shared.link.repository.sql | 65 +------------------ .../repositories/shared-link.repository.ts | 32 +-------- server/src/services/shared-link.service.ts | 23 +++++-- .../services/shared-link.service.spec.ts | 7 +- 4 files changed, 25 insertions(+), 102 deletions(-) diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index e9ad212f2..5aa3adc2c 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -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 diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 735c4e89c..cd7998906 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -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 | null>().as('album')) diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 8a5206093..86a04d8a9 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -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 { 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 { @@ -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'); } diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts index 39e0e895c..5bff8f295 100644 --- a/server/test/medium/specs/services/shared-link.service.spec.ts +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -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; const setup = (db?: Kysely) => { 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', }), }); });