perf(server): stop loading every album asset to build album responses (#32413)

* perf(server): stop loading every album asset to build album responses

Album create, update (rename, description, cover, sort order, activity
toggle) and add-users json_agg'd every asset of the album with its exif
row, only for mapAlbum to read the count and the first/last
localDateTime. Use getMetadataForIds for those fields, as GET /albums/:id
already does, and drop the now unused withAssets option from getById.

The responses now carry the same date format and lastModifiedAssetTimestamp
as GET /albums/:id.

PATCH /albums/:id on a 150k-asset album, PG 14 (response is ~900 B either
way): 3.4-3.6 s -> 0.13-0.16 s.

* refactor(server): share album metadata mapping

(cherry picked from commit 2a4e876ec990a4a8d5fdca00291cbeebd3d1bccf)
This commit is contained in:
bo0tzz
2026-10-14 21:24:13 +03:00
committed by Codex
parent c7a5ac3d11
commit 1978b77a40
6 changed files with 43 additions and 116 deletions
+2 -42
View File
@@ -61,27 +61,7 @@ select
where
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks",
(
select
json_agg("asset") as "assets"
from
(
select
"asset".*,
"asset_exif" as "exifInfo"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"album_asset"."albumId" = "album"."id"
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline')
order by
"asset"."fileCreatedAt" desc
) as "asset"
) as "assets"
) as "sharedLinks"
from
"album"
where
@@ -396,27 +376,7 @@ select
"album_user"."role",
"user"."name" asc
) as agg
) as "albumUsers",
(
select
json_agg("asset") as "assets"
from
(
select
"asset".*,
"asset_exif" as "exifInfo"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"album_asset"."albumId" = "album"."id"
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline')
order by
"asset"."fileCreatedAt" desc
) as "asset"
) as "assets"
) as "albumUsers"
from
"album"
+3 -42
View File
@@ -1,14 +1,5 @@
import { Injectable } from '@nestjs/common';
import {
type ExpressionBuilder,
type Insertable,
type Kysely,
type NotNull,
type Selectable,
type ShallowDehydrateObject,
type Updateable,
sql,
} from 'kysely';
import { type ExpressionBuilder, type Insertable, type Kysely, type Updateable, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database.js';
@@ -17,7 +8,6 @@ import { AlbumUserCreateDto, MapAlbumDto } from 'src/dtos/album.dto.js';
import { AlbumUserRole } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import { AlbumTable } from 'src/schema/tables/album.table.js';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table.js';
import { asUuid, dummy, withDefaultVisibility } from 'src/utils/database.js';
export interface AlbumAssetCount {
@@ -28,10 +18,6 @@ export interface AlbumAssetCount {
lastModifiedAssetTimestamp: Date | null;
}
export interface AlbumInfoOptions {
withAssets: boolean;
}
const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb
@@ -52,27 +38,6 @@ const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) =>
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
).as('sharedLinks');
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
.selectFrom((eb) =>
eb
.selectFrom('asset')
.selectAll('asset')
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) =>
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
)
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.whereRef('album_asset.albumId', '=', 'album.id')
.where('asset.deletedAt', 'is', null)
.$call(withDefaultVisibility)
.orderBy('asset.fileCreatedAt', 'desc')
.as('asset'),
)
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
.as('assets');
};
const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
eb.exists(
eb
@@ -86,8 +51,8 @@ const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =
export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
getById(id: string, options: AlbumInfoOptions, authUserId?: string) {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getById(id: string, authUserId?: string) {
return this.db
.with('album_user', (qb) => qb.selectFrom('album_user').selectAll().where('album_user.albumId', '=', id))
.selectFrom('album')
@@ -96,8 +61,6 @@ export class AlbumRepository {
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(authUserId))
.select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets))
.$narrowType<{ assets: NotNull }>()
.executeTakeFirst();
}
@@ -351,8 +314,6 @@ export class AlbumRepository {
.selectFrom('album')
.selectAll('album')
.select(withAlbumUsers(authUserId))
.select(withAssets)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
return result;
+5 -4
View File
@@ -18,6 +18,7 @@ describe(AlbumService.name, () => {
beforeEach(() => {
({ sut, mocks } = newTestService(AlbumService));
mocks.album.getMetadataForIds.mockResolvedValue([]);
});
it('should work', () => {
@@ -591,7 +592,7 @@ describe(AlbumService.name, () => {
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, owner.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, owner.id);
});
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
@@ -693,7 +694,7 @@ describe(AlbumService.name, () => {
await sut.get(AuthFactory.create(owner), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, owner.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, owner.id);
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([album.id]));
});
@@ -714,7 +715,7 @@ describe(AlbumService.name, () => {
const auth = AuthFactory.from().sharedLink().build();
await sut.get(auth, album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, auth.user.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, auth.user.id);
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id]));
});
@@ -735,7 +736,7 @@ describe(AlbumService.name, () => {
await sut.get(AuthFactory.create(user), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, user.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, user.id);
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
user.id,
new Set([album.id]),
+30 -25
View File
@@ -7,6 +7,7 @@ import {
AlbumsAddAssetsResponseDto,
CreateAlbumDto,
GetAlbumsDto,
MapAlbumDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
mapAlbum,
@@ -15,13 +16,21 @@ import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset
import { AuthDto } from 'src/dtos/auth.dto.js';
import { MapMarkerResponseDto } from 'src/dtos/map.dto.js';
import { AlbumUserRole, Permission } from 'src/enum.js';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository.js';
import { AlbumAssetCount } from 'src/repositories/album.repository.js';
import { BaseService } from 'src/services/base.service.js';
import { addAssets, removeAssets } from 'src/utils/asset.util.js';
import { asDateTimeString } from 'src/utils/date.js';
import { findOrFail } from 'src/utils/misc.js';
import { getPreferences } from 'src/utils/preferences.js';
const mapAlbumMetadata = (metadata?: AlbumAssetCount) => ({
startDate: asDateTimeString(metadata?.startDate ?? undefined),
endDate: asDateTimeString(metadata?.endDate ?? undefined),
assetCount: metadata?.assetCount ?? 0,
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
lastModifiedAssetTimestamp: asDateTimeString(metadata?.lastModifiedAssetTimestamp ?? undefined),
});
@Injectable()
export class AlbumService extends BaseService {
async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
@@ -60,30 +69,21 @@ export class AlbumService extends BaseService {
return albums.map((album) => ({
...mapAlbum(album),
sharedLinks: undefined,
startDate: asDateTimeString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateTimeString(albumMetadata[album.id]?.endDate ?? undefined),
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
lastModifiedAssetTimestamp: asDateTimeString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined),
...mapAlbumMetadata(albumMetadata[album.id]),
}));
}
async get(auth: AuthDto, id: string): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
await this.albumRepository.updateThumbnails();
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
const album = await this.findOrFail(id, auth.user.id);
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 1;
const hasSharedLink = album.sharedLinks && album.sharedLinks.length > 0;
const isShared = hasSharedUsers || hasSharedLink;
return {
...mapAlbum(album),
startDate: asDateTimeString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateTimeString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: asDateTimeString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
...(await this.mapWithMetadata(album)),
contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined,
};
}
@@ -134,13 +134,13 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
return mapAlbum(album);
return this.mapWithMetadata(album);
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
const album = await this.findOrFail(id, auth.user.id, { withAssets: true });
const album = await this.findOrFail(id, auth.user.id);
if (dto.albumThumbnailAssetId) {
const results = await this.albumRepository.getAssetIds(id, [dto.albumThumbnailAssetId]);
@@ -161,7 +161,7 @@ export class AlbumService extends BaseService {
auth.user.id,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
return this.mapWithMetadata(updatedAlbum);
}
async delete(auth: AuthDto, id: string): Promise<void> {
@@ -170,7 +170,7 @@ export class AlbumService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id);
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
const results = await addAssets(
@@ -229,7 +229,7 @@ export class AlbumService extends BaseService {
if (notPresentAssetIds.length === 0) {
continue;
}
const album = await this.findOrFail(albumId, auth.user.id, { withAssets: false });
const album = await this.findOrFail(albumId, auth.user.id);
results.error = undefined;
results.success = true;
@@ -261,7 +261,7 @@ export class AlbumService extends BaseService {
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id);
const results = await removeAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
@@ -287,7 +287,7 @@ export class AlbumService extends BaseService {
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id);
for (const { userId, role } of albumUsers) {
if (role === AlbumUserRole.Owner) {
@@ -309,7 +309,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return mapAlbum(await this.findOrFail(id, auth.user.id, { withAssets: true }));
return this.mapWithMetadata(await this.findOrFail(id, auth.user.id));
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -317,7 +317,7 @@ export class AlbumService extends BaseService {
userId = auth.user.id;
}
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id);
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (!exists) {
@@ -342,7 +342,7 @@ export class AlbumService extends BaseService {
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, userId, { withAssets: false });
const album = await this.findOrFail(id, userId);
const owner = album.albumUsers[0];
if (owner.user.id === userId) {
@@ -352,7 +352,12 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
private findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
return findOrFail(() => this.albumRepository.getById(id, options, authUserId), 'Album');
private findOrFail(id: string, authUserId: string) {
return findOrFail(() => this.albumRepository.getById(id, authUserId), 'Album');
}
private async mapWithMetadata(album: MapAlbumDto): Promise<AlbumResponseDto> {
const [metadata] = await this.albumRepository.getMetadataForIds([album.id]);
return { ...mapAlbum(album), ...mapAlbumMetadata(metadata) };
}
}
+1 -1
View File
@@ -351,7 +351,7 @@ export class AssetMediaService extends BaseService {
return;
}
const album = await this.albumRepository.getById(sharedLink.albumId, { withAssets: false });
const album = await this.albumRepository.getById(sharedLink.albumId);
if (!album) {
return;
}
+2 -2
View File
@@ -324,7 +324,7 @@ export class NotificationService extends BaseService {
@OnJob({ name: JobName.NotifyAlbumInvite, queue: QueueName.Notification })
async handleAlbumInvite({ id, recipientId, senderName }: JobOf<JobName.NotifyAlbumInvite>) {
const album = await this.albumRepository.getById(id, { withAssets: false });
const album = await this.albumRepository.getById(id);
if (!album) {
return JobStatus.Skipped;
}
@@ -374,7 +374,7 @@ export class NotificationService extends BaseService {
@OnJob({ name: JobName.NotifyAlbumUpdate, queue: QueueName.Notification })
async handleAlbumUpdate({ id, recipientId }: JobOf<JobName.NotifyAlbumUpdate>) {
const album = await this.albumRepository.getById(id, { withAssets: false });
const album = await this.albumRepository.getById(id);
if (!album) {
return JobStatus.Skipped;