towerops/lib/towerops/workers/discovery_worker.ex
Graham McIntire 3b8e1ea639
Reliability improvements: Task.Supervisor, discovery retries, polling visibility
- Add Towerops.TaskSupervisor to supervision tree, replace bare spawn/1
  in agent channel with supervised tasks for polling data processing
- Change DiscoveryWorker from max_attempts: 1 to 3 with retryable error
  classification (transient errors retry, permanent errors discard)
- Name all 9 parallel polling tasks and log timeouts/crashes instead of
  silently dropping results via Stream.run()
- Add tests for TaskSupervisor presence and error classification
2026-02-09 16:05:30 -06:00

554 lines
16 KiB
Elixir

defmodule Towerops.Workers.DiscoveryWorker do
@moduledoc """
Oban worker for SNMP device discovery.
Enqueued when:
- A new device is created with SNMP enabled
- An existing device's SNMP configuration is changed
- User manually triggers discovery via "Rediscover Device" button
## Agent-based Discovery
If the device has an assigned agent, discovery is delegated to that agent.
The agent receives a discovery job via PubSub broadcast, performs SNMP operations,
and sends results back to the server.
If no agent is assigned or the agent is offline, discovery falls back to direct
SNMP operations from the Phoenix cluster.
"""
use Oban.Worker,
queue: :discovery,
unique: [period: 60, states: [:available, :scheduled, :executing]],
max_attempts: 3
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.JobMonitoring.Events
alias Towerops.Snmp
require Logger
# Timeout for waiting for agent-based discovery (120 seconds)
# Increased to match agent SNMP timeout - SNMPv3 operations can take 50+ seconds
@agent_discovery_timeout_ms 120_000
# Overall job timeout (5 minutes) - job will be cancelled if it exceeds this
@job_timeout_ms 300_000
# Consider agents online if they checked in within last 10 minutes
@agent_online_threshold_minutes 10
@impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok | :discard | {:error, term()}
def perform(%Oban.Job{args: %{"device_id" => device_id}} = job) do
Events.broadcast_job_event(job, :started)
start_time = System.monotonic_time(:second)
try do
result =
case Devices.get_device_with_details(device_id) do
nil ->
Logger.warning("Device #{device_id} not found, discarding discovery job")
:discard
device ->
perform_device_discovery(device, device_id)
end
duration = System.monotonic_time(:second) - start_time
case result do
:ok ->
Events.broadcast_job_event(job, :completed, %{duration: duration})
:discard ->
Events.broadcast_job_event(job, :completed, %{duration: duration})
{:error, reason} ->
Events.broadcast_job_event(job, :failed, %{
error: inspect(reason),
duration: duration
})
end
result
rescue
error ->
duration = System.monotonic_time(:second) - start_time
error_string = Exception.format(:error, error, __STACKTRACE__)
Logger.error(
"Discovery job crashed unexpectedly",
device_id: device_id,
error: error_string
)
Events.broadcast_job_event(job, :failed, %{
error: error_string,
duration: duration
})
# Crashes are not retryable - they indicate a code bug, not a transient issue
:discard
end
end
defp perform_device_discovery(device, device_id) do
# Use Task.async/await with timeout to prevent jobs from hanging indefinitely
task = Task.async(fn -> perform_discovery(device) end)
case Task.yield(task, @job_timeout_ms) || Task.shutdown(task) do
{:ok, :ok} ->
:ok
{:ok, {:error, reason}} ->
if retryable_error?(reason) do
Logger.warning(
"Discovery failed for device #{device_id} with transient error, will retry",
device_id: device_id,
error: inspect(reason)
)
{:error, reason}
else
Logger.warning(
"Discovery failed for device #{device_id} with permanent error, discarding",
device_id: device_id,
error: inspect(reason)
)
:discard
end
nil ->
Logger.error(
"Discovery job timeout after #{@job_timeout_ms}ms, will retry",
device_id: device_id,
timeout_ms: @job_timeout_ms
)
{:error, :job_timeout}
end
end
defp perform_discovery(device) do
start_time = System.monotonic_time(:millisecond)
Logger.info(
"DISCOVERY STARTED: #{device.name} (#{device.ip_address})",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
snmp_enabled: device.snmp_enabled,
snmp_port: device.snmp_port || 161
)
result = execute_discovery_with_agent_routing(device)
duration_ms = System.monotonic_time(:millisecond) - start_time
log_discovery_result(device, result, duration_ms)
result
end
defp execute_discovery_with_agent_routing(device) do
case get_assigned_agent(device) do
{agent_token_id, source} when not is_nil(agent_token_id) ->
handle_assigned_agent_discovery(device, agent_token_id, source)
{nil, :none} ->
Logger.info(
"Starting SNMP discovery for device #{device.id} with no assigned agent, will try cloud pollers then cluster fallback",
device_id: device.id
)
attempt_cloud_poller_discovery(device, [])
end
end
defp handle_assigned_agent_discovery(device, agent_token_id, source) do
agent = Agents.get_agent_token!(agent_token_id)
log_agent_assignment(device, agent, agent_token_id, source)
attempt_agent_discovery(device, agent_token_id, [agent_token_id])
rescue
Ecto.NoResultsError ->
Logger.warning(
"Agent token #{agent_token_id} no longer exists (#{source} assignment), falling back to cloud pollers",
device_id: device.id,
agent_token_id: agent_token_id,
source: source
)
attempt_cloud_poller_discovery(device, [])
end
defp log_agent_assignment(device, agent, agent_token_id, source) do
poller_type = if agent.is_cloud_poller, do: "cloud poller", else: "user's agent"
Logger.info(
"Agent assignment found: using #{poller_type} '#{agent.name}' (assigned via #{source})",
device_id: device.id,
device_name: device.name,
agent_token_id: agent_token_id,
agent_name: agent.name,
is_cloud_poller: agent.is_cloud_poller,
assignment_source: source,
agent_enabled: agent.enabled,
agent_last_seen: agent.last_seen_at
)
end
defp log_discovery_result(device, :ok, duration_ms) do
Logger.info(
"DISCOVERY COMPLETED: #{device.name} (#{device.ip_address}) in #{duration_ms}ms",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
duration_ms: duration_ms
)
end
defp log_discovery_result(device, {:error, reason}, duration_ms) do
Logger.error(
"DISCOVERY FAILED: #{device.name} (#{device.ip_address}) after #{duration_ms}ms - #{inspect(reason)}",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
duration_ms: duration_ms,
error: reason
)
end
@doc """
Enqueues a discovery job for a device.
Uses Oban unique constraint to prevent duplicate jobs within 60 seconds.
"""
def enqueue(device_id) do
# Log who's enqueueing this to track down runaway triggers
caller_module =
self()
|> Process.info(:current_stacktrace)
|> elem(1)
|> Enum.at(1)
|> elem(0)
result =
%{device_id: device_id}
|> new()
|> Oban.insert()
case result do
{:ok, %Oban.Job{conflict?: true}} ->
_ =
Logger.debug(
"Discovery job already enqueued for device, skipping duplicate",
device_id: device_id,
caller: caller_module
)
:ok
{:ok, _job} ->
_ =
Logger.debug(
"Discovery job enqueued for device",
device_id: device_id,
caller: caller_module
)
:ok
{:error, _} = error ->
_ =
Logger.warning(
"Failed to enqueue discovery job",
device_id: device_id,
caller: caller_module
)
error
end
end
@doc """
Classifies whether a discovery error is transient (retryable) or permanent.
Transient errors (network issues, timeouts) will be retried by Oban.
Permanent errors (device not found, invalid config) are discarded immediately.
Unknown errors default to retryable to avoid silent data loss.
"""
@spec retryable_error?(atom() | term()) :: boolean()
def retryable_error?(:device_not_found), do: false
def retryable_error?(:invalid_config), do: false
def retryable_error?(:no_snmp_credentials), do: false
def retryable_error?(:device_deleted), do: false
def retryable_error?(_reason), do: true
# Private helpers
defp get_assigned_agent(device) do
Agents.get_effective_agent_token_with_source(device)
end
defp attempt_agent_discovery(device, agent_token_id, tried_agents) do
# First check if the agent is online based on recent heartbeat
if agent_online?(agent_token_id) do
Logger.info(
"Agent is online, delegating discovery",
device_id: device.id,
agent_token_id: agent_token_id
)
case trigger_agent_discovery(device, agent_token_id) do
:ok ->
:ok
{:error, :device_deleted} ->
Logger.info("Device was deleted during discovery, skipping",
device_id: device.id
)
:ok
{:error, :timeout} ->
Logger.warning(
"Agent discovery timeout, trying next cloud poller",
device_id: device.id,
failed_agent_id: agent_token_id
)
attempt_cloud_poller_discovery(device, tried_agents)
{:error, :agent_offline} ->
Logger.warning(
"Agent went offline during discovery, trying next cloud poller",
device_id: device.id,
failed_agent_id: agent_token_id
)
attempt_cloud_poller_discovery(device, tried_agents)
end
else
Logger.warning(
"Agent is offline, skipping to next cloud poller",
device_id: device.id,
offline_agent_id: agent_token_id
)
attempt_cloud_poller_discovery(device, tried_agents)
end
end
defp attempt_cloud_poller_discovery(device, tried_agents) do
Logger.info(
"Checking for cloud pollers...",
device_id: device.id,
device_name: device.name,
tried_agents: tried_agents
)
case get_next_cloud_poller(tried_agents) do
nil ->
Logger.info(
"No cloud pollers available, using direct SNMP from Phoenix cluster",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
tried_agents: tried_agents
)
perform_direct_discovery(device)
next_agent_id ->
agent = Agents.get_agent_token!(next_agent_id)
Logger.info(
"Trying global cloud poller '#{agent.name}'",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
cloud_poller_id: next_agent_id,
cloud_poller_name: agent.name,
agent_enabled: agent.enabled,
agent_last_seen: agent.last_seen_at
)
attempt_agent_discovery(device, next_agent_id, [next_agent_id | tried_agents])
end
end
defp get_next_cloud_poller(tried_agents) do
Agents.list_cloud_pollers()
|> Enum.reject(fn agent -> agent.id in tried_agents end)
|> Enum.find(fn agent -> agent_online?(agent.id) end)
|> case do
nil -> nil
agent -> agent.id
end
end
defp agent_online?(agent_token_id) do
agent = Agents.get_agent_token!(agent_token_id)
cutoff = DateTime.add(DateTime.utc_now(), -@agent_online_threshold_minutes, :minute)
agent.enabled && agent.last_seen_at && DateTime.after?(agent.last_seen_at, cutoff)
rescue
Ecto.NoResultsError -> false
end
defp trigger_agent_discovery(device, agent_token_id) do
# Broadcast discovery request to agent's channel
# The agent will receive this via PubSub and execute discovery
_ =
Logger.info(
"Sending discovery request to remote agent via PubSub",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
agent_token_id: agent_token_id,
pubsub_channel: "agent:#{agent_token_id}:discovery"
)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token_id}:discovery",
{:discovery_requested, device.id}
)
Logger.info(
"Discovery request sent, waiting up to #{@agent_discovery_timeout_ms}ms for agent to respond",
device_id: device.id,
agent_token_id: agent_token_id,
timeout_ms: @agent_discovery_timeout_ms
)
# Wait for agent to complete discovery (with timeout)
# The agent will update last_discovery_at when done
wait_for_agent_discovery(device, agent_token_id, @agent_discovery_timeout_ms)
end
defp wait_for_agent_discovery(device, agent_token_id, timeout_ms) do
start_time = System.monotonic_time(:millisecond)
initial_discovery_at = device.last_discovery_at
wait_loop(device.id, agent_token_id, initial_discovery_at, start_time, timeout_ms)
end
defp wait_loop(device_id, agent_token_id, initial_discovery_at, start_time, timeout_ms) do
elapsed = System.monotonic_time(:millisecond) - start_time
cond do
# Check if agent went offline during discovery
!agent_online?(agent_token_id) ->
Logger.warning(
"Agent went offline during discovery",
device_id: device_id,
agent_token_id: agent_token_id,
elapsed_ms: elapsed
)
{:error, :agent_offline}
# Check if we've exceeded timeout
elapsed >= timeout_ms ->
Logger.warning(
"Agent discovery timeout - no response within #{timeout_ms}ms",
device_id: device_id,
agent_token_id: agent_token_id,
elapsed_ms: elapsed
)
{:error, :timeout}
# Check if discovery completed (last_discovery_at changed)
true ->
check_discovery_completion(
device_id,
agent_token_id,
initial_discovery_at,
start_time,
timeout_ms,
elapsed
)
end
end
defp check_discovery_completion(device_id, agent_token_id, initial_discovery_at, start_time, timeout_ms, elapsed) do
case Devices.get_device_with_details(device_id) do
nil ->
Logger.warning(
"Device not found for discovery request",
device_id: device_id
)
{:error, :device_deleted}
device ->
handle_device_discovery_status(
device,
device_id,
agent_token_id,
initial_discovery_at,
start_time,
timeout_ms,
elapsed
)
end
end
defp handle_device_discovery_status(
device,
device_id,
agent_token_id,
initial_discovery_at,
start_time,
timeout_ms,
elapsed
) do
if device.last_discovery_at == initial_discovery_at do
# Wait 500ms before checking again
Process.sleep(500)
wait_loop(device_id, agent_token_id, initial_discovery_at, start_time, timeout_ms)
else
Logger.info(
"Agent discovery completed successfully",
device_id: device_id,
agent_token_id: agent_token_id,
elapsed_ms: elapsed
)
:ok
end
end
defp perform_direct_discovery(device) do
Logger.info(
"Starting direct SNMP discovery from Phoenix cluster (no remote agents available)",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address
)
case Snmp.discover_device(device) do
{:ok, _snmp_device} ->
Logger.info(
"Direct SNMP discovery completed successfully",
device_id: device.id,
device_name: device.name
)
:ok
{:error, reason} ->
Logger.error(
"Direct SNMP discovery failed: #{inspect(reason)}",
device_id: device.id,
device_name: device.name,
device_ip: device.ip_address,
error: reason
)
{:error, reason}
end
end
end