Person endpoint slow due to cartesian-explosion includes and client-side pagination #101
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
GET /api/person/{id}(cosplayer detail) andGET /api/person(cosplayer search) are slow (~1s at scale of 1M assets / 25k albums / 500 cosplayers) due to three compounding issues: cartesian explosion from multi-levelInclude().ThenInclude()chains, client-side pagination/filtering after loading everything, and missing indexes.Root Cause Analysis
1. Cartesian Explosion —
PersonRepository.Find()(line 17-22)This produces a single SQL query with two many-to-many joins (
AlbumAsset+AssetUser). With ~50 albums/person × ~40 assets/album × ~3 shared-users/asset, this returns ~6,000 rows per person — SQL multiplies, not unions. All data is then paginated client-side inPersonController.Get()(lines 86-89) via.Skip().Take()on the in-memory collection.2. Full Graph Loaded in
SearchQuery()—PersonRepository.SearchQuery()(line 89-92)The second pass of the two-pass pattern loads the full album → asset → shared-with graph for every person on the page:
For a page of 30 cosplayers: 30 × 50 × 40 × 3 = 180k rows transferred from DB to application — mostly wasted, since only
TotalAlbumsand person identity fields are used in the DTO.3. Unnecessary Asset Loading for Admin/Curator
When
accessLevel != EAccessLevel.User, visibility filtering is skipped entirely, but the full asset/shared-with graph is still loaded. Admin/curator browsing needs only person fields + album count.4. Duplicate Visibility Filtering
The same nested visibility predicate (
asset.IsPubliclyShared || asset.OwnerId == userId || asset.SharedWith.Any(u => u.Id == userId)) runs twice per person: once for filtering people (lines 96-103) and once forTotalAlbumscount (lines 114-119).5. Missing Indexes
Person.CreatedAtSearchQuery()(line 70)Album.PersonOwnerIdFindByPerson(), new paginated queryAlbum.UserOwnerIdFindByOwner()Proposed Fix
A. Single Person Endpoint —
GET /api/person/{id}Goal: Server-side pagination of albums with minimal data loading.
Lighten
PersonRepository.Find()— remove all album includes, keep only:Add paginated album-by-person method — either in
AlbumRepositoryor as a newPersonRepository.GetAlbumsPaginated()method:Album.PersonOwnerIdAlbumRepository.SearchQuerybut scoped to a person)totalAlbums+totalAssetscomputed from the filtered setController calls both methods in sequence (2 round-trips, each lightweight) instead of one massive load.
B. Search People —
GET /api/personGoal: Don't load assets/shared-with unless necessary.
Branch second pass by access level:
TotalAlbums=p.Albums.Count.Project in first pass instead of second for admin/curator:
Select()projection in the first pass:C. User-Level Visibility Optimization
Instead of loading
Assets → SharedWithinline (cartesian explosion), use batch resolution:AssetUserfor those asset IDs + current userIdThis reduces the join from 3 levels → 2 levels and avoids the cartesian product with
AssetUser.D. Add Missing Indexes
Add fluent API indexes in
LactoseDbContext.OnModelCreating:E. Eliminate Duplicate Filtering (Bonus)
Memoize or restructure
SearchQuery's visibility check so it runs once per person and the result is re-used for both person-filtering andTotalAlbumscounting.Implementation Order
GET /api/person/{id}(Part A) — split find + paginated album querySearchQueryfor admin/curator (Part B)Affected Files
Lactose/Repositories/PersonRepository.csLactose/Repositories/IPersonRepository.csLactose/Repositories/AlbumRepository.cs(may need new method)Lactose/Repositories/IAlbumRepository.cs(interface)Lactose/Controllers/PersonController.csLactose/Mapper/PersonMapper.csLactose/Context/LactoseDbContext.cs(indexes)Butter/Dtos/Person/PersonDetailedDto.cs(may need to add pagination metadata)