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:
bo0tzz
2026-10-14 21:24:13 +03:00
committed by Codex
parent 1978b77a40
commit 4fe7d42cfc
4 changed files with 25 additions and 102 deletions
+1 -64
View File
@@ -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'))
+19 -4
View File
@@ -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',
}),
});
});