Skip to content

Importer perf: pre-fetch Folder pk map + narrow prune prefetch - #630

Merged
ajslater merged 2 commits into
v1.11-performancefrom
importer-query-prune
Apr 28, 2026
Merged

ajslater merged 2 commits into
v1.11-performancefrom
importer-query-prune

Conversation

@ajslater

Copy link
Copy Markdown
Owner

Summary

Implements tasks/importer-perf/03-query-prune.md from PR #627. Third surgical batch after #628 (link prepare) and #629 (create FKs).

Two independently revertable commits:

f2bddf25 — moved comics: pre-fetch Folder pk map (headline)

_prepare_moved_comic previously fired two SELECTs per moved comic (parent folder + ancestor M2M rebuild). For a 600k-comic library reorg that was ~1.2M small SELECTs. _bulk_comics_moved_ensure_folders already runs first and creates any missing destination folders, so a single values_list pass at the top of the prepare loop builds a {path: pk} map covering this library; the per-comic loop becomes dict lookups.

Bonus correctness fix: the prebuilt map is library=self.library-scoped, while the prior Folder.objects.get(path=...) was not — on multi-library installs a parent_folder could resolve to another library sharing the same on-disk path. Tightens the search to this library.

9b3af11f — prune: narrow prefetch/select_related, hoist status update

  • query_prune_comic_m2m_links was calling prefetch_related(*COMIC_M2M_FIELD_NAMES) (14 M2Ms) per batch even though most imports only touch credits/tags/characters. Now computes the LINK_M2MS-referenced subset once per phase and prefetches only that — typically 3-5 IN-queries instead of 14, with proportional drop in through-row memory pressure.
  • query_prune_comic_fk_links similarly narrowed from a 12-table select_related JOIN to a 3-table JOIN.
  • _query_prune_comic_m2m_links_field and _query_prune_comic_fk_links_comic were calling status_controller.update() inside their per-through-row / per-FK-field inner loops. The controller already rate-limits at _UPDATE_DELTA = 5s, so the inner calls only burned CPU on the early-return path. Hoisted to once per (comic, field) and once per comic respectively; visible refresh cadence is identical.
  • Drops the unused module-level _QUERY_LINK_FK_PRUNE_ONLY constant.

Query count summary

Hot path Before After
Moved (600k moves) ~1.2M SELECTs 2 SELECTs
Prune M2M (per batch) 14 prefetch IN-queries 3-5
Prune FK (per batch) 12-table JOIN ~3-table JOIN

Test plan

  • make fix clean
  • make lint-python clean (0 errors, 0 warnings)
  • pytest tests/importer/ tests/test_search_fts.py — 7 passed
  • Field check on a real fixture: rename a 1k-comic library and assert resulting Comic.parent_folder and Comic.folders M2M rows match the pre-PR baseline
  • Wall-clock measurement on a real-scale fixture once one is available

🤖 Generated with Claude Code

ajslater and others added 2 commits April 27, 2026 20:51
Previously _prepare_moved_comic fired two SELECTs per moved comic:
Folder.objects.get(path=new_path.parent) for the parent_folder FK,
then Folder.objects.filter(path__in=new_path.parents) for the
folders M2M rebuild. For a 600k-comic library reorg that was
~1.2M small SELECTs.

_bulk_comics_moved_ensure_folders already runs first and creates
any missing destination folders, so by the time we hit the prepare
loop the table contains every parent path the loop can possibly
need. One values_list pass at the top of _bulk_comics_move_prepare
builds a {path: pk} map covering this library; the per-comic loop
then does dict lookups instead of SQL.

Bonus correctness fix: the prebuilt map is filtered by
library=self.library, while the prior Folder.objects.get(path=...)
was not library-scoped — so on a multi-library install a
parent_folder could resolve to another library sharing the same
on-disk path. Bracket access on the parent lookup raises KeyError
on a genuinely missing parent, which the broad except below catches
identically to the prior DoesNotExist.

Implements hot path A from tasks/importer-perf/03-query-prune.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously query_prune_comic_m2m_links called prefetch_related with
the full COMIC_M2M_FIELD_NAMES (14 M2Ms) per batch, and
query_prune_comic_fk_links called select_related with the full
COMIC_FK_FIELD_NAMES (12 FKs). Most imports only touch a handful of
each — credits, tags, characters on the M2M side; volume,
parent_folder, age_rating on the FK side — so the wide prefetch
fired 10+ wasted IN-queries and hauled their through-rows into
Python memory only to be ignored.

Compute the actually-referenced field set once per phase by walking
LINK_M2MS / LINK_FKS keys, then pass that subset through to the
batch helpers. For a typical import this drops the prune phase from
14 prefetch IN-queries to 3-5, and from a 12-table join to a
3-table join.

Also hoist self.status_controller.update(status) out of the
per-(through-row, field) inner loops in _query_prune_comic_m2m_links_field
and _query_prune_comic_fk_links_comic. The controller already
rate-limits at _UPDATE_DELTA so the inner calls only burned CPU on
the early-return path; one update per (comic, field) and one per
comic respectively keeps the visible refresh cadence identical.

The unused module-level _QUERY_LINK_FK_PRUNE_ONLY constant is
dropped; only_fields is now computed inline from the narrowed
select_related set.

Implements hot paths B and C from
tasks/importer-perf/03-query-prune.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ajslater
ajslater merged commit 5dda17c into v1.11-performance Apr 28, 2026
1 check failed
@ajslater
ajslater deleted the importer-query-prune branch May 2, 2026 22:39
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