Skip to content

Batch child node lookups to avoid N+1 queries - #6212

Draft
VPS-thodax wants to merge 1 commit into
claude/n-plus-1-query-errors-wx73pgfrom
claude/batch-page-tree-child-node-queries
Draft

Batch child node lookups to avoid N+1 queries#6212
VPS-thodax wants to merge 1 commit into
claude/n-plus-1-query-errors-wx73pgfrom
claude/batch-page-tree-child-node-queries

Conversation

@VPS-thodax

@VPS-thodax VPS-thodax commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

depends on #6211

Problem

Walking the page tree loads the children of one node per query. Rendering a three-level menu over 200 root nodes issues 612 PageTreeNode queries; getDescendants() and resolving a path segment by segment scale the same way.

Reason

getChildNodes(), getNodeByPath() and pageTreeRootNodeList() all go through queryNodes(), which runs a live query per call whenever the scope has not been preloaded, so every node's children cost a round trip. getDescendants() additionally awaits one child after another, so those queries cannot even overlap.

Fix

Lookups of the form "children of node X" go through a DataLoader keyed by scope and parent: all such lookups in the same tick collapse into one query per scope (parentId in (…), with a separate condition for root nodes, which cannot be expressed inside $in). getDescendants() descends into all children of a level at once, so one query per tree level replaces one per node.

Like the node loader in #6211, this loader and its cache are per request. They live on the read API instance, and PageTreeReadApiService is request-scoped, so a batch is never shared between requests and there is nothing to invalidate when the tree changes.

Scopes that were preloaded still short-circuit before the loader, so resolvers calling preloadNodes() are unaffected. Lookups that are not keyed by a parent keep the previous live query — getNodes() filters by category and document type across a whole scope and supports sorting and pagination, which cannot share a batch.

Decisions

  • Batches only lookups keyed by a parent, and only those without sort, limit and offset. Callers passing different sorting or pagination cannot share a result set, so merging them into one batch would mean re-implementing pagination in memory. getNodes() is the only caller affected and stays on the live-query path.
  • Applies the remaining filters in memory instead of in SQL. Because the batch key is scope + parent, a category, slug or excludeHiddenInMenu condition in the query would split the batch again. Children of a parent are therefore fetched in full and filtered afterwards through filterPreloadedNodes() — the same in-memory filtering the preload path already does, so the results are unchanged while a few more rows are read.

Verification

Measured on the demo API against a page tree of 200 root nodes, each four levels deep, counting statements in the PostgreSQL log. The query is topMenu { id childNodes { id childNodes { id childNodes { id } } } }, chosen because topMenu deliberately does not preload:

PageTreeNode queries Response time
Before 612 609 ms
After 4 119 ms

The response is byte-identical before and after, as are the ten page tree queries checked in #6211 — among them pageTreeNodeList, mainMenu, topMenu, parentNodes, pageTreeFullTextSearch, paginatedPageTreeNodes and paginatedRedirects.

New unit tests cover the children of several nodes collapsing into one query, repeated lookups of the same node, nodes without children, root nodes batching together with child nodes, the remaining filters still being applied, one query per level in getDescendants(), and one query per segment when several paths are resolved concurrently.

Further information


Generated by Claude Code

Walking the page tree issued one query per node to load its children, so
rendering a menu, resolving a path segment by segment or collecting
descendants scaled with the number of nodes.

Batch "children of node X" lookups into one query per scope and tick, and
descend into all children of a tree level at once in getDescendants
instead of one after another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PrvQarKxE7b5obfJnS3kRJ
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.

2 participants