Skip to content

perf(refresh): stop refetching Book+Author+Editions+Files per book during author refresh (#163) - #166

Open
jordanfelle wants to merge 1 commit into
Chaptarr:developfrom
jordanfelle:perf-163-refresh-book-n-plus-1
Open

jordanfelle wants to merge 1 commit into
Chaptarr:developfrom
jordanfelle:perf-163-refresh-book-n-plus-1

Conversation

@jordanfelle

@jordanfelle jordanfelle commented Sep 22, 2026 •

Copy link
Copy Markdown

Description

RefreshBookService.PublishEntityUpdatedEvent called _bookService.GetBook(id) unconditionally for every book that changed during an author refresh - a full Book + Author + Editions + BookFiles reload, even though the author-level refresh already batch-loaded Editions/BookFiles for every book up front (RefreshAuthorService.HydrateLocalChildrenForRefresh), and remoteData (the author) is already in memory and identical for every book in a single author-scoped refresh call.

On a large library this produced a very large number of avoidable per-book queries. Measured via pg_stat_statements on a live instance (204s window, mid-refresh): 254,056 calls to Authors JOIN Books WHERE Books.Id = $1 and 233,131 calls to Editions WHERE BookId = $1. This is a direct contributor to why authors with very large catalogues (e.g. Charles Dickens, 10k+ books) can take hours to refresh, and starve other commands of chaptarr's single disk-access slot for the duration.

Reuse the already-hydrated entity when its Editions/BookFiles are already loaded and its Author is already set, instead of re-querying. Falls back to the original full GetBook() fetch when something is genuinely missing (a merge target fetched via GetEntityByForeignId, or a direct single-book refresh call that didn't pre-hydrate), so behavior for every other caller/subscriber is unchanged.

Also fixes a related footgun found while testing this: Book.Author / .Editions / .BookFiles are backed by LazyLoaded<TParent,TChild> proxies that transparently issue a DB query the first time the materializing property is read. A naive == null check on those properties fires the exact query it's trying to detect. The fix checks the non-materializing Lazy*.IsLoaded flag instead, and pre-assigns book.Author before any code path can read it for the first time.

Fixes # (part of #163, tracked under #173)

Database Migration

NO

How was this tested?

Screenshots (UI changes only)

N/A

… author refresh (Chaptarr#163)

RefreshBookService.PublishEntityUpdatedEvent called _bookService.GetBook(id)
unconditionally for every book that changed during an author refresh, doing a
full Book+Author+Editions+BookFiles reload even though the author-level
refresh had already batch-loaded Editions/BookFiles for every book, and
already has the author in memory (remoteData). On a large library this
produced hundreds of thousands of avoidable per-book queries (measured via
pg_stat_statements: 254k "Authors JOIN Books WHERE Books.Id = $1" calls and
233k "Editions WHERE BookId = $1" calls in a 204s window), which is why
authors with very large catalogues (Charles Dickens, 10k+ books) can take
hours to refresh and starve other commands of chaptarr's single disk-access
slot in the meantime.

Reuse the already-hydrated entity when its Editions/BookFiles were already
loaded, falling back to the original full fetch otherwise (e.g. a merge
target, or a direct single-book refresh that didn't pre-hydrate) so behavior
for every other caller is unchanged.

While testing this against an isolated copy of a production database, found
and fixed a related footgun: Book.Author/.Editions/.BookFiles are backed by
LazyLoaded<TParent,TChild> proxies that transparently issue a DB query the
first time the materializing property is read. A naive `== null` check on
those properties fires the exact query it's trying to detect; the fix checks
the non-materializing Lazy*.IsLoaded flag instead.

Testing: built and ran in an isolated container against a template copy of
a production chaptarr-main database (10k+ book author), confirmed no
regressions in a RefreshAuthor run. Query-volume improvement was smaller
than expected in that isolated test, suggesting at least one more
contributor to the per-book query volume remains; investigating separately.
jordanfelle added a commit to jordanfelle/chaptarr that referenced this pull request Sep 22, 2026
…oring sync (Chaptarr#163)

GetSyncUpdatesForMutations (called via SaveEntity on every book save) calls
ApplyMutationSyncForWorkGroup once per author-owned work group when that
author has SyncMonitoredAcrossFormats enabled. That path could call
EnsureOneMonitoredOnFormat -> CanEnableMonitoringForMediaType ->
HasCompatibleRootFolderForMediaType, which called
_rootFolderService.All() fresh every time - an uncached full table read of
a small, rarely-changing table, repeated once per media type per work group
per changed book.

Fetch it once per author (only for authors that actually have sync
enabled) and thread it through instead. Existing call sites that aren't on
this hot path (ApplyInsertSyncDefaults, BuildReconcileSyncUpdates, the
direct CanEnableMonitoringForMediaType caller) are unchanged - the new
parameter is optional and falls back to the original per-call fetch when
not supplied.

Measured on an isolated copy of a production database, on top of Chaptarr#166 and
Chaptarr#167: the RootFolders query, which was running ~2,000/min during a
RefreshAuthor pass, dropped out of the top statements entirely.
@jordanfelle

Copy link
Copy Markdown
Author

Tracked under #173 (author-refresh N+1/O(N^2) query cluster).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant