Add distributed coordination for SNMP polling

Prevents duplicate polling when multiple pods are running by using
database-based coordination.

Changes:
- Added last_snmp_poll_at timestamp to equipment table
- Added Equipment.update_snmp_poll_time/1 function
- Updated PollerWorker to check last_snmp_poll_at before polling
- Polls are skipped if equipment was polled within (interval - 5s)
- Updates timestamp before polling (optimistic locking)

This ensures that when K8s scales up/down or pods restart, only one
pod polls each piece of equipment at a time, preventing wasteful
duplicate SNMP queries and database writes.

The 5-second grace period accounts for clock drift and processing delays
between pods.
This commit is contained in:
Graham McIntire 2026-01-04 13:28:06 -06:00
parent 91759ac16c
commit 20f6a9171d
No known key found for this signature in database
4 changed files with 76 additions and 24 deletions

View file

@ -123,4 +123,16 @@ defmodule Towerops.Equipment do
|> Ecto.Changeset.change(changes)
|> Repo.update()
end
@doc """
Updates the last SNMP poll timestamp for equipment.
Used for distributed coordination to prevent duplicate polling across pods.
"""
def update_snmp_poll_time(%EquipmentSchema{} = equipment) do
now = DateTime.truncate(DateTime.utc_now(), :second)
equipment
|> Ecto.Changeset.change(%{last_snmp_poll_at: now})
|> Repo.update()
end
end

View file

@ -22,6 +22,7 @@ defmodule Towerops.Equipment.Equipment do
field :snmp_community, :string
field :snmp_port, :integer, default: 161
field :last_discovery_at, :utc_datetime
field :last_snmp_poll_at, :utc_datetime
belongs_to :site, Towerops.Sites.Site

View file

@ -82,33 +82,46 @@ defmodule Towerops.Snmp.PollerWorker do
device = Snmp.get_device_with_associations(equipment_id)
if device do
client_opts = build_client_opts(equipment)
now = DateTime.truncate(DateTime.utc_now(), :second)
# Check if recently polled by another pod (distributed coordination)
poll_interval = get_poll_interval(equipment)
grace_period = 5
# Poll all sensors
try do
poll_sensors(device.sensors, client_opts, now)
Logger.debug("Polled #{length(device.sensors)} sensors for #{equipment.name}")
rescue
error ->
Logger.error(
"Error polling sensors for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
)
if !should_skip_poll?(equipment, poll_interval, grace_period) do
# Update poll timestamp before polling (optimistic locking)
Equipment.update_snmp_poll_time(equipment)
client_opts = build_client_opts(equipment)
now = DateTime.truncate(DateTime.utc_now(), :second)
# Poll all sensors
try do
poll_sensors(device.sensors, client_opts, now)
Logger.debug("Polled #{length(device.sensors)} sensors for #{equipment.name}")
rescue
error ->
Logger.error(
"Error polling sensors for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
)
end
# Poll all interfaces
try do
poll_interfaces(device.interfaces, client_opts, now)
Logger.debug("Polled #{length(device.interfaces)} interfaces for #{equipment.name}")
rescue
error ->
Logger.error(
"Error polling interfaces for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
)
end
else
Logger.debug(
"Skipping poll for #{equipment.name} - recently polled by another process"
)
end
# Poll all interfaces
try do
poll_interfaces(device.interfaces, client_opts, now)
Logger.debug("Polled #{length(device.interfaces)} interfaces for #{equipment.name}")
rescue
error ->
Logger.error(
"Error polling interfaces for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
)
end
# Schedule next poll
schedule_next_poll(get_poll_interval(equipment))
# Schedule next poll regardless of whether we polled or skipped
schedule_next_poll(poll_interval)
end
end
end
@ -266,6 +279,21 @@ defmodule Towerops.Snmp.PollerWorker do
Process.send_after(self(), :poll_data, interval_seconds * 1000)
end
defp should_skip_poll?(equipment, poll_interval_seconds, grace_period_seconds) do
case equipment.last_snmp_poll_at do
nil ->
false
last_poll ->
now = DateTime.utc_now()
seconds_since_poll = DateTime.diff(now, last_poll, :second)
min_interval = poll_interval_seconds - grace_period_seconds
# Skip if polled within the minimum interval
seconds_since_poll < min_interval
end
end
defp via_tuple(equipment_id) do
{:via, Registry, {PollerRegistry, equipment_id}}
end

View file

@ -0,0 +1,11 @@
defmodule Towerops.Repo.Migrations.AddLastSnmpPollToEquipment do
use Ecto.Migration
def change do
alter table(:equipment) do
add :last_snmp_poll_at, :utc_datetime
end
create index(:equipment, [:last_snmp_poll_at])
end
end