towerops/lib/towerops/workers/device_monitor_worker.ex
Graham McIntire d0946c3cd0
refactor: simplify job architecture from Oban coordinators to direct workers
Removes unnecessary two-layer architecture (Oban Coordinator → GenServer)
in favor of direct Oban workers that perform the actual work.

Changes:
- Replace DevicePollerCoordinator + PollerWorker with DevicePollerWorker
- Replace DeviceMonitorCoordinator + DeviceMonitor with DeviceMonitorWorker
- Simplify Monitoring.Supervisor (removed Registries, DynamicSupervisors)
- Remove all Exq dependencies (workers, supervisor, mix deps)
- Convert DiscoveryWorker from Exq to Oban pattern
- Update Devices context to auto-start/stop monitoring and polling
- Update all LiveView callers to use new Oban enqueue pattern
- Fix all tests to use Oban.Job struct instead of raw IDs

Benefits:
- Simpler codebase (~850 lines removed)
- Better reliability (Oban handles retries, failures)
- Lower memory (no persistent GenServers)
- Better observability (work visible in Oban dashboard)
- Cluster-wide coordination via PostgreSQL
- Extensive PubSub usage for real-time events

Files deleted:
- lib/towerops/workers/device_monitor_coordinator.ex
- lib/towerops/workers/device_poller_coordinator.ex
- lib/towerops/snmp/poller_worker.ex
- lib/towerops/monitoring/device_monitor.ex
- lib/towerops/workers/monitor_worker.ex
- lib/towerops/workers/poll_worker.ex
- lib/towerops/exq_supervisor.ex
- All related test files

Files created:
- lib/towerops/workers/device_poller_worker.ex
- lib/towerops/workers/device_monitor_worker.ex

All 3,686 tests passing.
2026-01-24 16:36:57 -06:00

213 lines
5 KiB
Elixir

defmodule Towerops.Workers.DeviceMonitorWorker do
@moduledoc """
Oban worker that monitors device health by pinging it.
Uses Oban's unique job feature to ensure only one monitor per device
runs cluster-wide. Monitors check device connectivity and create alerts
on status changes.
"""
use Oban.Worker,
queue: :monitors,
unique: [
period: 60,
keys: [:device_id],
states: [:available, :scheduled, :executing, :retryable]
]
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Monitoring
require Logger
@ping_module Application.compile_env(:towerops, :ping_module, Towerops.Monitoring.Ping)
@compile {:no_warn_undefined, Towerops.Monitoring.PingMock}
@monitor_interval 30
@impl Oban.Worker
def perform(%Oban.Job{args: %{"device_id" => device_id}}) do
case Devices.get_device(device_id) do
nil ->
Logger.debug("Device #{device_id} no longer exists, skipping monitor")
:ok
device ->
if device.monitoring_enabled do
perform_check(device)
end
# Schedule next check
schedule_next_check(device_id)
:ok
end
end
@doc """
Starts monitoring for a device.
"""
def start_monitoring(device_id) do
%{device_id: device_id}
|> new()
|> Oban.insert()
end
@doc """
Stops monitoring for a device by cancelling its jobs.
"""
def stop_monitoring(device_id) do
import Ecto.Query
Oban.cancel_all_jobs(
from(j in Oban.Job,
where: j.worker == "Towerops.Workers.DeviceMonitorWorker",
where: fragment("args->>'device_id' = ?", ^device_id),
where: j.state in ["available", "scheduled", "executing", "retryable"]
)
)
end
@doc """
Triggers an immediate check for a device.
"""
def trigger_check(device_id) do
%{device_id: device_id}
|> new()
|> Oban.insert()
end
# Private Functions
defp perform_check(device) do
check_result = check_device_health(device)
now = DateTime.truncate(DateTime.utc_now(), :second)
{status, response_time_ms} =
case check_result do
{:ok, time} -> {:success, time}
{:error, _reason} -> {:failure, nil}
end
case Monitoring.create_check(%{
device_id: device.id,
status: status,
response_time_ms: response_time_ms,
checked_at: now
}) do
{:ok, _check} ->
:ok
{:error, changeset} ->
Logger.error("Failed to create monitoring check for device #{device.id}: #{inspect(changeset.errors)}")
end
new_status = if status == :success, do: :up, else: :down
old_status = device.status
_ = Devices.update_device_status(device, new_status)
if old_status != new_status do
handle_status_change(device, old_status, new_status)
end
# Broadcast status change via PubSub
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:device_status_changed, device.id, new_status, nil}
)
end
defp check_device_health(device) do
@ping_module.ping(device.ip_address)
end
defp handle_status_change(device, old_status, new_status) do
now = DateTime.truncate(DateTime.utc_now(), :second)
case {old_status, new_status} do
{_, :down} ->
handle_equipment_down(device, now)
{_, :up} ->
handle_equipment_up(device, now)
end
end
defp handle_equipment_down(device, now) do
if Alerts.has_active_alert?(device.id, :device_down) do
:ok
else
create_device_down_alert(device, now)
end
end
defp create_device_down_alert(device, now) do
alert_message = get_down_alert_message(device)
{:ok, _alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: now,
message: alert_message
})
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:new",
{:new_alert, device.id, :device_down}
)
end
defp get_down_alert_message(device) do
if device.snmp_enabled do
"Device is not responding to SNMP"
else
"Device is not responding to ping"
end
end
defp handle_equipment_up(device, now) do
recovery_message = get_recovery_message(device)
{:ok, _alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_up,
triggered_at: now,
message: recovery_message
})
resolve_down_alert(device)
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:resolved",
{:alert_resolved, device.id, :device_down}
)
end
defp get_recovery_message(device) do
if device.snmp_enabled do
"Device is now responding to SNMP"
else
"Device is now responding to ping"
end
end
defp resolve_down_alert(device) do
case Alerts.get_active_alert(device.id, :device_down) do
nil -> :ok
alert -> Alerts.resolve_alert(alert)
end
end
defp schedule_next_check(device_id) do
%{device_id: device_id}
|> new(schedule_in: @monitor_interval)
|> Oban.insert()
end
end