towerops/lib/towerops/profiles/mib_cache.ex

144 lines
3.9 KiB
Elixir

defmodule Towerops.Profiles.MibCache do
@moduledoc """
GenServer that manages a cache of MIB names to numeric OIDs.
Pre-resolves MIB names from device profiles at startup (asynchronously)
and provides fast lookup with runtime fallback for cache misses.
This prevents blocking application startup while still providing fast
MIB name resolution during profile matching.
"""
use GenServer
require Logger
@table :mib_cache
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Looks up a MIB name in the cache and returns the numeric OID.
If the name is not in cache, it will be resolved at runtime and cached
for future lookups.
Returns the numeric OID string, or the original input if resolution fails.
"""
def lookup(mib_name) do
case :ets.lookup(@table, mib_name) do
[{^mib_name, oid}] ->
# Cache hit - return immediately
oid
[] ->
# Cache miss - resolve at runtime and cache result
resolve_and_cache(mib_name)
end
end
@doc """
Pre-resolves a list of MIB names asynchronously.
This is called by YamlProfiles after loading profiles to warm the cache.
"""
def pre_resolve(mib_names) when is_list(mib_names) do
GenServer.cast(__MODULE__, {:pre_resolve, mib_names})
end
@doc """
Returns the current cache size (number of cached MIB names).
"""
def size do
:ets.info(@table, :size)
end
# GenServer callbacks
@impl true
def init(_opts) do
# Create ETS table for MIB cache
_ = :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
Logger.info("MIB cache initialized (empty)")
{:ok, %{}}
end
@impl true
def handle_cast({:pre_resolve, mib_names}, state) do
# Pre-resolve MIB names in the background
# This happens asynchronously and doesn't block callers
Logger.info("Pre-resolving #{length(mib_names)} unique MIB names from profiles...")
resolved_count =
Enum.reduce(mib_names, 0, fn mib_name, acc ->
case resolve_oid_with_timeout(mib_name) do
{:ok, oid} when is_binary(oid) ->
:ets.insert(@table, {mib_name, oid})
acc + 1
{:error, _reason} ->
Logger.warning("Failed to resolve MIB name: #{mib_name}")
acc
end
end)
Logger.info("Successfully pre-resolved #{resolved_count}/#{length(mib_names)} MIB names")
{:noreply, state}
end
# Private helpers
defp resolve_and_cache(mib_name) do
Logger.info("Cache miss for MIB name #{mib_name}, resolving at runtime (fast)")
case resolve_oid_with_timeout(mib_name) do
{:ok, oid} when is_binary(oid) ->
:ets.insert(@table, {mib_name, oid})
oid
{:error, _reason} ->
# Resolution failed or timeout - at minimum, strip module prefix
# Handle MODULE-NAME::objectName format by returning just objectName
stripped =
if String.contains?(mib_name, "::") do
mib_name |> String.split("::") |> List.last()
else
mib_name
end
stripped
end
end
defp resolve_oid_with_timeout(mib_name, timeout \\ 5000) do
# In test environment, skip resolution to avoid hanging
# Tests should stub ToweropsNative if they need specific behavior
if Application.get_env(:towerops, :env) == :test do
{:error, :test_environment}
else
# Add timeout to prevent hanging when snmptranslate is unavailable
task =
Task.async(fn ->
ToweropsNative.resolve_oid(mib_name)
end)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, oid} when is_binary(oid) ->
{:ok, oid}
{:ok, {:error, reason}} ->
{:error, reason}
nil ->
# Timeout
Logger.warning("MIB name resolution timeout for #{mib_name}")
{:error, :timeout}
end
end
end
end