Add batch loading for sensor/interface stats to eliminate N+1 queries

Added two new functions to Snmp context:
- get_latest_sensor_readings_batch/1 - Loads latest readings for multiple sensors in one query
- get_latest_interface_stats_batch/1 - Loads latest stats for multiple interfaces in one query

Both use PostgreSQL DISTINCT ON to efficiently get only the latest record per ID.

Updated EquipmentLive.Show.load_snmp_data/1 to use batch functions:
- Equipment with 20 sensors: 20 queries → 1 query (20x reduction)
- Equipment with 10 interfaces: 10 queries → 1 query (10x reduction)
- Total improvement: 30 queries → 2 queries for typical equipment page load

This significantly improves page load performance for equipment with many sensors/interfaces.
This commit is contained in:
Graham McIntire 2026-01-15 07:31:57 -06:00
parent ccae030569
commit 723f064315
No known key found for this signature in database
2 changed files with 68 additions and 4 deletions

View file

@ -227,6 +227,35 @@ defmodule Towerops.Snmp do
|> Repo.one()
end
@doc """
Gets the latest sensor readings for multiple sensors in a single query.
Returns a map of %{sensor_id => reading} for efficient batch loading.
Sensors without readings will not be present in the map.
## Examples
iex> get_latest_sensor_readings_batch([sensor_id1, sensor_id2])
%{sensor_id1 => %SensorReading{}, sensor_id2 => %SensorReading{}}
"""
def get_latest_sensor_readings_batch(sensor_ids) when is_list(sensor_ids) do
if Enum.empty?(sensor_ids) do
%{}
else
# Use DISTINCT ON to get only the latest reading per sensor
query =
from(r in SensorReading,
where: r.sensor_id in ^sensor_ids,
distinct: r.sensor_id,
order_by: [asc: r.sensor_id, desc: r.checked_at]
)
query
|> Repo.all()
|> Map.new(fn reading -> {reading.sensor_id, reading} end)
end
end
# Interface stats queries
@doc """
@ -268,6 +297,35 @@ defmodule Towerops.Snmp do
|> Repo.one()
end
@doc """
Gets the latest interface stats for multiple interfaces in a single query.
Returns a map of %{interface_id => stat} for efficient batch loading.
Interfaces without stats will not be present in the map.
## Examples
iex> get_latest_interface_stats_batch([if_id1, if_id2])
%{if_id1 => %InterfaceStat{}, if_id2 => %InterfaceStat{}}
"""
def get_latest_interface_stats_batch(interface_ids) when is_list(interface_ids) do
if Enum.empty?(interface_ids) do
%{}
else
# Use DISTINCT ON to get only the latest stat per interface
query =
from(s in InterfaceStat,
where: s.interface_id in ^interface_ids,
distinct: s.interface_id,
order_by: [asc: s.interface_id, desc: s.checked_at]
)
query
|> Repo.all()
|> Map.new(fn stat -> {stat.interface_id, stat} end)
end
end
@doc """
Records a new sensor reading.
"""

View file

@ -215,17 +215,23 @@ defmodule ToweropsWeb.EquipmentLive.Show do
%{device: nil, interfaces: [], sensors: []}
device ->
# Preload latest readings for sensors
# Batch load latest readings for all sensors in a single query
sensor_ids = Enum.map(device.sensors, & &1.id)
latest_readings_map = Snmp.get_latest_sensor_readings_batch(sensor_ids)
sensors_with_readings =
Enum.map(device.sensors, fn sensor ->
latest_reading = Snmp.get_latest_sensor_reading(sensor.id)
latest_reading = Map.get(latest_readings_map, sensor.id)
Map.put(sensor, :latest_reading, latest_reading)
end)
# Preload latest stats for interfaces
# Batch load latest stats for all interfaces in a single query
interface_ids = Enum.map(device.interfaces, & &1.id)
latest_stats_map = Snmp.get_latest_interface_stats_batch(interface_ids)
interfaces_with_stats =
Enum.map(device.interfaces, fn interface ->
latest_stat = Snmp.get_latest_interface_stat(interface.id)
latest_stat = Map.get(latest_stats_map, interface.id)
Map.put(interface, :latest_stat, latest_stat)
end)