Person endpoint slow due to cartesian-explosion includes and client-side pagination #101
Closed
opened 2026-07-12 19:04:33 +00:00 by Ai_Agent
·
2 comments
Labels
Clear labels
AI Gen
area:auth
area:backend
area:ci
area:db
area:frontend
area:shared
good first issue
Need Triage
page:admin-users
page:album-detail
page:albums
page:cosplayer-detail
page:cosplayers
page:home
page:jobs
page:login
page:photos
page:register
page:settings
page:stats
page:user
performance
priority:critical
priority:high
priority:low
priority:medium
type:docs
type:refactor
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
Autogenerated By AI
Authentication, JWT, refresh tokens
Lactose API (controllers, repos, services, models)
Docker, CI/CD, Gitea Actions
EF Core, migrations, pgvector
MilkStream WASM client (Blazor UI, SCSS, components)
Butter shared library (DTOs, enums, MIME types)
Good for new contributors
Needs to be categorized by a Human
Admin user management (/Users)
Album detail page (/albums/{id})
Albums list (/albums)
Cosplayer detail page (/cosplayer/{id})
Cosplayers list (/cosplayers)
Home page (/)
Background jobs page (/Jobs)
Login page (/login)
Photos page (/photos)
Register page (/Register)
Settings page (/Settings)
Statistics page (/Stats)
User profile page (/User/{id})
Performance or scalability concern
Blocker / must fix immediately
Must fix / urgent
Nice to have
Should fix
Documentation
Code cleanup or refactoring
Something is not working
This issue or pull request already exists
New feature
Need some help
Something is wrong
More information is needed
This won't be fixed
Milestone
No items
No Milestone
v1.1 - Upload, Admin Tooling & Site Quality
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: MilkyShots/MilkyShots#101
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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)This has been largely outdated due to a lot of other PR improving this side. Closing for now as complete.
Verified resolved against the current code (2026-08-18):
PersonRepository.FindVisibleno longer loads the album→asset→shared-with graph. It loads the person +Maintainersonly, then runs a separate, paginated album query.GET /api/person/{id}are now done at the DB level inFindVisible(visibility-filtered, withILikesearch against the trgm index).SearchQuery(GET /api/person) now uses a two-pass pattern: page of person IDs → grouped album-count query (TotalAlbums) → DTO projection. No full graph materialization, no duplicated visibility predicate.The three proposed index additions (
IX_People_CreatedAt,IX_Albums_PersonOwnerId,IX_Albums_UserOwnerId) were implemented as part of the visibility redesign (#93) work.Closing as resolved. If slow response times are still observed at scale, the remaining candidate is the correlated
Assets.Count/Albums.Countsort-order subquery (tracked in #98).