Skip to content

Commit b5a8757

Browse files
authored
Optimize SONIC configuration generation performance (#1778)
Eliminates N+1 query problems and implements bulk fetching strategies across NetBox API calls in SONIC configuration generation. Changes in config_generator.py: 1. _get_transfer_role_ipv4_addresses(): - Use get_cached_device_interfaces() instead of direct interface query - Bulk fetch all device IP addresses: utils.nb.ipam.ip_addresses.filter(device_id=device.id) - Bulk fetch all transfer role prefixes: utils.nb.ipam.prefixes.filter(role="transfer") - Build transfer_networks list with ipaddress.ip_network() for containment checks - Create interface_map dictionary for O(1) lookups - Process all IPs with in-memory containment checks instead of per-IP prefix queries 2. _add_tagged_vlans_to_ports() and _add_vlan_configuration(): - Build netbox_name_to_sonic reverse mapping dictionary once - Use O(1) dictionary lookups instead of O(n) iterations through netbox_interfaces - Remove backward compatibility fallback to convert_netbox_interface_to_sonic() - Add explicit error handling with warning logs for missing interfaces 3. _get_metalbox_ip_for_device(): - Add module-level _metalbox_ip_cache dictionary - Cache both successful and failed lookup results - Check cache before executing lookup logic - Add clear_metalbox_ip_cache() function - Integrate cache clearing into clear_all_caches() Changes in netbox.py: 1. get_device_vlans(): - Replace utils.nb.dcim.interfaces.filter() with get_cached_device_interfaces() - Bulk fetch all IP addresses: utils.nb.ipam.ip_addresses.filter(device_id=device.id) - Build interface_ips_map dictionary for O(1) IP lookups by interface ID - Replace per-VLAN-interface IP queries with dictionary lookups 2. get_device_loopbacks(): - Replace utils.nb.dcim.interfaces.filter() with get_cached_device_interfaces() - Bulk fetch all IP addresses: utils.nb.ipam.ip_addresses.filter(device_id=device.id) - Build interface_ips_map dictionary for O(1) IP lookups by interface ID - Replace per-loopback IP queries with dictionary lookups 3. get_device_interface_ips(): - Replace utils.nb.dcim.interfaces.filter() with get_cached_device_interfaces() - Bulk fetch all IP addresses: utils.nb.ipam.ip_addresses.filter(device_id=device.id) - Build interface_ips_map dictionary for O(1) IP lookups by interface ID - Replace per-interface IP queries with dictionary lookups All function signatures remain unchanged. Existing error handling and logging patterns preserved. AI-assisted: Claude Code Signed-off-by: Christian Berendt <[email protected]>
1 parent b15562b commit b5a8757

File tree

2 files changed

+158
-79
lines changed

2 files changed

+158
-79
lines changed

osism/tasks/conductor/netbox.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,26 @@ def get_device_vlans(device):
163163
'vlan_interfaces': {vid: {'addresses': [ip_with_prefix, ...]}}
164164
}
165165
"""
166+
from .sonic.cache import get_cached_device_interfaces
167+
166168
vlans = {}
167169
vlan_members = {}
168170
vlan_interfaces = {}
169171

170172
try:
171-
# Get all interfaces for the device and convert to list for multiple iterations
172-
interfaces = list(utils.nb.dcim.interfaces.filter(device_id=device.id))
173+
# Use cached interfaces instead of separate query
174+
interfaces = get_cached_device_interfaces(device.id)
175+
176+
# Fetch ALL IP addresses for the device in ONE query
177+
all_ip_addresses = list(utils.nb.ipam.ip_addresses.filter(device_id=device.id))
178+
179+
# Build lookup dictionary: interface_id -> list of IPs (O(1) lookups)
180+
interface_ips_map = {}
181+
for ip_addr in all_ip_addresses:
182+
if ip_addr.assigned_object_id:
183+
if ip_addr.assigned_object_id not in interface_ips_map:
184+
interface_ips_map[ip_addr.assigned_object_id] = []
185+
interface_ips_map[ip_addr.assigned_object_id].append(ip_addr)
173186

174187
for interface in interfaces:
175188
# Skip management interfaces and virtual interfaces
@@ -229,10 +242,8 @@ def get_device_vlans(device):
229242
):
230243
try:
231244
vid = int(interface.name[4:])
232-
# Get IP addresses for this VLAN interface
233-
ip_addresses = utils.nb.ipam.ip_addresses.filter(
234-
assigned_object_id=interface.id,
235-
)
245+
# Use O(1) lookup instead of N queries
246+
ip_addresses = interface_ips_map.get(interface.id, [])
236247

237248
addresses = []
238249
for ip_addr in ip_addresses:
@@ -270,11 +281,24 @@ def get_device_loopbacks(device):
270281
'loopbacks': {'Loopback0': {'addresses': [ip_with_prefix, ...]}}
271282
}
272283
"""
284+
from .sonic.cache import get_cached_device_interfaces
285+
273286
loopbacks = {}
274287

275288
try:
276-
# Get all interfaces for the device
277-
interfaces = list(utils.nb.dcim.interfaces.filter(device_id=device.id))
289+
# Use cached interfaces instead of separate query
290+
interfaces = get_cached_device_interfaces(device.id)
291+
292+
# Fetch ALL IP addresses for the device in ONE query
293+
all_ip_addresses = list(utils.nb.ipam.ip_addresses.filter(device_id=device.id))
294+
295+
# Build lookup dictionary: interface_id -> list of IPs (O(1) lookups)
296+
interface_ips_map = {}
297+
for ip_addr in all_ip_addresses:
298+
if ip_addr.assigned_object_id:
299+
if ip_addr.assigned_object_id not in interface_ips_map:
300+
interface_ips_map[ip_addr.assigned_object_id] = []
301+
interface_ips_map[ip_addr.assigned_object_id].append(ip_addr)
278302

279303
for interface in interfaces:
280304
# Check if interface is virtual type and is a Loopback interface
@@ -286,10 +310,8 @@ def get_device_loopbacks(device):
286310
):
287311

288312
try:
289-
# Get IP addresses for this Loopback interface
290-
ip_addresses = utils.nb.ipam.ip_addresses.filter(
291-
assigned_object_id=interface.id,
292-
)
313+
# Use O(1) lookup instead of N queries
314+
ip_addresses = interface_ips_map.get(interface.id, [])
293315

294316
addresses = []
295317
for ip_addr in ip_addresses:
@@ -325,11 +347,24 @@ def get_device_interface_ips(device):
325347
...
326348
}
327349
"""
350+
from .sonic.cache import get_cached_device_interfaces
351+
328352
interface_ips = {}
329353

330354
try:
331-
# Get all interfaces for the device
332-
interfaces = list(utils.nb.dcim.interfaces.filter(device_id=device.id))
355+
# Use cached interfaces instead of separate query
356+
interfaces = get_cached_device_interfaces(device.id)
357+
358+
# Fetch ALL IP addresses for the device in ONE query
359+
all_ip_addresses = list(utils.nb.ipam.ip_addresses.filter(device_id=device.id))
360+
361+
# Build lookup dictionary: interface_id -> list of IPs (O(1) lookups)
362+
interface_ips_map = {}
363+
for ip_addr in all_ip_addresses:
364+
if ip_addr.assigned_object_id:
365+
if ip_addr.assigned_object_id not in interface_ips_map:
366+
interface_ips_map[ip_addr.assigned_object_id] = []
367+
interface_ips_map[ip_addr.assigned_object_id].append(ip_addr)
333368

334369
for interface in interfaces:
335370
# Skip management interfaces and virtual interfaces for now
@@ -340,10 +375,8 @@ def get_device_interface_ips(device):
340375
):
341376
continue
342377

343-
# Get IP addresses assigned to this interface
344-
ip_addresses = utils.nb.ipam.ip_addresses.filter(
345-
assigned_object_id=interface.id,
346-
)
378+
# Use O(1) lookup instead of N queries
379+
ip_addresses = interface_ips_map.get(interface.id, [])
347380

348381
for ip_addr in ip_addresses:
349382
if ip_addr.address:

osism/tasks/conductor/sonic/config_generator.py

Lines changed: 107 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import os
99
import re
10+
from typing import Optional
1011
from loguru import logger
1112

1213
from osism import utils
@@ -37,6 +38,9 @@
3738
# Global cache for NTP servers to avoid multiple queries
3839
_ntp_servers_cache = None
3940

41+
# Global cache for metalbox IPs per device to avoid duplicate lookups
42+
_metalbox_ip_cache: dict[int, Optional[str]] = {}
43+
4044

4145
def natural_sort_key(port_name):
4246
"""Extract numeric part from port name for natural sorting."""
@@ -606,20 +610,23 @@ def _add_missing_breakout_ports(
606610

607611
def _add_tagged_vlans_to_ports(config, vlan_info, netbox_interfaces, device):
608612
"""Add tagged VLANs to PORT configuration."""
613+
# Build reverse mapping once: NetBox interface name -> SONiC interface name (O(1) lookups)
614+
netbox_name_to_sonic = {
615+
info["netbox_name"]: sonic_name
616+
for sonic_name, info in netbox_interfaces.items()
617+
}
618+
609619
# Build a mapping of ports to their tagged VLANs
610620
port_tagged_vlans = {}
611621
for vid, members in vlan_info["vlan_members"].items():
612622
for netbox_interface_name, tagging_mode in members.items():
613-
# Convert NetBox interface name to SONiC format
614-
# Try to find speed from netbox_interfaces
615-
speed = None
616-
for sonic_name, iface_info in netbox_interfaces.items():
617-
if iface_info["netbox_name"] == netbox_interface_name:
618-
speed = iface_info["speed"]
619-
break
620-
sonic_interface_name = convert_netbox_interface_to_sonic(
621-
netbox_interface_name, device
622-
)
623+
# Convert NetBox interface name to SONiC format using O(1) lookup
624+
sonic_interface_name = netbox_name_to_sonic.get(netbox_interface_name)
625+
if not sonic_interface_name:
626+
logger.warning(
627+
f"Interface {netbox_interface_name} not found in mapping"
628+
)
629+
continue
623630

624631
# Only add if this is a tagged VLAN (not untagged)
625632
if tagging_mode == "tagged":
@@ -699,9 +706,26 @@ def _get_transfer_role_ipv4_addresses(device):
699706
transfer_ips = {}
700707

701708
try:
702-
# Get all interfaces for the device
703-
interfaces = list(utils.nb.dcim.interfaces.filter(device_id=device.id))
709+
# Use cached interfaces to avoid redundant API calls
710+
interfaces = get_cached_device_interfaces(device.id)
711+
712+
# Bulk fetch all IP addresses for this device (single API call)
713+
all_ip_addresses = list(utils.nb.ipam.ip_addresses.filter(device_id=device.id))
704714

715+
# Bulk fetch all transfer role prefixes (single API call)
716+
transfer_prefixes = list(utils.nb.ipam.prefixes.filter(role="transfer"))
717+
718+
# Convert transfer prefixes to ipaddress network objects for efficient containment checks
719+
transfer_networks = []
720+
for prefix in transfer_prefixes:
721+
try:
722+
transfer_networks.append(ipaddress.ip_network(str(prefix.prefix)))
723+
except (ValueError, ipaddress.AddressValueError) as e:
724+
logger.debug(f"Invalid transfer prefix {prefix.prefix}: {e}")
725+
continue
726+
727+
# Build a mapping from interface ID to interface object
728+
interface_map = {}
705729
for interface in interfaces:
706730
# Skip management interfaces and virtual interfaces
707731
if interface.mgmt_only or (
@@ -710,39 +734,42 @@ def _get_transfer_role_ipv4_addresses(device):
710734
and interface.type.value == "virtual"
711735
):
712736
continue
737+
interface_map[interface.id] = interface
713738

714-
# Get IP addresses assigned to this interface
715-
ip_addresses = utils.nb.ipam.ip_addresses.filter(
716-
assigned_object_id=interface.id,
717-
)
739+
# Process IP addresses and check if they belong to transfer role prefixes
740+
for ip_addr in all_ip_addresses:
741+
# Skip if IP not assigned to a valid interface
742+
if (
743+
not hasattr(ip_addr, "assigned_object_id")
744+
or not ip_addr.assigned_object_id
745+
):
746+
continue
718747

719-
for ip_addr in ip_addresses:
720-
if ip_addr.address:
721-
try:
722-
ip_obj = ipaddress.ip_interface(ip_addr.address)
723-
if ip_obj.version == 4:
724-
# Check if this IP belongs to a prefix with 'transfer' role
725-
# Query for the prefix this IP belongs to
726-
prefixes = utils.nb.ipam.prefixes.filter(
727-
contains=str(ip_obj.ip)
728-
)
748+
if ip_addr.assigned_object_id not in interface_map:
749+
continue
729750

730-
for prefix in prefixes:
731-
# Check if prefix has role and it's 'transfer'
732-
if hasattr(prefix, "role") and prefix.role:
733-
if prefix.role.slug == "transfer":
734-
transfer_ips[interface.name] = ip_addr.address
735-
logger.debug(
736-
f"Found transfer role IPv4 {ip_addr.address} on interface {interface.name} of device {device.name}"
737-
)
738-
break
739-
740-
# Break after first IPv4 found (transfer or not)
741-
if interface.name in transfer_ips:
751+
interface = interface_map[ip_addr.assigned_object_id]
752+
753+
# Check if interface already has a transfer IP assigned
754+
if interface.name in transfer_ips:
755+
continue
756+
757+
if ip_addr.address:
758+
try:
759+
ip_obj = ipaddress.ip_interface(ip_addr.address)
760+
if ip_obj.version == 4:
761+
# Check if this IP belongs to any transfer role prefix
762+
# Using in-memory containment check with ipaddress module
763+
for transfer_net in transfer_networks:
764+
if ip_obj.ip in transfer_net:
765+
transfer_ips[interface.name] = ip_addr.address
766+
logger.debug(
767+
f"Found transfer role IPv4 {ip_addr.address} on interface {interface.name} of device {device.name}"
768+
)
742769
break
743-
except (ValueError, ipaddress.AddressValueError):
744-
# Skip invalid IP addresses
745-
continue
770+
except (ValueError, ipaddress.AddressValueError):
771+
# Skip invalid IP addresses
772+
continue
746773

747774
except Exception as e:
748775
logger.warning(
@@ -1112,6 +1139,11 @@ def _get_metalbox_ip_for_device(device):
11121139
Returns:
11131140
str: IP address of the Metalbox or None if not found
11141141
"""
1142+
# Check cache first
1143+
if device.id in _metalbox_ip_cache:
1144+
logger.debug(f"Using cached metalbox IP for device {device.name}")
1145+
return _metalbox_ip_cache[device.id]
1146+
11151147
try:
11161148
# Get the OOB IP configuration for this SONiC device
11171149
oob_ip_result = get_device_oob_ip(device)
@@ -1173,16 +1205,22 @@ def _get_metalbox_ip_for_device(device):
11731205
f"Found Metalbox {ip_only} on {metalbox.name} "
11741206
f"{interface_type} {interface.name} for SONiC device {device.name}"
11751207
)
1208+
# Cache the result
1209+
_metalbox_ip_cache[device.id] = ip_only
11761210
return ip_only
11771211
except ValueError:
11781212
# Skip non-IPv4 addresses
11791213
continue
11801214

11811215
logger.warning(f"No suitable Metalbox found for SONiC device {device.name}")
1216+
# Cache None result to avoid repeated lookups
1217+
_metalbox_ip_cache[device.id] = None
11821218
return None
11831219

11841220
except Exception as e:
11851221
logger.warning(f"Could not determine Metalbox IP for device {device.name}: {e}")
1222+
# Cache None result to avoid repeated lookups
1223+
_metalbox_ip_cache[device.id] = None
11861224
return None
11871225

11881226

@@ -1276,6 +1314,13 @@ def clear_ntp_cache():
12761314
logger.debug("Cleared NTP servers cache")
12771315

12781316

1317+
def clear_metalbox_ip_cache():
1318+
"""Clear the metalbox IP cache. Should be called at the start of sync_sonic."""
1319+
global _metalbox_ip_cache
1320+
_metalbox_ip_cache = {}
1321+
logger.debug("Cleared metalbox IP cache")
1322+
1323+
12791324
def _add_dns_configuration(config, device):
12801325
"""Add DNS_NAMESERVER configuration to device config.
12811326
@@ -1300,12 +1345,19 @@ def _add_dns_configuration(config, device):
13001345
def clear_all_caches():
13011346
"""Clear all caches in config_generator module."""
13021347
clear_ntp_cache()
1348+
clear_metalbox_ip_cache()
13031349
clear_port_config_cache()
13041350
logger.debug("Cleared all config_generator caches")
13051351

13061352

13071353
def _add_vlan_configuration(config, vlan_info, netbox_interfaces, device):
13081354
"""Add VLAN configuration from NetBox."""
1355+
# Build reverse mapping once: NetBox interface name -> SONiC interface name (O(1) lookups)
1356+
netbox_name_to_sonic = {
1357+
info["netbox_name"]: sonic_name
1358+
for sonic_name, info in netbox_interfaces.items()
1359+
}
1360+
13091361
# Add VLAN configuration
13101362
for vid, vlan_data in vlan_info["vlans"].items():
13111363
vlan_name = f"Vlan{vid}"
@@ -1314,16 +1366,13 @@ def _add_vlan_configuration(config, vlan_info, netbox_interfaces, device):
13141366
members = []
13151367
if vid in vlan_info["vlan_members"]:
13161368
for netbox_interface_name in vlan_info["vlan_members"][vid].keys():
1317-
# Convert NetBox interface name to SONiC format
1318-
# Try to find speed from netbox_interfaces
1319-
speed = None
1320-
for sonic_name, iface_info in netbox_interfaces.items():
1321-
if iface_info["netbox_name"] == netbox_interface_name:
1322-
speed = iface_info["speed"]
1323-
break
1324-
sonic_interface_name = convert_netbox_interface_to_sonic(
1325-
netbox_interface_name, device
1326-
)
1369+
# Convert NetBox interface name to SONiC format using O(1) lookup
1370+
sonic_interface_name = netbox_name_to_sonic.get(netbox_interface_name)
1371+
if not sonic_interface_name:
1372+
logger.warning(
1373+
f"Interface {netbox_interface_name} not found in mapping"
1374+
)
1375+
continue
13271376
members.append(sonic_interface_name)
13281377

13291378
config["VLAN"][vlan_name] = {
@@ -1337,16 +1386,13 @@ def _add_vlan_configuration(config, vlan_info, netbox_interfaces, device):
13371386
for vid, members in vlan_info["vlan_members"].items():
13381387
vlan_name = f"Vlan{vid}"
13391388
for netbox_interface_name, tagging_mode in members.items():
1340-
# Convert NetBox interface name to SONiC format
1341-
# Try to find speed from netbox_interfaces
1342-
speed = None
1343-
for sonic_name, iface_info in netbox_interfaces.items():
1344-
if iface_info["netbox_name"] == netbox_interface_name:
1345-
speed = iface_info["speed"]
1346-
break
1347-
sonic_interface_name = convert_netbox_interface_to_sonic(
1348-
netbox_interface_name, device
1349-
)
1389+
# Convert NetBox interface name to SONiC format using O(1) lookup
1390+
sonic_interface_name = netbox_name_to_sonic.get(netbox_interface_name)
1391+
if not sonic_interface_name:
1392+
logger.warning(
1393+
f"Interface {netbox_interface_name} not found in mapping"
1394+
)
1395+
continue
13501396
# Create VLAN_MEMBER key in format "Vlan<vid>|<port_name>"
13511397
member_key = f"{vlan_name}|{sonic_interface_name}"
13521398
config["VLAN_MEMBER"][member_key] = {"tagging_mode": tagging_mode}

0 commit comments

Comments
 (0)