Skip to content

fix: N+1 node queries in dhcp, api and websocket handlers - #439

Open
xtrusia wants to merge 8 commits into
canonical:masterfrom
xtrusia:fix/node-query-n-plus-one
Open

fix: N+1 node queries in dhcp, api and websocket handlers#439
xtrusia wants to merge 8 commits into
canonical:masterfrom
xtrusia:fix/node-query-n-plus-one

Conversation

@xtrusia

@xtrusia xtrusia commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

On large subnets, several code paths run one or more node queries per object. With about 6,000 allocated IP addresses on a /19 subnet, one DHCP configuration rebuild runs about 18,000 queries. The ipaddresses API has the same N+1 query problem.

On one deployment at this scale, slow API responses and deployment timeouts occurred while these DHCP configuration rebuilds were running.

This PR removes the N+1 queries from these paths and adds count_queries() regression tests.

  • DHCP host generation in make_hosts_for_subnets()
  • IPAddressesHandler.read()
  • the interface listing in Fabric.delete()
  • tracked VMs in the VM cluster handler
  • Pod.host lookups through pod hints in the pod WebSocket handler

The fixes use select_related(), prefetch_related(), and targeted ORM queries to retrieve the required node data in a fixed number of queries.
The regression tests confirm that query counts stay constant for the covered result sets as their object counts increase.

Performance measurements

The DHCP results below come from a source-level reproducer that calls make_hosts_for_subnets([subnet]) directly.

  1. Create a 10.42.0.0/19 subnet.
  2. Create 6,000 physical interfaces and attach one AUTO StaticIPAddress to each interface.
  3. Call make_hosts_for_subnets([subnet]).
  4. Add the 6,001st physical interface and AUTO address with the MAAS factory.
  5. Call make_hosts_for_subnets([subnet]) again.

Query counts were collected with connection.execute_wrapper(). Runtimes are the median of two uninstrumented runs, with the baseline and patched results collected in the same environment.

The table shows the result after adding the 6,001st address.

MAAS Queries before Queries after Runtime before Runtime after
3.4 18,004 3 18.54 s 1.25 s
3.5 18,004 3 18.68 s 1.32 s
3.6 18,005 4 17.39 s 1.18 s
3.7 18,005 4 21.10 s 1.81 s
master 18,005 4 21.22 s 1.47 s

The 3.4 and 3.5 code paths do not load ReservedIP entries here, so the fixed count is 3. Versions 3.6, 3.7, and master also load ReservedIP entries, so the fixed count is 4.

The large-IP paths use prefetch_related(), which keeps related rows in memory and uses an IN clause containing the returned IP IDs. This is the memory trade-off for removing the per-IP queries.

Backports are planned for stable branches down to 3.4, where the same code paths run in production.

Resolves LP:2161952

@xtrusia
xtrusia requested a review from a team July 8, 2026 04:19
@maas-lander

Copy link
Copy Markdown
Collaborator

Check where you would like a Mattermost message to be sent to when CI completes and this PR is merged

  • Direct message
  • ~maas

@xtrusia
xtrusia force-pushed the fix/node-query-n-plus-one branch 4 times, most recently from 239efcc to 30d680d Compare July 13, 2026 14:09
@xtrusia
xtrusia force-pushed the fix/node-query-n-plus-one branch 2 times, most recently from 47d1f39 to 202808b Compare July 18, 2026 21:07
xtrusia added 5 commits July 28, 2026 08:55
make_hosts_for_subnets iterated sip.interface_set.order_by("id") for
every allocated IP and then read each interface's node config, node
and parents -- several database queries per host, i.e. O(number of
hosts) round-trips (~18000 for a subnet with 6000 static IPs).
The whole configuration is regenerated on every change and runs in a
database thread, so this scales badly on large subnets.

Move the ordering into a Prefetch and load node_config/node and
parents alongside, so the query count stays constant regardless of
host count.

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
IPAddressesHandler.read serialized each IP's interface_set, whose
system_id/resource_uri call Interface.get_node() (node_config.node),
so it ran a query per IP against maasserver_nodeconfig and
maasserver_node. interface_set is a M2M, so those relations are not
covered by a plain prefetch and must be named explicitly.

Prefetch interface_set with the node selected in the same query so
the node query count stays constant regardless of the number of IPs
returned.

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
Fabric.delete lists the still-connected interfaces via
get_log_string(), which calls get_node() per interface.
select_related node_config__node so building that error message does
not query the node per interface.

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
The vmcluster handler reads vm.machine (system_id/hostname) for each
tracked VM, querying the node per VM. select_related("machine") in
tracked_virtual_machines so the query count does not grow with the
number of VMs.

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
Pod.host calls hints.nodes.first(), which queried the node once per
pod in the handler's list. Prefetch hints__nodes ordered by id in the
handler queryset so first() is served from the cache.

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
@xtrusia
xtrusia force-pushed the fix/node-query-n-plus-one branch from 202808b to ab90d7e Compare July 27, 2026 23:55

@skatsaounis skatsaounis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @xtrusia

Thank you for the PR. Before starting a complete review, I wasn't able to find a linked bug report or tests that are counting queries to prove the improvements. The latter, will also stay in the code base and used as a guard for future changes - no change will be able to introduce more queries than what your changes are achieving in this code path.

Please file a bug report and add it as a footer to your PR description, then proceed with tests that are counting queries. When you are done, let us know to start the review.

This is a link to a past commit that is introducing such tests. You can use it as reference.

xtrusia added 2 commits July 28, 2026 08:20
The existing tests verify that query counts stay constant as object
counts grow, but would still pass if both measurements increased.
Assert the counts achieved by the fixes to catch that regression.

Resolves LP:2161952

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
Master also queries ReservedIP entries when building DHCP hosts.
Include that constant query in the exact regression count.

Resolves LP:2161952

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
@xtrusia
xtrusia requested a review from skatsaounis July 28, 2026 22:46

@skatsaounis skatsaounis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for creating the bug report and introducing query count tests. This is what I was able to detect in your changes. Please take a look, especially at the 2nd comment.

Comment thread src/maasserver/api/ip_addresses.py
Comment thread src/maasserver/models/fabric.py Outdated
Comment thread src/maasserver/websockets/handlers/pod.py
r00ta
r00ta previously approved these changes Jul 29, 2026

@r00ta r00ta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm once @skatsaounis comments are addressed

@r00ta

r00ta commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

It would be nice if you could grab some numbers with this new implementation so to include them in the release notes. Something like "for a /19 subnet with 6000 allocation X queries were executed; on the same environment with this fix Y queries were executed"

Fetch only the values needed for the validation error and keep query
counts constant for parent-resolved interfaces.

Signed-off-by: Seyeong Kim <seyeong.kim@canonical.com>
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.

4 participants